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/.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user