Files
tiny-forge/internal/api/deploy_history_test.go
T
alexei.dolgolyov 0c4c338bfe feat(apps): per-workload deploy history, rollback, and resource metrics
Two additions to the app detail page, each backed by a per-workload
endpoint.

Deploy history + rollback:
- New deploy_history table — a structured, version-pinned ledger of every
  dispatch (success AND failure), distinct from the free-text event_log.
  Recorded at the single DispatchPlugin choke point so every source kind
  is covered. The raw deploy error is never persisted (it can carry
  registry-auth / compose-stdout secrets) — only a generic marker, with
  detail going to slog. Pruned to the newest N per workload; cascade-
  deleted with the workload.
- GET /api/workloads/{id}/deploys lists the ledger; POST .../rollback
  (admin) replays a prior successful deploy's pinned reference as a
  rollback-reason dispatch. Phase 1 is image-source only (RollbackCapable);
  git-built sources need checkout-by-commit, a later phase.
- DeployHistoryPanel.svelte renders the ledger with confirm-gated rollback.

Per-workload metrics:
- ListContainerStatsSamplesByWorkload joins the existing container stats
  samples through the containers index; GET /api/workloads/{id}/stats/history
  aggregates CPU/memory per timestamp across the workload's containers.
- WorkloadMetricsPanel.svelte reuses ResourceChart (CPU% + memory MiB,
  windowed, 15s poll).

en/ru i18n added with parity. Tests: store CRUD + cascade + workload-scoped
join, deployer recording (incl. secret-non-leak on failure), API rollback
guards, and per-timestamp aggregation. Plans under docs/plans/.
2026-06-19 16:22:12 +03:00

127 lines
4.6 KiB
Go

package api
import (
"net/http"
"testing"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
)
// createImageWorkload creates an image-source workload through the API so
// source_kind is persisted exactly as production does, returning its id.
func createImageWorkload(t *testing.T, e *apiTestEnv, name string) string {
t.Helper()
resp := e.do(t, http.MethodPost, "/api/workloads", pluginWorkloadRequest{
Name: name, SourceKind: "image", SourceConfig: validImageSourceConfig(),
})
if resp.StatusCode != http.StatusCreated {
_ = decodeEnvelope(t, resp, nil)
t.Fatalf("create workload: status %d", resp.StatusCode)
}
var got plugin.Workload
if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
t.Fatalf("create workload envelope error: %q", errMsg)
}
return got.ID
}
func TestListWorkloadDeploys_ComputesRollbackable(t *testing.T) {
e := newAPITestEnv(t)
id := createImageWorkload(t, e, "app")
// success + reference + image => rollbackable
e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: id, SourceKind: "image", Reference: "v1", Outcome: "success",
})
// failure => not rollbackable
e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: id, SourceKind: "image", Reference: "v2", Outcome: "failure",
})
// success but empty reference => not rollbackable
e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: id, SourceKind: "image", Reference: "", Outcome: "success",
})
resp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/deploys", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var rows []store.DeployHistoryEntry
if errMsg := decodeEnvelope(t, resp, &rows); errMsg != "" {
t.Fatalf("envelope error: %q", errMsg)
}
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
// Newest-first: empty-ref success, failure, then v1 success.
if !rows[2].Rollbackable {
t.Fatalf("v1 success row should be rollbackable: %+v", rows[2])
}
if rows[1].Rollbackable || rows[0].Rollbackable {
t.Fatalf("failure / empty-ref rows must not be rollbackable")
}
}
func TestRollback_HappyPath_DispatchesRollbackIntent(t *testing.T) {
e := newAPITestEnv(t)
id := createImageWorkload(t, e, "app")
entry, _ := e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: id, SourceKind: "image", Reference: "v1", Outcome: "success",
})
before := e.dispatcher.deployCount.Load()
resp := e.do(t, http.MethodPost, "/api/workloads/"+id+"/rollback",
map[string]any{"deploy_id": entry.ID})
if resp.StatusCode != http.StatusAccepted {
errMsg := decodeEnvelope(t, resp, nil)
t.Fatalf("status = %d, want 202 (err=%q)", resp.StatusCode, errMsg)
}
if got := e.dispatcher.deployCount.Load(); got != before+1 {
t.Fatalf("expected one dispatch, got delta %d", got-before)
}
intent := e.dispatcher.lastIntent.Load()
if intent == nil || intent.Reason != "rollback" || intent.Reference != "v1" {
t.Fatalf("expected rollback intent for v1, got %+v", intent)
}
}
func TestRollback_Guards(t *testing.T) {
e := newAPITestEnv(t)
imageID := createImageWorkload(t, e, "img")
otherID := createImageWorkload(t, e, "other")
success, _ := e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: imageID, SourceKind: "image", Reference: "v1", Outcome: "success",
})
failed, _ := e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: imageID, SourceKind: "image", Reference: "v2", Outcome: "failure",
})
otherWL, _ := e.store.InsertDeployHistory(store.DeployHistoryEntry{
WorkloadID: otherID, SourceKind: "image", Reference: "v1", Outcome: "success",
})
cases := []struct {
name string
workload string
body any
wantCode int
}{
{"missing deploy_id", imageID, map[string]any{}, http.StatusBadRequest},
{"zero deploy_id", imageID, map[string]any{"deploy_id": 0}, http.StatusBadRequest},
{"unknown deploy_id", imageID, map[string]any{"deploy_id": 999999}, http.StatusNotFound},
{"unknown workload", "nope", map[string]any{"deploy_id": success.ID}, http.StatusNotFound},
{"failed deploy", imageID, map[string]any{"deploy_id": failed.ID}, http.StatusBadRequest},
{"cross-workload entry", imageID, map[string]any{"deploy_id": otherWL.ID}, http.StatusBadRequest},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
resp := e.do(t, http.MethodPost, "/api/workloads/"+c.workload+"/rollback", c.body)
if resp.StatusCode != c.wantCode {
errMsg := decodeEnvelope(t, resp, nil)
t.Fatalf("status = %d, want %d (err=%q)", resp.StatusCode, c.wantCode, errMsg)
}
})
}
}