controller v0.47.0: backups page — whole-guest backup visibility + manual trigger
Part 2 of the USB/backup spec. agentapi: StatusResponse.Backup record, DueResponse
age_seconds, RestoreTestStatus(). New "Rendszermentés (teljes mentés)" section
(read-only: last backup/target PBS-vs-local/next-due/restore-test) + "Mentés most"
manual trigger that goes through the quiesce loop (controller owns quiescing):
quiesce.Loop gains mutex + TriggerNow() (single-flight, async). New
/api/guest-backup/{trigger,status} (distinct from apiRouter's /api/backup/*).
App-data rows relabeled under an "Alkalmazás-mentések" divider. Config → slice 10.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// backupStub serves the agent's backup endpoints with the documented payload shapes so the client's
|
||||
// parse + the trigger POST are asserted against real JSON (non-hollow).
|
||||
func backupStub(t *testing.T, started chan<- struct{}) (*httptest.Server, string) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"phase":"done","job_id":"backup-9201-1",` +
|
||||
`"backup":{"target_id":"local","vmid":9201,"archive":"local:backup/vzdump-lxc-9201-x.tar.zst",` +
|
||||
`"mode":"snapshot","crash_consistent":true,"size_bytes":1399160221,"success":true,` +
|
||||
`"started_at":"2026-06-12T07:37:41Z","duration_seconds":31.14}}}`))
|
||||
})
|
||||
mux.HandleFunc("GET /backup/due", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"due":false,"reason":"within cadence window","age_seconds":5234}}`))
|
||||
})
|
||||
mux.HandleFunc("GET /restore-test/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"restore_test":{"source_archive":"local:backup/x","source_tier":"local",` +
|
||||
`"pass":true,"verified":"boot+running","tested_at":"2026-06-11T03:00:00Z","duration_seconds":42.0}}}`))
|
||||
})
|
||||
mux.HandleFunc("POST /backup", func(w http.ResponseWriter, r *http.Request) {
|
||||
if started != nil {
|
||||
started <- struct{}{}
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"job_id":"backup-9201-2","phase":"running"}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
return s, strings.TrimPrefix(s.URL, "https://")
|
||||
}
|
||||
|
||||
func TestBackupStatus_ParsesRecord(t *testing.T) {
|
||||
s, ep := backupStub(t, nil)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, ep)
|
||||
|
||||
st, err := c.BackupStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("BackupStatus: %v", err)
|
||||
}
|
||||
if st.Phase != "done" || st.JobID != "backup-9201-1" {
|
||||
t.Fatalf("status fields: %+v", st)
|
||||
}
|
||||
if st.Backup == nil {
|
||||
t.Fatal("expected a backup record")
|
||||
}
|
||||
if st.Backup.TargetID != "local" || st.Backup.Mode != "snapshot" || !st.Backup.Success {
|
||||
t.Fatalf("backup record: %+v", st.Backup)
|
||||
}
|
||||
if st.Backup.SizeBytes != 1399160221 || st.Backup.Archive == "" || st.Backup.StartedAt == "" {
|
||||
t.Fatalf("backup record details: %+v", st.Backup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupDue_Parses(t *testing.T) {
|
||||
s, ep := backupStub(t, nil)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, ep)
|
||||
|
||||
due, err := c.BackupDue(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("BackupDue: %v", err)
|
||||
}
|
||||
if due.Due || due.Reason != "within cadence window" {
|
||||
t.Fatalf("due: %+v", due)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTestStatus_Parses(t *testing.T) {
|
||||
s, ep := backupStub(t, nil)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, ep)
|
||||
|
||||
rt, err := c.RestoreTestStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("RestoreTestStatus: %v", err)
|
||||
}
|
||||
if rt == nil {
|
||||
t.Fatal("expected a restore-test record")
|
||||
}
|
||||
if !rt.Pass || rt.Verified != "boot+running" || rt.SourceTier != "local" {
|
||||
t.Fatalf("restore-test: %+v", rt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTestStatus_Null(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /restore-test/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"restore_test":null}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
rt, err := c.RestoreTestStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("RestoreTestStatus(null): %v", err)
|
||||
}
|
||||
if rt != nil {
|
||||
t.Fatalf("expected nil restore-test when none has run, got %+v", rt)
|
||||
}
|
||||
}
|
||||
|
||||
// StartBackup must POST to /backup (the manual-trigger contract goes through this).
|
||||
func TestStartBackup_PostsToBackup(t *testing.T) {
|
||||
started := make(chan struct{}, 1)
|
||||
s, ep := backupStub(t, started)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, ep)
|
||||
|
||||
resp, err := c.StartBackup(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("StartBackup: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-started:
|
||||
default:
|
||||
t.Fatal("StartBackup did not POST to /backup")
|
||||
}
|
||||
if resp.JobID != "backup-9201-2" || resp.Phase != "running" {
|
||||
t.Fatalf("StartBackup response: %+v", resp)
|
||||
}
|
||||
}
|
||||
@@ -105,11 +105,13 @@ func (c *Client) Storage(ctx context.Context) (StorageResponse, error) {
|
||||
|
||||
// ---- slice 8B: app-consistent backup (quiesce loop) -------------------------------------
|
||||
|
||||
// DueResponse mirrors the agent's GET /backup/due payload.
|
||||
// DueResponse mirrors the agent's GET /backup/due payload. AgeSecs is the age of the newest
|
||||
// successful backup (nil when none has run yet).
|
||||
type DueResponse struct {
|
||||
VMID int `json:"vmid"`
|
||||
Due bool `json:"due"`
|
||||
Reason string `json:"reason"`
|
||||
VMID int `json:"vmid"`
|
||||
Due bool `json:"due"`
|
||||
Reason string `json:"reason"`
|
||||
AgeSecs *int64 `json:"age_seconds"`
|
||||
}
|
||||
|
||||
// BackupResponse mirrors the agent's POST /backup payload.
|
||||
@@ -119,12 +121,42 @@ type BackupResponse struct {
|
||||
Phase string `json:"phase"`
|
||||
}
|
||||
|
||||
// StatusResponse mirrors the agent's GET /backup/status payload.
|
||||
// StatusResponse mirrors the agent's GET /backup/status payload. Backup is the latest RECORDED
|
||||
// whole-guest backup (nil until one has run), surfaced to the controller's backup page for visibility.
|
||||
type StatusResponse struct {
|
||||
VMID int `json:"vmid"`
|
||||
Phase string `json:"phase"` // idle | running | done | failed
|
||||
JobID string `json:"job_id"`
|
||||
Error string `json:"error"`
|
||||
VMID int `json:"vmid"`
|
||||
Phase string `json:"phase"` // idle | running | snapshotted | done | failed
|
||||
JobID string `json:"job_id"`
|
||||
Error string `json:"error"`
|
||||
Backup *BackupRecord `json:"backup,omitempty"`
|
||||
}
|
||||
|
||||
// BackupRecord mirrors the agent's hub.Backup — one whole-guest vzdump/PBS backup result. The
|
||||
// controller renders it read-only (it does NOT own whole-guest backup; the agent does).
|
||||
type BackupRecord struct {
|
||||
TargetID string `json:"target_id"` // backup storage name (e.g. "local", "felhom-pbs")
|
||||
VMID int `json:"vmid"`
|
||||
Archive string `json:"archive"` // produced vzdump volid (e.g. "local:backup/vzdump-lxc-…")
|
||||
Mode string `json:"mode"` // snapshot | stop
|
||||
CrashConsistent bool `json:"crash_consistent"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt string `json:"started_at"` // RFC3339
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
}
|
||||
|
||||
// RestoreTestRecord mirrors the agent's hub.RestoreTest — the latest self-restore-test (the "backup
|
||||
// verified restorable" trust signal). Nil when none has run yet.
|
||||
type RestoreTestRecord struct {
|
||||
SourceArchive string `json:"source_archive"`
|
||||
SourceTier string `json:"source_tier"` // "local" (pbs = Phase B)
|
||||
Pass bool `json:"pass"`
|
||||
Verified string `json:"verified"` // "boot+running" this slice
|
||||
Error string `json:"error,omitempty"`
|
||||
TestedAt string `json:"tested_at"` // RFC3339
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// Backup status phases (mirror the agent's vocabulary).
|
||||
@@ -174,6 +206,22 @@ func (c *Client) BackupStatus(ctx context.Context) (StatusResponse, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RestoreTestStatus calls GET /restore-test/status and returns the latest self-restore-test result
|
||||
// (nil when none has run yet — the agent payload is {"restore_test": {...}|null}).
|
||||
func (c *Client) RestoreTestStatus(ctx context.Context) (*RestoreTestRecord, error) {
|
||||
body, err := c.get(ctx, "/restore-test/status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out struct {
|
||||
RestoreTest *RestoreTestRecord `json:"restore_test"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("agentapi: decode /restore-test/status: %w", err)
|
||||
}
|
||||
return out.RestoreTest, nil
|
||||
}
|
||||
|
||||
// ---- slice 8C: disk management (execution is the agent's) --------------------------------
|
||||
|
||||
// DiskInfo mirrors the agent's GET /disks entry.
|
||||
|
||||
Reference in New Issue
Block a user