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.
140 lines
5.6 KiB
Go
140 lines
5.6 KiB
Go
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,
|
|
})
|
|
}
|