diff --git a/cmd/felis/backupnow.go b/cmd/felis/backupnow.go new file mode 100644 index 0000000..435e21d --- /dev/null +++ b/cmd/felis/backupnow.go @@ -0,0 +1,122 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "felis.lolicon.best/internal/naming" + "felis.lolicon.best/internal/platform" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// backupnow is the break-glass "back up a world now" op (§B4 "Sync"). Unlike halt — +// which writes the CRD directly — a backup needs felis-api's deployment coordinates +// (FELIS_IMAGE / FELIS_BACKUP_PVC) to render the one-shot backup Job, so the console +// cannot do it in-process. It POSTs the felis-api INTERNAL face (service-token auth) +// while the API is alive, and the API renders the Job and audits the action. This file +// is the pure core (no bubbletea); tui_backupnow.go is the terminal glue. + +// backupNowOutcome is the durable result of a backup request, re-printed after the TUI +// alt-screen tears down. +type backupNowOutcome struct { + name string + status string // "backing_up" on success +} + +// resolveInternalAPI reads the two things the on-node console needs to reach the +// felis-api internal face: the felis-api-internal Service ClusterIP (the host's +// resolver is not CoreDNS, so the cluster-DNS name is useless here) and the service +// token. Both live in the control namespace. +func resolveInternalAPI(ctx context.Context, cl client.Client, controlNamespace string) (baseURL, token string, err error) { + var svc corev1.Service + if err := cl.Get(ctx, types.NamespacedName{Namespace: controlNamespace, Name: platform.APIInternalServiceName}, &svc); err != nil { + return "", "", fmt.Errorf("get %s Service: %w", platform.APIInternalServiceName, err) + } + ip := svc.Spec.ClusterIP + if ip == "" || ip == corev1.ClusterIPNone { + return "", "", fmt.Errorf("%s Service has no ClusterIP yet", platform.APIInternalServiceName) + } + + var sec corev1.Secret + if err := cl.Get(ctx, types.NamespacedName{Namespace: controlNamespace, Name: naming.ServiceTokenSecretName}, &sec); err != nil { + return "", "", fmt.Errorf("get %s Secret: %w", naming.ServiceTokenSecretName, err) + } + token = string(sec.Data[naming.ServiceTokenSecretKey]) + if token == "" { + return "", "", fmt.Errorf("Secret %s has no %s key", naming.ServiceTokenSecretName, naming.ServiceTokenSecretKey) + } + + return fmt.Sprintf("http://%s:%d", ip, platform.APIInternalPort), token, nil +} + +// requestBackup POSTs the internal backup endpoint and maps the response to a friendly +// outcome. osUser is sent for audit attribution (parity with halt); the API records it +// as the actor. A transport failure is distinguished from an HTTP error status because +// break-glass runs when things are broken — and this op needs the API alive by design, +// so "the API is down" is the useful message. +func requestBackup(ctx context.Context, hc *http.Client, baseURL, token, name, osUser string) (backupNowOutcome, error) { + body, _ := json.Marshal(map[string]string{"os_user": osUser}) + url := baseURL + "/api/v1/internal/servers/" + name + "/backup" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return backupNowOutcome{}, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := hc.Do(req) + if err != nil { + return backupNowOutcome{}, fmt.Errorf("felis-api unreachable (a backup needs it alive): %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusAccepted { + return backupNowOutcome{name: name, status: "backing_up"}, nil + } + return backupNowOutcome{}, backupErrorFromResponse(resp) +} + +// backupErrorFromResponse turns a non-202 into a human message. The well-known codes get +// an operator-facing explanation; anything else falls back to the API's +// {"error":{message}} body, then the bare status code. +func backupErrorFromResponse(resp *http.Response) error { + switch resp.StatusCode { + case http.StatusConflict: // not_stopped + return fmt.Errorf("the server must be stopped before its world can be backed up — halt it first") + case http.StatusServiceUnavailable: // backup_unavailable + return fmt.Errorf("the backup subsystem is not configured on felis-api (FELIS_IMAGE / FELIS_BACKUP_PVC unset)") + case http.StatusNotFound: + return fmt.Errorf("no such server") + } + var e struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + _ = json.Unmarshal(raw, &e) + if e.Error.Message != "" { + return fmt.Errorf("felis-api: %s", e.Error.Message) + } + return fmt.Errorf("felis-api returned HTTP %d", resp.StatusCode) +} + +// performBackupNow composes resolve + request against a short-timeout HTTP client (a +// non-routable ClusterIP must fail fast, not hang the TUI). controlNamespace holds the +// Service + token. +func performBackupNow(ctx context.Context, cl client.Client, controlNamespace, name, osUser string) (backupNowOutcome, error) { + baseURL, token, err := resolveInternalAPI(ctx, cl, controlNamespace) + if err != nil { + return backupNowOutcome{}, err + } + hc := &http.Client{Timeout: 10 * time.Second} + return requestBackup(ctx, hc, baseURL, token, name, osUser) +} diff --git a/cmd/felis/backupnow_test.go b/cmd/felis/backupnow_test.go new file mode 100644 index 0000000..b7de2b5 --- /dev/null +++ b/cmd/felis/backupnow_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "felis.lolicon.best/internal/naming" + "felis.lolicon.best/internal/platform" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const bgControlNS = "felis-system" + +func internalAPIObjs(clusterIP, token string) []client.Object { + return []client.Object{ + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: platform.APIInternalServiceName, Namespace: bgControlNS}, + Spec: corev1.ServiceSpec{ClusterIP: clusterIP}, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: bgControlNS}, + Data: map[string][]byte{naming.ServiceTokenSecretKey: []byte(token)}, + }, + } +} + +func fakeInternalAPIClient(t *testing.T, objs ...client.Object) client.Client { + t.Helper() + return fake.NewClientBuilder().WithScheme(haltScheme(t)).WithObjects(objs...).Build() +} + +func TestResolveInternalAPI(t *testing.T) { + t.Run("happy: ClusterIP + token -> baseURL, token", func(t *testing.T) { + cl := fakeInternalAPIClient(t, internalAPIObjs("10.43.0.9", "s3cr3t")...) + base, tok, err := resolveInternalAPI(context.Background(), cl, bgControlNS) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tok != "s3cr3t" { + t.Fatalf("token = %q, want s3cr3t", tok) + } + // The URL must carry the resolved ClusterIP + internal port — not the cluster-DNS + // name, which the on-node host cannot resolve. + want := fmt.Sprintf("http://10.43.0.9:%d", platform.APIInternalPort) + if base != want { + t.Fatalf("baseURL = %q, want %q", base, want) + } + }) + + t.Run("headless Service (no ClusterIP) -> error", func(t *testing.T) { + cl := fakeInternalAPIClient(t, internalAPIObjs(corev1.ClusterIPNone, "s3cr3t")...) + if _, _, err := resolveInternalAPI(context.Background(), cl, bgControlNS); err == nil { + t.Fatal("want error for a Service with no ClusterIP") + } + }) + + t.Run("empty token -> error", func(t *testing.T) { + cl := fakeInternalAPIClient(t, internalAPIObjs("10.43.0.9", "")...) + if _, _, err := resolveInternalAPI(context.Background(), cl, bgControlNS); err == nil { + t.Fatal("want error for a Secret with no token") + } + }) +} + +func TestRequestBackup(t *testing.T) { + hc := &http.Client{Timeout: 2 * time.Second} + + t.Run("202 -> backing_up, and the request carries Bearer + os_user", func(t *testing.T) { + var gotAuth, gotOSUser, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.Path + var body struct { + OSUser string `json:"os_user"` + } + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + gotOSUser = body.OSUser + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + out, err := requestBackup(context.Background(), hc, srv.URL, "tok123", "survival", "alice") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.name != "survival" || out.status != "backing_up" { + t.Fatalf("outcome = %+v, want {survival backing_up}", out) + } + if gotAuth != "Bearer tok123" { + t.Fatalf("Authorization = %q, want Bearer tok123", gotAuth) + } + if gotOSUser != "alice" { + t.Fatalf("os_user in body = %q, want alice", gotOSUser) + } + if gotPath != "/api/v1/internal/servers/survival/backup" { + t.Fatalf("path = %q, want the internal backup path", gotPath) + } + }) + + // The status-code → friendly-message mapping is the peer's real logic; assert each + // well-known code produces a distinct operator-facing message. + cases := []struct { + name string + code int + expect string + }{ + {"409 not_stopped", http.StatusConflict, "must be stopped"}, + {"503 backup_unavailable", http.StatusServiceUnavailable, "not configured"}, + {"404 not found", http.StatusNotFound, "no such server"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + })) + defer srv.Close() + _, err := requestBackup(context.Background(), hc, srv.URL, "tok", "survival", "alice") + if err == nil || !strings.Contains(err.Error(), tc.expect) { + t.Fatalf("err = %v, want it to contain %q", err, tc.expect) + } + }) + } + + t.Run("transport failure -> unreachable message (break-glass needs the API alive)", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() // dial will fail + _, err := requestBackup(context.Background(), hc, srv.URL, "tok", "survival", "alice") + if err == nil || !strings.Contains(err.Error(), "unreachable") { + t.Fatalf("err = %v, want an 'unreachable' transport error", err) + } + }) +} diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index 7815889..410a124 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -152,7 +152,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { return 1 } - if !res.provisioned && !res.edgeConfigured && !res.halted { + if !res.provisioned && !res.edgeConfigured && !res.halted && !res.backedUp { fmt.Fprintln(stdout, "felis breakGlass: cancelled — no changes made.") return 0 } @@ -215,6 +215,14 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { } fmt.Fprintf(stdout, "Restart it from the panel, or set the MinecraftServer's spec.desiredState back to Running.\n") } + + if res.backedUp { + // The backup runs through the live felis-api (which audits it), so unlike halt + // there is no local audit-warning to surface — a resolve/HTTP failure would have + // come back as an error, not a backedUp result. + fmt.Fprintf(stdout, "\nfelis breakGlass: backup of %q started (status: %s).\n", res.backupServer, res.backupStatus) + fmt.Fprintln(stdout, "A one-shot Job writes the archive asynchronously; it appears in the panel's backups list when finished.") + } return 0 } @@ -516,6 +524,11 @@ type breakGlassResult struct { haltSystemServer bool haltAuditWarning string + // backup outcome (break-glass "back up a world now / Sync" op #31 §B4) + backedUp bool + backupServer string + backupStatus string + // Cloudflare-specific edge detail (set only when connectMethod is Cloudflare) edgeConfigured bool edgeAud string diff --git a/cmd/felis/tui_backupnow.go b/cmd/felis/tui_backupnow.go new file mode 100644 index 0000000..0906d74 --- /dev/null +++ b/cmd/felis/tui_backupnow.go @@ -0,0 +1,248 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// backupModel is the break-glass "back up a world now" screen (§B4 "Sync"). It mirrors +// haltModel's shape (async load → huh pick → async work → outcome card), but the work +// step is an HTTP call to the felis-api internal face rather than a direct CRD write: +// rendering a backup Job needs felis-api's deployment coordinates. All decision logic +// lives in backupnow.go (unit-tested); this file is the untested terminal glue. +// +// It reuses listServersForHalt for the picker (the same "list servers with phase" +// need) — the API enforces the stopped-gate, so a running pick returns a friendly 409. +type backupStep int + +const ( + backupLoading backupStep = iota // building the cluster client + listing servers + backupPick // choosing which world to snapshot + backupWorking // POSTing the internal backup endpoint + backupDone // outcome card, or the empty/error terminal note +) + +type backupListMsg struct { + cl client.Client + servers []haltableServer + err error +} + +type backupPerformedMsg struct { + outcome backupNowOutcome + err error +} + +// backupResultMsg is the terminal signal to the root: it records the outcome into +// breakGlassResult and quits. done is false for a cancel or an empty fleet. +type backupResultMsg struct { + outcome backupNowOutcome + done bool + err error +} + +type backupModel struct { + ctx context.Context + namespace string // minecraft ns: where the servers live (the picker lists these) + controlNS string // control ns: where the felis-api-internal Service + token live + osUser string + + step backupStep + cl client.Client + sp spinner.Model + form *huh.Form + + servers []haltableServer + pick string // huh-bound selected server name + outcome backupNowOutcome + loadErr error + empty bool + + width, height int +} + +func newBackupModel(ctx context.Context, namespace, controlNS, osUser string) *backupModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = tuiLabel + return &backupModel{ctx: ctx, namespace: namespace, controlNS: controlNS, osUser: osUser, sp: sp, step: backupLoading} +} + +func (m *backupModel) Init() tea.Cmd { return tea.Batch(m.sp.Tick, m.loadCmd()) } + +func (m *backupModel) loadCmd() tea.Cmd { + return func() tea.Msg { + cl, err := buildSystemServerClient() + if err != nil { + return backupListMsg{err: fmt.Errorf("connect to cluster: %w", err)} + } + servers, err := listServersForHalt(m.ctx, cl, m.namespace) + if err != nil { + return backupListMsg{err: fmt.Errorf("list servers: %w", err)} + } + return backupListMsg{cl: cl, servers: servers} + } +} + +func (m *backupModel) setSize(w, h int) { + m.width, m.height = w, h + if m.form != nil { + m.form = m.form.WithWidth(w).WithHeight(h) + } +} + +func (m *backupModel) sized(f *huh.Form) *huh.Form { + if m.width > 0 { + return f.WithWidth(m.width).WithHeight(m.height) + } + return f +} + +func (m *backupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case backupListMsg: + if msg.err != nil { + m.loadErr = msg.err + m.step = backupDone + return m, nil + } + m.cl = msg.cl + m.servers = msg.servers + if len(m.servers) == 0 { + m.empty = true + m.step = backupDone + return m, nil + } + m.step = backupPick + m.form = m.sized(m.buildPickForm()) + return m, m.form.Init() + + case backupPerformedMsg: + m.step = backupDone + if msg.err != nil { + m.loadErr = msg.err + return m, nil + } + m.outcome = msg.outcome + return m, nil + + case spinner.TickMsg: + if m.step == backupLoading || m.step == backupWorking { + var cmd tea.Cmd + m.sp, cmd = m.sp.Update(msg) + return m, cmd + } + return m, nil + + case tea.KeyMsg: + switch m.step { + case backupDone: + switch msg.String() { + case "ctrl+c", "esc", "enter": + return m, m.exitCmd() + } + return m, nil + case backupLoading, backupWorking: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + return m, nil + case backupPick: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + } + } + } + + if m.step == backupPick && m.form != nil { + form, cmd := m.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.form = f + } + switch m.form.State { + case huh.StateCompleted: + return m.onPicked() + case huh.StateAborted: + return m, tea.Quit + } + return m, cmd + } + return m, nil +} + +func (m *backupModel) onPicked() (tea.Model, tea.Cmd) { + name := strings.TrimSpace(m.pick) + m.step = backupWorking + cl, controlNS, osUser := m.cl, m.controlNS, m.osUser + return m, tea.Batch(m.sp.Tick, func() tea.Msg { + out, err := performBackupNow(m.ctx, cl, controlNS, name, osUser) + return backupPerformedMsg{outcome: out, err: err} + }) +} + +func (m *backupModel) exitCmd() tea.Cmd { + out, done, err := m.outcome, !m.empty && m.loadErr == nil, m.loadErr + return func() tea.Msg { return backupResultMsg{outcome: out, done: done, err: err} } +} + +func (m *backupModel) buildPickForm() *huh.Form { + opts := make([]huh.Option[string], 0, len(m.servers)) + for _, s := range m.servers { + opts = append(opts, huh.NewOption(fmt.Sprintf("%s (%s)", s.name, s.phase), s.name)) + } + return m.sized(newFelisForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Back up a world now"). + Description("Snapshots a STOPPED server's world through the live felis-api."). + Value(&m.pick). + Options(opts...), + huh.NewNote().Description( + "The world PVC is single-writer, so the server must be stopped. If it is still "+ + "running, halt it first, then back it up."), + ))) +} + +func (m *backupModel) View() string { + switch m.step { + case backupLoading: + return " " + m.sp.View() + " " + tuiHint.Render("Connecting to the cluster…") + "\n" + case backupWorking: + return " " + m.sp.View() + " " + tuiHint.Render("Requesting backup of "+strings.TrimSpace(m.pick)+"…") + "\n" + case backupDone: + return m.doneView() + default: + if m.form == nil { + return "" + } + return m.form.View() + } +} + +func (m *backupModel) doneView() string { + var b strings.Builder + switch { + case m.loadErr != nil: + b.WriteString(tuiWarn.Render("Backup could not be started.") + "\n\n") + b.WriteString(tuiCardStyle.Render(m.loadErr.Error()) + "\n\n") + case m.empty: + b.WriteString(tuiHint.Render("No servers to back up in namespace "+m.namespace+".") + "\n\n") + default: + b.WriteString(tuiSuccessBanner(m.outcome.name+" backup started.") + "\n\n") + var box strings.Builder + box.WriteString(tuiLabel.Render("server ") + m.outcome.name + "\n") + box.WriteString(tuiLabel.Render("status ") + m.outcome.status) + box.WriteString("\n\n" + tuiHint.Render( + "A one-shot Job writes the archive asynchronously; it appears in the panel's "+ + "backups list when finished.")) + b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n") + } + b.WriteString(tuiAction("enter", "continue")) + return b.String() +} diff --git a/cmd/felis/tui_menu.go b/cmd/felis/tui_menu.go index 77e856c..c4102b1 100644 --- a/cmd/felis/tui_menu.go +++ b/cmd/felis/tui_menu.go @@ -15,6 +15,7 @@ const ( bgProvisionOwner bgOperation = iota bgAddOperator bgHaltServer + bgSyncBackup ) // menuChoiceMsg is emitted to the root once the operator picks an operation. The @@ -52,6 +53,7 @@ func (m *menuModel) build() *huh.Form { huh.NewOption("Provision or reset the Owner account", bgProvisionOwner), huh.NewOption("Add an Operator account", bgAddOperator), huh.NewOption("Halt a running server", bgHaltServer), + huh.NewOption("Back up a world now (Sync)", bgSyncBackup), ), // A dim footnote spelling out the one behavioural difference that matters: // Owner-reset re-enables local-password login, operator-add never touches the diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go index 762a3d9..2ce4c54 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -5,6 +5,7 @@ import ( "strings" "felis.lolicon.best/internal/cfsetup" + "felis.lolicon.best/internal/platform" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -223,6 +224,10 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.adopt(newOperatorModel(m.ctx, m.store, m.osUser)) case bgHaltServer: return m.adopt(newHaltModel(m.ctx, m.store, m.namespace, m.osUser)) + case bgSyncBackup: + // The picker lists workload servers (m.namespace); the felis-api-internal + // Service + service token the peer dials live in the control namespace. + return m.adopt(newBackupModel(m.ctx, m.namespace, platform.DefaultControlNamespace, m.osUser)) default: return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, m.adminExists)) } @@ -248,6 +253,21 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Quit + case backupResultMsg: + // Sync backup is terminal in break-glass, mirroring halt: record the durable + // summary and quit. A resolve/HTTP failure routes through the error path; an + // empty fleet or cancel leaves backedUp false. + if msg.err != nil { + m.err = msg.err + return m, tea.Quit + } + if msg.done { + m.result.backedUp = true + m.result.backupServer = msg.outcome.name + m.result.backupStatus = msg.outcome.status + } + return m, tea.Quit + case ownerResultMsg: if msg.err != nil { m.err = msg.err diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d8f009a..16eef2a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1034,6 +1034,18 @@ paths: security: [{ serviceToken: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } + requestBody: + required: false + description: >- + Optional accountability hint. The console passes the OS user at the + keyboard so the audit row names the operator rather than the generic + "break-glass"; absent/blank falls back to "break-glass". + content: + application/json: + schema: + type: object + properties: + os_user: { type: string } responses: '202': description: Backup started. diff --git a/internal/api/handlers_backup_now_test.go b/internal/api/handlers_backup_now_test.go index be148eb..42af752 100644 --- a/internal/api/handlers_backup_now_test.go +++ b/internal/api/handlers_backup_now_test.go @@ -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 diff --git a/internal/api/handlers_backups.go b/internal/api/handlers_backups.go index 65d98a8..bd4b877 100644 --- a/internal/api/handlers_backups.go +++ b/internal/api/handlers_backups.go @@ -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