0c4c338bfe
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/.
127 lines
4.6 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|