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

+122
View File
@@ -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)
}
+144
View File
@@ -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)
}
})
}
+14 -1
View File
@@ -152,7 +152,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
return 1 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.") fmt.Fprintln(stdout, "felis breakGlass: cancelled — no changes made.")
return 0 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") 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 return 0
} }
@@ -516,6 +524,11 @@ type breakGlassResult struct {
haltSystemServer bool haltSystemServer bool
haltAuditWarning string 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) // Cloudflare-specific edge detail (set only when connectMethod is Cloudflare)
edgeConfigured bool edgeConfigured bool
edgeAud string edgeAud string
+248
View File
@@ -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()
}
+2
View File
@@ -15,6 +15,7 @@ const (
bgProvisionOwner bgOperation = iota bgProvisionOwner bgOperation = iota
bgAddOperator bgAddOperator
bgHaltServer bgHaltServer
bgSyncBackup
) )
// menuChoiceMsg is emitted to the root once the operator picks an operation. The // 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("Provision or reset the Owner account", bgProvisionOwner),
huh.NewOption("Add an Operator account", bgAddOperator), huh.NewOption("Add an Operator account", bgAddOperator),
huh.NewOption("Halt a running server", bgHaltServer), 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: // A dim footnote spelling out the one behavioural difference that matters:
// Owner-reset re-enables local-password login, operator-add never touches the // Owner-reset re-enables local-password login, operator-add never touches the
+20
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"felis.lolicon.best/internal/cfsetup" "felis.lolicon.best/internal/cfsetup"
"felis.lolicon.best/internal/platform"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "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)) return m.adopt(newOperatorModel(m.ctx, m.store, m.osUser))
case bgHaltServer: case bgHaltServer:
return m.adopt(newHaltModel(m.ctx, m.store, m.namespace, m.osUser)) 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: default:
return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, m.adminExists)) 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 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: case ownerResultMsg:
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
+12
View File
@@ -1034,6 +1034,18 @@ paths:
security: [{ serviceToken: [] }] security: [{ serviceToken: [] }]
parameters: parameters:
- { name: name, in: path, required: true, schema: { type: string } } - { 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: responses:
'202': '202':
description: Backup started. description: Backup started.
+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) { t.Run("running server -> 409 not_stopped, no backup", func(t *testing.T) {
api, _, cl, backuper := mk() api, _, cl, backuper := mk()
cl.byName["survival"].Ready = true 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 // 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 // on the node. It audits the action to "break-glass" so a console-initiated backup is
// distinguishable from an owner's self-service one. // 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) { func (a *API) handleInternalBackup(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name") name := r.PathValue("name")
if err := naming.ValidateServerName(name); err != nil { if err := naming.ValidateServerName(name); err != nil {
@@ -236,13 +242,27 @@ func (a *API) handleInternalBackup(w http.ResponseWriter, r *http.Request) {
return 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) rec, err := a.Repo.ServerByName(r.Context(), name)
if err != nil { if err != nil {
a.writeLookupError(w, r, err) a.writeLookupError(w, r, err)
return 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 // enqueueBackup is the shared tail of both backup faces: the RWO stopped-gate, the