diff --git a/go-services/docker-compose.go.yml b/go-services/docker-compose.go.yml index c699f522..c285d4cf 100644 --- a/go-services/docker-compose.go.yml +++ b/go-services/docker-compose.go.yml @@ -124,5 +124,33 @@ services: max-size: "10m" max-file: "3" + # Go port of inventory-service. Phase A: read side, READ-ONLY against the + # production inventory_db, validated vs the Python service. Loopback :8772. + inventory-go: + build: + context: ./go-services/inventory-go + args: + BUILD_VERSION: ${BUILD_VERSION:-dev} + image: inventory-go:local + container_name: inventory-go + ports: + - "127.0.0.1:8772:8772" + environment: + PORT: "8772" + DATABASE_URL: "postgresql://inventory_user:${INVENTORY_DB_PASSWORD}@inventory-db:5432/inventory_db" + READ_ONLY: "true" + ENUM_DB_PATH: "/enums/comprehensive_enum_database_v2.json" + LOG_LEVEL: "INFO" + volumes: + - ./inventory-service/comprehensive_enum_database_v2.json:/enums/comprehensive_enum_database_v2.json:ro + depends_on: + - inventory-db + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + volumes: dereth-go-data: diff --git a/go-services/inventory-go/Dockerfile b/go-services/inventory-go/Dockerfile new file mode 100644 index 00000000..67c15ee3 --- /dev/null +++ b/go-services/inventory-go/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.25-bookworm AS build +WORKDIR /src +COPY . . +RUN go mod tidy +ARG BUILD_VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath -ldflags "-s -w -X main.buildVersion=${BUILD_VERSION}" -o /out/inventory-go . + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/inventory-go /inventory-go +EXPOSE 8772 +ENTRYPOINT ["/inventory-go"] diff --git a/go-services/inventory-go/go.mod b/go-services/inventory-go/go.mod new file mode 100644 index 00000000..e0615528 --- /dev/null +++ b/go-services/inventory-go/go.mod @@ -0,0 +1,5 @@ +module git.snakedesert.se/SawatoMosswartsEnjoyersClub/MosswartOverlord/go-services/inventory-go + +go 1.25 + +require github.com/jackc/pgx/v5 v5.10.0 diff --git a/go-services/inventory-go/main.go b/go-services/inventory-go/main.go new file mode 100644 index 00000000..c1f364ca --- /dev/null +++ b/go-services/inventory-go/main.go @@ -0,0 +1,202 @@ +// Command inventory-go is a Go reimplementation of the MosswartOverlord +// inventory-service (FastAPI), deployed in parallel for comparison. +// +// Phase A: read side. Connects READ-ONLY to the existing inventory_db and +// reimplements the read endpoints, validated against the Python service on the +// same data. The heavy item-processing ingestion and the suitbuilder solver +// follow in later phases. +package main + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var buildVersion = "dev" + +type Server struct { + pool *pgxpool.Pool + attributeSets map[string]string // AttributeSetInfo: set-id -> set name + log *slog.Logger +} + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + slog.SetDefault(logger) + + addr := ":" + envOr("PORT", "8772") + dsn := os.Getenv("DATABASE_URL") + enumPath := envOr("ENUM_DB_PATH", "comprehensive_enum_database_v2.json") + readOnly := envOr("READ_ONLY", "true") != "false" + + logger.Info("starting inventory-go", "version", buildVersion, "addr", addr, "read_only", readOnly) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + srv := &Server{log: logger, attributeSets: map[string]string{}} + + if sets, err := loadAttributeSets(enumPath); err != nil { + logger.Warn("could not load enum AttributeSetInfo (set names will be unknown)", "err", err, "path", enumPath) + } else { + srv.attributeSets = sets + logger.Info("loaded enum AttributeSetInfo", "sets", len(sets)) + } + + if dsn == "" { + logger.Error("DATABASE_URL is required") + os.Exit(1) + } + connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + pool, err := newPool(connectCtx, dsn, readOnly) + cancel() + if err != nil { + logger.Error("db pool init failed", "err", err) + os.Exit(1) + } + defer pool.Close() + srv.pool = pool + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", srv.handleHealth) + mux.HandleFunc("GET /sets/list", srv.handleSetsList) + mux.HandleFunc("GET /characters/list", srv.handleCharactersList) + + httpSrv := &http.Server{Addr: addr, Handler: withLogging(mux), ReadHeaderTimeout: 10 * time.Second} + go func() { + if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("http server failed", "err", err) + os.Exit(1) + } + }() + logger.Info("listening", "addr", addr) + + <-ctx.Done() + shutdownCtx, c := context.WithTimeout(context.Background(), 10*time.Second) + defer c() + _ = httpSrv.Shutdown(shutdownCtx) + logger.Info("stopped") +} + +// GET /health (main.py:2674) +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + dbOK := s.pool.Ping(ctx) == nil + status := "healthy" + if !dbOK { + status = "degraded" + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": status, + "timestamp": pyISO(time.Now()), + "database_connected": dbOK, + "version": "1.0.0", + }) +} + +// GET /sets/list (main.py:2712) +func (s *Server) handleSetsList(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + rows, err := queryRowsAsMaps(ctx, s.pool, ` + SELECT enh.item_set, COUNT(*) AS item_count + FROM item_enhancements enh + WHERE enh.item_set IS NOT NULL AND enh.item_set != '' + GROUP BY enh.item_set + ORDER BY item_count DESC, enh.item_set`) + if err != nil { + s.dbErr(w, "sets/list", err) + return + } + sets := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + setID := toStr(row["item_set"]) + name, ok := s.attributeSets[setID] + if !ok { + name = "Unknown Set " + setID + } + sets = append(sets, map[string]any{"id": setID, "name": name, "item_count": row["item_count"]}) + } + writeJSON(w, http.StatusOK, map[string]any{"sets": sets}) +} + +// GET /characters/list (main.py:4291) +func (s *Server) handleCharactersList(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + rows, err := queryRowsAsMaps(ctx, s.pool, ` + SELECT character_name, COUNT(*) AS item_count, MAX(timestamp) AS last_updated + FROM items GROUP BY character_name ORDER BY character_name`) + if err != nil { + s.dbErr(w, "characters/list", err) + return + } + formatTimes(rows, "last_updated") + chars := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + chars = append(chars, map[string]any{ + "character_name": row["character_name"], + "item_count": row["item_count"], + "last_updated": row["last_updated"], + }) + } + writeJSON(w, http.StatusOK, map[string]any{"characters": chars, "total_characters": len(chars)}) +} + +func (s *Server) dbErr(w http.ResponseWriter, where string, err error) { + s.log.Error("db query failed", "where", where, "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Internal server error"}) +} + +// loadAttributeSets reads the comprehensive enum DB and extracts AttributeSetInfo +// (set-id -> name), mirroring load_comprehensive_enums (dictionaries first, then +// enums). Only the slice needed for /sets/list is decoded. +func loadAttributeSets(path string) (map[string]string, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var db struct { + Dictionaries map[string]struct { + Values map[string]string `json:"values"` + } `json:"dictionaries"` + Enums map[string]struct { + Values map[string]string `json:"values"` + } `json:"enums"` + } + if err := json.Unmarshal(b, &db); err != nil { + return nil, err + } + if d, ok := db.Dictionaries["AttributeSetInfo"]; ok && len(d.Values) > 0 { + return d.Values, nil + } + if e, ok := db.Enums["AttributeSetInfo"]; ok { + return e.Values, nil + } + return map[string]string{}, nil +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func withLogging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + slog.Info("http", "method", r.Method, "path", r.URL.Path, "dur_ms", time.Since(start).Milliseconds()) + }) +} diff --git a/go-services/inventory-go/store.go b/go-services/inventory-go/store.go new file mode 100644 index 00000000..8ebb1b9e --- /dev/null +++ b/go-services/inventory-go/store.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// newPool creates a pgx pool. When readOnly (the default in parallel mode), every +// connection is forced into read-only transactions so the Go service can never +// mutate the production inventory_db it shares with the Python service. +func newPool(ctx context.Context, dsn string, readOnly bool) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parse DATABASE_URL: %w", err) + } + cfg.MaxConns = 10 + cfg.MaxConnIdleTime = 5 * time.Minute + if readOnly { + cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + _, err := conn.Exec(ctx, "SET default_transaction_read_only = on") + return err + } + } + return pgxpool.NewWithConfig(ctx, cfg) +} + +func queryRowsAsMaps(ctx context.Context, pool *pgxpool.Pool, sql string, args ...any) ([]map[string]any, error) { + rows, err := pool.Query(ctx, sql, args...) + if err != nil { + return nil, err + } + out, err := pgx.CollectRows(rows, pgx.RowToMap) + if err != nil { + return nil, err + } + if out == nil { + out = []map[string]any{} + } + return out, nil +} + +// pyISO mirrors Python datetime.isoformat() for a UTC value (matches FastAPI's +// jsonable_encoder). Note the inventory-service stores naive datetimes (no tz), +// so isoformat has no offset — we format without one. +func pyISO(t time.Time) string { + t = t.UTC() + if t.Nanosecond() == 0 { + return t.Format("2006-01-02T15:04:05") + } + return t.Format("2006-01-02T15:04:05") + fmt.Sprintf(".%06d", t.Nanosecond()/1000) +} + +func formatTimes(rows []map[string]any, keys ...string) { + for _, m := range rows { + for _, k := range keys { + if t, ok := m[k].(time.Time); ok { + m[k] = pyISO(t) + } + } + } +} + +func toStr(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("json encode failed", "err", err) + } +}