Merge pull request #31 from rede5/codex/convert-api-seeder-to-golang-and-create-dockerfile

Convert seeder API to Go with Node seeder support
This commit is contained in:
Tiago Yamamoto 2026-01-02 10:29:14 -03:00 committed by GitHub
commit b5e9ef60ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 348 additions and 4 deletions

View file

@ -1,17 +1,31 @@
# =============================================================================
# GoHorse Jobs Seeder API - Production Dockerfile
# GoHorse Jobs Seeder API - Production Dockerfile (Go API + Node seeders)
# =============================================================================
FROM mirror.gcr.io/library/golang:1.22-alpine AS go-builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /seeder-api
FROM mirror.gcr.io/library/node:20-alpine
WORKDIR /app
# Install dependencies
# Install Node.js dependencies for seed scripts
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy source
# Copy seeders and SQL assets
COPY src/ ./src/
COPY sql/ ./sql/
# Copy Go API binary
COPY --from=go-builder /seeder-api /usr/local/bin/seeder-api
# Security: Run as non-root
RUN addgroup -g 1001 -S nodejs && \
@ -30,4 +44,4 @@ EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3001/health || exit 1
CMD ["node", "src/server.js"]
CMD ["seeder-api"]

17
seeder-api/go.mod Normal file
View file

@ -0,0 +1,17 @@
module gohorsejobs/seeder-api
go 1.22
require (
github.com/jackc/pgx/v5 v5.7.1
github.com/joho/godotenv v1.5.1
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/text v0.18.0 // indirect
)

30
seeder-api/go.sum Normal file
View file

@ -0,0 +1,30 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

283
seeder-api/main.go Normal file
View file

@ -0,0 +1,283 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
)
type server struct {
pool *pgxpool.Pool
mu sync.Mutex
seeding bool
}
type seedRequest struct {
Type string `json:"type"`
Password string `json:"password"`
}
type healthResponse struct {
Status string `json:"status"`
Database string `json:"database"`
Version string `json:"version"`
Error string `json:"error,omitempty"`
}
type seedResponse struct {
Message string `json:"message"`
Type string `json:"type,omitempty"`
}
func main() {
_ = godotenv.Load()
databaseURL, err := buildDatabaseURL()
if err != nil {
log.Fatalf("❌ Failed to build database URL: %v", err)
}
pool, err := pgxpool.New(context.Background(), databaseURL)
if err != nil {
log.Fatalf("❌ Failed to create database pool: %v", err)
}
defer pool.Close()
logDatabaseConfig(databaseURL)
srv := &server{pool: pool}
mux := http.NewServeMux()
mux.HandleFunc("/health", srv.healthHandler)
mux.HandleFunc("/seed", srv.seedHandler)
mux.HandleFunc("/reset", srv.resetHandler)
handler := withCORS(withLogging(mux))
port := getenv("PORT", "8080")
log.Printf("🌱 GoHorseJobs Seeder API listening on port %s", port)
log.Printf(" Health check: http://localhost:%s/health", port)
if err := http.ListenAndServe(":"+port, handler); err != nil {
log.Fatalf("❌ Server failed: %v", err)
}
}
func (s *server) healthHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, healthResponse{Status: "error", Database: "disconnected", Error: "Method not allowed"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
var result int
if err := s.pool.QueryRow(ctx, "SELECT 1").Scan(&result); err != nil {
log.Printf("Health check failed: %v", err)
writeJSON(w, http.StatusInternalServerError, healthResponse{Status: "error", Database: "disconnected", Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, healthResponse{Status: "ok", Database: "connected", Version: "1.0.0"})
}
func (s *server) seedHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
return
}
var req seedRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON body"})
return
}
if !isAuthorized(req.Password) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
return
}
if !s.tryStartSeeding() {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Seeding already in progress"})
return
}
seedType := strings.TrimSpace(req.Type)
if seedType == "" {
seedType = "full"
}
writeJSON(w, http.StatusOK, seedResponse{Message: "Seeding started", Type: seedType})
go func() {
defer s.finishSeeding()
log.Printf("🚀 Starting manual seed (%s)...", seedType)
if err := runSeed(seedType); err != nil {
log.Printf("❌ Manual seed failed: %v", err)
return
}
log.Printf("✅ Manual seed completed")
}()
}
func (s *server) resetHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
return
}
var req seedRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON body"})
return
}
if !isAuthorized(req.Password) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
return
}
if !s.tryStartSeeding() {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Seeding/Reset in progress"})
return
}
writeJSON(w, http.StatusOK, seedResponse{Message: "Reset started"})
go func() {
defer s.finishSeeding()
log.Printf("🚀 Starting manual reset...")
if err := runReset(); err != nil {
log.Printf("❌ Manual reset failed: %v", err)
return
}
log.Printf("✅ Manual reset completed")
}()
}
func (s *server) tryStartSeeding() bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.seeding {
return false
}
s.seeding = true
return true
}
func (s *server) finishSeeding() {
s.mu.Lock()
s.seeding = false
s.mu.Unlock()
}
func runSeed(seedType string) error {
args := []string{"src/index.js"}
switch seedType {
case "lite":
args = append(args, "--lite")
case "no-locations":
args = append(args, "--skip-locations")
case "full":
default:
log.Printf("⚠️ Unknown seed type '%s', using full", seedType)
}
return runNodeCommand(args...)
}
func runReset() error {
return runNodeCommand("src/index.js", "--reset")
}
func runNodeCommand(args ...string) error {
cmd := exec.Command("node", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
return cmd.Run()
}
func buildDatabaseURL() (string, error) {
if databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL")); databaseURL != "" {
cleaned := strings.ReplaceAll(databaseURL, "?sslmode=require", "")
cleaned = strings.ReplaceAll(cleaned, "&sslmode=require", "")
return cleaned, nil
}
host := getenv("DB_HOST", "localhost")
port := getenv("DB_PORT", "5432")
user := getenv("DB_USER", "postgres")
password := getenv("DB_PASSWORD", "postgres")
name := getenv("DB_NAME", "gohorsejobs")
sslmode := getenv("DB_SSLMODE", "disable")
if sslmode == "" {
sslmode = "disable"
}
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", url.QueryEscape(user), url.QueryEscape(password), host, port, name, sslmode), nil
}
func logDatabaseConfig(databaseURL string) {
masked := databaseURL
if at := strings.Index(masked, "@"); at != -1 {
masked = "postgres://***@" + masked[at+1:]
}
log.Printf("🔌 DB Config: connectionString=%s", masked)
}
func isAuthorized(password string) bool {
expected := strings.TrimSpace(os.Getenv("SEED_PASSWORD"))
if expected == "" {
return true
}
return password == expected
}
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("[%s] %s %s", time.Now().Format(time.RFC3339), r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func getenv(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}