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

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.
parent b508fccc
Loading
Loading
Loading
Loading

cmd/felis/api.go

0 → 100644
+215 −0
Changes for cmd/felis/api.go: 215 added lines, 0 removed lines.
Original line number Diff line number Diff line
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)
			}
		}
	}
}

cmd/felis/main.go

0 → 100644
+13 −0
Changes for cmd/felis/main.go: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
// 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))
}

cmd/felis/manifests.go

0 → 100644
+134 −0
Changes for cmd/felis/manifests.go: 134 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 −0
Changes for cmd/felis/manifests_test.go: 158 added lines, 0 removed lines.
Original line number Diff line number Diff line
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())
		}
	}
}

cmd/felis/migrate.go

0 → 100644
+58 −0
Changes for cmd/felis/migrate.go: 58 added lines, 0 removed lines.
Original line number Diff line number Diff line
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
}
Loading