Convert seeder API to Go server
This commit is contained in:
parent
c0e73cd1e2
commit
7c78632765
4 changed files with 348 additions and 4 deletions
|
|
@ -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
17
seeder-api/go.mod
Normal 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
30
seeder-api/go.sum
Normal 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
283
seeder-api/main.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue