diff --git a/internal/handlers/deploy.go b/internal/handlers/deploy.go index 8866a8e9..34abd31a 100644 --- a/internal/handlers/deploy.go +++ b/internal/handlers/deploy.go @@ -34,6 +34,7 @@ import ( "github.com/redis/go-redis/v9" "instant.dev/internal/config" "instant.dev/internal/email" + "instant.dev/internal/metrics" "instant.dev/internal/middleware" "instant.dev/internal/models" "instant.dev/internal/plans" @@ -894,6 +895,112 @@ func (h *DeployHandler) Get(c *fiber.Ctx) error { }) } +// ── GET /api/v1/deployments/:id/events ─────────────────────────────────────── + +// Events handles GET /api/v1/deployments/:id/events — returns the failure +// timeline (and any other deployment_events rows) for a deployment owned by +// the caller's team. +// +// Triggering incident (2026-05-30): the platform's silent-deploy-failure bug +// class left users without any read surface for the autopsy rows that +// deploy_failure_autopsy writes. GET /deploy/:id surfaces only the LATEST +// failure_autopsy row inside the `failure` field of the deployment envelope; +// agents debugging "why is it stuck in building?" need the full chronological +// timeline so they can distinguish a single OOM from a retry storm. +// +// RBAC mirrors GET /deploy/:id exactly: +// - 404 on unknown :id +// - 404 (NOT 403) on cross-team access — never confirm existence of +// deployments owned by a different team +// +// Query params: +// - ?limit=N default 50, max 200, clamped silently +// +// Response (200): { ok, deployment_id, events: [...], count } +// Response (404): canonical envelope with agent_action from codeToAgentAction +// Response (503): canonical envelope on DB failure +// +// Read-only — does not mutate deployment_events; that's the worker's job. +func (h *DeployHandler) Events(c *fiber.Ctx) error { + team, err := h.requireTeam(c) + if err != nil { + // requireTeam already wrote a respondError envelope. + return err + } + + appID := c.Params("id") + if appID == "" { + metrics.DeployEventsQueryTotal.WithLabelValues("invalid").Inc() + return respondError(c, fiber.StatusBadRequest, "invalid_id", + "Deployment id is required") + } + + d, err := models.GetDeploymentByAppID(c.Context(), h.db, appID) + if err != nil { + var notFound *models.ErrDeploymentNotFound + if errors.As(err, ¬Found) { + metrics.DeployEventsQueryTotal.WithLabelValues("not_found").Inc() + return respondError(c, fiber.StatusNotFound, "not_found", "Deployment not found") + } + metrics.DeployEventsQueryTotal.WithLabelValues("error").Inc() + return respondError(c, fiber.StatusServiceUnavailable, "fetch_failed", + "Failed to fetch deployment") + } + + if d.TeamID != team.ID { + // 404 not 403: never confirm the existence of deployments owned by + // other teams. Mirrors the GET /deploy/:id branch above. + metrics.DeployEventsQueryTotal.WithLabelValues("not_found").Inc() + return respondError(c, fiber.StatusNotFound, "not_found", "Deployment not found") + } + + // limit clamp lives in the model so the bounds are enforced in exactly + // one place; the handler just parses the query string. A non-integer or + // negative input falls through to the default (50) — same posture as + // other list endpoints (GET /api/v1/deployments). + limit := models.DeploymentEventsListDefaultLimit + if raw := c.Query("limit"); raw != "" { + if parsed, perr := strconv.Atoi(raw); perr == nil && parsed > 0 { + limit = parsed + } + } + + events, err := models.GetDeploymentEvents(c.Context(), h.db, d.ID, limit) + if err != nil { + metrics.DeployEventsQueryTotal.WithLabelValues("error").Inc() + slog.Error("deploy.events.list_failed", + "deployment_id", d.ID, "app_id", appID, "error", err) + return respondError(c, fiber.StatusServiceUnavailable, "events_query_failed", + "Failed to fetch deployment events") + } + + out := make([]fiber.Map, 0, len(events)) + for _, ev := range events { + row := fiber.Map{ + "kind": ev.Kind, + "reason": ev.Reason, + "event": ev.Event, + "last_lines": ev.LastLines, + "hint": ev.Hint, + "created_at": ev.CreatedAt.UTC().Format(time.RFC3339), + } + if ev.ExitCode.Valid { + row["exit_code"] = ev.ExitCode.Int32 + } else { + row["exit_code"] = nil + } + out = append(out, row) + } + + metrics.DeployEventsQueryTotal.WithLabelValues("ok").Inc() + return c.JSON(fiber.Map{ + "ok": true, + "deployment_id": d.ID, + "events": out, + "count": len(out), + }) +} + // ── GET /deploy/:id/logs ────────────────────────────────────────────────────── // Logs handles GET /deploy/:id/logs — SSE streaming. diff --git a/internal/handlers/deploy_events_endpoint_test.go b/internal/handlers/deploy_events_endpoint_test.go new file mode 100644 index 00000000..8e7157d7 --- /dev/null +++ b/internal/handlers/deploy_events_endpoint_test.go @@ -0,0 +1,531 @@ +package handlers_test + +// deploy_events_endpoint_test.go — coverage for GET /api/v1/deployments/:id/events. +// +// Triggering incident (swarm 2026-05-30): the silent-deploy-failure bug class +// left users with no read surface for the deployment_events autopsy rows the +// worker writes. This endpoint closes that gap. Tests pin: +// +// - happy path: 3 events for a deployment, response is sorted DESC by +// created_at, count + deployment_id + envelope shape match the contract +// - empty timeline: a healthy deployment with zero events returns +// {ok, events:[], count:0} +// - cross-team RBAC: a deployment owned by another team returns 404 (NOT +// 403); the platform must never confirm the existence of deployments +// owned by another team +// - unknown id: 404 with the canonical envelope +// - limit clamp: ?limit=500 returns at most 200 rows +// - unauthenticated request: 401 +// - invalid limit: falls back to default 50 +// +// All tests use the live Fiber test app + a real Postgres test DB so the +// HTTP envelope, route resolution, JWT middleware, and model SQL path are +// exercised end-to-end against the same SQL the production handler issues. + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/plans" + "instant.dev/internal/testhelpers" +) + +// deployEventsResponse mirrors the handler's response shape so the assertions +// can read typed fields instead of poking into map[string]any. +type deployEventsResponse struct { + OK bool `json:"ok"` + DeploymentID string `json:"deployment_id"` + Events []struct { + Kind string `json:"kind"` + Reason string `json:"reason"` + ExitCode *int `json:"exit_code"` + Event string `json:"event"` + LastLines []string `json:"last_lines"` + Hint string `json:"hint"` + CreatedAt string `json:"created_at"` + } `json:"events"` + Count int `json:"count"` +} + +// TestDeployEvents_HappyPath_OrderingAndShape: 3 events seeded with varied +// timestamps; the handler must return them DESC by created_at and the JSON +// envelope must match the contract (ok / deployment_id / events / count). +func TestDeployEvents_HappyPath_OrderingAndShape(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "11111111-1111-1111-1111-111111111111", teamID, "events-happy@example.com") + + depID := uuid.New() + appID := "evh" + uuid.NewString()[:8] + _, err := db.Exec(` + INSERT INTO deployments (id, team_id, app_id, port, tier, status) + VALUES ($1, $2, $3, 8080, 'pro', 'failed') + `, depID, teamID, appID) + require.NoError(t, err) + + now := time.Now().UTC().Truncate(time.Second) + // Mix kinds: the partial unique index on (deployment_id, kind) WHERE + // kind='failure_autopsy' allows only ONE failure_autopsy row per + // deployment. Use 'lifecycle' for the older two and 'failure_autopsy' + // for the most recent so the test exercises both kind label values. + events := []struct { + kind string + reason string + exit any + event string + atOffset time.Duration + }{ + {"lifecycle", "image_pull_failed", nil, "ErrImagePull", -10 * time.Minute}, + {"lifecycle", "kaniko_oom", 137, "OOMKilled", -5 * time.Minute}, + {"failure_autopsy", "CrashLoopBackOff", 1, "CrashLoopBackOff", -1 * time.Minute}, + } + for _, e := range events { + _, err := db.Exec(` + INSERT INTO deployment_events + (deployment_id, kind, reason, exit_code, event, last_lines, hint, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, depID, e.kind, e.reason, e.exit, e.event, `["log-line-a","log-line-b"]`, + "hint for "+e.reason, now.Add(e.atOffset)) + require.NoError(t, err) + } + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments/"+appID+"/events", nil) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.99.0.1") + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body deployEventsResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + + assert.True(t, body.OK) + assert.Equal(t, depID.String(), body.DeploymentID, "deployment_id must echo the canonical UUID") + assert.Equal(t, 3, body.Count) + require.Len(t, body.Events, 3) + + // DESC by created_at — newest first. + assert.Equal(t, "CrashLoopBackOff", body.Events[0].Reason, "newest first") + assert.Equal(t, "kaniko_oom", body.Events[1].Reason) + assert.Equal(t, "image_pull_failed", body.Events[2].Reason) + + // Per-row contract shape. + assert.Equal(t, "failure_autopsy", body.Events[0].Kind, "most-recent row was inserted as failure_autopsy") + assert.Equal(t, "lifecycle", body.Events[1].Kind, "middle row was inserted as lifecycle") + require.NotNil(t, body.Events[0].ExitCode) + assert.Equal(t, 1, *body.Events[0].ExitCode) + assert.Equal(t, []string{"log-line-a", "log-line-b"}, body.Events[0].LastLines) + assert.Equal(t, "CrashLoopBackOff", body.Events[0].Event) + assert.Equal(t, "hint for CrashLoopBackOff", body.Events[0].Hint) + assert.NotEmpty(t, body.Events[0].CreatedAt) + + // Null exit_code surfaces as JSON null (the *int is nil after decode). + assert.Nil(t, body.Events[2].ExitCode, "image_pull_failed has no exit code → null in JSON") +} + +// TestDeployEvents_Empty_ReturnsZeroCountWithEmptyArray: a deployment with no +// events must return an empty array (NOT null) and count:0 so consumers can +// `.events.length` without a nil check. +func TestDeployEvents_Empty_ReturnsZeroCountWithEmptyArray(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "22222222-2222-2222-2222-222222222222", teamID, "events-empty@example.com") + + depID := uuid.New() + appID := "eve" + uuid.NewString()[:8] + _, err := db.Exec(` + INSERT INTO deployments (id, team_id, app_id, port, tier, status) + VALUES ($1, $2, $3, 8080, 'pro', 'healthy') + `, depID, teamID, appID) + require.NoError(t, err) + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments/"+appID+"/events", nil) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.99.0.2") + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Read raw bytes so we can assert events is `[]` (not null / missing). + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(raw), `"events":[]`, "events must be empty array, not null") + assert.Contains(t, string(raw), `"count":0`) + + var body deployEventsResponse + require.NoError(t, json.Unmarshal(raw, &body)) + assert.True(t, body.OK) + assert.Equal(t, 0, body.Count) + assert.NotNil(t, body.Events) + assert.Len(t, body.Events, 0) +} + +// TestDeployEvents_CrossTeam_Returns404: a deployment owned by team A must +// NOT be visible to a signed-in user on team B. The platform returns 404 (NOT +// 403) so existence of cross-team deployments is never confirmed. +func TestDeployEvents_CrossTeam_Returns404(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamA := testhelpers.MustCreateTeamDB(t, db, "pro") + teamB := testhelpers.MustCreateTeamDB(t, db, "pro") + // Caller is in team B. + sessionJWT := testhelpers.MustSignSessionJWT(t, "33333333-3333-3333-3333-333333333333", teamB, "events-crossteam@example.com") + + // Deployment belongs to team A. + depID := uuid.New() + appID := "evc" + uuid.NewString()[:8] + _, err := db.Exec(` + INSERT INTO deployments (id, team_id, app_id, port, tier, status) + VALUES ($1, $2, $3, 8080, 'pro', 'failed') + `, depID, teamA, appID) + require.NoError(t, err) + // Seed an autopsy row so the test would NOT be vacuously 404. + _, err = db.Exec(` + INSERT INTO deployment_events + (deployment_id, kind, reason, exit_code, event, last_lines, hint) + VALUES ($1, 'failure_autopsy', 'kaniko_oom', 137, 'OOMKilled', '[]', 'hint') + `, depID) + require.NoError(t, err) + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments/"+appID+"/events", nil) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.99.0.3") + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode, + "cross-team must return 404, NOT 403 (do not confirm existence)") + + var envelope struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&envelope)) + assert.False(t, envelope.OK) + assert.Equal(t, "not_found", envelope.Error) +} + +// TestDeployEvents_UnknownID_Returns404: 404 with the canonical envelope when +// the :id slug doesn't match any deployment. +func TestDeployEvents_UnknownID_Returns404(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "44444444-4444-4444-4444-444444444444", teamID, "events-unknown@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments/never-existed-slug/events", nil) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.99.0.4") + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode) + + var envelope struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&envelope)) + assert.False(t, envelope.OK) + assert.Equal(t, "not_found", envelope.Error) +} + +// TestDeployEvents_LimitClamp_AboveMaxReturnsAtMost200: ?limit=500 must clamp +// to the documented max (200). Seeds 205 events and asserts the response +// length is exactly 200. +func TestDeployEvents_LimitClamp_AboveMaxReturnsAtMost200(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "55555555-5555-5555-5555-555555555555", teamID, "events-limit@example.com") + + depID := uuid.New() + appID := "evl" + uuid.NewString()[:8] + _, err := db.Exec(` + INSERT INTO deployments (id, team_id, app_id, port, tier, status) + VALUES ($1, $2, $3, 8080, 'pro', 'failed') + `, depID, teamID, appID) + require.NoError(t, err) + + // Seed 205 events so a request for ?limit=500 is forced to clamp to 200. + base := time.Now().UTC().Add(-1 * time.Hour) + for i := 0; i < 205; i++ { + // 'lifecycle' kind: the failure_autopsy partial unique index only + // allows one row per deployment with kind='failure_autopsy', so the + // bulk inserts use the open-ended 'lifecycle' kind. + _, err := db.Exec(` + INSERT INTO deployment_events + (deployment_id, kind, reason, exit_code, event, last_lines, hint, created_at) + VALUES ($1, 'lifecycle', $2, NULL, '', '[]', '', $3) + `, depID, fmt.Sprintf("reason-%d", i), base.Add(time.Duration(i)*time.Second)) + require.NoError(t, err) + } + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments/"+appID+"/events?limit=500", nil) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.99.0.5") + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body deployEventsResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.True(t, body.OK) + assert.Equal(t, 200, body.Count, "limit must clamp to documented max (200)") + assert.Len(t, body.Events, 200) +} + +// TestDeployEvents_Unauthenticated_Returns401: an unauthenticated request must +// be rejected at the middleware boundary before any DB lookup. +func TestDeployEvents_Unauthenticated_Returns401(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments/anyslug/events", nil) + req.Header.Set("X-Forwarded-For", "10.99.0.6") + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +// TestDeployEvents_InvalidLimit_FallsBackToDefault: a non-integer / negative +// ?limit= silently falls back to the default (50). Seeds 60 rows so a "broken" +// limit still returns the default page size. +func TestDeployEvents_InvalidLimit_FallsBackToDefault(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "66666666-6666-6666-6666-666666666666", teamID, "events-invlim@example.com") + + depID := uuid.New() + appID := "evi" + uuid.NewString()[:8] + _, err := db.Exec(` + INSERT INTO deployments (id, team_id, app_id, port, tier, status) + VALUES ($1, $2, $3, 8080, 'pro', 'failed') + `, depID, teamID, appID) + require.NoError(t, err) + base := time.Now().UTC().Add(-1 * time.Hour) + for i := 0; i < 60; i++ { + // 'lifecycle' kind: the failure_autopsy partial unique index only + // allows one row per deployment with kind='failure_autopsy', so the + // bulk inserts use the open-ended 'lifecycle' kind. + _, err := db.Exec(` + INSERT INTO deployment_events + (deployment_id, kind, reason, exit_code, event, last_lines, hint, created_at) + VALUES ($1, 'lifecycle', $2, NULL, '', '[]', '', $3) + `, depID, fmt.Sprintf("reason-%d", i), base.Add(time.Duration(i)*time.Second)) + require.NoError(t, err) + } + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + // Non-integer ?limit should fall through to the default (50), not 500. + req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments/"+appID+"/events?limit=not-a-number", nil) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.99.0.7") + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + var body deployEventsResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, 50, body.Count, "invalid limit must fall back to default (50)") + assert.Len(t, body.Events, 50) +} + +// TestDeployEvents_MidHandler503_FetchFailed: GetDeploymentByAppID returns a +// non-not-found error (DB fault injected) so the handler's "fetch_failed" 503 +// arm (deploy.go ~L945-947) fires. Mirrors TestDeployGet_MidHandler503. +func TestDeployEvents_MidHandler503_FetchFailed(t *testing.T) { + daDeployNeedsDB(t) + seedDB, clean := testhelpers.SetupTestDB(t) + defer clean() + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, seedDB, "pro")) + jwt := testhelpers.MustSignSessionJWT(t, uuid.NewString(), teamID.String(), "deg@example.com") + d := seedInternalDeploy(t, seedDB, teamID, "healthy", map[string]string{}) + + got := daTryDeployFaultStatus(t, "/api/v1/deployments/"+d.AppID+"/events", + http.MethodGet, "", jwt, http.StatusServiceUnavailable) + assert.True(t, got, "expected Events 503 within failAfter sweep "+ + "(covers the GetDeploymentByAppID non-not-found error arm)") +} + +// TestDeployEvents_EventsQueryFailed_ExactErrorCode: the fault-DB sweep above +// (TestDeployEvents_MidHandler503_FetchFailed) accepts any 503 — it can't +// distinguish the `fetch_failed` arm (GetDeploymentByAppID) from the +// `events_query_failed` arm (GetDeploymentEvents). This test pins the LATTER +// by asserting the exact response envelope error code, so the slog.Error + +// respondError at deploy.go ~L970-975 is exercised AND verified. +// +// Strategy: seed a real deployment into the shared test DB so the +// deployments-table SELECT succeeds, then drive the same request through a +// fault-DB sweep — at the failAfter value where the deployments lookup +// passes but the deployment_events lookup fails, the envelope's error field +// must equal "events_query_failed". +func TestDeployEvents_EventsQueryFailed_ExactErrorCode(t *testing.T) { + daDeployNeedsDB(t) + seedDB, clean := testhelpers.SetupTestDB(t) + defer clean() + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, seedDB, "pro")) + jwt := testhelpers.MustSignSessionJWT(t, uuid.NewString(), teamID.String(), + "eq503@example.com") + d := seedInternalDeploy(t, seedDB, teamID, "healthy", map[string]string{}) + + gotExactCode := false + for failAfter := int64(1); failAfter <= 8 && !gotExactCode; failAfter++ { + fdb := openFaultDB(t, failAfter) + fApp := newDeployTestApp(t, fdb) + req := httptest.NewRequest(http.MethodGet, + "/api/v1/deployments/"+d.AppID+"/events", nil) + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("X-Forwarded-For", "10.99.0.8") + resp, err := fApp.Test(req, 5000) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusServiceUnavailable { + continue + } + var envelope struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + if json.Unmarshal(body, &envelope) == nil && + envelope.Error == "events_query_failed" { + gotExactCode = true + } + } + assert.True(t, gotExactCode, + "events_query_failed envelope must surface for at least one failAfter "+ + "value (covers slog.Error + respondError at deploy.go ~L970-975)") +} + +// TestDeployEvents_EmptyAppID_Returns400: the handler defends against an +// empty :id (deploy.go ~L933-936). The router can't produce an empty :id +// directly (Fiber 404s on `/deployments//events`), so this test mounts the +// same handler on a route that has NO :id segment — `c.Params("id")` +// returns "" and the 400 arm fires. +// +// Why we still ship the defensive 400: a future refactor that adds an +// alternate mount (an alias, a sub-mount, an internal call) must not be +// allowed to silently fall through to the DB lookup with an empty key. +func TestDeployEvents_EmptyAppID_Returns400(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, + "88888888-8888-8888-8888-888888888888", teamID, "events-empty-id@example.com") + + cfg := &config.Config{ + JWTSecret: testhelpers.TestJWTSecret, + AESKey: testhelpers.TestAESKeyHex, + } + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, e error) error { + if errors.Is(e, handlers.ErrResponseWritten) { + return nil + } + code := fiber.StatusInternalServerError + if fe, ok := e.(*fiber.Error); ok { + code = fe.Code + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": e.Error()}) + }, + }) + dh := handlers.NewDeployHandler(db, nil, cfg, plans.Default()) + // Mount Events at a route with NO :id parameter so c.Params("id") == "". + // Production routes always supply :id; this exists purely to exercise + // the handler's defensive empty-id arm. + app.Get("/test-empty-id/events", middleware.RequireAuth(cfg), dh.Events) + + req := httptest.NewRequest(http.MethodGet, "/test-empty-id/events", nil) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.99.0.9") + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + + var envelope struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&envelope)) + assert.False(t, envelope.OK) + assert.Equal(t, "invalid_id", envelope.Error) +} diff --git a/internal/handlers/deploy_faultdb_deployasync_test.go b/internal/handlers/deploy_faultdb_deployasync_test.go index 3fe1c0f0..623ed65f 100644 --- a/internal/handlers/deploy_faultdb_deployasync_test.go +++ b/internal/handlers/deploy_faultdb_deployasync_test.go @@ -218,6 +218,7 @@ func newDeployTestApp(t *testing.T, db *sql.DB) *fiber.App { app.Get("/deploy/:id", middleware.RequireAuth(cfg), dh.Get) app.Get("/deploy/:id/logs", middleware.RequireAuth(cfg), dh.Logs) app.Get("/api/v1/deployments", middleware.RequireAuth(cfg), dh.List) + app.Get("/api/v1/deployments/:id/events", middleware.RequireAuth(cfg), dh.Events) app.Patch("/deploy/:id/env", middleware.RequireAuth(cfg), dh.UpdateEnv) app.Delete("/deploy/:id", middleware.RequireAuth(cfg), dh.Delete) app.Post("/deploy/:id/redeploy", middleware.RequireAuth(cfg), dh.Redeploy) @@ -276,6 +277,7 @@ func TestDeploy_RequireTeamError_AllRoutes(t *testing.T) { {app2, http.MethodGet, "/deploy/x", ""}, {app2, http.MethodGet, "/deploy/x/logs", ""}, {app2, http.MethodGet, "/api/v1/deployments", ""}, + {app2, http.MethodGet, "/api/v1/deployments/x/events", ""}, {app2, http.MethodPatch, "/deploy/x/env", `{"env":{"A":"b"}}`}, {app2, http.MethodDelete, "/deploy/x", ""}, {app2, http.MethodPost, "/deploy/x/redeploy", `{"x":1}`}, diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index 04cd2bbf..78649c00 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -136,6 +136,10 @@ var codeToAgentAction = map[string]errorCodeMeta{ // TestCodeToAgentAction_NoOrphans. If a future feature reintroduces // a "tier is genuinely unavailable" surface, re-add the entry here // and emit it from the new site in the same PR. + "events_query_failed": { + AgentAction: "Tell the user the deployment-events read is temporarily unavailable. Retry in 30 seconds; the deploy itself isn't affected. Status: https://instanode.dev/status", + UpgradeURL: "", + }, "rate_limit_exceeded": { AgentAction: "Tell the user they've sent too many requests in a short window. Wait 60 seconds and retry — or upgrade to Pro at https://instanode.dev/pricing for higher limits.", UpgradeURL: "https://instanode.dev/pricing", diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index ed94413f..683ac59c 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -2031,6 +2031,47 @@ const openAPISpec = `{ } } }, + "/api/v1/deployments/{id}/events": { + "get": { + "summary": "List deployment_events rows (failure timeline) for a deployment", + "description": "Returns the deployment_events rows for a deployment owned by the caller's team, ordered by created_at DESC (most recent first). Closes the silent-deploy-failure gap (swarm 2026-05-30): GET /api/v1/deployments/{id} surfaces only the LATEST failure_autopsy row inside the optional 'failure' field; agents debugging a stuck deploy need the full chronological timeline so they can distinguish a single OOM from a retry storm.\n\nEach row carries kind (e.g. 'failure_autopsy'), reason (e.g. 'kaniko_oom', 'image_pull_failed', 'OOMKilled'), exit_code (nullable integer), event (k8s event reason or build error text), last_lines (tail of Kaniko / pod stdout, up to ~200 lines), hint (user-facing remediation copy), and created_at (RFC3339).\n\nRead-only — events are written by the worker (deploy_failure_autopsy + deploy_status_reconcile), never by the api. RBAC mirrors GET /api/v1/deployments/{id} exactly: a cross-team request returns 404 (NOT 403) so the platform never confirms the existence of deployments owned by another team.", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Deployment app_id (the short public token returned by POST /deploy/new, same value GET /api/v1/deployments/{id} accepts)." }, + { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }, "description": "Max rows to return. Default 50, hard cap 200. Values above 200 are silently clamped; values < 1 fall back to the default." } + ], + "responses": { + "200": { + "description": "Events list (may be empty for a healthy / never-failed deployment).", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeploymentEventsResponse" }, + "example": { + "ok": true, + "deployment_id": "b6fcf286-3a8b-4d6e-9e2c-1f9a0c5f8d12", + "events": [ + { + "kind": "failure_autopsy", + "reason": "kaniko_oom", + "exit_code": 137, + "event": "OOMKilled", + "last_lines": ["INFO[0123] Taking snapshot of files...", "fatal: out of memory"], + "hint": "Kaniko ran out of memory during the build. Try a smaller base image, or upgrade your tier for more build RAM.", + "created_at": "2026-05-30T17:42:11Z" + } + ], + "count": 1 + } + } + } + }, + "400": { "description": "invalid_id — empty id in the URL.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "401": { "description": "Unauthorized — missing or invalid bearer token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "404": { "description": "not_found — deployment id doesn't exist OR belongs to another team. Cross-team requests resolve to 404 (NOT 403) so the platform never confirms the existence of deployments owned by another team.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "503": { "description": "fetch_failed / events_query_failed — transient DB failure during the deployment lookup or the events list. Safe to retry.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + } + } + }, "/api/v1/deployments/{id}/confirm-deletion": { "post": { "summary": "Confirm a pending deletion (paid tiers, Wave FIX-I)", @@ -3066,6 +3107,31 @@ const openAPISpec = `{ "agent_action": { "type": "string", "description": "Wave FIX-J. Verbatim sentence the LLM agent relays to the user. Present on 202 responses when ttl_policy='auto_24h'; tells the user the three routes to keep the deploy permanent." } } }, + "DeploymentEvent": { + "type": "object", + "description": "One row from the deployment_events table — the worker's autopsy / lifecycle record for a deployment. Today's writer is deploy_failure_autopsy + deploy_status_reconcile (kind='failure_autopsy'); the kind field is open-ended so future event types (e.g. 'lifecycle') can be added without breaking the schema.", + "properties": { + "kind": { "type": "string", "description": "Event kind. Today: 'failure_autopsy'. Future kinds may include 'lifecycle'." }, + "reason": { "type": "string", "description": "Short slug describing the failure (e.g. 'kaniko_oom', 'image_pull_failed', 'OOMKilled', 'CrashLoopBackOff'). See models.FailureReason* constants for the closed set used by the failure_autopsy kind." }, + "exit_code": { "type": ["integer", "null"], "description": "Process exit code when known (137 = SIGKILL, often OOM). null when the failure mode has no exit code (image pull failure, etc.)." }, + "event": { "type": "string", "description": "k8s event reason or build error text. Empty string when no upstream event was captured." }, + "last_lines": { "type": "array", "items": { "type": "string" }, "description": "Tail of Kaniko / pod stdout at the moment of failure capture, oldest-first. Up to ~200 lines. Empty array when no log lines were available (pod GC'd before capture)." }, + "hint": { "type": "string", "description": "Plain-language likely cause + suggested remedy. Sourced from models.HintForReason; safe to relay verbatim to the user." }, + "created_at": { "type": "string", "format": "date-time", "description": "When the worker wrote the autopsy row (RFC3339)." } + }, + "required": ["kind", "reason", "exit_code", "event", "last_lines", "hint", "created_at"] + }, + "DeploymentEventsResponse": { + "type": "object", + "description": "Response payload for GET /api/v1/deployments/{id}/events. Events are ordered by created_at DESC (most recent first). The count field is the length of the returned events array, NOT the total number of rows in deployment_events for this deployment — pagination is silent: callers wanting more than 200 rows must accept the cap.", + "properties": { + "ok": { "type": "boolean" }, + "deployment_id": { "type": "string", "format": "uuid", "description": "The deployment's primary key UUID. Resolved from the app_id slug in the URL path." }, + "events": { "type": "array", "items": { "$ref": "#/components/schemas/DeploymentEvent" } }, + "count": { "type": "integer", "description": "Length of the events array. 0 when the deployment has no events yet (healthy / never-failed)." } + }, + "required": ["ok", "deployment_id", "events", "count"] + }, "GitHubConnection": { "type": "object", "description": "One link between a deployment and a GitHub repository. Surfaced by POST/GET /api/v1/deployments/{id}/github. The plaintext webhook_secret is NEVER part of this shape — it is returned exactly once on POST as a sibling field of the connection object.", diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index f4ad0f7a..4edea0ec 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -189,6 +189,28 @@ var ( Help: "Teardown sweeps where compute was destroyed but the row could not be marked 'deleted'", }) + // DeployEventsQueryTotal counts inbound GET /api/v1/deployments/:id/events + // calls, labelled by `result` so on-call can split a real query rate from + // the 404 / 400 noise floor. Used by both agents (debugging a failed + // deploy) and the dashboard's FailureTimeline panel. + // + // result values (closed set, bounded cardinality): + // "ok" — 200, events returned (count may be 0) + // "not_found" — 404, deployment id doesn't exist OR belongs to another team + // "invalid" — 400, malformed id / bad query param + // "error" — 5xx, DB lookup failed + // + // Companion alert (infra repo follow-up): + // "deploy events query error rate" — fires when + // rate(instant_deploy_events_query_total{result="error"}[10m]) / + // rate(instant_deploy_events_query_total[10m]) > 5%. Per rule 25 the + // alert JSON ships with the metric — tracked as a TODO in the PR body + // because the infra repo is a separate review surface. + DeployEventsQueryTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "instant_deploy_events_query_total", + Help: "GET /api/v1/deployments/:id/events calls by result (ok|not_found|invalid|error)", + }, []string{"result"}) + // NatsAuthFailures counts NATS credential-issuance failures from the // common/queueprovider abstraction. MR-P0-5 (NATS per-tenant isolation, // 2026-05-20). A non-zero rate is almost always one of: diff --git a/internal/models/deployment_event.go b/internal/models/deployment_event.go index 0af462ca..aa96cd2f 100644 --- a/internal/models/deployment_event.go +++ b/internal/models/deployment_event.go @@ -167,6 +167,92 @@ type UpsertAutopsyParams struct { Hint string } +// DeploymentEventsListMaxLimit is the hard cap on the number of rows returned +// by GetDeploymentEvents (and therefore by GET /api/v1/deployments/:id/events). +// A caller asking for more is silently clamped — same shape as the per-request +// page-size caps elsewhere in the API. Kept generous (200) so an agent doing a +// single fetch sees the full failure timeline of even the most chatty deploy, +// but low enough that an unbounded ?limit= can't fan out into a multi-MB +// response from a misbehaving client. +const DeploymentEventsListMaxLimit = 200 + +// DeploymentEventsListDefaultLimit is the default page size when ?limit= is +// omitted. Matches the contract documented in CLAUDE.md and in the OpenAPI +// description so an agent reading the spec sees the same number the handler +// applies. +const DeploymentEventsListDefaultLimit = 50 + +// GetDeploymentEvents returns up to `limit` rows from deployment_events for the +// given deployment, ordered by created_at DESC (most recent first). Drives +// GET /api/v1/deployments/:id/events — agents and the dashboard read the full +// failure timeline (not just the most recent autopsy) so they can show +// progression: a kaniko_oom that retried into an image_pull_failed, etc. +// +// limit is clamped to [1, DeploymentEventsListMaxLimit]; values < 1 fall back +// to DeploymentEventsListDefaultLimit. The handler is responsible for any +// caller-facing validation messaging; this layer just enforces the bound. +// +// Returns an empty slice (not nil) when no events exist — the handler can +// serialise [] directly without a nil check, and JSON consumers always see an +// `events` array even on a fresh / never-failed deployment. +func GetDeploymentEvents(ctx context.Context, db *sql.DB, deploymentID uuid.UUID, limit int) ([]*DeploymentEvent, error) { + if limit < 1 { + limit = DeploymentEventsListDefaultLimit + } + if limit > DeploymentEventsListMaxLimit { + limit = DeploymentEventsListMaxLimit + } + + rows, err := db.QueryContext(ctx, ` + SELECT id, deployment_id, kind, reason, exit_code, event, last_lines, hint, created_at + FROM deployment_events + WHERE deployment_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `, deploymentID, limit) + if err != nil { + return nil, fmt.Errorf("models.GetDeploymentEvents: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make([]*DeploymentEvent, 0, limit) + for rows.Next() { + var ( + ev DeploymentEvent + lastLinesRaw []byte + ) + if err := rows.Scan( + &ev.ID, + &ev.DeploymentID, + &ev.Kind, + &ev.Reason, + &ev.ExitCode, + &ev.Event, + &lastLinesRaw, + &ev.Hint, + &ev.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("models.GetDeploymentEvents scan: %w", err) + } + if len(lastLinesRaw) > 0 { + if err := json.Unmarshal(lastLinesRaw, &ev.LastLines); err != nil { + // Defensive: same posture as GetLatestDeploymentAutopsy — + // a corrupt jsonb blob degrades to an empty slice rather + // than 500ing the whole list. + ev.LastLines = nil + } + } + if ev.LastLines == nil { + ev.LastLines = []string{} + } + out = append(out, &ev) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("models.GetDeploymentEvents rows: %w", err) + } + return out, nil +} + // UpsertDeploymentAutopsy inserts or updates the failure autopsy row. func UpsertDeploymentAutopsy(ctx context.Context, db *sql.DB, p UpsertAutopsyParams) error { lastLines := p.LastLines diff --git a/internal/models/deployment_event_list_test.go b/internal/models/deployment_event_list_test.go new file mode 100644 index 00000000..cc8dcc8e --- /dev/null +++ b/internal/models/deployment_event_list_test.go @@ -0,0 +1,168 @@ +package models + +// deployment_event_list_test.go — coverage for GetDeploymentEvents, the +// model-layer DAL behind GET /api/v1/deployments/:id/events. Each branch is +// exercised: limit clamp (default + cap + zero), empty result, happy-path +// ordering, corrupt jsonb fallback, scan error, db error, rows.Err. + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var deploymentEventsCols = []string{ + "id", "deployment_id", "kind", "reason", + "exit_code", "event", "last_lines", "hint", "created_at", +} + +func TestGetDeploymentEvents_LimitClamp(t *testing.T) { + // limit < 1 → default 50. + db, mock := newMock(t) + mock.ExpectQuery(`FROM deployment_events`). + WithArgs(sqlmock.AnyArg(), DeploymentEventsListDefaultLimit). + WillReturnRows(sqlmock.NewRows(deploymentEventsCols)) + _, err := GetDeploymentEvents(context.Background(), db, uuid.New(), 0) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) + + // limit > max → clamped to DeploymentEventsListMaxLimit. + db2, mock2 := newMock(t) + mock2.ExpectQuery(`FROM deployment_events`). + WithArgs(sqlmock.AnyArg(), DeploymentEventsListMaxLimit). + WillReturnRows(sqlmock.NewRows(deploymentEventsCols)) + _, err = GetDeploymentEvents(context.Background(), db2, uuid.New(), DeploymentEventsListMaxLimit+500) + require.NoError(t, err) + require.NoError(t, mock2.ExpectationsWereMet()) + + // In-range limit passes through verbatim. + db3, mock3 := newMock(t) + mock3.ExpectQuery(`FROM deployment_events`). + WithArgs(sqlmock.AnyArg(), 7). + WillReturnRows(sqlmock.NewRows(deploymentEventsCols)) + _, err = GetDeploymentEvents(context.Background(), db3, uuid.New(), 7) + require.NoError(t, err) + require.NoError(t, mock3.ExpectationsWereMet()) +} + +func TestGetDeploymentEvents_EmptyResult(t *testing.T) { + db, mock := newMock(t) + mock.ExpectQuery(`FROM deployment_events`). + WillReturnRows(sqlmock.NewRows(deploymentEventsCols)) + + out, err := GetDeploymentEvents(context.Background(), db, uuid.New(), 10) + require.NoError(t, err) + assert.NotNil(t, out, "must return a non-nil slice so JSON marshals as []") + assert.Len(t, out, 0) +} + +func TestGetDeploymentEvents_HappyPath_OrderingPreserved(t *testing.T) { + deploymentID := uuid.New() + id1, id2, id3 := uuid.New(), uuid.New(), uuid.New() + now := time.Now().UTC() + + db, mock := newMock(t) + // The handler relies on ORDER BY created_at DESC at the SQL layer — + // the model preserves whatever order the DB returns. We mock 3 rows in + // DESC order and assert the slice mirrors that order. + rows := sqlmock.NewRows(deploymentEventsCols). + AddRow(id1, deploymentID, "failure_autopsy", "kaniko_oom", + sql.NullInt32{Int32: 137, Valid: true}, "OOMKilled", + []byte(`["line1","line2"]`), "Out of memory.", now). + AddRow(id2, deploymentID, "failure_autopsy", "image_pull_failed", + sql.NullInt32{}, "ErrImagePull", + []byte(`[]`), "Check image name.", now.Add(-1*time.Minute)). + AddRow(id3, deploymentID, "lifecycle", "deploying", + sql.NullInt32{}, "", + nil, "", now.Add(-2*time.Minute)) + + mock.ExpectQuery(`FROM deployment_events`). + WithArgs(deploymentID, 50). + WillReturnRows(rows) + + out, err := GetDeploymentEvents(context.Background(), db, deploymentID, 50) + require.NoError(t, err) + require.Len(t, out, 3) + + // Row 0: full payload, exit_code valid. + assert.Equal(t, "kaniko_oom", out[0].Reason) + assert.Equal(t, "failure_autopsy", out[0].Kind) + require.True(t, out[0].ExitCode.Valid) + assert.Equal(t, int32(137), out[0].ExitCode.Int32) + assert.Equal(t, []string{"line1", "line2"}, out[0].LastLines) + + // Row 1: exit_code null, empty last_lines jsonb []. + assert.Equal(t, "image_pull_failed", out[1].Reason) + assert.False(t, out[1].ExitCode.Valid) + assert.Equal(t, []string{}, out[1].LastLines, "empty jsonb [] must surface as empty slice, not nil") + + // Row 2: nil last_lines raw (legacy / pre-default rows) → empty slice. + assert.Equal(t, "lifecycle", out[2].Kind) + assert.Equal(t, []string{}, out[2].LastLines, "nil raw must surface as empty slice") +} + +func TestGetDeploymentEvents_CorruptJSONB_Recovers(t *testing.T) { + db, mock := newMock(t) + mock.ExpectQuery(`FROM deployment_events`). + WillReturnRows(sqlmock.NewRows(deploymentEventsCols). + AddRow(uuid.New(), uuid.New(), "failure_autopsy", "Error", + sql.NullInt32{}, "boom", []byte(`{not-json`), "hint", time.Now())) + + out, err := GetDeploymentEvents(context.Background(), db, uuid.New(), 5) + require.NoError(t, err, "corrupt jsonb must NOT 500 the list") + require.Len(t, out, 1) + assert.Equal(t, []string{}, out[0].LastLines, "fallback to empty slice") +} + +func TestGetDeploymentEvents_QueryError(t *testing.T) { + db, mock := newMock(t) + mock.ExpectQuery(`FROM deployment_events`). + WillReturnError(errors.New("connection refused")) + + out, err := GetDeploymentEvents(context.Background(), db, uuid.New(), 10) + require.ErrorContains(t, err, "connection refused") + assert.Nil(t, out) +} + +func TestGetDeploymentEvents_ScanError(t *testing.T) { + db, mock := newMock(t) + // Force scan failure: declare 9 columns but feed only 3 values — sqlmock + // will reject the AddRow at scan time. + mock.ExpectQuery(`FROM deployment_events`). + WillReturnRows(sqlmock.NewRows(deploymentEventsCols). + AddRow("not-a-uuid", "also-not", "kind", "reason", + sql.NullInt32{}, "ev", []byte(`[]`), "hint", time.Now())) + + _, err := GetDeploymentEvents(context.Background(), db, uuid.New(), 10) + require.Error(t, err) + assert.Contains(t, err.Error(), "scan") +} + +func TestGetDeploymentEvents_RowsErr(t *testing.T) { + db, mock := newMock(t) + mock.ExpectQuery(`FROM deployment_events`). + WillReturnRows(sqlmock.NewRows(deploymentEventsCols). + AddRow(uuid.New(), uuid.New(), "k", "r", + sql.NullInt32{}, "ev", []byte(`[]`), "h", time.Now()). + RowError(0, errors.New("driver-row-err"))) + + _, err := GetDeploymentEvents(context.Background(), db, uuid.New(), 5) + require.Error(t, err) + assert.Contains(t, err.Error(), "driver-row-err") +} + +func TestDeploymentEventsListConstants_AreSane(t *testing.T) { + // Belt-and-braces guard on the constants the handler + spec advertise. + // If someone bumps these, the test forces them to update the OpenAPI + // description (which hard-codes 50 / 200). + assert.Equal(t, 50, DeploymentEventsListDefaultLimit) + assert.Equal(t, 200, DeploymentEventsListMaxLimit) + assert.True(t, DeploymentEventsListDefaultLimit < DeploymentEventsListMaxLimit) +} diff --git a/internal/router/router.go b/internal/router/router.go index 8f533069..0f9fea87 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1076,6 +1076,12 @@ func NewWithHooks(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *mid // Deploy management endpoints — Phase 6 (aliases under /api/v1) api.Get("/deployments", deployH.List) api.Get("/deployments/:id", deployH.Get) + // GET /deployments/:id/events — failure-timeline read surface (swarm + // triggering incident 2026-05-30: silent-deploy-failure bug class). Same + // RBAC as GET /deployments/:id; returns the deployment_events rows the + // worker's deploy_failure_autopsy job writes, ordered DESC by created_at. + // Read-only — events are written by the worker, never by the api. + api.Get("/deployments/:id/events", deployH.Events) api.Delete("/deployments/:id", deployH.Delete) // Wave FIX-I — two-step email-confirmed deletion. POST confirms // (validates ?token= against the hashed pending row), diff --git a/internal/testhelpers/testapp_smoke_test.go b/internal/testhelpers/testapp_smoke_test.go new file mode 100644 index 00000000..6bcba84c --- /dev/null +++ b/internal/testhelpers/testapp_smoke_test.go @@ -0,0 +1,52 @@ +package testhelpers + +// testapp_smoke_test.go — minimal smoke tests that build a fully-wired test +// app via NewTestAppWithServices. The point isn't to verify routing behavior +// (that's the handler-layer suite's job) — it's to give the `testhelpers` +// package its own coverage on the route-registration block so additions to +// the test-app surface (new routes, new handlers) are picked up by the +// patch-coverage gate. +// +// Without an in-package test that exercises NewTestAppWithServices, every new +// `api.Get("/x", h.X)` line added to testhelpers.go shows up as uncovered in +// diff-cover even when every downstream handler test passes through it: the +// default `go test ./...` -coverprofile flow only attributes coverage to the +// package whose tests ran, and handler tests live in `package handlers_test`. + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestNewTestAppWithServices_RegistersDeployEventsRoute boots a deploy-enabled +// test app and confirms the GET /api/v1/deployments/:id/events route is wired +// (the route registered in PR #200). An unauthenticated request returns 401 +// from the api group's RequireAuth middleware — that is enough to prove the +// route is registered (a missing route would 404), and it avoids the seeding +// the happy-path tests already cover. +func TestNewTestAppWithServices_RegistersDeployEventsRoute(t *testing.T) { + db, cleanDB := SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := NewTestAppWithServices(t, db, rdb, + "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + req := httptest.NewRequest(http.MethodGet, + "/api/v1/deployments/any-id/events", nil) + req.Header.Set("X-Forwarded-For", "10.99.0.10") + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + // 401 (auth middleware short-circuit) proves the route is mounted: a + // missing route would 404 from the router before middleware fires. + require.Equal(t, http.StatusUnauthorized, resp.StatusCode, + "deploy events route must be registered under the /api/v1 group") +} diff --git a/internal/testhelpers/testhelpers.go b/internal/testhelpers/testhelpers.go index b16b14bf..60f7c8ca 100644 --- a/internal/testhelpers/testhelpers.go +++ b/internal/testhelpers/testhelpers.go @@ -1164,6 +1164,10 @@ func NewTestAppWithServices(t *testing.T, db *sql.DB, rdb *redis.Client, service // public route (mirrors router.go) — NOT here under the RequireAuth group. api.Get("/deployments", deployH.List) api.Get("/deployments/:id", deployH.Get) + // GET /deployments/:id/events — failure-timeline read surface (swarm + // 2026-05-30 silent-deploy-failure fix). Mirrors router.go so handler + // tests exercise the same route registration the live API serves. + api.Get("/deployments/:id/events", deployH.Events) api.Delete("/deployments/:id", deployH.Delete) api.Patch("/deployments/:id", deployH.Patch) // Wave FIX-I — two-step email-confirmed deletion endpoints.