feat(api): add internal-face break-glass world backup endpoint (§B4 Sync)

Add POST /api/v1/internal/servers/{name}/backup so the on-node break-glass
console can snapshot a stopped world while felis-api is alive. It goes through
the API (not direct-to-CRD like halt) because rendering the backup Job needs
deployment coordinates (FELIS_IMAGE, FELIS_BACKUP_PVC) only felis-api holds.

Service-token auth (no Principal); the middleware IS the authorization, since
the operator already has root on the node. Refactor the RWO stopped-gate,
optional-Backuper 503, async hand-off and audit+202 into a shared enqueueBackup
tail so the external (owner/admin) and internal (break-glass) faces cannot
diverge on the security-critical stopped-gate. The internal audit is attributed
to break-glass/internal so a console-initiated backup is distinguishable from an
owner self-service one.
This commit is contained in:
flyemoji committed 2026-07-07 10:48:01 +09:00
1 parent c3b4d7b957
commit f2fc57cad9
4 files changed
+170 -1

No files matched your search

+78
View File
@@ -176,3 +176,81 @@ func TestBackupNow(t *testing.T) {
}
})
}
// TestInternalBackup exercises POST /api/v1/internal/servers/{name}/backup, the
// break-glass console's face. It shares enqueueBackup with the external handler, so
// the stopped-gate / 503 / async-202 behaviour is proven there; here the focus is the
// internal-face difference: no Principal (service-token auth), no owner gate — even a
// server owned by someone else backs up (the on-node operator is trusted) — and the
// audit is attributed to "break-glass"/"internal", not an email/"external".
func TestInternalBackup(t *testing.T) {
mk := func() (*API, *fakeRepo, *fakeCluster, *fakeBackuper) {
repo := newFakeRepo()
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "someone-else"}
cl := newFakeCluster()
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped",
Ready: false, DesiredState: string(v1alpha1.DesiredStopped)}
backuper := &fakeBackuper{}
api := newTestAPI(repo, cl)
api.Backuper = backuper
return api, repo, cl, backuper
}
const path = "/api/v1/internal/servers/survival/backup"
t.Run("stopped server -> 202 + backuper(currentOwner) + break-glass audit", func(t *testing.T) {
api, repo, _, backuper := mk()
w := do(api.InternalHandler(), "POST", path, "", jsonHeader)
if w.Code != http.StatusAccepted {
t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String())
}
// No owner gate on the internal face: a server owned by someone else still backs
// up, and the recorded former owner is the server's CURRENT owner.
if backuper.calls != 1 || backuper.gotName != "survival" || backuper.gotFormerOwn != "someone-else" {
t.Fatalf("backuper saw (calls=%d,%q,%q), want (1,survival,someone-else)",
backuper.calls, backuper.gotName, backuper.gotFormerOwn)
}
if len(repo.audits) != 1 || repo.audits[0].Action != "backup.create" ||
repo.audits[0].Actor != "break-glass" || repo.audits[0].Source != "internal" {
t.Fatalf("audit not attributed to break-glass/internal: %+v", repo.audits)
}
})
t.Run("running server -> 409 not_stopped, no backup", func(t *testing.T) {
api, _, cl, backuper := mk()
cl.byName["survival"].Ready = true
cl.byName["survival"].DesiredState = string(v1alpha1.DesiredRunning)
w := do(api.InternalHandler(), "POST", path, "", jsonHeader)
if w.Code != http.StatusConflict || decodeErr(t, w) != "not_stopped" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if backuper.calls != 0 {
t.Fatal("a running server holds the RWO world PVC — backup must be refused")
}
})
t.Run("nil Backuper -> 503 backup_unavailable", func(t *testing.T) {
api, _, _, _ := mk()
api.Backuper = nil
w := do(api.InternalHandler(), "POST", path, "", jsonHeader)
if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "backup_unavailable" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
t.Run("unknown server -> 404", func(t *testing.T) {
api, _, _, _ := mk()
w := do(api.InternalHandler(), "POST", "/api/v1/internal/servers/missing/backup", "", jsonHeader)
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
}
})
t.Run("invalid server name -> 400 bad_name", func(t *testing.T) {
api, _, _, _ := mk()
w := do(api.InternalHandler(), "POST", "/api/v1/internal/servers/X/backup", "", jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_name" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
}