feat(breakglass): add halt-a-server op to the recovery console (§B4)

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.
This commit is contained in:
flyemoji committed 2026-07-06 23:50:09 +09:00
1 parent abad137a01
commit c2ee21ae05
8 files changed
+649 -10

No files matched your search

+33 -8
View File
@@ -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
+139
View File
@@ -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,
})
}
+179
View File
@@ -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)
}
})
}
+1 -1
View File
@@ -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
+268
View File
@@ -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."
}
+2
View File
@@ -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
+26 -1
View File
@@ -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
+1
View File
@@ -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,