From c906cd46adfaaee759add99d9d0968540bf4e8f4 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 7 May 2026 14:16:05 -0400 Subject: [PATCH] CUB-124: scaffold Control Center Go backend --- go-backend/.dockerignore | 35 ++++++++++++++++ go-backend/Dockerfile | 35 ++++++++++++++++ go-backend/cmd/server/main.go | 21 +++++++++- go-backend/go.mod | 5 +++ go-backend/go.sum | 16 +++++++ go-backend/internal/db/db.go | 62 ++++++++++++++++++++++++++++ go-backend/internal/router/router.go | 47 ++++++++++++++------- 7 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 go-backend/.dockerignore create mode 100644 go-backend/Dockerfile create mode 100644 go-backend/internal/db/db.go diff --git a/go-backend/.dockerignore b/go-backend/.dockerignore new file mode 100644 index 0000000..7b3505c --- /dev/null +++ b/go-backend/.dockerignore @@ -0,0 +1,35 @@ +# Ignore local build artifacts and version-control files +*.exe +*.dll +*.so +*.dylib +*.test +*.out +bin/ +dist/ + +# Version control +.git +.gitignore + +# IDE / editor +.idea +.vscode +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Dependency cache (already fetched in Dockerfile) +vendor/ + +# Documentation +README.md +*.md + +# CI / CD +.github/ +.gitea/ diff --git a/go-backend/Dockerfile b/go-backend/Dockerfile new file mode 100644 index 0000000..8076d63 --- /dev/null +++ b/go-backend/Dockerfile @@ -0,0 +1,35 @@ +# Build stage +FROM golang:1.23-alpine AS builder +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git ca-certificates + +# Copy dependency files first for better layer caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/server ./cmd/server + +# ── Final stage ───────────────────────────────────────────────────────── +FROM alpine:latest +WORKDIR /app + +# Install ca-certificates for HTTPS outbound calls +RUN apk --no-cache add ca-certificates + +# Copy binary from builder +COPY --from=builder /bin/server /app/server + +# Expose the default port (overridden by PORT env var) +EXPOSE 8080 + +# Run as non-root +RUN adduser -D -s /bin/sh appuser +USER appuser + +ENTRYPOINT ["/app/server"] diff --git a/go-backend/cmd/server/main.go b/go-backend/cmd/server/main.go index 0105016..2dcc49e 100644 --- a/go-backend/cmd/server/main.go +++ b/go-backend/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "time" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/config" + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/db" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/router" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store" @@ -27,6 +28,16 @@ func main() { })) slog.SetDefault(logger) + // ── Database (optional until CUB-120 schema is ready) ────────────────── + var pool *db.Pool + if cfg.DatabaseURL != "" { + var err error + pool, err = db.New(cfg.DatabaseURL) + if err != nil { + slog.Warn("database connection failed; running without DB", "error", err) + } + } + // ── Stores (in-memory for now; PostgreSQL after CUB-120) ──────────────── agentStore := store.NewAgentStore() sessionStore := store.NewSessionStore() @@ -37,7 +48,11 @@ func main() { h := handler.NewHandler(agentStore, sessionStore, taskStore, projectStore) // ── Router ───────────────────────────────────────────────────────────── - r := router.New(h) + r := router.New(&router.Dependencies{ + Handler: h, + DB: pool, + CORSOrigin: cfg.CORSOrigin, + }) // ── Server ───────────────────────────────────────────────────────────── srv := &http.Server{ @@ -71,6 +86,10 @@ func main() { os.Exit(1) } + if pool != nil { + pool.Close() + } + slog.Info("server exited cleanly") } diff --git a/go-backend/go.mod b/go-backend/go.mod index d3fa197..4b9db15 100644 --- a/go-backend/go.mod +++ b/go-backend/go.mod @@ -7,15 +7,20 @@ require ( github.com/go-chi/cors v1.2.1 github.com/go-playground/validator/v10 v10.24.0 github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 ) require ( github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + 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 github.com/leodido/go-urn v1.4.0 // indirect golang.org/x/crypto v0.32.0 // indirect golang.org/x/net v0.34.0 // indirect + golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect ) diff --git a/go-backend/go.sum b/go-backend/go.sum index c4799b1..f4041e3 100644 --- a/go-backend/go.sum +++ b/go-backend/go.sum @@ -1,3 +1,4 @@ +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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= @@ -16,19 +17,34 @@ github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +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/go-backend/internal/db/db.go b/go-backend/internal/db/db.go new file mode 100644 index 0000000..46561df --- /dev/null +++ b/go-backend/internal/db/db.go @@ -0,0 +1,62 @@ +// Package db provides PostgreSQL connection management using pgx. +package db + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Pool wraps a pgx connection pool with lifecycle helpers. +type Pool struct { + *pgxpool.Pool +} + +// New creates a connection pool from a PostgreSQL DSN. +func New(dsn string) (*Pool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parse pgx config: %w", err) + } + + // Sensible defaults + cfg.MaxConns = 20 + cfg.MinConns = 2 + cfg.MaxConnLifetime = 30 * time.Minute + cfg.MaxConnIdleTime = 10 * time.Minute + cfg.HealthCheckPeriod = 5 * time.Second + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("create pgx pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + slog.Info("database connected", "pool", cfg.ConnConfig.Database) + return &Pool{Pool: pool}, nil +} + +// Close shuts down the pool gracefully. +func (p *Pool) Close() { + if p.Pool != nil { + p.Pool.Close() + slog.Info("database pool closed") + } +} + +// Health returns nil if the database is reachable. +func (p *Pool) Health(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + return p.Ping(ctx) +} diff --git a/go-backend/internal/router/router.go b/go-backend/internal/router/router.go index c509731..c5c24cb 100644 --- a/go-backend/internal/router/router.go +++ b/go-backend/internal/router/router.go @@ -6,14 +6,22 @@ import ( "net/http" "time" + "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/db" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" ) +// Dependencies carries the handler and database pool into the router. +type Dependencies struct { + Handler *handler.Handler + DB *db.Pool + CORSOrigin string +} + // New creates a fully-configured chi router with all API routes mounted. -func New(h *handler.Handler) *chi.Mux { +func New(deps *Dependencies) *chi.Mux { r := chi.NewRouter() // ── Global middleware ────────────────────────────────────────────────── @@ -23,9 +31,13 @@ func New(h *handler.Handler) *chi.Mux { r.Use(middleware.Recoverer) r.Use(middleware.Timeout(30 * time.Second)) - // ── CORS — permissive for development ────────────────────────────────── + // ── CORS ─────────────────────────────────────────────────────────────── + corsOrigin := deps.CORSOrigin + if corsOrigin == "" { + corsOrigin = "*" + } r.Use(cors.Handler(cors.Options{ - AllowedOrigins: []string{"*"}, + AllowedOrigins: []string{corsOrigin}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, ExposedHeaders: []string{"Link", "X-Total-Count"}, @@ -33,32 +45,39 @@ func New(h *handler.Handler) *chi.Mux { MaxAge: 300, })) - // ── Health check ─────────────────────────────────────────────────────── + // ── Health check (with DB connectivity probe) ────────────────────────── r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":"ok"}`)) + status := "ok" + if deps.DB != nil { + if err := deps.DB.Health(r.Context()); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + status = "db_unhealthy" + } + } + w.Write([]byte(`{"status":"` + status + `"}`)) }) // ── API v1 routes ────────────────────────────────────────────────────── r.Route("/api", func(api chi.Router) { // Agents CRUD api.Route("/agents", func(agents chi.Router) { - agents.Get("/", h.ListAgents) // GET /api/agents - agents.Post("/", h.CreateAgent) // POST /api/agents - agents.Get("/{id}", h.GetAgent) // GET /api/agents/{id} - agents.Put("/{id}", h.UpdateAgent) // PUT /api/agents/{id} - agents.Delete("/{id}", h.DeleteAgent) // DELETE /api/agents/{id} - agents.Get("/{id}/history", h.AgentHistory) // GET /api/agents/{id}/history + agents.Get("/", deps.Handler.ListAgents) // GET /api/agents + agents.Post("/", deps.Handler.CreateAgent) // POST /api/agents + agents.Get("/{id}", deps.Handler.GetAgent) // GET /api/agents/{id} + agents.Put("/{id}", deps.Handler.UpdateAgent) // PUT /api/agents/{id} + agents.Delete("/{id}", deps.Handler.DeleteAgent) // DELETE /api/agents/{id} + agents.Get("/{id}/history", deps.Handler.AgentHistory) // GET /api/agents/{id}/history }) // Sessions - api.Get("/sessions", h.ListSessions) + api.Get("/sessions", deps.Handler.ListSessions) // Tasks - api.Get("/tasks", h.ListTasks) + api.Get("/tasks", deps.Handler.ListTasks) // Projects - api.Get("/projects", h.ListProjects) + api.Get("/projects", deps.Handler.ListProjects) }) return r