diff --git a/seeder-api/Dockerfile b/seeder-api/Dockerfile index 3660363..6ea2b72 100644 --- a/seeder-api/Dockerfile +++ b/seeder-api/Dockerfile @@ -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"] diff --git a/seeder-api/go.mod b/seeder-api/go.mod new file mode 100644 index 0000000..530b3d9 --- /dev/null +++ b/seeder-api/go.mod @@ -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 +) diff --git a/seeder-api/go.sum b/seeder-api/go.sum new file mode 100644 index 0000000..a892562 --- /dev/null +++ b/seeder-api/go.sum @@ -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= diff --git a/seeder-api/main.go b/seeder-api/main.go new file mode 100644 index 0000000..8910662 --- /dev/null +++ b/seeder-api/main.go @@ -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 +}