feat(platform): add node orchestration and the felis entrypoint

The platform package that places servers across nodes and wires the operator, build, restore, and reaper subsystems, plus cmd/felis, the single binary that runs them.
This commit is contained in:
flyemoji committed 2026-06-26 23:32:38 +09:00
1 parent b508fccc6f
commit 47fcd90f75
21 files changed
+3713

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"net/http"
"os"
"time"
"felis.lolicon.best/internal/api"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/build"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/restore"
"felis.lolicon.best/internal/store"
"felis.lolicon.best/internal/submit"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/client-go/kubernetes"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// cmdAPI runs felis-api: two listeners, two middleware chains (spec §7). The
// internal face (service token) is fully wired. The external face is wired but
// fails closed until an Access JWKS key function is configured — the verifier's
// audience logic is unit-tested (internal/api), the JWKS source is a deployment
// integration point.
func cmdAPI(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("api", flag.ContinueOnError)
fs.SetOutput(stderr)
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
internalAddr := fs.String("internal-addr", ":8081", "internal-face listen address (service token, no Zero Trust)")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, err := config.Load(*cfgPath)
if err != nil {
fmt.Fprintf(stderr, "felis api: %v\n", err)
return 1
}
ctx := ctrl.SetupSignalHandler()
drv, err := store.Open(ctx, cfg.Database.URL)
if err != nil {
fmt.Fprintf(stderr, "felis api: open database: %v\n", err)
return 1
}
defer drv.Close()
scheme := runtime.NewScheme()
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(v1alpha1.AddToScheme(scheme))
// Both clients are built from the SAME rest.Config. The controller-runtime
// client.Client drives CRDs/Secrets/Jobs (cluster, console-write, restore); the
// typed clientset is needed solely for the read-side console, because the
// pods/log subresource (GetLogs(...).Stream) lives only on the typed CoreV1
// client, not on client.Client (spec §8 读=pods/log follow).
restCfg := ctrl.GetConfigOrDie()
cl, err := client.New(restCfg, client.Options{Scheme: scheme})
if err != nil {
fmt.Fprintf(stderr, "felis api: build k8s client: %v\n", err)
return 1
}
clientset, err := kubernetes.NewForConfig(restCfg)
if err != nil {
fmt.Fprintf(stderr, "felis api: build k8s clientset: %v\n", err)
return 1
}
token := os.Getenv("FELIS_SERVICE_TOKEN")
if token == "" {
fmt.Fprintln(stderr, "felis api: warning: FELIS_SERVICE_TOKEN unset — internal face will reject all callers")
}
// Build subsystem (spec §16): the weak-SA build Job runs in the configured
// build namespace and pushes to the internal registry. The build Pod never
// holds DB credentials — felis-api owns the PG store and admits scanned
// images, so the Builder is constructed here with both bindings.
builder := &build.Builder{
Store: build.NewPGStore(drv.DB()),
Jobs: build.NewK8sJobs(cl, buildConfig(cfg)),
Config: buildConfig(cfg),
}
// User-modpack approval lane (user-directed extension over §16; see
// internal/submit). An ordinary user may only SUBMIT a
// modpack; an admin must approve it before anything is built, at which point
// the SAME Trivy-gated Builder runs as for an admin's direct build. Registry
// MUST match the Builder's RegistryURL (cfg.Registry.URL) — both are wired from
// the one field here so the lane's pre-CAS validate and the Builder's Submit
// can never disagree about the push target. The blob upload transport that
// populates the derived context ref is deferred (INTEGRATION-ONLY): the
// create→approve→reject state machine is real Postgres truth, but a real
// Kaniko context pull needs that transport in place.
submissions := &submit.Manager{
Store: submit.NewPGStore(drv.DB()),
Builds: builder,
Registry: cfg.Registry.URL,
ContextStore: cfg.Registry.UserUploadsContext,
}
// Restore subsystem (spec §7): the weak-SA restore Job mounts the target
// world PVC + the backup PVC and runs `felis restore`. It needs deployment-
// specific values that have no safe default — the felis image to run and the
// backup PVC to mount — so it is wired only when both are supplied. Otherwise
// the Restorer is left nil and the restore endpoint honestly returns 503
// rather than enqueuing a Job that cannot run. (The archive store no longer
// gates wiring here: config.Validate rejects any recognized-but-unimplemented
// store at load, so by this point cfg.Archive.Store is guaranteed tarLocal.)
var restorer api.Restorer
felisImage, backupPVC := os.Getenv("FELIS_IMAGE"), os.Getenv("FELIS_BACKUP_PVC")
if felisImage != "" && backupPVC != "" {
rcfg := restoreConfig(cfg, felisImage, backupPVC)
restorer = &restore.Restorer{Jobs: restore.NewK8sJobs(cl), Config: rcfg}
} else {
fmt.Fprintln(stderr, "felis api: restore executor disabled (needs FELIS_IMAGE and FELIS_BACKUP_PVC) — restore endpoint returns 503")
}
a := &api.API{
Repo: api.NewPGRepo(drv.DB()),
Cluster: api.NewK8sCluster(cl, cfg.K8s.Namespace),
Console: api.NewK8sConsole(cl, cfg.K8s.Namespace),
Logs: api.NewK8sLogStreamer(clientset, cfg.K8s.Namespace),
// Build-log stream (spec §16) is scoped to the BUILD namespace — the same
// value the Builder renders Jobs into — so it follows where build Pods run.
BuildLogs: api.NewK8sBuildLogStreamer(clientset, cfg.Registry.BuildNamespace),
Internal: api.BearerTokenAuth{Token: token},
Builder: builder,
Restorer: restorer,
Submissions: submissions,
// Keyfunc is intentionally nil: the external face fails closed until a
// JWKS-backed key function is wired (deployment integration point).
External: api.AccessVerifier{Audience: cfg.Auth.AccessJWTAud},
RootDomain: cfg.Server.RootDomain,
WakeCooldown: 30 * time.Second,
}
fmt.Fprintln(stderr, "felis api: external face fails closed (Access JWKS key function not configured)")
internalSrv := &http.Server{Addr: *internalAddr, Handler: a.InternalHandler()}
externalSrv := &http.Server{Addr: cfg.Server.Listen, Handler: a.ExternalHandler()}
errc := make(chan error, 2)
go func() { errc <- internalSrv.ListenAndServe() }()
go func() { errc <- externalSrv.ListenAndServe() }()
fmt.Fprintf(stdout, "felis api: internal=%s external=%s\n", *internalAddr, cfg.Server.Listen)
// reconcileBuilds drives the scan-gate translation: poll unfinished builds
// and advance any whose Job has reached a terminal phase. GET on a build also
// reconciles it, but this loop converges builds nobody is polling.
go reconcileBuilds(ctx, builder, stderr)
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = internalSrv.Shutdown(shutdownCtx)
_ = externalSrv.Shutdown(shutdownCtx)
return 0
case err := <-errc:
if err != nil && err != http.ErrServerClosed {
fmt.Fprintf(stderr, "felis api: listener exited: %v\n", err)
return 1
}
return 0
}
}
// buildConfig projects felis.toml onto the build subsystem config (spec §16,
// §24). Unset fields fall back to the build package's hardened defaults
// (felis-build namespace + weak SA, 30m deadline, resource limits).
func buildConfig(cfg *config.Config) build.Config {
return build.Config{
Namespace: cfg.Registry.BuildNamespace,
RegistryURL: cfg.Registry.URL,
}
}
// restoreConfig projects felis.toml + the deployment-supplied image and backup
// PVC onto the restore subsystem config (spec §7). The runtime identity, mount
// roots, resource limits, and weak SA fall back to the restore package's
// hardened defaults. BackupRoot tracks cfg.Archive.LocalPath because tarLocal
// archive refs are absolute: the restore Pod must mount the backup PVC at the
// same path the reaper wrote archives under, or the stored ref won't resolve.
func restoreConfig(cfg *config.Config, image, backupPVC string) restore.Config {
return restore.Config{
Namespace: cfg.K8s.Namespace,
Image: image,
BackupPVC: backupPVC,
ArchiveStore: cfg.Archive.Store,
BackupRoot: cfg.Archive.LocalPath,
}
}
// reconcileBuilds polls unfinished builds on an interval and advances any whose
// Job has reached a terminal phase. It exits when ctx is cancelled.
func reconcileBuilds(ctx context.Context, b *build.Builder, stderr io.Writer) {
t := time.NewTicker(15 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if _, err := b.SyncAll(ctx); err != nil {
fmt.Fprintf(stderr, "felis api: build reconcile: %v\n", err)
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
// Command felis is the single multi-call binary for the platform (spec §25):
// it dispatches to the api, operator, migrate, reaper, and apply subcommands.
// Building one binary keeps the shared packages (scheme, store, config) linked
// once and shipped in a single image.
package main
import (
"os"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"flag"
"fmt"
"io"
"net"
"strings"
"felis.lolicon.best/internal/platform"
)
// multiFlag collects a repeatable string flag (e.g. --velocity-cidr a --velocity-cidr b).
type multiFlag []string
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
func (m *multiFlag) Set(v string) error {
*m = append(*m, v)
return nil
}
// cmdManifests renders the control-plane install bundle (spec §21, §22) —
// namespaces, the control-plane identities (SAs + namespaced Roles +
// RoleBindings — felis-api and felis-operator always, plus the destructive
// felis-reaper identity only when the retention reaper is enabled, gated with
// its CronJob), the weak build/restore Job SAs, the build/minecraft
// NetworkPolicies, and the running control-plane workloads (felis-api/operator
// Deployments + the in-cluster registry Deployment/Service/PVC) — as a single
// multi-document YAML stream on stdout, ready for `kubectl apply -f -`.
//
// It is a pure renderer: it never contacts a cluster and holds no credentials.
// --velocity-cidr is REQUIRED because the game NetworkPolicy fails closed without
// it; emitting a bundle whose 25565 ingress admitted no one would silently break
// the server, so the generator refuses rather than guess.
func cmdManifests(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("manifests", flag.ContinueOnError)
fs.SetOutput(stderr)
controlNS := fs.String("control-namespace", platform.DefaultControlNamespace, "namespace the control plane (api/operator/reaper) runs in")
minecraftNS := fs.String("minecraft-namespace", platform.DefaultMinecraftNamespace, "namespace MinecraftServer workloads run in")
buildNS := fs.String("build-namespace", platform.DefaultBuildNamespace, "namespace image-build Jobs run in")
registryNS := fs.String("registry-namespace", "", "namespace of the in-cluster registry (default: control namespace)")
registryPort := fs.Int("registry-port", 5000, "port the in-cluster registry listens on")
felisImage := fs.String("felis-image", "", "container image the felis-api/operator Deployments run, also passed through as FELIS_IMAGE (REQUIRED)")
registryImage := fs.String("registry-image", "", "in-cluster registry image (default: registry:2)")
backupPVC := fs.String("backup-pvc", "", "name of the backup PVC advertised to the restore executor via FELIS_BACKUP_PVC (default none = restore endpoint returns 503)")
worldsHostPath := fs.String("worlds-host-path", "", "node directory under which each world PVC is visible as <path>/<pvc>; enables the reaper CronJob (requires --backup-pvc and --archive-local-path)")
archiveLocalPath := fs.String("archive-local-path", "", "path the backup PVC is mounted at in the reaper CronJob; MUST equal felis.toml [archive] local_path")
var velocityCIDRs multiFlag
fs.Var(&velocityCIDRs, "velocity-cidr", "CIDR of an off-cluster Velocity proxy host allowed to reach game port 25565 (repeatable, REQUIRED)")
var packageCIDRs multiFlag
fs.Var(&packageCIDRs, "package-cidr", "CIDR of a package mirror build Pods may reach (repeatable; default none = no internet egress)")
if err := fs.Parse(args); err != nil {
return 2
}
// --velocity-cidr is mandatory: the game policy is fail-closed, so omitting it
// would render a server nobody can reach. Fail loudly at generation time.
if len(velocityCIDRs) == 0 {
fmt.Fprintln(stderr, "felis manifests: at least one --velocity-cidr is required "+
"(the game NetworkPolicy fails closed without it; pass the Velocity proxy host CIDR, e.g. --velocity-cidr 10.0.0.5/32)")
return 2
}
// --felis-image is mandatory: the api/operator Deployments and the FELIS_IMAGE
// passthrough (used to launch the restore Job) have no safe default image. Same
// fail-loud contract as --velocity-cidr.
if *felisImage == "" {
fmt.Fprintln(stderr, "felis manifests: --felis-image is required "+
"(the felis-api/operator Deployments run it and it is passed through as FELIS_IMAGE, e.g. --felis-image registry.felis.svc:5000/felis:v1)")
return 2
}
for _, cidr := range append(append([]string{}, velocityCIDRs...), packageCIDRs...) {
if _, _, err := net.ParseCIDR(cidr); err != nil {
fmt.Fprintf(stderr, "felis manifests: invalid CIDR %q: %v\n", cidr, err)
return 2
}
}
// Retention/reaper rendering is opt-in and needs all three storage coordinates
// together: where worlds live (to read+archive them), the backup PVC (to write
// archives into), and the path it is mounted at (which MUST equal felis.toml
// [archive] local_path so tarLocal's absolute archive refs resolve). A partial
// configuration is almost certainly an operator mistake, so fail loud rather than
// silently drop retention. Asking for it without the other two is rejected; an
// empty trio renders the bundle WITHOUT the reaper and says so.
if *worldsHostPath != "" {
if *backupPVC == "" || *archiveLocalPath == "" {
fmt.Fprintln(stderr, "felis manifests: --worlds-host-path enables the reaper CronJob and requires "+
"--backup-pvc and --archive-local-path too (--archive-local-path must equal felis.toml [archive] local_path)")
return 2
}
// The reaper WILL render. Two deployment preconditions this generator cannot
// check would SILENTLY turn retention into a no-op if unmet — surface them as
// loudly as the fail-closed cases above, so an operator is never left with a
// reaper that reaps an empty directory. (Both are also in the WorldsHostPath
// flag/field docs, but nobody deploying from stdout reads those.)
fmt.Fprintf(stderr, "felis manifests: note: rendering the retention reaper CronJob (worlds hostPath %q). "+
"Two preconditions are NOT verified here:\n"+
" - each world PVC must be visible at %s/<pvc> on the node: a stock local-path-provisioner lays "+
"volumes under PV-name paths (.../pvc-<uuid>_<ns>_<pvc>/), so unless the worlds StorageClass is "+
"arranged to expose <path>/<pvc>, the reaper tars an empty directory;\n"+
" - the CronJob sets NO nodeSelector: a single-node starter pins it to the worlds implicitly, but "+
"on a multi-node cluster you MUST add a nodeSelector for the node holding the worlds, or the reaper "+
"may schedule where the hostPath is empty.\n", *worldsHostPath, *worldsHostPath)
} else {
fmt.Fprintln(stderr, "felis manifests: note: retention reaper CronJob not rendered "+
"(pass --worlds-host-path, --backup-pvc and --archive-local-path to enable it)")
}
out, err := platform.RenderYAML(platform.Params{
ControlNamespace: *controlNS,
MinecraftNamespace: *minecraftNS,
BuildNamespace: *buildNS,
RegistryNamespace: *registryNS,
RegistryPort: int32(*registryPort),
FelisImage: *felisImage,
RegistryImage: *registryImage,
BackupPVC: *backupPVC,
WorldsHostPath: *worldsHostPath,
ArchiveLocalPath: *archiveLocalPath,
VelocityCIDRs: []string(velocityCIDRs),
PackageSourceCIDRs: []string(packageCIDRs),
})
if err != nil {
fmt.Fprintf(stderr, "felis manifests: render: %v\n", err)
return 1
}
if _, err := stdout.Write(out); err != nil {
fmt.Fprintf(stderr, "felis manifests: write: %v\n", err)
return 1
}
return 0
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"bytes"
"strings"
"testing"
)
// TestManifestsRequiresVelocityCIDR proves the generator refuses to emit a bundle
// without --velocity-cidr (the game policy would otherwise fail closed silently).
func TestManifestsRequiresVelocityCIDR(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{"manifests"}, &out, &errBuf)
if code == 0 {
t.Fatalf("exit code = 0, want nonzero (missing --velocity-cidr)")
}
if !strings.Contains(errBuf.String(), "velocity-cidr") {
t.Errorf("expected a --velocity-cidr error, got %q", errBuf.String())
}
if out.Len() != 0 {
t.Errorf("no YAML must be written when the flag is missing, got %q", out.String())
}
}
// TestManifestsRejectsBadCIDR proves CIDR inputs are validated. --felis-image is
// supplied so the only defect is the CIDR (the felis-image requirement is checked
// before CIDR validation, so omitting it would surface the wrong error).
func TestManifestsRejectsBadCIDR(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{"manifests", "--felis-image", "reg/felis:test", "--velocity-cidr", "not-a-cidr"}, &out, &errBuf)
if code == 0 {
t.Fatalf("exit code = 0, want nonzero (invalid CIDR)")
}
if !strings.Contains(errBuf.String(), "invalid CIDR") {
t.Errorf("expected an invalid-CIDR error, got %q", errBuf.String())
}
}
// TestManifestsRequiresFelisImage proves the generator refuses to emit a bundle
// without --felis-image (the api/operator Deployments have no default image, and
// FELIS_IMAGE has no safe guess). Same fail-loud contract as --velocity-cidr.
func TestManifestsRequiresFelisImage(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{"manifests", "--velocity-cidr", "10.0.0.5/32"}, &out, &errBuf)
if code == 0 {
t.Fatalf("exit code = 0, want nonzero (missing --felis-image)")
}
if !strings.Contains(errBuf.String(), "felis-image") {
t.Errorf("expected a --felis-image error, got %q", errBuf.String())
}
if out.Len() != 0 {
t.Errorf("no YAML must be written when --felis-image is missing, got %q", out.String())
}
}
// TestManifestsRendersBundle proves the happy path: a valid invocation writes a
// multi-doc YAML bundle containing the fence kinds and no cluster-scoped RBAC.
func TestManifestsRendersBundle(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{"manifests", "--felis-image", "registry.felis.svc:5000/felis:v1", "--velocity-cidr", "10.0.0.5/32"}, &out, &errBuf)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, errBuf.String())
}
text := out.String()
for _, want := range []string{
"kind: Namespace",
"kind: ServiceAccount",
"kind: Role",
"kind: RoleBinding",
"kind: NetworkPolicy",
// The running control-plane workloads now in the bundle.
"kind: Deployment",
"kind: Service",
"kind: PersistentVolumeClaim",
"felis-allow-rcon-from-control-plane",
"felis-allow-game-from-velocity",
"10.0.0.5/32",
// The felis image flows through to the Deployments.
"registry.felis.svc:5000/felis:v1",
} {
if !strings.Contains(text, want) {
t.Errorf("rendered bundle missing %q", want)
}
}
if strings.Contains(text, "ClusterRole") {
t.Error("rendered bundle must not contain ClusterRole/ClusterRoleBinding")
}
// Without the retention flags, the reaper CronJob is not rendered and the
// generator says so on stderr.
if strings.Contains(text, "kind: CronJob") {
t.Error("no reaper CronJob must render without --worlds-host-path")
}
if !strings.Contains(errBuf.String(), "not rendered") {
t.Errorf("expected a 'reaper not rendered' notice on stderr, got %q", errBuf.String())
}
}
// TestManifestsReaperRequiresTrio proves --worlds-host-path is a fail-loud opt-in:
// asking for the reaper without the backup PVC and its mount path (which must equal
// [archive] local_path) is rejected rather than silently dropping retention.
func TestManifestsReaperRequiresTrio(t *testing.T) {
base := []string{"manifests", "--felis-image", "reg/felis:test", "--velocity-cidr", "10.0.0.5/32", "--worlds-host-path", "/var/lib/felis/worlds"}
for _, extra := range [][]string{
{}, // neither backup-pvc nor archive-local-path
{"--backup-pvc", "felis-backups"}, // missing archive-local-path
{"--archive-local-path", "/backups"}, // missing backup-pvc
} {
var out, errBuf bytes.Buffer
code := run(append(append([]string{}, base...), extra...), &out, &errBuf)
if code == 0 {
t.Fatalf("extra=%v: exit code = 0, want nonzero (incomplete reaper config)", extra)
}
if !strings.Contains(errBuf.String(), "backup-pvc") || !strings.Contains(errBuf.String(), "archive-local-path") {
t.Errorf("extra=%v: expected the trio requirement on stderr, got %q", extra, errBuf.String())
}
if out.Len() != 0 {
t.Errorf("extra=%v: no YAML must be written on a fail-loud reject, got %q", extra, out.String())
}
}
}
// TestManifestsRendersReaper proves the happy path with the full retention trio:
// a batch/v1 CronJob is emitted, named felis-reaper, mounting the backup PVC at the
// supplied archive path.
func TestManifestsRendersReaper(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{
"manifests",
"--felis-image", "registry.felis.svc:5000/felis:v1",
"--velocity-cidr", "10.0.0.5/32",
"--worlds-host-path", "/var/lib/felis/worlds",
"--backup-pvc", "felis-backups",
"--archive-local-path", "/backups",
}, &out, &errBuf)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, errBuf.String())
}
text := out.String()
for _, want := range []string{
"kind: CronJob",
"name: felis-reaper",
"/var/lib/felis/worlds", // the worlds hostPath
"claimName: felis-backups",
} {
if !strings.Contains(text, want) {
t.Errorf("rendered bundle with reaper missing %q", want)
}
}
// Rendering the reaper must also warn the operator about the two preconditions
// this generator cannot verify (else a misarranged hostPath silently no-ops
// retention): the <path>/<pvc> arrangement-dependency and the multi-node
// nodeSelector hazard.
for _, want := range []string{"local-path-provisioner", "nodeSelector"} {
if !strings.Contains(errBuf.String(), want) {
t.Errorf("reaper render must warn operators about %q on stderr, got %q", want, errBuf.String())
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/store"
)
// cmdMigrate implements `felis migrate up`: load config, open the database, and
// apply every pending embedded migration under the advisory lock (spec §6).
func cmdMigrate(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
fs.SetOutput(stderr)
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.Arg(0) != "up" {
fmt.Fprintln(stderr, "usage: felis migrate up [-config path]")
return 2
}
cfg, err := config.Load(*cfgPath)
if err != nil {
fmt.Fprintf(stderr, "felis migrate: %v\n", err)
return 1
}
ctx := context.Background()
drv, err := store.Open(ctx, cfg.Database.URL)
if err != nil {
fmt.Fprintf(stderr, "felis migrate: open database: %v\n", err)
return 1
}
defer drv.Close()
migrations, err := store.LoadMigrations()
if err != nil {
fmt.Fprintf(stderr, "felis migrate: load migrations: %v\n", err)
return 1
}
applied, err := store.Up(ctx, drv, migrations)
if err != nil {
fmt.Fprintf(stderr, "felis migrate: %v\n", err)
return 1
}
if len(applied) == 0 {
fmt.Fprintln(stdout, "felis migrate: database already up to date")
} else {
fmt.Fprintf(stdout, "felis migrate: applied %d migration(s): %v\n", len(applied), applied)
}
return 0
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"flag"
"fmt"
"io"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/operator"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
)
// cmdOperator runs the MinecraftServer controller-manager (spec §5). It builds
// the scheme, wires the Reconciler with the production RCON prober, and blocks
// on the manager until the process receives a termination signal.
func cmdOperator(args []string, _, stderr io.Writer) int {
fs := flag.NewFlagSet("operator", flag.ContinueOnError)
fs.SetOutput(stderr)
metricsAddr := fs.String("metrics-bind-address", ":8080", "address the metric endpoint binds to")
// namespace MUST equal the [k8s] namespace felis-api is configured with, and
// the deployment manifests (felis manifests) render both from one value. It
// scopes the manager's cache (informers) to a single namespace so the operator
// can run under a namespaced Role instead of cluster-admin (spec §21). The
// default matches config.defaultNamespace, so an unconfigured deployment
// agrees; a mismatch would silently scope the cache to the wrong namespace and
// every reconcile would see zero servers — hence the watched namespace is
// logged at startup so a divergence surfaces immediately rather than silently.
namespace := fs.String("namespace", "minecraft", "namespace to watch; must match felis-api's [k8s] namespace")
if err := fs.Parse(args); err != nil {
return 2
}
scheme := runtime.NewScheme()
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(v1alpha1.AddToScheme(scheme))
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: *metricsAddr},
// Scope every informer to the single watched namespace. Without this the
// cached client (mgr.GetClient) would LIST/WATCH cluster-wide, which a
// namespaced Role cannot grant — the operator would fail closed at runtime
// or, worse, demand cluster-admin. With it, the platform.OperatorRole
// (get/list/watch in one namespace) is exactly sufficient.
Cache: cache.Options{
DefaultNamespaces: map[string]cache.Config{*namespace: {}},
},
})
if err != nil {
fmt.Fprintf(stderr, "felis operator: create manager: %v\n", err)
return 1
}
fmt.Fprintf(stderr, "felis operator: watching namespace %q\n", *namespace)
r := &operator.Reconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Prober: operator.RconProber{},
}
if err := r.SetupWithManager(mgr); err != nil {
fmt.Fprintf(stderr, "felis operator: setup controller: %v\n", err)
return 1
}
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
fmt.Fprintf(stderr, "felis operator: manager exited: %v\n", err)
return 1
}
return 0
}
+187
View File
@@ -0,0 +1,187 @@
package main
import (
"flag"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/backup"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/reaper"
"felis.lolicon.best/internal/store"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// cmdReaper runs one pass of the world reaper / retention batch (spec §18). It
// is intentionally run-once-and-exit: a Kubernetes CronJob drives the daily
// cadence, and RunOnce is idempotent and restart-safe, so a missed or retried
// run simply converges. Only the tarLocal archive backend is wired in this
// build; the snapshot backends (§19) are a later integration.
func cmdReaper(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("reaper", flag.ContinueOnError)
fs.SetOutput(stderr)
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml")
worldsRoot := fs.String("worlds-root", "/worlds", "mount root under which world PVCs are visible (tarLocal: <root>/<pvc>)")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, err := config.Load(*cfgPath)
if err != nil {
fmt.Fprintf(stderr, "felis reaper: %v\n", err)
return 1
}
rcfg, err := reaperConfig(cfg)
if err != nil {
fmt.Fprintf(stderr, "felis reaper: %v\n", err)
return 1
}
archiver, err := buildArchiver(cfg, *worldsRoot)
if err != nil {
fmt.Fprintf(stderr, "felis reaper: %v\n", err)
return 1
}
ctx := ctrl.SetupSignalHandler()
drv, err := store.Open(ctx, cfg.Database.URL)
if err != nil {
fmt.Fprintf(stderr, "felis reaper: open database: %v\n", err)
return 1
}
defer drv.Close()
scheme := runtime.NewScheme()
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(v1alpha1.AddToScheme(scheme))
cl, err := client.New(ctrl.GetConfigOrDie(), client.Options{Scheme: scheme})
if err != nil {
fmt.Fprintf(stderr, "felis reaper: build k8s client: %v\n", err)
return 1
}
r := &reaper.Reaper{
Cfg: rcfg,
Store: reaper.NewPGStore(drv.DB()),
Cluster: reaper.NewK8sCluster(cl, cfg.K8s.Namespace),
Archiver: archiver,
}
sum, err := r.RunOnce(ctx)
if err != nil {
fmt.Fprintf(stderr, "felis reaper: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "felis reaper: evaluated=%d reaped=%d warned=%d skipped=%d evicted=%d expired=%d\n",
sum.Evaluated, sum.WorldsReaped, sum.Warned, sum.Skipped, sum.EvictedEarly, sum.BackupsExpired)
return 0
}
// reaperConfig derives the reaper's retention windows from felis.toml. The 15d
// idle deadline is fixed by §18; only the warning offsets, retention, and the
// store soft-cap are configurable (§24).
func reaperConfig(cfg *config.Config) (reaper.Config, error) {
rc := reaper.DefaultConfig()
if v := cfg.Archive.Retention; v != "" {
d, err := parseSpanDuration(v)
if err != nil {
return rc, fmt.Errorf("[archive] retention %q: %w", v, err)
}
rc.Retention = d
}
if len(cfg.Archive.WarnBefore) > 0 {
offs := make([]time.Duration, 0, len(cfg.Archive.WarnBefore))
for _, w := range cfg.Archive.WarnBefore {
d, err := parseSpanDuration(w)
if err != nil {
return rc, fmt.Errorf("[archive] warn_before %q: %w", w, err)
}
offs = append(offs, d)
}
rc.WarnBefore = offs
}
if v := cfg.Archive.MaxLocalBytes; v != "" {
b, err := parseByteSize(v)
if err != nil {
return rc, fmt.Errorf("[archive] max_local_bytes %q: %w", v, err)
}
rc.MaxLocalBytes = b
}
return rc, nil
}
// buildArchiver constructs the WorldArchiver. Only tarLocal is implemented in
// this build; the resolver maps each world PVC to <worldsRoot>/<pvc>, the mount
// convention the reaper Job is deployed with.
func buildArchiver(cfg *config.Config, worldsRoot string) (backup.WorldArchiver, error) {
switch cfg.Archive.Store {
case "tarLocal":
return &backup.TarLocal{
BackupRoot: cfg.Archive.LocalPath,
Resolve: func(pvc string) (string, error) {
return filepath.Join(worldsRoot, pvc), nil
},
}, nil
default:
return nil, fmt.Errorf("[archive] store %q is not implemented in this build (only tarLocal)", cfg.Archive.Store)
}
}
// parseSpanDuration parses the human spans used in felis.toml's [archive] table:
// "3mo" (months≈30d), "15d" (days), or any time.ParseDuration unit ("12h").
func parseSpanDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
switch {
case strings.HasSuffix(s, "mo"):
n, err := strconv.Atoi(strings.TrimSuffix(s, "mo"))
if err != nil {
return 0, err
}
return time.Duration(n) * 30 * 24 * time.Hour, nil
case strings.HasSuffix(s, "d"):
n, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
if err != nil {
return 0, err
}
return time.Duration(n) * 24 * time.Hour, nil
default:
return time.ParseDuration(s)
}
}
// parseByteSize parses a Kubernetes-style quantity ("200Gi", "10G") into bytes.
// An empty string means unlimited (0).
func parseByteSize(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, nil
}
units := []struct {
suffix string
mul int64
}{
{"Gi", 1 << 30}, {"Mi", 1 << 20}, {"Ki", 1 << 10},
{"G", 1_000_000_000}, {"M", 1_000_000}, {"K", 1_000},
}
for _, u := range units {
if strings.HasSuffix(s, u.suffix) {
n, err := strconv.ParseFloat(strings.TrimSuffix(s, u.suffix), 64)
if err != nil {
return 0, err
}
return int64(n * float64(u.mul)), nil
}
}
return strconv.ParseInt(s, 10, 64)
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"flag"
"fmt"
"io"
"path/filepath"
"strings"
"felis.lolicon.best/internal/backup"
ctrl "sigs.k8s.io/controller-runtime"
)
// cmdRestore is the in-Pod entrypoint the restore Job runs (internal/restore
// renders a Pod whose command is `felis restore`). It extracts a world archive
// from the backup mount into the world mount and exits — it is NOT a
// user-facing command and is never invoked by hand.
//
// It deliberately holds NO database credentials and never calls config.Load:
// the felis-api made the authorization decision and looked up the archive ref;
// this process is the unprivileged hands that move bytes. Its entire input is
// the five flags below, mirroring the four-power isolation the rendered Job
// enforces (a restore Pod must not be able to reach the felis DB or the K8s
// API). All of those guarantees live in the Pod spec; this command only needs
// the archive store and the two mount roots.
func cmdRestore(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("restore", flag.ContinueOnError)
fs.SetOutput(stderr)
server := fs.String("server", "", "server name being restored (for logging)")
ref := fs.String("ref", "", "absolute path to the archive on the backup mount")
store := fs.String("archive-store", "tarLocal", "archive backend (only tarLocal is implemented)")
backupRoot := fs.String("backup-root", "/backups", "mount path of the backup PVC (archive refs must resolve under it)")
worldsRoot := fs.String("worlds-root", "/world", "mount path of the world PVC the archive extracts into")
if err := fs.Parse(args); err != nil {
return 2
}
if *ref == "" {
fmt.Fprintln(stderr, "felis restore: --ref is required")
return 2
}
if *store != "tarLocal" {
fmt.Fprintf(stderr, "felis restore: archive store %q is not implemented in this build (only tarLocal)\n", *store)
return 1
}
// Defense in depth: the ref comes from felis-api (trusted), but this process
// is the one that opens it, so it confirms the ref stays within the backup
// mount. A ref outside it would mean reading an arbitrary host path, which a
// restore Pod must never do.
if !refWithinRoot(*ref, *backupRoot) {
fmt.Fprintf(stderr, "felis restore: ref %q is not under backup root %q\n", *ref, *backupRoot)
return 1
}
// The world PVC is mounted directly at worldsRoot, so the resolver returns it
// for any target; the archive ref is absolute and is opened directly. This is
// the same TarLocal the reaper uses to write archives, run in reverse.
archiver := &backup.TarLocal{
BackupRoot: *backupRoot,
Resolve: func(string) (string, error) {
return *worldsRoot, nil
},
}
ctx := ctrl.SetupSignalHandler()
if err := archiver.Restore(ctx, backup.ArchiveRef(*ref), *server); err != nil {
fmt.Fprintf(stderr, "felis restore: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "felis restore: server=%s restored from %s into %s\n", *server, *ref, *worldsRoot)
return 0
}
// refWithinRoot reports whether ref resolves to a path inside root. Both are
// cleaned first so "/backups/../etc/passwd" cannot slip through.
func refWithinRoot(ref, root string) bool {
cleanRoot := filepath.Clean(root)
cleanRef := filepath.Clean(ref)
if cleanRef == cleanRoot {
return true
}
return strings.HasPrefix(cleanRef, cleanRoot+string(filepath.Separator))
}
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"felis.lolicon.best/internal/backup"
)
// archiveTempWorld tars a freshly populated world directory with the real
// TarLocal and returns the absolute archive ref plus the backup root it lives
// under, so the restore round-trip below exercises production code end to end
// without a cluster.
func archiveTempWorld(t *testing.T, files map[string]string) (ref string, backupRoot string) {
t.Helper()
srcDir := t.TempDir()
for name, content := range files {
full := filepath.Join(srcDir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, []byte(content), 0o640); err != nil {
t.Fatalf("write: %v", err)
}
}
backupRoot = t.TempDir()
ar := &backup.TarLocal{
BackupRoot: backupRoot,
Resolve: func(string) (string, error) { return srcDir, nil },
}
got, _, err := ar.Archive(context.Background(), "survival", "world-survival-0")
if err != nil {
t.Fatalf("Archive: %v", err)
}
return string(got), backupRoot
}
// The restore subcommand must extract the archived world into the target world
// mount — the real reverse of what the reaper wrote.
func TestRestoreSubcommandRoundTrips(t *testing.T) {
files := map[string]string{
"level.dat": "world-seed",
"region/r.0.0.mca": "chunk-bytes",
"playerdata/uuid.json": "{}",
}
ref, backupRoot := archiveTempWorld(t, files)
// Seed the target with a stale file the archive does not contain: the restore
// must prune it (replace semantics), not leave it behind. This is the path the
// admin/owner rollback-onto-a-stopped-populated-PVC case actually takes.
targetDir := t.TempDir()
stale := filepath.Join(targetDir, "region", "r.9.9.mca")
if err := os.MkdirAll(filepath.Dir(stale), 0o750); err != nil {
t.Fatalf("mkdir stale: %v", err)
}
if err := os.WriteFile(stale, []byte("griefer-chunk"), 0o640); err != nil {
t.Fatalf("seed stale: %v", err)
}
var out, errBuf bytes.Buffer
code := run([]string{
"restore",
"--server", "survival",
"--ref", ref,
"--archive-store", "tarLocal",
"--backup-root", backupRoot,
"--worlds-root", targetDir,
}, &out, &errBuf)
if code != 0 {
t.Fatalf("restore exit = %d, stderr=%q", code, errBuf.String())
}
for name, want := range files {
full := filepath.Join(targetDir, filepath.FromSlash(name))
got, err := os.ReadFile(full)
if err != nil {
t.Errorf("restored file %q missing: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("restored %q = %q, want %q", name, got, want)
}
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Errorf("stale file survived restore (err=%v); replace semantics broken", err)
}
}
// A ref outside the backup mount must be rejected before any file is opened —
// the restore Pod must never read an arbitrary host path.
func TestRestoreSubcommandRejectsRefOutsideBackupRoot(t *testing.T) {
backupRoot := t.TempDir()
outside := filepath.Join(t.TempDir(), "evil.tar.gz")
var out, errBuf bytes.Buffer
code := run([]string{
"restore",
"--server", "survival",
"--ref", outside,
"--backup-root", backupRoot,
"--worlds-root", t.TempDir(),
}, &out, &errBuf)
if code != 1 {
t.Fatalf("exit = %d, want 1 for an out-of-root ref", code)
}
if !strings.Contains(errBuf.String(), "not under backup root") {
t.Errorf("expected containment error, got %q", errBuf.String())
}
}
// An unimplemented archive store must fail loudly, not silently no-op.
func TestRestoreSubcommandRejectsUnknownStore(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{
"restore",
"--ref", "/backups/x.tar.gz",
"--archive-store", "s3snapshot",
"--backup-root", "/backups",
}, &out, &errBuf)
if code != 1 {
t.Fatalf("exit = %d, want 1 for an unknown store", code)
}
if !strings.Contains(errBuf.String(), "not implemented") {
t.Errorf("expected not-implemented error, got %q", errBuf.String())
}
}
// --ref is mandatory: a missing ref is a usage error, not a panic.
func TestRestoreSubcommandRequiresRef(t *testing.T) {
var out, errBuf bytes.Buffer
code := run([]string{"restore", "--backup-root", "/backups"}, &out, &errBuf)
if code != 2 {
t.Fatalf("exit = %d, want 2 for a missing --ref", code)
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"fmt"
"io"
)
const usage = `felis — Kubernetes-native Minecraft server orchestration
Usage:
felis <command> [flags]
Commands:
migrate up Apply embedded database migrations under an advisory lock
operator Run the MinecraftServer controller-manager
api Run the felis-api HTTP server
reaper Run the world reaper / backup batch
restore Extract a world archive into a world volume (internal Job entrypoint)
manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML
apply Apply a MinecraftServer manifest
Run "felis <command> -h" for command-specific flags.
`
// run dispatches a subcommand. It is separate from main so the router is
// testable without spawning a process.
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, usage)
return 2
}
cmd, rest := args[0], args[1:]
switch cmd {
case "migrate":
return cmdMigrate(rest, stdout, stderr)
case "operator":
return cmdOperator(rest, stdout, stderr)
case "api":
return cmdAPI(rest, stdout, stderr)
case "reaper":
return cmdReaper(rest, stdout, stderr)
case "restore":
return cmdRestore(rest, stdout, stderr)
case "manifests":
return cmdManifests(rest, stdout, stderr)
case "apply":
return notImplemented("apply", "MinecraftServer manifest apply", stderr)
case "-h", "--help", "help":
fmt.Fprint(stdout, usage)
return 0
default:
fmt.Fprintf(stderr, "felis: unknown command %q\n\n%s", cmd, usage)
return 2
}
}
// notImplemented reports a subcommand that is wired into the CLI surface but
// whose implementation lands in a later phase. It fails loudly rather than
// pretending to do work.
func notImplemented(name, desc string, stderr io.Writer) int {
fmt.Fprintf(stderr, "felis %s: not implemented yet — %s\n", name, desc)
return 3
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestRunNoArgsPrintsUsage(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run(nil, &out, &errBuf); code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
if !strings.Contains(errBuf.String(), "Usage:") {
t.Errorf("expected usage on stderr, got %q", errBuf.String())
}
}
func TestRunHelp(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"help"}, &out, &errBuf); code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
if !strings.Contains(out.String(), "felis") {
t.Errorf("expected usage on stdout, got %q", out.String())
}
}
func TestRunUnknownCommand(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"frobnicate"}, &out, &errBuf); code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
if !strings.Contains(errBuf.String(), "unknown command") {
t.Errorf("expected unknown-command error, got %q", errBuf.String())
}
}
func TestRunNotImplementedSubcommands(t *testing.T) {
for _, cmd := range []string{"apply"} {
var out, errBuf bytes.Buffer
if code := run([]string{cmd}, &out, &errBuf); code != 3 {
t.Errorf("%s exit code = %d, want 3", cmd, code)
}
if !strings.Contains(errBuf.String(), "not implemented yet") {
t.Errorf("%s: expected not-implemented notice, got %q", cmd, errBuf.String())
}
}
}
func TestRunReaperValidatesConfigBeforeDialing(t *testing.T) {
var out, errBuf bytes.Buffer
// Like api, reaper must fail fast (exit 1) at config load, before any
// database or cluster contact.
code := run([]string{"reaper", "-config", "this-file-does-not-exist.toml"}, &out, &errBuf)
if code != 1 {
t.Errorf("exit code = %d, want 1", code)
}
if !strings.Contains(errBuf.String(), "felis reaper:") {
t.Errorf("expected reaper error on stderr, got %q", errBuf.String())
}
}
func TestRunAPIValidatesConfigBeforeDialing(t *testing.T) {
var out, errBuf bytes.Buffer
// A non-existent config must fail fast (exit 1) at config load, before any
// database or cluster contact.
code := run([]string{"api", "-config", "this-file-does-not-exist.toml"}, &out, &errBuf)
if code != 1 {
t.Errorf("exit code = %d, want 1", code)
}
if !strings.Contains(errBuf.String(), "felis api:") {
t.Errorf("expected api error on stderr, got %q", errBuf.String())
}
}
func TestRunMigrateRequiresUpVerb(t *testing.T) {
var out, errBuf bytes.Buffer
// "migrate" with no verb should fail fast on usage, not touch a database.
if code := run([]string{"migrate"}, &out, &errBuf); code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
if !strings.Contains(errBuf.String(), "felis migrate up") {
t.Errorf("expected migrate usage, got %q", errBuf.String())
}
}