From f2e38bd8024e88144388b041cfffb202c7b68f86 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 13 May 2026 09:12:40 +0530 Subject: [PATCH] =?UTF-8?q?audit:=20deploys=5Faudit=20table=20+=20admin=20?= =?UTF-8?q?endpoint=20=E2=80=94=20answers=20what=20was=20running=20when?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an append-only deploy-identity log so the founder can answer "what image was serving traffic at $TIME on service $X?" — a question /healthz can't answer once a pod has rolled. One row per unique (service, commit_id, image_digest) tuple, written by the binary itself on startup via ON CONFLICT DO NOTHING, served at GET /api/v1//deploys behind the existing RequireAdmin + ADMIN_PATH_PREFIX gates. Migration 022_deploys_audit.sql creates the table + unique index (backs the self-report INSERT's ON CONFLICT) + service+time index (backs the admin endpoint's default sort). The image digest source is the IMAGE_DIGEST env var, populated by k8s via valueFrom.fieldRef on status.containerStatuses[0].imageID — unset (local dev) falls back to the literal "local-build" sentinel so dev boots collapse onto one row instead of being randomly attributed. The /healthz handler is left alone — INSERTs on every kube probe would hammer the platform DB. Self-report fires exactly once per process at startup; the unique index defends against accidental re-fires. Tests: 9 new model tests pin the SQL contract (basic insert, dedup on same tuple, two rows for different digests, buildinfo sentinels → NULL, identity-field validation, service filter, ORDER BY DESC, since filter, invalid service rejected). 8 new handler tests pin the HTTP contract (admin-closed-by-default, non-admin → 403, empty table → [], single-row round-trip, service filter, invalid service/since/limit → 400). 3 new main-package tests pin the IMAGE_DIGEST resolver (unset → local-build, empty → local-build, real value passes through). All 20 new tests green; `make test-unit` all packages green. Worker + provisioner self-reports are out of scope for this PR — those services live in sibling repos (InstaNode-dev/worker, InstaNode-dev/provisioner) and own their own startup paths. The model exposes DeployServiceWorker / DeployServiceProvisioner constants so their startup hooks can call InsertSelfReport against the shared platform DB once they pull this migration through their own RunMigrations pipelines. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/db/migrations/022_deploys_audit.sql | 54 ++++ internal/handlers/deploys_audit.go | 156 +++++++++ internal/handlers/deploys_audit_test.go | 294 +++++++++++++++++ internal/models/deploys_audit.go | 227 ++++++++++++++ internal/models/deploys_audit_test.go | 313 +++++++++++++++++++ internal/router/router.go | 7 + internal/testhelpers/testhelpers.go | 18 ++ main.go | 74 +++++ main_test.go | 44 +++ 9 files changed, 1187 insertions(+) create mode 100644 internal/db/migrations/022_deploys_audit.sql create mode 100644 internal/handlers/deploys_audit.go create mode 100644 internal/handlers/deploys_audit_test.go create mode 100644 internal/models/deploys_audit.go create mode 100644 internal/models/deploys_audit_test.go diff --git a/internal/db/migrations/022_deploys_audit.sql b/internal/db/migrations/022_deploys_audit.sql new file mode 100644 index 00000000..3b0b8bf8 --- /dev/null +++ b/internal/db/migrations/022_deploys_audit.sql @@ -0,0 +1,54 @@ +-- Migration: 022_deploys_audit — append-only audit trail of every distinct +-- (service, commit_id, image_digest) tuple that has actually run on this +-- platform. +-- +-- Why this table exists: /healthz returns the live pod's commit_id + +-- version + build_time, but the moment a Deployment rolls the pod is gone +-- and the previous identity is unrecoverable. `kubectl rollout history` +-- is namespace-scoped, ephemeral, and tells you what was *configured*, +-- not what actually started serving traffic. There is no answer today +-- for "which image was serving /api/v1/resources at 14:00 UTC last +-- Tuesday?". This table answers that question — every binary that boots +-- writes one row the first time it sees itself, and the row stays +-- forever. +-- +-- Self-report contract: on pod startup each service inserts a row keyed +-- on (service, commit_id, image_digest). ON CONFLICT DO NOTHING means +-- the second-and-subsequent boots of the same image are no-ops; the +-- table grows once per *unique* deploy, not once per pod restart. A +-- normal autoscale event that spawns 10 replicas of one image still +-- writes a single row. +-- +-- The unique index backing ON CONFLICT also doubles as the safety belt +-- against a misbehaving probe that calls the insert path more than once +-- per process — duplicates collapse silently rather than bloating the +-- table. +-- +-- Read path: GET /api/v1//deploys (admin-only — same +-- prefix-obscurity + email-allowlist gates as /api/v1//customers). +-- Founders answer support tickets with this view; the dashboard does not +-- consume it. + +CREATE TABLE IF NOT EXISTS deploys_audit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + service TEXT NOT NULL, -- 'api' | 'worker' | 'provisioner' + commit_id TEXT NOT NULL, -- short Git SHA from buildinfo + image_digest TEXT NOT NULL, -- 'sha256:abc...' from k8s status.containerStatuses[].imageID + version TEXT, -- semver / release tag from buildinfo (nullable for un-ldflagged dev builds) + build_time TIMESTAMPTZ, -- RFC-3339 build timestamp from buildinfo (nullable when "unknown") + applied_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- first time this tuple was observed running + migration_version TEXT, -- highest migration filename present at startup (e.g. '022_deploys_audit.sql') + noticed_by TEXT NOT NULL DEFAULT 'self-report' -- 'self-report' (binary inserted on its own startup) | 'admin-import' (operator backfill) +); + +-- Backs the ON CONFLICT clause on the self-report INSERT path. The +-- (service, commit_id, image_digest) triple is the natural identity of +-- "what is running" — same binary on different services is two rows; +-- same binary re-tagged but identical bits (same digest) is one row. +CREATE UNIQUE INDEX IF NOT EXISTS uq_deploys_audit_identity + ON deploys_audit(service, commit_id, image_digest); + +-- Supports the primary read pattern: "show me the last N deploys of +-- service X, newest first." Used by the admin endpoint's default sort. +CREATE INDEX IF NOT EXISTS idx_deploys_audit_service_time + ON deploys_audit(service, applied_at DESC); diff --git a/internal/handlers/deploys_audit.go b/internal/handlers/deploys_audit.go new file mode 100644 index 00000000..519ce4e6 --- /dev/null +++ b/internal/handlers/deploys_audit.go @@ -0,0 +1,156 @@ +package handlers + +// deploys_audit.go — GET /api/v1//deploys. +// +// Answers the founder/operator question: "what binary was running at +// $TIME on service $X?" Reads from the deploys_audit table; one row per +// unique (service, commit_id, image_digest) tuple that has ever booted, +// written by the binary itself on startup (see models.InsertSelfReport +// + main.go's emitDeployAuditSelfReport). +// +// Auth: this handler does NOT implement its own gate. The router only +// registers it under the admin group, which already chains: +// +// middleware.RequireAuth → middleware.RequireAdmin +// +// plus the unguessable-path-prefix obscurity gate (route only registered +// when ADMIN_PATH_PREFIX is set, served under /api/v1//deploys +// not /api/v1/admin/deploys). The OpenAPI spec intentionally omits this +// route — see internal/handlers/openapi.go. +// +// Freshness: every call is a live SQL read. The table is small (one row +// per deploy, not per pod) and the founder hits this endpoint a handful +// of times a day — caching would buy nothing and risk staleness on the +// "which binary is running RIGHT NOW" question this endpoint exists to +// answer. + +import ( + "database/sql" + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "instant.dev/internal/models" +) + +// deploysAuditMaxSinceWindow caps the `since` query parameter to one +// year back. A request for `since=1970-01-01T00:00:00Z` would still be +// answered (the table is small), but bounding the input keeps the +// surface predictable and stops a typo from accidentally scanning a +// pathological history. +const deploysAuditMaxSinceWindow = 365 * 24 * time.Hour + +// DeploysAuditHandler serves GET /api/v1//deploys. +type DeploysAuditHandler struct { + db *sql.DB +} + +// NewDeploysAuditHandler constructs the handler. The only dependency is +// the platform DB — the table this reads is owned by the api repo, so +// every read is local. +func NewDeploysAuditHandler(db *sql.DB) *DeploysAuditHandler { + return &DeploysAuditHandler{db: db} +} + +// deployAuditItem is the JSON shape of one row in the response. Time +// fields are serialized as RFC-3339 UTC for predictable parsing on the +// caller side. Nullable columns surface as null (not empty string) so +// "I never set a version" is distinguishable from `version=""`. +type deployAuditItem struct { + ID string `json:"id"` + Service string `json:"service"` + CommitID string `json:"commit_id"` + ImageDigest string `json:"image_digest"` + Version *string `json:"version"` + BuildTime *string `json:"build_time"` + AppliedAt string `json:"applied_at"` + MigrationVersion *string `json:"migration_version"` + NoticedBy string `json:"noticed_by"` +} + +// List handles GET /api/v1//deploys. +// +// Query params: +// +// service — optional, must be one of {api, worker, provisioner} +// since — optional RFC-3339 timestamp; rows with applied_at >= since +// limit — optional, 1..models.DeployListMaxLimit (default +// models.DeployListDefaultLimit) +// +// Response: { ok: true, deploys: [...] }. Sorted newest-first. +func (h *DeploysAuditHandler) List(c *fiber.Ctx) error { + service := strings.TrimSpace(c.Query("service")) + if service != "" && !models.ValidDeployServices[service] { + return respondError(c, fiber.StatusBadRequest, "invalid_service", + fmt.Sprintf("service must be one of: %s, %s, %s", + models.DeployServiceAPI, models.DeployServiceWorker, models.DeployServiceProvisioner)) + } + + var since time.Time + if raw := strings.TrimSpace(c.Query("since")); raw != "" { + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + return respondError(c, fiber.StatusBadRequest, "invalid_since", + "since must be an RFC-3339 timestamp (e.g. 2026-05-12T14:00:00Z)") + } + if cutoff := time.Now().Add(-deploysAuditMaxSinceWindow); parsed.Before(cutoff) { + return respondError(c, fiber.StatusBadRequest, "since_too_old", + "since must be within the last 365 days") + } + since = parsed.UTC() + } + + limit := models.DeployListDefaultLimit + if raw := strings.TrimSpace(c.Query("limit")); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return respondError(c, fiber.StatusBadRequest, "invalid_limit", + "limit must be a positive integer") + } + limit = n + } + + rows, err := models.ListDeploys(c.Context(), h.db, models.ListDeploysParams{ + Service: service, + Since: since, + Limit: limit, + }) + if err != nil { + slog.Error("admin.deploys_audit.list.failed", "error", err) + return respondError(c, fiber.StatusServiceUnavailable, "db_failed", + "Failed to list deploys") + } + + out := make([]deployAuditItem, 0, len(rows)) + for _, r := range rows { + item := deployAuditItem{ + ID: r.ID.String(), + Service: r.Service, + CommitID: r.CommitID, + ImageDigest: r.ImageDigest, + AppliedAt: r.AppliedAt.UTC().Format(time.RFC3339), + NoticedBy: r.NoticedBy, + } + if r.Version.Valid { + v := r.Version.String + item.Version = &v + } + if r.BuildTime.Valid { + bt := r.BuildTime.Time.UTC().Format(time.RFC3339) + item.BuildTime = &bt + } + if r.MigrationVersion.Valid { + mv := r.MigrationVersion.String + item.MigrationVersion = &mv + } + out = append(out, item) + } + + return c.JSON(fiber.Map{ + "ok": true, + "deploys": out, + }) +} diff --git a/internal/handlers/deploys_audit_test.go b/internal/handlers/deploys_audit_test.go new file mode 100644 index 00000000..fb2d6d59 --- /dev/null +++ b/internal/handlers/deploys_audit_test.go @@ -0,0 +1,294 @@ +package handlers_test + +// deploys_audit_test.go — integration coverage for the +// GET /api/v1//deploys handler. Drives the real handler +// behind a fake-auth shim that injects the JWT email into Fiber locals +// (so we don't have to mint real JWTs in every test), then chains the +// production RequireAdmin middleware. Real DB writes against +// TEST_DATABASE_URL. +// +// What we're asserting: +// 1. RequireAdmin closed-by-default: empty ADMIN_EMAILS rejects every +// caller with 403 + agent_action. +// 2. Non-admin JWT email → 403 even when ADMIN_EMAILS is populated +// with someone else's address. +// 3. Admin caller, empty table → 200 with deploys=[]. +// 4. Admin caller, after one self-report → 200 with one row whose +// service / commit_id / image_digest match. +// 5. service filter narrows the result to one service's rows. +// 6. limit honors the cap on the model side and bounds the response. +// 7. invalid service param → 400. +// 8. invalid since param → 400. + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// deploysAuditAdminEmail / deploysAuditNonAdminEmail are the email +// addresses the fake-auth shim stamps onto Fiber locals so RequireAdmin +// sees a real value. Mirrors the constants in admin_customers_test.go +// but doesn't share them — these tests are co-located with the handler +// they cover, and the constants are intentionally separate so a future +// refactor that splits the test binary doesn't break one and silently +// leave the other in a confusing state. +const ( + deploysAuditAdminEmail = "founder@instanode.dev" + deploysAuditNonAdminEmail = "alice@example.com" +) + +// deploysAuditNeedsDB skips the test if TEST_DATABASE_URL isn't set. +// Mirrors the admin_customers_test.go convention. +func deploysAuditNeedsDB(t *testing.T) (*sql.DB, func()) { + t.Helper() + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("deploys_audit_test: TEST_DATABASE_URL not set — skipping integration test") + } + return testhelpers.SetupTestDB(t) +} + +// deploysAuditApp builds a minimal Fiber app that wires the +// DeploysAuditHandler behind the production RequireAdmin middleware. We +// don't drive router.New (it needs Redis + gRPC); instead we replicate +// just the admin-routes branch that this PR adds. The prefix is fixed +// at "admin" in tests for readability — the prefix-obscurity gate is +// covered separately in admin_path_prefix_test.go. +func deploysAuditApp(t *testing.T, db *sql.DB, callerEmail string) *fiber.App { + t.Helper() + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error", "message": err.Error()}) + }, + }) + + fakeAuth := func(c *fiber.Ctx) error { + if callerEmail != "" { + c.Locals(middleware.LocalKeyEmail, callerEmail) + } + c.Locals(middleware.LocalKeyUserID, uuid.NewString()) + c.Locals(middleware.LocalKeyTeamID, uuid.NewString()) + return c.Next() + } + + h := handlers.NewDeploysAuditHandler(db) + adminGroup := app.Group("/api/v1/admin", fakeAuth, middleware.RequireAdmin()) + adminGroup.Get("/deploys", h.List) + return app +} + +// deploysAuditDoGET performs a GET, parses JSON, and registers a body +// close on cleanup. Returns the status code and the decoded map. +func deploysAuditDoGET(t *testing.T, app *fiber.App, path string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + t.Cleanup(func() { resp.Body.Close() }) + var out map[string]any + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + out = map[string]any{} + } + return resp.StatusCode, out +} + +// seedDeploysAuditRow writes one row directly into deploys_audit so the +// list endpoint has something to return. Using the model here (rather +// than raw SQL) keeps this helper in lockstep with the production write +// path — if InsertSelfReport gains a column the seed function picks it +// up automatically. +func seedDeploysAuditRow(t *testing.T, db *sql.DB, service, commit, digest string) { + t.Helper() + err := models.InsertSelfReport(context.Background(), db, models.SelfReportParams{ + Service: service, + CommitID: commit, + ImageDigest: digest, + Version: "v0.0.0-test", + BuildTime: "2026-05-12T00:00:00Z", + }) + require.NoError(t, err) +} + +// TestDeploysAudit_RequireAdmin_ClosedByDefault — the bedrock invariant: +// empty ADMIN_EMAILS rejects every caller, even one whose JWT carries a +// founder-shaped email. Forgetting to configure the env var must fail +// closed. +func TestDeploysAudit_RequireAdmin_ClosedByDefault(t *testing.T) { + db, cleanup := deploysAuditNeedsDB(t) + defer cleanup() + + t.Setenv(middleware.AdminEmailsEnvVar, "") + app := deploysAuditApp(t, db, deploysAuditAdminEmail) + + status, body := deploysAuditDoGET(t, app, "/api/v1/admin/deploys") + assert.Equal(t, http.StatusForbidden, status, "empty ADMIN_EMAILS must reject") + assert.Equal(t, "forbidden", body["error"]) + aa, _ := body["agent_action"].(string) + assert.Contains(t, aa, "Tell the user this endpoint requires platform-admin access", + "agent_action must be populated on the rejection path") +} + +// TestDeploysAudit_RequireAdmin_NonAdminRejected — ADMIN_EMAILS is set +// but to a different person; the caller's JWT email isn't on the list. +// 403 with the same agent_action shape as the closed-by-default case. +func TestDeploysAudit_RequireAdmin_NonAdminRejected(t *testing.T) { + db, cleanup := deploysAuditNeedsDB(t) + defer cleanup() + + t.Setenv(middleware.AdminEmailsEnvVar, deploysAuditAdminEmail) + app := deploysAuditApp(t, db, deploysAuditNonAdminEmail) + + status, body := deploysAuditDoGET(t, app, "/api/v1/admin/deploys") + assert.Equal(t, http.StatusForbidden, status, + "a JWT email not in ADMIN_EMAILS must be rejected even when the env var is populated") + assert.Equal(t, "forbidden", body["error"]) +} + +// TestDeploysAudit_AdminEmptyTable — happy path with no rows. We still +// expect 200 + a JSON-encodable empty array (not null), because callers +// that iterate over `deploys` shouldn't have to special-case the empty +// case. +func TestDeploysAudit_AdminEmptyTable(t *testing.T) { + db, cleanup := deploysAuditNeedsDB(t) + defer cleanup() + + _, err := db.Exec(`DELETE FROM deploys_audit`) + require.NoError(t, err) + t.Cleanup(func() { db.Exec(`DELETE FROM deploys_audit`) }) + + t.Setenv(middleware.AdminEmailsEnvVar, deploysAuditAdminEmail) + app := deploysAuditApp(t, db, deploysAuditAdminEmail) + + status, body := deploysAuditDoGET(t, app, "/api/v1/admin/deploys") + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + deploys, ok := body["deploys"].([]any) + require.True(t, ok, "deploys field must be present as a JSON array (got: %T)", body["deploys"]) + assert.Empty(t, deploys, "empty table must return an empty array, not null") +} + +// TestDeploysAudit_AdminReadsOneRow — round-trips a single seeded row +// through the handler. Asserts the JSON keys the founder-facing client +// (curl, the in-progress admin dashboard) will rely on. +func TestDeploysAudit_AdminReadsOneRow(t *testing.T) { + db, cleanup := deploysAuditNeedsDB(t) + defer cleanup() + + _, err := db.Exec(`DELETE FROM deploys_audit`) + require.NoError(t, err) + t.Cleanup(func() { db.Exec(`DELETE FROM deploys_audit`) }) + + seedDeploysAuditRow(t, db, models.DeployServiceAPI, "abc1234", "sha256:deadbeef") + + t.Setenv(middleware.AdminEmailsEnvVar, deploysAuditAdminEmail) + app := deploysAuditApp(t, db, deploysAuditAdminEmail) + + status, body := deploysAuditDoGET(t, app, "/api/v1/admin/deploys") + require.Equal(t, http.StatusOK, status) + deploys, ok := body["deploys"].([]any) + require.True(t, ok) + require.Len(t, deploys, 1) + row := deploys[0].(map[string]any) + assert.Equal(t, models.DeployServiceAPI, row["service"]) + assert.Equal(t, "abc1234", row["commit_id"]) + assert.Equal(t, "sha256:deadbeef", row["image_digest"]) + assert.Equal(t, models.DeployNoticedBySelfReport, row["noticed_by"]) + // Nullable fields must serialize as either a string or null — never + // the empty string, which would be ambiguous. + if v := row["version"]; v != nil { + _, isStr := v.(string) + assert.True(t, isStr, "version must be a JSON string or null") + } +} + +// TestDeploysAudit_FilterByService — multi-service rows in the table: +// asking for ?service=api returns only api rows. +func TestDeploysAudit_FilterByService(t *testing.T) { + db, cleanup := deploysAuditNeedsDB(t) + defer cleanup() + + _, err := db.Exec(`DELETE FROM deploys_audit`) + require.NoError(t, err) + t.Cleanup(func() { db.Exec(`DELETE FROM deploys_audit`) }) + + seedDeploysAuditRow(t, db, models.DeployServiceAPI, "c1", "d1") + seedDeploysAuditRow(t, db, models.DeployServiceWorker, "c2", "d2") + seedDeploysAuditRow(t, db, models.DeployServiceProvisioner, "c3", "d3") + + t.Setenv(middleware.AdminEmailsEnvVar, deploysAuditAdminEmail) + app := deploysAuditApp(t, db, deploysAuditAdminEmail) + + status, body := deploysAuditDoGET(t, app, "/api/v1/admin/deploys?service=worker") + require.Equal(t, http.StatusOK, status) + deploys, ok := body["deploys"].([]any) + require.True(t, ok) + require.Len(t, deploys, 1, "service=worker must filter to one row") + row := deploys[0].(map[string]any) + assert.Equal(t, models.DeployServiceWorker, row["service"]) +} + +// TestDeploysAudit_RejectsInvalidService — unknown service value is a +// 400, never a SQL pass-through. +func TestDeploysAudit_RejectsInvalidService(t *testing.T) { + db, cleanup := deploysAuditNeedsDB(t) + defer cleanup() + + t.Setenv(middleware.AdminEmailsEnvVar, deploysAuditAdminEmail) + app := deploysAuditApp(t, db, deploysAuditAdminEmail) + + status, body := deploysAuditDoGET(t, app, "/api/v1/admin/deploys?service=not-real") + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_service", body["error"]) +} + +// TestDeploysAudit_RejectsInvalidSince — non-RFC3339 since param surfaces +// as 400 with a specific error code so the operator knows what to fix. +func TestDeploysAudit_RejectsInvalidSince(t *testing.T) { + db, cleanup := deploysAuditNeedsDB(t) + defer cleanup() + + t.Setenv(middleware.AdminEmailsEnvVar, deploysAuditAdminEmail) + app := deploysAuditApp(t, db, deploysAuditAdminEmail) + + status, body := deploysAuditDoGET(t, app, "/api/v1/admin/deploys?since=yesterday") + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_since", body["error"]) +} + +// TestDeploysAudit_RejectsInvalidLimit — limit must be a positive +// integer. Negative or zero or non-numeric → 400. +func TestDeploysAudit_RejectsInvalidLimit(t *testing.T) { + db, cleanup := deploysAuditNeedsDB(t) + defer cleanup() + + t.Setenv(middleware.AdminEmailsEnvVar, deploysAuditAdminEmail) + app := deploysAuditApp(t, db, deploysAuditAdminEmail) + + for _, raw := range []string{"abc", "0", "-1"} { + status, body := deploysAuditDoGET(t, app, "/api/v1/admin/deploys?limit="+raw) + assert.Equal(t, http.StatusBadRequest, status, "limit=%q must be rejected", raw) + assert.Equal(t, "invalid_limit", body["error"], "limit=%q must surface invalid_limit", raw) + } +} diff --git a/internal/models/deploys_audit.go b/internal/models/deploys_audit.go new file mode 100644 index 00000000..66423fc7 --- /dev/null +++ b/internal/models/deploys_audit.go @@ -0,0 +1,227 @@ +package models + +// deploys_audit.go — append-only deploy-identity log. One row per unique +// (service, commit_id, image_digest) tuple that has ever booted on this +// platform. +// +// Why a dedicated model (not folded into audit_log): audit_log is +// per-team — every row carries a team_id and FKs to teams.id. Deploy +// identity is platform-global; there is no team that "owns" a binary +// roll. We don't want to invent a sentinel team_id for the founder to +// hang these rows from, and we don't want NULLable team_id breaking the +// audit_log invariants. Separate table, separate read path. +// +// Write path: InsertSelfReport, called once at process startup. +// Idempotent via the unique index on (service, commit_id, image_digest). +// +// Read path: ListDeploys, called by the admin endpoint. Service + +// since-timestamp filters are pushed to SQL so the founder can answer +// "what was running yesterday afternoon" with one round-trip. + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// Service identifiers stamped on deploy_audit rows. Hard-coded so the set +// of accepted values is reviewable here, not derived from caller input. +// The admin read endpoint validates against this set before pushing to +// the WHERE clause — never interpolate raw user input into SQL. +const ( + DeployServiceAPI = "api" + DeployServiceWorker = "worker" + DeployServiceProvisioner = "provisioner" +) + +// ValidDeployServices is the closed set used for input validation on the +// admin endpoint. Stored as a map for O(1) lookup. +var ValidDeployServices = map[string]bool{ + DeployServiceAPI: true, + DeployServiceWorker: true, + DeployServiceProvisioner: true, +} + +// NoticedBy enumerates how a row landed in the table. Self-report is the +// common case (the binary inserted itself on boot). Admin-import is for +// historical backfill — an operator filling in rows that pre-date the +// self-report code. The handler does not currently expose admin-import +// writes; the constant is here so the column's value space is documented +// in one place. +const ( + DeployNoticedBySelfReport = "self-report" + DeployNoticedByAdminImport = "admin-import" +) + +// DeployAudit mirrors one row of the deploys_audit table. BuildTime is +// nullable because an un-ldflagged dev build emits the sentinel string +// "unknown" rather than a real RFC-3339 timestamp; the model parses +// "unknown" as nil so JSON responses surface null rather than a parse +// error. +type DeployAudit struct { + ID uuid.UUID + Service string + CommitID string + ImageDigest string + Version sql.NullString + BuildTime sql.NullTime + AppliedAt time.Time + MigrationVersion sql.NullString + NoticedBy string +} + +// SelfReportParams collects the fields that the startup-time insert +// needs. Bundled so callers don't pass an 8-positional argument list, +// and so future fields (e.g. a pod name or k8s namespace) can be added +// without breaking every caller. +type SelfReportParams struct { + Service string + CommitID string + ImageDigest string + Version string + BuildTime string // RFC-3339 from buildinfo, or "unknown" + MigrationVersion string // highest migration filename present, or "" +} + +// buildinfoUnknown is the sentinel string buildinfo emits for an +// un-ldflagged build. Stored as nullable in the DB rather than the +// literal "unknown" so consumers can distinguish "not set" from a real +// value without string-matching. +const buildinfoUnknown = "unknown" + +// InsertSelfReport writes one row keyed on (service, commit_id, +// image_digest). The ON CONFLICT clause makes the call idempotent — a +// pod restart, an autoscale event, or a misfiring probe never produces a +// duplicate row. Returns nil on both fresh-insert and conflict-skip +// paths; the caller treats either as success. +// +// Failures here are non-fatal — the audit row is observability, not a +// correctness gate. main.go logs the error and continues. +func InsertSelfReport(ctx context.Context, db *sql.DB, p SelfReportParams) error { + if strings.TrimSpace(p.Service) == "" { + return fmt.Errorf("models.InsertSelfReport: service is required") + } + if strings.TrimSpace(p.CommitID) == "" { + return fmt.Errorf("models.InsertSelfReport: commit_id is required") + } + if strings.TrimSpace(p.ImageDigest) == "" { + return fmt.Errorf("models.InsertSelfReport: image_digest is required") + } + + var versionArg interface{} + if v := strings.TrimSpace(p.Version); v != "" && v != buildinfoUnknown && v != "dev" { + versionArg = v + } + + var buildTimeArg interface{} + if bt := strings.TrimSpace(p.BuildTime); bt != "" && bt != buildinfoUnknown { + if parsed, err := time.Parse(time.RFC3339, bt); err == nil { + buildTimeArg = parsed.UTC() + } + // Unparseable build_time → NULL. Surface the row with a missing + // timestamp rather than refusing to write it at all; the deploy + // happened either way. + } + + var migArg interface{} + if mv := strings.TrimSpace(p.MigrationVersion); mv != "" { + migArg = mv + } + + _, err := db.ExecContext(ctx, ` + INSERT INTO deploys_audit (service, commit_id, image_digest, version, build_time, migration_version, noticed_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (service, commit_id, image_digest) DO NOTHING + `, p.Service, p.CommitID, p.ImageDigest, versionArg, buildTimeArg, migArg, DeployNoticedBySelfReport) + if err != nil { + return fmt.Errorf("models.InsertSelfReport: %w", err) + } + return nil +} + +// ListDeploysParams collects the filters supported by the admin GET +// endpoint. Zero-values map to "no filter applied" for service / since; +// Limit clamps to deployListMaxLimit on the read side so a caller asking +// for ?limit=1000000 still gets a bounded response. +type ListDeploysParams struct { + Service string // "" → all services; otherwise must be in ValidDeployServices + Since time.Time // zero → no since filter; non-zero → applied_at >= since + Limit int // <= 0 → DeployListDefaultLimit; > DeployListMaxLimit → clamp +} + +// DeployListDefaultLimit / DeployListMaxLimit shape the admin read +// surface. The default is small so an operator browsing a long history +// doesn't pull the whole table; the max cap defends against +// `?limit=999999`. +const ( + DeployListDefaultLimit = 50 + DeployListMaxLimit = 500 +) + +// ListDeploys returns deploy_audit rows newest-first, optionally +// filtered by service and an absolute since-timestamp. Pagination is +// keyed off Limit alone — the table grows slowly (once per unique +// deploy, not per request) so offset-style scrolling is overkill. +// +// Returns an empty slice (never nil) when no rows match, so JSON +// serialization produces `[]` instead of `null`. +func ListDeploys(ctx context.Context, db *sql.DB, p ListDeploysParams) ([]*DeployAudit, error) { + if p.Service != "" && !ValidDeployServices[p.Service] { + return nil, fmt.Errorf("models.ListDeploys: invalid service %q", p.Service) + } + + limit := p.Limit + if limit <= 0 { + limit = DeployListDefaultLimit + } + if limit > DeployListMaxLimit { + limit = DeployListMaxLimit + } + + args := []interface{}{} + whereParts := []string{"1=1"} + if p.Service != "" { + args = append(args, p.Service) + whereParts = append(whereParts, fmt.Sprintf("service = $%d", len(args))) + } + if !p.Since.IsZero() { + args = append(args, p.Since.UTC()) + whereParts = append(whereParts, fmt.Sprintf("applied_at >= $%d", len(args))) + } + args = append(args, limit) + + query := fmt.Sprintf(` + SELECT id, service, commit_id, image_digest, version, build_time, + applied_at, migration_version, noticed_by + FROM deploys_audit + WHERE %s + ORDER BY applied_at DESC + LIMIT $%d + `, strings.Join(whereParts, " AND "), len(args)) + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("models.ListDeploys: query: %w", err) + } + defer rows.Close() + + out := make([]*DeployAudit, 0, limit) + for rows.Next() { + d := &DeployAudit{} + if err := rows.Scan( + &d.ID, &d.Service, &d.CommitID, &d.ImageDigest, &d.Version, + &d.BuildTime, &d.AppliedAt, &d.MigrationVersion, &d.NoticedBy, + ); err != nil { + return nil, fmt.Errorf("models.ListDeploys: scan: %w", err) + } + out = append(out, d) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("models.ListDeploys: rows: %w", err) + } + return out, nil +} diff --git a/internal/models/deploys_audit_test.go b/internal/models/deploys_audit_test.go new file mode 100644 index 00000000..41aa2a44 --- /dev/null +++ b/internal/models/deploys_audit_test.go @@ -0,0 +1,313 @@ +package models_test + +// deploys_audit_test.go — integration coverage for InsertSelfReport + +// ListDeploys. Drives the real Postgres table created by migration 022 +// (mirrored into testhelpers.runMigrations). +// +// Skips when TEST_DATABASE_URL is unset, mirroring the pattern in +// resource_env_test.go's requireDB. The handler-level test +// (handlers/deploys_audit_test.go) covers the HTTP surface; this file +// pins the SQL contract — the unique-index dedup, the nullable-column +// behavior, the timestamp parsing, and the ORDER BY / LIMIT shape of +// ListDeploys. + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// requireDBDeploys is a local copy of resource_env_test.go's requireDB — +// duplicated rather than imported because Go's _test.go files cannot +// share helpers across files unless they live in the same test binary +// with the same identifier visibility, and the existing helper in +// resource_env_test.go is package-local with a name that's already in +// use within this test binary (different file, same package). The +// behavior is identical. +func requireDBDeploys(t *testing.T) { + t.Helper() + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("TEST_DATABASE_URL not set; skipping integration test") + } +} + +// TestInsertSelfReport_BasicInsert — the happy path: a fresh row is +// written, all fields land in the DB, and the row is queryable via +// ListDeploys. This is the precondition for every dedup / filter test +// below. +func TestInsertSelfReport_BasicInsert(t *testing.T) { + requireDBDeploys(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + + ctx := context.Background() + _, _ = db.ExecContext(ctx, `DELETE FROM deploys_audit`) + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM deploys_audit`) }) + + err := models.InsertSelfReport(ctx, db, models.SelfReportParams{ + Service: models.DeployServiceAPI, + CommitID: "abc1234", + ImageDigest: "sha256:deadbeef", + Version: "v5.1.0", + BuildTime: "2026-05-12T16:00:00Z", + }) + require.NoError(t, err, "self-report on a fresh tuple must succeed") + + rows, err := models.ListDeploys(ctx, db, models.ListDeploysParams{ + Service: models.DeployServiceAPI, + }) + require.NoError(t, err) + require.Len(t, rows, 1, "exactly one row must come back for the inserted tuple") + got := rows[0] + assert.Equal(t, models.DeployServiceAPI, got.Service) + assert.Equal(t, "abc1234", got.CommitID) + assert.Equal(t, "sha256:deadbeef", got.ImageDigest) + require.True(t, got.Version.Valid) + assert.Equal(t, "v5.1.0", got.Version.String) + require.True(t, got.BuildTime.Valid) + assert.Equal(t, models.DeployNoticedBySelfReport, got.NoticedBy) +} + +// TestInsertSelfReport_IdempotentSameTuple — the central correctness +// property of the table: two startups of the same image produce exactly +// one row. This is what makes the table grow with deploys, not with +// pod-restarts. +func TestInsertSelfReport_IdempotentSameTuple(t *testing.T) { + requireDBDeploys(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + + ctx := context.Background() + _, _ = db.ExecContext(ctx, `DELETE FROM deploys_audit`) + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM deploys_audit`) }) + + p := models.SelfReportParams{ + Service: models.DeployServiceAPI, + CommitID: "samecommit", + ImageDigest: "sha256:samedigest", + Version: "v1.0.0", + BuildTime: "2026-05-12T16:00:00Z", + } + for i := 0; i < 3; i++ { + require.NoError(t, models.InsertSelfReport(ctx, db, p), + "insert %d must succeed — ON CONFLICT DO NOTHING is not an error", i) + } + + rows, err := models.ListDeploys(ctx, db, models.ListDeploysParams{ + Service: models.DeployServiceAPI, + }) + require.NoError(t, err) + assert.Len(t, rows, 1, + "three boots of the same image must collapse to one row via the unique index") +} + +// TestInsertSelfReport_DifferentDigestsDifferentRows — the dual of +// IdempotentSameTuple: when the digest changes (a new deploy), a new +// row appears. Same commit, different digest = two rows (the operator +// may have rebuilt without re-tagging; we still want to log it). +func TestInsertSelfReport_DifferentDigestsDifferentRows(t *testing.T) { + requireDBDeploys(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + + ctx := context.Background() + _, _ = db.ExecContext(ctx, `DELETE FROM deploys_audit`) + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM deploys_audit`) }) + + require.NoError(t, models.InsertSelfReport(ctx, db, models.SelfReportParams{ + Service: models.DeployServiceAPI, + CommitID: "commit-A", + ImageDigest: "sha256:digestA", + })) + require.NoError(t, models.InsertSelfReport(ctx, db, models.SelfReportParams{ + Service: models.DeployServiceAPI, + CommitID: "commit-B", + ImageDigest: "sha256:digestB", + })) + + rows, err := models.ListDeploys(ctx, db, models.ListDeploysParams{ + Service: models.DeployServiceAPI, + }) + require.NoError(t, err) + assert.Len(t, rows, 2, "two distinct (commit, digest) tuples = two rows") +} + +// TestInsertSelfReport_BuildinfoSentinelsBecomeNull — buildinfo emits +// "dev" / "unknown" for un-ldflagged builds. The model parses these as +// NULL so the JSON response surfaces `null` rather than the literal +// sentinel — operators reading the dashboard should see "no version +// recorded" instead of being misled into thinking "dev" is a real +// release. +func TestInsertSelfReport_BuildinfoSentinelsBecomeNull(t *testing.T) { + requireDBDeploys(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + + ctx := context.Background() + _, _ = db.ExecContext(ctx, `DELETE FROM deploys_audit`) + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM deploys_audit`) }) + + require.NoError(t, models.InsertSelfReport(ctx, db, models.SelfReportParams{ + Service: models.DeployServiceAPI, + CommitID: "dev-commit", + ImageDigest: "local-build", + Version: "dev", // buildinfo default → NULL + BuildTime: "unknown", // buildinfo default → NULL + })) + + rows, err := models.ListDeploys(ctx, db, models.ListDeploysParams{Service: models.DeployServiceAPI}) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.False(t, rows[0].Version.Valid, `"dev" must be stored as NULL, not the literal string`) + assert.False(t, rows[0].BuildTime.Valid, `"unknown" must be stored as NULL, not as a parse error`) +} + +// TestInsertSelfReport_RequiresIdentityFields — the three columns that +// back the unique index must be non-empty. Empty inputs are a caller +// bug (the startup hook should always have at least a service name and +// the buildinfo-stamped commit) — surface them as model errors rather +// than letting a row with empty strings sneak into the table. +func TestInsertSelfReport_RequiresIdentityFields(t *testing.T) { + requireDBDeploys(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + + ctx := context.Background() + + cases := []struct { + name string + p models.SelfReportParams + }{ + {"empty service", models.SelfReportParams{CommitID: "c", ImageDigest: "d"}}, + {"empty commit", models.SelfReportParams{Service: "api", ImageDigest: "d"}}, + {"empty digest", models.SelfReportParams{Service: "api", CommitID: "c"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := models.InsertSelfReport(ctx, db, tc.p) + assert.Error(t, err, "%s must reject", tc.name) + }) + } +} + +// TestListDeploys_FilterByService — multi-service rows in one table +// must not bleed into each other. An admin asking for ?service=worker +// gets only the worker's rows; the API's rows stay hidden. +func TestListDeploys_FilterByService(t *testing.T) { + requireDBDeploys(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + + ctx := context.Background() + _, _ = db.ExecContext(ctx, `DELETE FROM deploys_audit`) + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM deploys_audit`) }) + + require.NoError(t, models.InsertSelfReport(ctx, db, models.SelfReportParams{ + Service: models.DeployServiceAPI, CommitID: "c1", ImageDigest: "d1", + })) + require.NoError(t, models.InsertSelfReport(ctx, db, models.SelfReportParams{ + Service: models.DeployServiceWorker, CommitID: "c2", ImageDigest: "d2", + })) + require.NoError(t, models.InsertSelfReport(ctx, db, models.SelfReportParams{ + Service: models.DeployServiceProvisioner, CommitID: "c3", ImageDigest: "d3", + })) + + apiRows, err := models.ListDeploys(ctx, db, models.ListDeploysParams{Service: models.DeployServiceAPI}) + require.NoError(t, err) + require.Len(t, apiRows, 1) + assert.Equal(t, models.DeployServiceAPI, apiRows[0].Service) + + allRows, err := models.ListDeploys(ctx, db, models.ListDeploysParams{}) + require.NoError(t, err) + assert.Len(t, allRows, 3, "no filter = all services") +} + +// TestListDeploys_OrderByAppliedAtDesc — the read shape the admin +// endpoint depends on: newest first. We force two rows with known +// applied_at by post-update so the in-process clock skew doesn't make +// the assertion flaky. +func TestListDeploys_OrderByAppliedAtDesc(t *testing.T) { + requireDBDeploys(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + + ctx := context.Background() + _, _ = db.ExecContext(ctx, `DELETE FROM deploys_audit`) + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM deploys_audit`) }) + + require.NoError(t, models.InsertSelfReport(ctx, db, models.SelfReportParams{ + Service: models.DeployServiceAPI, CommitID: "old", ImageDigest: "old-digest", + })) + require.NoError(t, models.InsertSelfReport(ctx, db, models.SelfReportParams{ + Service: models.DeployServiceAPI, CommitID: "new", ImageDigest: "new-digest", + })) + + // Force a deterministic gap: backdate "old" by an hour. Otherwise + // both inserts hit `now()` in the same millisecond and the ORDER + // BY is non-deterministic. + _, err := db.ExecContext(ctx, + `UPDATE deploys_audit SET applied_at = $1 WHERE commit_id = 'old'`, + time.Now().Add(-1*time.Hour).UTC(), + ) + require.NoError(t, err) + + rows, err := models.ListDeploys(ctx, db, models.ListDeploysParams{Service: models.DeployServiceAPI}) + require.NoError(t, err) + require.Len(t, rows, 2) + assert.Equal(t, "new", rows[0].CommitID, "newest row must come first") + assert.Equal(t, "old", rows[1].CommitID) +} + +// TestListDeploys_SinceFilter — operators ask "what was running after +// 14:00 yesterday?" The since filter pushes the cutoff into the WHERE +// clause so the response is bounded by SQL, not the read-side limit. +func TestListDeploys_SinceFilter(t *testing.T) { + requireDBDeploys(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + + ctx := context.Background() + _, _ = db.ExecContext(ctx, `DELETE FROM deploys_audit`) + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM deploys_audit`) }) + + require.NoError(t, models.InsertSelfReport(ctx, db, models.SelfReportParams{ + Service: models.DeployServiceAPI, CommitID: "old", ImageDigest: "old-digest", + })) + require.NoError(t, models.InsertSelfReport(ctx, db, models.SelfReportParams{ + Service: models.DeployServiceAPI, CommitID: "new", ImageDigest: "new-digest", + })) + _, err := db.ExecContext(ctx, + `UPDATE deploys_audit SET applied_at = $1 WHERE commit_id = 'old'`, + time.Now().Add(-2*time.Hour).UTC(), + ) + require.NoError(t, err) + + rows, err := models.ListDeploys(ctx, db, models.ListDeploysParams{ + Service: models.DeployServiceAPI, + Since: time.Now().Add(-1 * time.Hour), + }) + require.NoError(t, err) + require.Len(t, rows, 1, "since=now-1h must exclude the 2h-old row") + assert.Equal(t, "new", rows[0].CommitID) +} + +// TestListDeploys_RejectsInvalidService — the admin endpoint's input +// validator hands a service value through to the model. Anything not +// in ValidDeployServices is a 400, not a SQL injection. +func TestListDeploys_RejectsInvalidService(t *testing.T) { + requireDBDeploys(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + + _, err := models.ListDeploys(context.Background(), db, models.ListDeploysParams{ + Service: "not-a-real-service", + }) + assert.Error(t, err, "unknown service must be rejected before reaching SQL") +} diff --git a/internal/router/router.go b/internal/router/router.go index 8fc65b75..9bf680d2 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -456,6 +456,13 @@ func New(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *middleware.G adminGroup.Get("/customers/:team_id", adminCustH.Detail) adminGroup.Post("/customers/:team_id/tier", adminCustH.ChangeTier) adminGroup.Post("/customers/:team_id/promo", adminCustH.IssuePromo) + + // GET /api/v1//deploys — append-only deploy-identity log. + // Answers "which binary was serving traffic at $TIME?" — see + // internal/handlers/deploys_audit.go and migration 022 for the + // table shape and self-report contract. + deploysAuditH := handlers.NewDeploysAuditHandler(db) + adminGroup.Get("/deploys", deploysAuditH.List) } // Quota-wall nudge endpoint — Track U1. Returns the most recent diff --git a/internal/testhelpers/testhelpers.go b/internal/testhelpers/testhelpers.go index d745ad71..94d3ff7a 100644 --- a/internal/testhelpers/testhelpers.go +++ b/internal/testhelpers/testhelpers.go @@ -278,6 +278,24 @@ func runMigrations(t *testing.T, db *sql.DB) { )`, `CREATE INDEX IF NOT EXISTS idx_admin_promo_codes_code ON admin_promo_codes(code) WHERE used_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_admin_promo_codes_team ON admin_promo_codes(team_id)`, + // 022_deploys_audit — append-only deploy-identity log. Mirrored here + // so handler tests bringing up a fresh test DB get the table without + // running the SQL migration separately. The unique index backs the + // self-report INSERT's ON CONFLICT clause; the service+time index + // supports the admin endpoint's default sort. + `CREATE TABLE IF NOT EXISTS deploys_audit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + service TEXT NOT NULL, + commit_id TEXT NOT NULL, + image_digest TEXT NOT NULL, + version TEXT, + build_time TIMESTAMPTZ, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now(), + migration_version TEXT, + noticed_by TEXT NOT NULL DEFAULT 'self-report' + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS uq_deploys_audit_identity ON deploys_audit(service, commit_id, image_digest)`, + `CREATE INDEX IF NOT EXISTS idx_deploys_audit_service_time ON deploys_audit(service, applied_at DESC)`, } for _, s := range stmts { diff --git a/main.go b/main.go index 1dccf7c8..4ed06453 100644 --- a/main.go +++ b/main.go @@ -7,8 +7,11 @@ package main import ( "context" + "database/sql" "log/slog" "os" + "strings" + "time" "github.com/newrelic/go-agent/v3/newrelic" "google.golang.org/grpc" @@ -18,6 +21,7 @@ import ( "instant.dev/internal/db" "instant.dev/internal/email" "instant.dev/internal/middleware" + "instant.dev/internal/models" "instant.dev/internal/plans" "instant.dev/internal/provisioner" "instant.dev/internal/router" @@ -69,6 +73,14 @@ func main() { os.Exit(1) } + // Deploy-audit self-report. Idempotent on (service, commit_id, + // image_digest) — every pod startup of the same image is a no-op + // at the DB level, so a 10-replica autoscale or a routine restart + // writes at most one row. Failures here are non-fatal: this is + // observability, not a correctness gate, and a DB hiccup on boot + // must not stop the server from listening. + emitDeployAuditSelfReport(database) + rdb := db.ConnectRedis(cfg.RedisURL) defer rdb.Close() @@ -158,3 +170,65 @@ func initNewRelic(service string) *newrelic.Application { slog.Info("newrelic.initialized", "app_name", appName) return app } + +// imageDigestEnvVar names the env var Kubernetes populates via +// `valueFrom.fieldRef: fieldPath: status.containerStatuses[0].imageID`. +// The Deployment spec for the api service wires this in so the pod +// learns its own image digest at boot. Unset → local-build fallback so +// `make run` doesn't have to fake a sha256 string. +const imageDigestEnvVar = "IMAGE_DIGEST" + +// imageDigestFallback is what we record when IMAGE_DIGEST is not in the +// environment. Treated as a normal value at the DB layer — the unique +// index works fine on the literal string. The point is that local +// dev / CI / smoke-test boots all collapse onto one row instead of +// being randomly attributed. +const imageDigestFallback = "local-build" + +// resolveImageDigest returns the value of the IMAGE_DIGEST env var with +// surrounding whitespace trimmed, or imageDigestFallback if the var is +// unset or empty. Extracted as a pure function so unit tests can pin the +// "unset → local-build" contract without spinning up a real DB. +func resolveImageDigest() string { + if v := strings.TrimSpace(os.Getenv(imageDigestEnvVar)); v != "" { + return v + } + return imageDigestFallback +} + +// emitDeployAuditSelfReport writes one row to deploys_audit reporting +// the running binary's identity (service name + commit + image digest + +// version + build time). Idempotent via the table's ON CONFLICT clause: +// the first pod of a given image writes the row, every subsequent pod +// of the same image is a no-op. +// +// Best-effort: a DB error here is logged at WARN and swallowed. The +// audit row is observability — it must never block startup. +// +// The "migration_version" column is left empty here; the value would +// have to come from peeking at the embedded migration FS at boot. We +// can populate it in a follow-up if we ever need it operationally. +// Right now the (service, commit, digest) tuple is enough to answer +// "what was running." +func emitDeployAuditSelfReport(database *sql.DB) { + digest := resolveImageDigest() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := models.InsertSelfReport(ctx, database, models.SelfReportParams{ + Service: "api", + CommitID: buildinfo.GitSHA, + ImageDigest: digest, + Version: buildinfo.Version, + BuildTime: buildinfo.BuildTime, + }); err != nil { + slog.Warn("deploys_audit.self_report_failed", "error", err, + "service", "api", "commit_id", buildinfo.GitSHA, "image_digest", digest) + return + } + slog.Info("deploys_audit.self_report", + "service", "api", "commit_id", buildinfo.GitSHA, "image_digest", digest, + "version", buildinfo.Version, "build_time", buildinfo.BuildTime) +} + diff --git a/main_test.go b/main_test.go index cc795fac..3c9410bc 100644 --- a/main_test.go +++ b/main_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -20,3 +21,46 @@ func TestInitNewRelic_FailOpenOnEmptyLicense(t *testing.T) { app := initNewRelic("api") require.Nil(t, app, "initNewRelic must return nil when NEW_RELIC_LICENSE_KEY is empty (fail-open contract)") } + +// TestResolveImageDigest_UnsetFallsBackToLocalBuild pins the contract +// the spec calls out as test case 8: when k8s hasn't populated +// IMAGE_DIGEST (`make run`, `go test`, smoke binaries) the recorded +// digest is the fixed sentinel "local-build" rather than an empty +// string. Empty strings would still satisfy the table's NOT NULL but +// would collide with the unique index in confusing ways once two +// different un-ldflagged commits boot — the sentinel makes the local- +// dev case visibly distinct in the admin endpoint's output. +func TestResolveImageDigest_UnsetFallsBackToLocalBuild(t *testing.T) { + prev, hadPrev := os.LookupEnv(imageDigestEnvVar) + t.Cleanup(func() { + if hadPrev { + _ = os.Setenv(imageDigestEnvVar, prev) + } else { + _ = os.Unsetenv(imageDigestEnvVar) + } + }) + require.NoError(t, os.Unsetenv(imageDigestEnvVar)) + + assert.Equal(t, imageDigestFallback, resolveImageDigest(), + `unset IMAGE_DIGEST must resolve to "local-build" — the fixed sentinel`) +} + +// TestResolveImageDigest_EmptyStringFallsBack — the env var being set +// but empty is the same as being unset. Catches the k8s-misconfig case +// where the fieldRef returns "" because the pod hasn't entered Running +// yet but the env injection happens before health-check gating. +func TestResolveImageDigest_EmptyStringFallsBack(t *testing.T) { + t.Setenv(imageDigestEnvVar, "") + assert.Equal(t, imageDigestFallback, resolveImageDigest(), + "empty IMAGE_DIGEST must resolve to the fallback (whitespace-trimmed)") +} + +// TestResolveImageDigest_RealValuePassesThrough — the happy path: when +// k8s gives us a real digest, we don't second-guess it. Whitespace is +// trimmed because the fieldRef path can leak a trailing newline through +// some operator pipelines. +func TestResolveImageDigest_RealValuePassesThrough(t *testing.T) { + t.Setenv(imageDigestEnvVar, " sha256:deadbeef ") + assert.Equal(t, "sha256:deadbeef", resolveImageDigest(), + "real digest values are passed through, with surrounding whitespace trimmed") +}