Unverified Commit fc748d34 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent 4918d934
Loading
Loading
Loading
Loading

cmd/felis/backupnow.go

0 → 100644
+122 −0
Changes for cmd/felis/backupnow.go: 122 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 −0
Changes for cmd/felis/backupnow_test.go: 144 added lines, 0 removed lines.
Original line number Diff line number Diff line
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
Changes for cmd/felis/breakglass.go: 14 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
+248 −0
Changes for cmd/felis/tui_backupnow.go: 248 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 −0
Changes for cmd/felis/tui_menu.go: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
Loading