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/.
134 lines
3.8 KiB
Go
134 lines
3.8 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func seedWorkload(t *testing.T, s *Store, name string) Workload {
|
|
t.Helper()
|
|
w, err := s.CreateWorkload(Workload{Kind: "project", RefID: name, Name: name})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorkload(%s): %v", name, err)
|
|
}
|
|
return w
|
|
}
|
|
|
|
func TestDeployHistory_InsertListGet(t *testing.T) {
|
|
s := newTestStore(t)
|
|
w := seedWorkload(t, s, "app1")
|
|
|
|
first, err := s.InsertDeployHistory(DeployHistoryEntry{
|
|
WorkloadID: w.ID, SourceKind: "image", Reference: "v1",
|
|
Reason: "manual", TriggeredBy: "admin", Outcome: "success",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InsertDeployHistory: %v", err)
|
|
}
|
|
if first.ID == 0 {
|
|
t.Fatal("expected non-zero id")
|
|
}
|
|
if first.StartedAt == "" || first.FinishedAt == "" {
|
|
t.Fatal("expected timestamps to be defaulted")
|
|
}
|
|
|
|
second, _ := s.InsertDeployHistory(DeployHistoryEntry{
|
|
WorkloadID: w.ID, SourceKind: "image", Reference: "v2",
|
|
Reason: "registry-push", Outcome: "success",
|
|
})
|
|
|
|
list, err := s.ListDeployHistory(w.ID, 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListDeployHistory: %v", err)
|
|
}
|
|
if len(list) != 2 {
|
|
t.Fatalf("expected 2 rows, got %d", len(list))
|
|
}
|
|
// Newest-first ordering.
|
|
if list[0].ID != second.ID || list[1].ID != first.ID {
|
|
t.Fatalf("expected newest-first ordering, got %d then %d", list[0].ID, list[1].ID)
|
|
}
|
|
|
|
got, err := s.GetDeployHistory(first.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDeployHistory: %v", err)
|
|
}
|
|
if got.Reference != "v1" || got.SourceKind != "image" {
|
|
t.Fatalf("unexpected row: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestDeployHistory_GetNotFound(t *testing.T) {
|
|
s := newTestStore(t)
|
|
_, err := s.GetDeployHistory(999)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeployHistory_ListScopedToWorkload(t *testing.T) {
|
|
s := newTestStore(t)
|
|
a := seedWorkload(t, s, "a")
|
|
b := seedWorkload(t, s, "b")
|
|
s.InsertDeployHistory(DeployHistoryEntry{WorkloadID: a.ID, Outcome: "success"})
|
|
s.InsertDeployHistory(DeployHistoryEntry{WorkloadID: b.ID, Outcome: "success"})
|
|
|
|
list, _ := s.ListDeployHistory(a.ID, 10, 0)
|
|
if len(list) != 1 || list[0].WorkloadID != a.ID {
|
|
t.Fatalf("expected only workload a's rows, got %+v", list)
|
|
}
|
|
}
|
|
|
|
func TestDeployHistory_Pagination(t *testing.T) {
|
|
s := newTestStore(t)
|
|
w := seedWorkload(t, s, "paged")
|
|
for i := 0; i < 5; i++ {
|
|
s.InsertDeployHistory(DeployHistoryEntry{WorkloadID: w.ID, Outcome: "success"})
|
|
}
|
|
page1, _ := s.ListDeployHistory(w.ID, 2, 0)
|
|
page2, _ := s.ListDeployHistory(w.ID, 2, 2)
|
|
if len(page1) != 2 || len(page2) != 2 {
|
|
t.Fatalf("expected 2 per page, got %d and %d", len(page1), len(page2))
|
|
}
|
|
if page1[0].ID == page2[0].ID {
|
|
t.Fatal("expected distinct rows across pages")
|
|
}
|
|
}
|
|
|
|
func TestDeployHistory_Prune(t *testing.T) {
|
|
s := newTestStore(t)
|
|
w := seedWorkload(t, s, "noisy")
|
|
for i := 0; i < 10; i++ {
|
|
s.InsertDeployHistory(DeployHistoryEntry{WorkloadID: w.ID, Outcome: "success"})
|
|
}
|
|
if err := s.PruneDeployHistory(w.ID, 3); err != nil {
|
|
t.Fatalf("PruneDeployHistory: %v", err)
|
|
}
|
|
list, _ := s.ListDeployHistory(w.ID, 100, 0)
|
|
if len(list) != 3 {
|
|
t.Fatalf("expected 3 rows after prune, got %d", len(list))
|
|
}
|
|
// Prune keeps the newest rows.
|
|
all, _ := s.ListDeployHistory(w.ID, 100, 0)
|
|
for i := 1; i < len(all); i++ {
|
|
if all[i-1].ID < all[i].ID {
|
|
t.Fatal("expected newest-first after prune")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeployHistory_CascadeOnWorkloadDelete(t *testing.T) {
|
|
s := newTestStore(t)
|
|
w := seedWorkload(t, s, "doomed")
|
|
s.InsertDeployHistory(DeployHistoryEntry{WorkloadID: w.ID, Outcome: "success"})
|
|
s.InsertDeployHistory(DeployHistoryEntry{WorkloadID: w.ID, Outcome: "failure"})
|
|
|
|
if err := s.DeleteWorkload(w.ID); err != nil {
|
|
t.Fatalf("DeleteWorkload: %v", err)
|
|
}
|
|
list, _ := s.ListDeployHistory(w.ID, 100, 0)
|
|
if len(list) != 0 {
|
|
t.Fatalf("expected history removed with workload, got %d rows", len(list))
|
|
}
|
|
}
|