Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions internal/db/migrations/022_deploys_audit.sql
Original file line number Diff line number Diff line change
@@ -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/<admin-prefix>/deploys (admin-only — same
-- prefix-obscurity + email-allowlist gates as /api/v1/<admin-prefix>/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);
156 changes: 156 additions & 0 deletions internal/handlers/deploys_audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package handlers

// deploys_audit.go — GET /api/v1/<admin-prefix>/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/<prefix>/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/<admin-prefix>/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/<admin-prefix>/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,
})
}
Loading