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:
21 files changed
+3713
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user