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:
9 files changed
+594
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user