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

124 lines
3.8 KiB
Go

package store
import (
"database/sql"
"errors"
"fmt"
)
// InsertDeployHistory appends one row to the per-workload deploy ledger.
// Callers (the deployer choke point) treat this as best-effort: a failure
// here must never fail an otherwise-successful deploy. Error is expected to
// be a fixed, secret-free marker — never the raw source error.
func (s *Store) InsertDeployHistory(e DeployHistoryEntry) (DeployHistoryEntry, error) {
if e.StartedAt == "" {
e.StartedAt = Now()
}
if e.FinishedAt == "" {
e.FinishedAt = Now()
}
res, err := s.db.Exec(
`INSERT INTO deploy_history
(workload_id, source_kind, reference, reason, triggered_by,
note, outcome, error, started_at, finished_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.WorkloadID, e.SourceKind, e.Reference, e.Reason, e.TriggeredBy,
e.Note, e.Outcome, e.Error, e.StartedAt, e.FinishedAt,
)
if err != nil {
return DeployHistoryEntry{}, fmt.Errorf("insert deploy history: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return DeployHistoryEntry{}, fmt.Errorf("get deploy history id: %w", err)
}
e.ID = id
return e, nil
}
// ListDeployHistory returns a workload's ledger newest-first. limit/offset
// are assumed pre-clamped by the API layer; a non-positive limit falls back
// to a sane default so a bad query can't return the whole table.
func (s *Store) ListDeployHistory(workloadID string, limit, offset int) ([]DeployHistoryEntry, error) {
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
rows, err := s.db.Query(
`SELECT id, workload_id, source_kind, reference, reason, triggered_by,
note, outcome, error, started_at, finished_at
FROM deploy_history
WHERE workload_id = ?
ORDER BY id DESC
LIMIT ? OFFSET ?`,
workloadID, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("query deploy history: %w", err)
}
defer rows.Close()
out := make([]DeployHistoryEntry, 0, limit)
for rows.Next() {
var e DeployHistoryEntry
if err := rows.Scan(
&e.ID, &e.WorkloadID, &e.SourceKind, &e.Reference, &e.Reason,
&e.TriggeredBy, &e.Note, &e.Outcome, &e.Error, &e.StartedAt, &e.FinishedAt,
); err != nil {
return nil, fmt.Errorf("scan deploy history: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// GetDeployHistory fetches one ledger row by id, or ErrNotFound. The
// rollback handler uses this to resolve the pinned reference to replay.
func (s *Store) GetDeployHistory(id int64) (DeployHistoryEntry, error) {
row := s.db.QueryRow(
`SELECT id, workload_id, source_kind, reference, reason, triggered_by,
note, outcome, error, started_at, finished_at
FROM deploy_history WHERE id = ?`, id,
)
var e DeployHistoryEntry
err := row.Scan(
&e.ID, &e.WorkloadID, &e.SourceKind, &e.Reference, &e.Reason,
&e.TriggeredBy, &e.Note, &e.Outcome, &e.Error, &e.StartedAt, &e.FinishedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return DeployHistoryEntry{}, fmt.Errorf("deploy history %d: %w", id, ErrNotFound)
}
if err != nil {
return DeployHistoryEntry{}, fmt.Errorf("scan deploy history: %w", err)
}
return e, nil
}
// PruneDeployHistory keeps only the newest `keep` rows for a workload,
// deleting older ones. Bounds unbounded growth on hot workloads. Best-
// effort and id-monotonic (newer rows always have larger ids), so it
// deletes everything below the keep-th id. A non-positive keep is treated
// as "keep a sane default" rather than "delete everything".
func (s *Store) PruneDeployHistory(workloadID string, keep int) error {
if keep <= 0 {
keep = 50
}
_, err := s.db.Exec(
`DELETE FROM deploy_history
WHERE workload_id = ?
AND id NOT IN (
SELECT id FROM deploy_history
WHERE workload_id = ?
ORDER BY id DESC
LIMIT ?
)`,
workloadID, workloadID, keep,
)
if err != nil {
return fmt.Errorf("prune deploy history: %w", err)
}
return nil
}