Files
tiny-forge/internal/store/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

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))
}
}