feat(breakglass): add "back up a world now" console peer (§B4 Sync)

Adds a break-glass console operation that snapshots a stopped world by
calling the felis-api internal face while the API is alive, rather than
rendering the backup Job locally: the Job needs felis-api deployment
coordinates the console does not hold.

The peer resolves the felis-api-internal ClusterIP Service + service
token from the control namespace, POSTs the internal backup endpoint
with the operator os_user for audit attribution, and maps 409/503/404
to friendly outcome cards. Core decision logic lives in backupnow.go
(unit-tested against a fake client + httptest); tui_backupnow.go is the
untested bubbletea glue mirroring tui_halt.go.
This commit is contained in:
flyemoji committed 2026-07-07 19:32:13 +09:00
1 parent 4918d93412
commit fc748d3462
9 files changed
+594 -2

No files matched your search

+11
View File
@@ -216,6 +216,17 @@ func TestInternalBackup(t *testing.T) {
}
})
t.Run("os_user body attributes the audit to the operator", func(t *testing.T) {
api, repo, _, _ := mk()
w := do(api.InternalHandler(), "POST", path, `{"os_user":"alice"}`, jsonHeader)
if w.Code != http.StatusAccepted {
t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String())
}
if len(repo.audits) != 1 || repo.audits[0].Actor != "alice" || repo.audits[0].Source != "internal" {
t.Fatalf("audit actor should be the os_user, not break-glass: %+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
+21 -1
View File
@@ -229,6 +229,12 @@ func (a *API) handleBackupNow(w http.ResponseWriter, r *http.Request) {
// the requireInternal middleware IS the authorization — the operator already has root
// on the node. It audits the action to "break-glass" so a console-initiated backup is
// distinguishable from an owner's self-service one.
//
// The console passes the OS user at the keyboard in an optional {"os_user":"..."} body,
// which becomes the audit actor (parity with the halt peer's accountability). The body
// is decoded whenever one is present — not gated on Content-Type — so a console that
// forgets the header still records the operator rather than silently attributing to the
// generic "break-glass". Absent/blank falls back to "break-glass".
func (a *API) handleInternalBackup(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil {
@@ -236,13 +242,27 @@ func (a *API) handleInternalBackup(w http.ResponseWriter, r *http.Request) {
return
}
actor := "break-glass"
if r.ContentLength != 0 {
var body struct {
OSUser string `json:"os_user"`
}
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
if u := strings.TrimSpace(body.OSUser); u != "" {
actor = u
}
}
rec, err := a.Repo.ServerByName(r.Context(), name)
if err != nil {
a.writeLookupError(w, r, err)
return
}
a.enqueueBackup(w, r, name, rec, "break-glass", "internal")
a.enqueueBackup(w, r, name, rec, actor, "internal")
}
// enqueueBackup is the shared tail of both backup faces: the RWO stopped-gate, the