From c2ee21ae05644fcbb124bf8ff841642ce1f41c51 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Mon, 6 Jul 2026 23:50:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(breakglass):=20add=20halt-a-server=20op=20?= =?UTF-8?q?to=20the=20recovery=20console=20(=C2=A7B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a root-gated "Halt a running server" operation to the break-glass console. The operator picks a server from the live fleet and the console flips that MinecraftServer CRD's spec.desiredState to Stopped via a spec-only merge patch, disjoint from the operator's status writes, so it cannot race or clobber reconciliation. It is the panel-independent emergency stop for when the box still has root plus a kubeconfig. System servers (login/lobby) are allowed but flagged: a system tag in the picker and an explicit WARNING in the post-exit summary, since halting login takes the shared auth front door down with no fallback. Audit is best-effort so a halt still works with the audit sink down. Already-stopped is a distinct no-op. The core (halt.go) is unit-tested against a real controller-runtime fake client that applies the patch. --- cmd/felis/breakglass.go | 41 ++++-- cmd/felis/halt.go | 139 +++++++++++++++++++ cmd/felis/halt_test.go | 179 +++++++++++++++++++++++++ cmd/felis/setup.go | 2 +- cmd/felis/tui_halt.go | 268 +++++++++++++++++++++++++++++++++++++ cmd/felis/tui_menu.go | 2 + cmd/felis/tui_root.go | 27 +++- cmd/felis/tui_root_test.go | 1 + 8 files changed, 649 insertions(+), 10 deletions(-) create mode 100644 cmd/felis/halt.go create mode 100644 cmd/felis/halt_test.go create mode 100644 cmd/felis/tui_halt.go diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index 707a6a4..7815889 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -146,13 +146,13 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { return 1 } - res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, accountableOSUser(), adminExists) + res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, cfg.K8s.Namespace, accountableOSUser(), adminExists) if err != nil { fmt.Fprintf(stderr, "felis breakGlass: %v\n", err) return 1 } - if !res.provisioned && !res.edgeConfigured { + if !res.provisioned && !res.edgeConfigured && !res.halted { fmt.Fprintln(stdout, "felis breakGlass: cancelled — no changes made.") return 0 } @@ -198,6 +198,23 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stdout, "Then start the tunnel: cloudflared tunnel run") fmt.Fprintln(stdout, "Verify the Access app actually guards the admin face before relying on it.") } + + if res.halted { + verb := "is now stopping" + if res.haltAlreadyStopped { + verb = "was already stopped" + } + fmt.Fprintf(stdout, "\nfelis breakGlass: server %q %s (namespace %s).\n", res.haltServer, verb, res.haltNamespace) + if res.haltSystemServer { + // login has no fallback (systemservers.go): stopping it takes the whole proxy + // front door down, so the summary says so explicitly rather than burying it. + fmt.Fprintf(stdout, "WARNING: %q is a system server — the shared front door is down until it is running again.\n", res.haltServer) + } + if res.haltAuditWarning != "" { + fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.haltAuditWarning) + } + fmt.Fprintf(stdout, "Restart it from the panel, or set the MinecraftServer's spec.desiredState back to Running.\n") + } return 0 } @@ -491,6 +508,14 @@ type breakGlassResult struct { storageMethod storageMethod storageDetail string + // halt outcome (break-glass "halt a server" op #31) + halted bool + haltServer string + haltNamespace string + haltAlreadyStopped bool + haltSystemServer bool + haltAuditWarning string + // Cloudflare-specific edge detail (set only when connectMethod is Cloudflare) edgeConfigured bool edgeAud string @@ -518,16 +543,16 @@ const ( cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/" ) -func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool) (breakGlassResult, error) { - return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, consoleModeBreakGlass) +func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) { + return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeBreakGlass) } -func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool) (breakGlassResult, error) { - return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, consoleModeSetup) +func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool) (breakGlassResult, error) { + return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup) } -func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) { - rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, mode) +func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) { + rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode) final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run() if err != nil { return breakGlassResult{}, err diff --git a/cmd/felis/halt.go b/cmd/felis/halt.go new file mode 100644 index 0000000..3ef1a6e --- /dev/null +++ b/cmd/felis/halt.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + + "felis.lolicon.best/internal/api" + "felis.lolicon.best/internal/apis/felis/v1alpha1" + "felis.lolicon.best/internal/naming" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// halt is the break-glass "stop a running server now" op (#31). Its authority is +// the same as the rest of the console: local root holding the cluster kubeconfig. +// The console does not stop the pod itself — it flips the MinecraftServer's desired +// state to Stopped and lets the operator reconcile that into a graceful shutdown, so +// a halt is exactly the CRD write the operator already knows how to honour. +// +// This file is the pure core — no bubbletea, no huh — so the whole thing is unit +// tested against a controller-runtime fake client. The TUI shell lives in +// tui_halt.go and only calls into here. + +// haltableServer is a MinecraftServer projected down to what the halt picker shows: +// its name, its observed phase (or desired state before the operator has reconciled +// it), and whether it is a platform system server whose halt takes the front door +// down. +type haltableServer struct { + name string + phase string + system bool +} + +// haltOutcome is the durable result of a halt attempt, surfaced in the TUI card and +// re-printed to the normal screen after the alt-screen tears down. +type haltOutcome struct { + name string + namespace string + alreadyStopped bool + system bool // login/lobby — halting these takes the auth front door down + auditErr error // non-nil if the accountability row could not be written +} + +// listServersForHalt lists the MinecraftServers an operator may halt in the given +// namespace, projecting only what the picker renders. The phase falls back to the +// desired state for a server the operator has not yet reconciled (empty status). +func listServersForHalt(ctx context.Context, cl client.Client, namespace string) ([]haltableServer, error) { + var list v1alpha1.MinecraftServerList + if err := cl.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + out := make([]haltableServer, 0, len(list.Items)) + for i := range list.Items { + ms := &list.Items[i] + phase := string(ms.Status.Phase) + if phase == "" { + phase = string(ms.Spec.DesiredState) // not yet reconciled — show intent + } + out = append(out, haltableServer{ + name: ms.Name, + phase: phase, + system: isSystemServer(ms.Name), + }) + } + return out, nil +} + +// haltServer flips one MinecraftServer's desiredState to Stopped with a spec-only +// merge patch. MergeFrom (not Update) is deliberate: the operator writes status on +// the same object continuously, and a full-object Update would race and clobber it, +// whereas a merge patch of spec.desiredState touches a disjoint field. Halting a +// server already Stopped is a no-op, reported via alreadyStopped so the console can +// say "already stopped" instead of claiming it just stopped it. +func haltServer(ctx context.Context, cl client.Client, namespace, name string) (haltOutcome, error) { + var ms v1alpha1.MinecraftServer + if err := cl.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, &ms); err != nil { + if apierrors.IsNotFound(err) { + return haltOutcome{}, fmt.Errorf("no MinecraftServer %q in namespace %q", name, namespace) + } + return haltOutcome{}, fmt.Errorf("get server %q: %w", name, err) + } + out := haltOutcome{name: name, namespace: namespace, system: isSystemServer(name)} + if ms.Spec.DesiredState == v1alpha1.DesiredStopped { + out.alreadyStopped = true + return out, nil + } + patch := client.MergeFrom(ms.DeepCopy()) + ms.Spec.DesiredState = v1alpha1.DesiredStopped + if err := cl.Patch(ctx, &ms, patch); err != nil { + return haltOutcome{}, fmt.Errorf("halt server %q: %w", name, err) + } + return out, nil +} + +// isSystemServer reports whether name is a platform-provisioned system server. The +// login gate has no fallback (systemservers.go), so halting it locks every player +// out of the whole proxy — the console warns when the target is one rather than +// forbidding it, since break-glass is deliberately full power. +func isSystemServer(name string) bool { + return name == naming.SystemLoginServer || name == naming.SystemLobbyServer +} + +// performHalt runs haltServer and records a best-effort accountability audit row. It +// mirrors performBreakGlass: the halt succeeds even when the audit sink is unhappy +// (break-glass must work with logging down), and any audit error rides back in the +// outcome for the console to surface. accountable is the OS user who escalated to +// root — attribution, not proof (the root gate is the real authority). +func performHalt(ctx context.Context, cl client.Client, s ownerStore, namespace, name, accountable string) (haltOutcome, error) { + out, err := haltServer(ctx, cl, namespace, name) + if err != nil { + return haltOutcome{}, err + } + out.auditErr = auditHalt(ctx, s, out, accountable) + return out, nil +} + +// auditHalt writes the halt accountability row under the break_glass.halt action, +// recording who halted what and whether it was a no-op or a system server. +func auditHalt(ctx context.Context, s ownerStore, out haltOutcome, accountable string) error { + blob, err := json.Marshal(map[string]any{ + "server": out.name, + "namespace": out.namespace, + "os_user": accountable, + "already_stopped": out.alreadyStopped, + "system_server": out.system, + }) + if err != nil { + return err + } + return s.Audit(ctx, api.AuditEntry{ + Actor: accountable, + Source: "break-glass", + Action: "break_glass.halt", + Payload: blob, + }) +} diff --git a/cmd/felis/halt_test.go b/cmd/felis/halt_test.go new file mode 100644 index 0000000..76080a8 --- /dev/null +++ b/cmd/felis/halt_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "context" + "strings" + "testing" + + "felis.lolicon.best/internal/apis/felis/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const haltNS = "minecraft" + +func haltScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatalf("clientgo scheme: %v", err) + } + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("v1alpha1 scheme: %v", err) + } + return scheme +} + +func mcServer(name string, desired v1alpha1.DesiredState, phase v1alpha1.Phase) *v1alpha1.MinecraftServer { + return &v1alpha1.MinecraftServer{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: haltNS}, + Spec: v1alpha1.MinecraftServerSpec{DesiredState: desired}, + Status: v1alpha1.MinecraftServerStatus{Phase: phase}, + } +} + +func haltClient(t *testing.T, objs ...client.Object) client.Client { + t.Helper() + return fake.NewClientBuilder().WithScheme(haltScheme(t)).WithObjects(objs...).Build() +} + +// desiredStateOf reads a server's spec.desiredState straight back from the client, +// so the patch is verified by its actual persisted effect, not by "Patch was called". +func desiredStateOf(t *testing.T, cl client.Client, name string) v1alpha1.DesiredState { + t.Helper() + var ms v1alpha1.MinecraftServer + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: haltNS, Name: name}, &ms); err != nil { + t.Fatalf("get %q back: %v", name, err) + } + return ms.Spec.DesiredState +} + +func TestHaltServer(t *testing.T) { + ctx := context.Background() + + t.Run("running server is patched to Stopped", func(t *testing.T) { + cl := haltClient(t, mcServer("survival", v1alpha1.DesiredRunning, v1alpha1.PhaseRunning)) + out, err := haltServer(ctx, cl, haltNS, "survival") + if err != nil { + t.Fatalf("haltServer: %v", err) + } + if out.alreadyStopped { + t.Error("alreadyStopped = true, want false for a running server") + } + if got := desiredStateOf(t, cl, "survival"); got != v1alpha1.DesiredStopped { + t.Errorf("desiredState after halt = %q, want Stopped", got) + } + }) + + t.Run("already-stopped server is a reported no-op", func(t *testing.T) { + cl := haltClient(t, mcServer("survival", v1alpha1.DesiredStopped, v1alpha1.PhaseStopped)) + out, err := haltServer(ctx, cl, haltNS, "survival") + if err != nil { + t.Fatalf("haltServer: %v", err) + } + if !out.alreadyStopped { + t.Error("alreadyStopped = false, want true for an already-stopped server") + } + if got := desiredStateOf(t, cl, "survival"); got != v1alpha1.DesiredStopped { + t.Errorf("desiredState = %q, want it left Stopped", got) + } + }) + + t.Run("missing server is a clear error, not a patch", func(t *testing.T) { + cl := haltClient(t) + _, err := haltServer(ctx, cl, haltNS, "ghost") + if err == nil { + t.Fatal("haltServer(ghost) = nil error, want not-found error") + } + if !strings.Contains(err.Error(), "ghost") { + t.Errorf("error %q does not name the missing server", err) + } + }) + + t.Run("halting login is allowed and flagged a system server", func(t *testing.T) { + cl := haltClient(t, mcServer("login", v1alpha1.DesiredRunning, v1alpha1.PhaseRunning)) + out, err := haltServer(ctx, cl, haltNS, "login") + if err != nil { + t.Fatalf("haltServer(login): %v", err) + } + if !out.system { + t.Error("system = false, want true for the login front-door server") + } + if got := desiredStateOf(t, cl, "login"); got != v1alpha1.DesiredStopped { + t.Errorf("desiredState after halt = %q, want Stopped", got) + } + }) +} + +func TestListServersForHalt(t *testing.T) { + cl := haltClient(t, + mcServer("survival", v1alpha1.DesiredRunning, ""), // no status yet → phase falls back to intent + mcServer("login", v1alpha1.DesiredRunning, ""), + ) + got, err := listServersForHalt(context.Background(), cl, haltNS) + if err != nil { + t.Fatalf("listServersForHalt: %v", err) + } + if len(got) != 2 { + t.Fatalf("listed %d servers, want 2", len(got)) + } + by := map[string]haltableServer{} + for _, s := range got { + by[s.name] = s + } + if s := by["survival"]; s.system || s.phase != "Running" { + t.Errorf("survival projected %+v, want system=false phase=Running (desired-state fallback)", s) + } + if s := by["login"]; !s.system { + t.Errorf("login projected system=false, want true") + } +} + +func TestPerformHalt(t *testing.T) { + ctx := context.Background() + + t.Run("halts and records an accountability audit row", func(t *testing.T) { + cl := haltClient(t, mcServer("survival", v1alpha1.DesiredRunning, v1alpha1.PhaseRunning)) + f := &fakeOwnerStore{} + out, err := performHalt(ctx, cl, f, haltNS, "survival", "deploybot") + if err != nil { + t.Fatalf("performHalt: %v", err) + } + if out.auditErr != nil { + t.Errorf("auditErr = %v, want nil", out.auditErr) + } + if got := desiredStateOf(t, cl, "survival"); got != v1alpha1.DesiredStopped { + t.Errorf("desiredState after performHalt = %q, want Stopped", got) + } + e, payload := auditOf(t, f) + if e.Action != "break_glass.halt" { + t.Errorf("audit action = %q, want break_glass.halt", e.Action) + } + if e.Actor != "deploybot" { + t.Errorf("audit actor = %q, want deploybot", e.Actor) + } + if payload["server"] != "survival" || payload["system_server"] != false { + t.Errorf("audit payload = %v, want server=survival system_server=false", payload) + } + }) + + t.Run("a failed audit sink does not fail the halt", func(t *testing.T) { + cl := haltClient(t, mcServer("survival", v1alpha1.DesiredRunning, v1alpha1.PhaseRunning)) + f := &fakeOwnerStore{auditErr: context.DeadlineExceeded} + out, err := performHalt(ctx, cl, f, haltNS, "survival", "deploybot") + if err != nil { + t.Fatalf("performHalt returned error on audit failure, want halt to still succeed: %v", err) + } + if out.auditErr == nil { + t.Error("auditErr = nil, want the sink failure surfaced") + } + if got := desiredStateOf(t, cl, "survival"); got != v1alpha1.DesiredStopped { + t.Errorf("desiredState = %q, want Stopped even though the audit failed", got) + } + }) +} diff --git a/cmd/felis/setup.go b/cmd/felis/setup.go index 563f04c..ac30edc 100644 --- a/cmd/felis/setup.go +++ b/cmd/felis/setup.go @@ -102,7 +102,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { } defer setup.drv.Close() - res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database.URL, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, accountableOSUser(), setup.adminExists) + res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database.URL, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, setup.cfg.K8s.Namespace, accountableOSUser(), setup.adminExists) if err != nil { fmt.Fprintf(stderr, "felis setup: %v\n", err) return 1 diff --git a/cmd/felis/tui_halt.go b/cmd/felis/tui_halt.go new file mode 100644 index 0000000..b17245b --- /dev/null +++ b/cmd/felis/tui_halt.go @@ -0,0 +1,268 @@ +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" +) + +// haltModel is the break-glass "halt a server" screen. It mirrors ownerModel's +// shape (async load → huh pick → async work → outcome card) but carries no auth +// branch: the console's root gate is the authority, and the accountable OS user is +// already known. All decision logic lives in halt.go (unit-tested); this file is the +// untested terminal glue, like the other console screens. +type haltStep int + +const ( + haltLoading haltStep = iota // building the cluster client + listing servers + haltPick // choosing which server to halt + haltWorking // patching desiredState=Stopped + haltDone // outcome card, or the empty/error terminal note +) + +// haltListMsg carries the built cluster client and haltable server list (or the +// failure of either) back from the async load. +type haltListMsg struct { + cl client.Client + servers []haltableServer + err error +} + +type haltPerformedMsg struct { + outcome haltOutcome + err error +} + +// haltResultMsg is the terminal signal to the root: it records the outcome into +// breakGlassResult and quits, so cmdBreakGlass can re-print it after the alt-screen +// is torn down. done is false for a cancel or an empty fleet (no change made). +type haltResultMsg struct { + outcome haltOutcome + done bool + err error +} + +type haltModel struct { + ctx context.Context + store ownerStore + namespace string + osUser string + + step haltStep + cl client.Client + sp spinner.Model + form *huh.Form + + servers []haltableServer + pick string // huh-bound selected server name + outcome haltOutcome + loadErr error + empty bool + + width, height int +} + +func newHaltModel(ctx context.Context, store ownerStore, namespace, osUser string) *haltModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = tuiLabel + return &haltModel{ctx: ctx, store: store, namespace: namespace, osUser: osUser, sp: sp, step: haltLoading} +} + +func (m *haltModel) Init() tea.Cmd { return tea.Batch(m.sp.Tick, m.loadCmd()) } + +// loadCmd builds the cluster client and lists haltable servers off the UI thread. +// The client build (no kubeconfig) and the list (no API) can each fail; either +// becomes the screen's terminal error rather than a panic. +func (m *haltModel) loadCmd() tea.Cmd { + return func() tea.Msg { + cl, err := buildSystemServerClient() + if err != nil { + return haltListMsg{err: fmt.Errorf("connect to cluster: %w", err)} + } + servers, err := listServersForHalt(m.ctx, cl, m.namespace) + if err != nil { + return haltListMsg{err: fmt.Errorf("list servers: %w", err)} + } + return haltListMsg{cl: cl, servers: servers} + } +} + +func (m *haltModel) setSize(w, h int) { + m.width, m.height = w, h + if m.form != nil { + m.form = m.form.WithWidth(w).WithHeight(h) + } +} + +func (m *haltModel) sized(f *huh.Form) *huh.Form { + if m.width > 0 { + return f.WithWidth(m.width).WithHeight(m.height) + } + return f +} + +func (m *haltModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case haltListMsg: + if msg.err != nil { + m.loadErr = msg.err + m.step = haltDone + return m, nil + } + m.cl = msg.cl + m.servers = msg.servers + if len(m.servers) == 0 { + m.empty = true + m.step = haltDone + return m, nil + } + m.step = haltPick + m.form = m.sized(m.buildPickForm()) + return m, m.form.Init() + + case haltPerformedMsg: + m.step = haltDone + if msg.err != nil { + m.loadErr = msg.err + return m, nil + } + m.outcome = msg.outcome + return m, nil + + case spinner.TickMsg: + if m.step == haltLoading || m.step == haltWorking { + var cmd tea.Cmd + m.sp, cmd = m.sp.Update(msg) + return m, cmd + } + return m, nil + + case tea.KeyMsg: + switch m.step { + case haltDone: + switch msg.String() { + case "ctrl+c", "esc", "enter": + return m, m.exitCmd() + } + return m, nil + case haltLoading, haltWorking: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + return m, nil + case haltPick: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + } + } + } + + if m.step == haltPick && 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 *haltModel) onPicked() (tea.Model, tea.Cmd) { + name := strings.TrimSpace(m.pick) + m.step = haltWorking + cl, ns, store, osUser := m.cl, m.namespace, m.store, m.osUser + return m, tea.Batch(m.sp.Tick, func() tea.Msg { + out, err := performHalt(m.ctx, cl, store, ns, name, osUser) + return haltPerformedMsg{outcome: out, err: err} + }) +} + +// exitCmd hands the outcome to the root: a load/perform error routes through the +// root's error path, a real halt records the durable summary, and an empty fleet is +// a benign cancel. +func (m *haltModel) exitCmd() tea.Cmd { + out, done, err := m.outcome, !m.empty && m.loadErr == nil, m.loadErr + return func() tea.Msg { return haltResultMsg{outcome: out, done: done, err: err} } +} + +func (m *haltModel) buildPickForm() *huh.Form { + opts := make([]huh.Option[string], 0, len(m.servers)) + for _, s := range m.servers { + label := fmt.Sprintf("%s (%s)", s.name, s.phase) + if s.system { + label += " ⚠ system — front door" + } + opts = append(opts, huh.NewOption(label, s.name)) + } + return m.sized(newFelisForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Halt a server"). + Description("Sets desiredState=Stopped; the operator reconciles a graceful shutdown."). + Value(&m.pick). + Options(opts...), + huh.NewNote().Description( + "Halting a ⚠ system server stops shared infrastructure: halting login takes "+ + "the whole auth front door down (it has no fallback)."), + ))) +} + +func (m *haltModel) View() string { + switch m.step { + case haltLoading: + return " " + m.sp.View() + " " + tuiHint.Render("Connecting to the cluster…") + "\n" + case haltWorking: + return " " + m.sp.View() + " " + tuiHint.Render("Halting "+strings.TrimSpace(m.pick)+"…") + "\n" + case haltDone: + return m.doneView() + default: + if m.form == nil { + return "" + } + return m.form.View() + } +} + +func (m *haltModel) doneView() string { + var b strings.Builder + switch { + case m.loadErr != nil: + b.WriteString(tuiWarn.Render("Halt failed.") + "\n\n") + b.WriteString(tuiCardStyle.Render(m.loadErr.Error()) + "\n\n") + case m.empty: + b.WriteString(tuiHint.Render("No servers to halt in namespace "+m.namespace+".") + "\n\n") + default: + b.WriteString(tuiSuccessBanner(haltHeadline(m.outcome)) + "\n\n") + var box strings.Builder + box.WriteString(tuiLabel.Render("server ") + m.outcome.name + "\n") + box.WriteString(tuiLabel.Render("namespace ") + m.outcome.namespace) + if m.outcome.system { + box.WriteString("\n\n" + tuiWarn.Render("This is a system server — the shared front door is now going down.")) + } + if m.outcome.auditErr != nil { + box.WriteString("\n\n" + tuiWarn.Render("Audit warning: "+m.outcome.auditErr.Error())) + } + b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n") + } + b.WriteString(tuiAction("enter", "continue")) + return b.String() +} + +func haltHeadline(out haltOutcome) string { + if out.alreadyStopped { + return out.name + " was already stopped." + } + return out.name + " is stopping." +} diff --git a/cmd/felis/tui_menu.go b/cmd/felis/tui_menu.go index 8b38c42..77e856c 100644 --- a/cmd/felis/tui_menu.go +++ b/cmd/felis/tui_menu.go @@ -14,6 +14,7 @@ type bgOperation int const ( bgProvisionOwner bgOperation = iota bgAddOperator + bgHaltServer ) // menuChoiceMsg is emitted to the root once the operator picks an operation. The @@ -50,6 +51,7 @@ func (m *menuModel) build() *huh.Form { // recovery flow, and landing on it keeps that path a single Enter. huh.NewOption("Provision or reset the Owner account", bgProvisionOwner), huh.NewOption("Add an Operator account", bgAddOperator), + huh.NewOption("Halt a running server", bgHaltServer), ), // 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 4c7d114..762a3d9 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -139,10 +139,11 @@ type rootModel struct { adminHost string panelHost string accessAud string + namespace string // minecraft workload namespace (cfg.K8s.Namespace); target of the halt op adminExists bool } -func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool, mode consoleMode) *rootModel { +func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode) *rootModel { rm := &rootModel{ ctx: ctx, reviewing: -1, @@ -153,6 +154,7 @@ func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, admi adminHost: adminHostname, panelHost: panelHostname, accessAud: accessAud, + namespace: namespace, adminExists: adminExists, mode: mode, result: breakGlassResult{ @@ -219,10 +221,33 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.op { case bgAddOperator: return m.adopt(newOperatorModel(m.ctx, m.store, m.osUser)) + case bgHaltServer: + return m.adopt(newHaltModel(m.ctx, m.store, m.namespace, m.osUser)) default: return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, m.adminExists)) } + case haltResultMsg: + // Halt is terminal in break-glass: record the durable summary (so cmdBreakGlass + // can re-print it past the alt-screen teardown) and quit. A load/perform failure + // routes through the root's error path; an empty fleet or cancel leaves halted + // false, so the summary reports "no changes made". + if msg.err != nil { + m.err = msg.err + return m, tea.Quit + } + if msg.done { + m.result.halted = true + m.result.haltServer = msg.outcome.name + m.result.haltNamespace = msg.outcome.namespace + m.result.haltAlreadyStopped = msg.outcome.alreadyStopped + m.result.haltSystemServer = msg.outcome.system + if msg.outcome.auditErr != nil { + m.result.haltAuditWarning = msg.outcome.auditErr.Error() + } + } + return m, tea.Quit + case ownerResultMsg: if msg.err != nil { m.err = msg.err diff --git a/cmd/felis/tui_root_test.go b/cmd/felis/tui_root_test.go index 45b9c9d..e73736e 100644 --- a/cmd/felis/tui_root_test.go +++ b/cmd/felis/tui_root_test.go @@ -35,6 +35,7 @@ func newTestRoot(adminExists bool, mode consoleMode, accessAud string) *rootMode "admin.felis.example.com", "panel.felis.example.com", accessAud, + "minecraft", "root", adminExists, mode,