Unverified Commit 0770a4d6 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(reaper): 未配置世界目录时渲染只清理备份库存的 CronJob,默认安装也每日删除过期备份,安装器与清单生成器说明回收开关状态

parent fe15b560
Loading
Loading
Loading
Loading
+1 −1
Changes for README.md: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -19,7 +19,7 @@ A Kubernetes-driven Minecraft server hosting platform — one command to deploy,
- **Web 控制面板**:浏览器中查看服务器状态、在线玩家与资源用量,管理备份与恢复。
- **备份与恢复**:一键把整服数据(世界、配置、插件/模组,即整个 /data 卷)打包进集群内的归档库,支持从任意备份点回滚;默认安装就已启用(归档 PVC 与路径由安装器一并生成)。
- **控制面数据库备份**:账号、服务器归属、配额与存档索引所在的数据库每天自动备份,每次升级迁移前先快照,出错可用 `felis db restore` 整库原子回滚;面板「维护与备份」页显示备份是否新鲜(见 [故障排查 §16](docs/troubleshooting.md))。
- **智慧回收(可选开启)**:超过 15 天无人游玩的世界自动备份后删除,释放磁盘空间;安装时设置 `FELIS_WORLDS_HOST_PATH`(k3s 默认 `/var/lib/rancher/k3s/storage`)即启用每日回收,不设置则不删任何世界。
- **智慧回收(可选开启)**:超过 15 天无人游玩的世界自动备份后删除,释放磁盘空间;安装时设置 `FELIS_WORLDS_HOST_PATH`(k3s 默认 `/var/lib/rancher/k3s/storage`)即启用每日回收,不设置则不删任何世界。过期备份无论是否开启都会每天清理。
- **多核心支持**:兼容 Paper、Fabric、Forge、NeoForge,经由 Velocity 代理统一入口。
- **模组自助提交**:玩家自行上传模组包,服主审批通过后自动构建;构建产物进入镜像白名单,可直接选用为服务器镜像完成部署。
- **Passkey 登录**:支持指纹、面容、硬件密钥等无密码认证方式。
+17 −3
Changes for cmd/felis/manifests.go: 17 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -48,7 +48,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
	registryImage := fs.String("registry-image", "", "in-cluster registry image (default: registry 2.8.3, pinned by digest)")
	backupPVC := fs.String("backup-pvc", "felis-backups", "name of the world-archive PVC this bundle renders in the Minecraft namespace and advertises to the backup/restore executors via FELIS_BACKUP_PVC (default: felis-backups; pass an empty value to render none, leaving backup/restore answering 503)")
	worldsHostPath := fs.String("worlds-host-path", "", "node directory the reaper reads worlds from: each world PVC resolves as <path>/<pvc>, or as the stock local-path directory <path>/<pv-name>_<ns>_<pvc-name> (k3s storage root: /var/lib/rancher/k3s/storage); enables the reaper CronJob (requires --archive-local-path and a non-empty --backup-pvc)")
	archiveLocalPath := fs.String("archive-local-path", "", "path the backup PVC is mounted at in the reaper CronJob; MUST equal felis.toml [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. With the backup PVC alone it renders the retention-only CronJob, which deletes backups past their expiry and never touches a world")
	registryStorage := fs.String("registry-storage", "", "capacity the registry PVC requests (default 10Gi; k3s local-path does not enforce it)")
	uploadsStorage := fs.String("uploads-storage", "", "capacity the uploads PVC requests (default 5Gi; k3s local-path does not enforce it)")
	backupStorage := fs.String("backup-storage", "", "capacity the world-archive PVC requests (default 10Gi; k3s local-path does not enforce it)")
@@ -138,8 +138,22 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int {
			"<path>/<pvc>, or each candidate's archive fails and the world is preserved;\n"+
			"  - %s.\n", *worldsHostPath, *worldsHostPath, *worldsHostPath, pin)
	} else {
		fmt.Fprintln(stderr, "felis manifests: note: retention reaper CronJob not rendered "+
			"(pass --worlds-host-path and --archive-local-path — the archive PVC defaults to felis-backups — to enable it)")
		switch {
		case *archiveLocalPath != "" && *backupPVC == "":
			fmt.Fprintln(stderr, "felis manifests: --archive-local-path names where the backup PVC is mounted, "+
				"but --backup-pvc is empty (no archive store renders); drop one or the other")
			return 2
		case *archiveLocalPath != "":
			fmt.Fprintln(stderr, "felis manifests: note: rendering the reaper CronJob retention-only: backups past "+
				"their expiry are deleted daily, idle worlds are never archived or deleted "+
				"(pass --worlds-host-path to reap them too)")
		case *backupPVC != "":
			fmt.Fprintln(stderr, "felis manifests: note: reaper CronJob not rendered: backups are never expired, so "+
				"the archive store only grows until the disk fills (pass --archive-local-path, equal to felis.toml "+
				"[archive] local_path, for the retention-only CronJob, and --worlds-host-path as well to reap idle worlds)")
		default:
			fmt.Fprintln(stderr, "felis manifests: note: reaper CronJob not rendered (backups are disabled)")
		}
	}

	params := platform.Params{
+39 −4
Changes for cmd/felis/manifests_test.go: 39 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -90,12 +90,13 @@ func TestManifestsRendersBundle(t *testing.T) {
		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.
	// generator says so on stderr, naming what that leaves: backups that never
	// expire.
	if strings.Contains(text, "kind: CronJob") {
		t.Error("no reaper CronJob must render without --worlds-host-path")
		t.Error("no reaper CronJob must render without --archive-local-path")
	}
	if !strings.Contains(errBuf.String(), "not rendered") {
		t.Errorf("expected a 'reaper not rendered' notice on stderr, got %q", errBuf.String())
	if !strings.Contains(errBuf.String(), "not rendered") || !strings.Contains(errBuf.String(), "never expired") {
		t.Errorf("expected a 'reaper not rendered, backups never expired' notice on stderr, got %q", errBuf.String())
	}
}

@@ -144,6 +145,40 @@ func TestManifestsBackupPVCOptOut(t *testing.T) {
	}
}

// TestManifestsRendersRetentionOnly: the archive store without a worlds root
// still gets the daily CronJob, retention-only, so backups past their expiry
// leave the store on an install that never reaps a world; the operator is told
// which of the two it got. An archive path with the store switched off is a
// mistake and fails loud.
func TestManifestsRendersRetentionOnly(t *testing.T) {
	var out, errBuf bytes.Buffer
	code := run([]string{"manifests", "--felis-image", "reg/felis:test", "--velocity-cidr", "10.0.0.5/32",
		"--archive-local-path", "/var/lib/felis/archives"}, &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", "--retention-only", "claimName: felis-backups"} {
		if !strings.Contains(text, want) {
			t.Errorf("retention-only bundle missing %q", want)
		}
	}
	if strings.Contains(text, "kind: PersistentVolume\n") || strings.Contains(text, "--worlds-root") {
		t.Error("a retention-only bundle must not reach for a worlds root")
	}
	if !strings.Contains(errBuf.String(), "retention-only") {
		t.Errorf("stderr must say the CronJob is retention-only, got %q", errBuf.String())
	}

	out.Reset()
	errBuf.Reset()
	code = run([]string{"manifests", "--felis-image", "reg/felis:test", "--velocity-cidr", "10.0.0.5/32",
		"--archive-local-path", "/var/lib/felis/archives", "--backup-pvc="}, &out, &errBuf)
	if code != 2 || out.Len() != 0 || !strings.Contains(errBuf.String(), "--backup-pvc is empty") {
		t.Errorf("archive path without an archive store: exit=%d out=%d bytes stderr=%q, want a fail-loud 2", code, out.Len(), errBuf.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.
+26 −7
Changes for cmd/felis/reaper.go: 26 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -32,11 +32,17 @@ import (
// 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.
//
// --retention-only runs the archive-store half alone (reaper.RunRetention):
// the CronJob an install without a worlds root gets, so backups past their
// expiry still leave the store there. It builds no Kubernetes client and reads
// no world.
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>, else the stock local-path <root>/<pv-name>_<ns>_<pvc-name>)")
	retentionOnly := fs.Bool("retention-only", false, "only expire, read back and sweep the archive store: no server is evaluated and no world is read or deleted, so neither the worlds root nor the Kubernetes API is needed")
	if err := fs.Parse(args); err != nil {
		return 2
	}
@@ -55,14 +61,17 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {

	ctx := ctrl.SetupSignalHandler()

	var cl client.Client
	if !*retentionOnly {
		scheme := runtime.NewScheme()
		utilruntime.Must(clientgoscheme.AddToScheme(scheme))
		utilruntime.Must(v1alpha1.AddToScheme(scheme))
	cl, err := client.New(ctrl.GetConfigOrDie(), client.Options{Scheme: 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
		}
	}

	archiver, err := buildArchiver(ctx, cfg, *worldsRoot, cl)
	if err != nil {
@@ -80,9 +89,13 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {
	r := &reaper.Reaper{
		Cfg:      rcfg,
		Store:    reaper.NewPGStore(drv.DB()),
		Cluster:  reaper.NewK8sCluster(cl, cfg.K8s.Namespace),
		Archiver: archiver,
	}
	if *retentionOnly {
		fmt.Fprintln(stderr, "felis reaper: retention only — no worlds root is configured, so idle worlds are neither archived nor released")
		return reportReaperRun(r.RunRetention(ctx), stdout, stderr)
	}
	r.Cluster = reaper.NewK8sCluster(cl, cfg.K8s.Namespace)

	// Pre-reap warnings go out by email when [smtp] is configured (the same
	// relay and password_ref convention felis-api uses); without it the channel
@@ -248,14 +261,20 @@ func reaperConfig(cfg *config.Config) (reaper.Config, error) {

// buildArchiver constructs the WorldArchiver. Only tarLocal is implemented in
// this build; the resolver maps each world PVC to its directory under worldsRoot
// (resolveWorldDir).
// (resolveWorldDir). A nil cl is the retention-only run, which never archives a
// world, so its archiver resolves none.
func buildArchiver(ctx context.Context, cfg *config.Config, worldsRoot string, cl client.Client) (backup.WorldArchiver, error) {
	switch cfg.Archive.Store {
	case "tarLocal":
		return &backup.TarLocal{
			BackupRoot: cfg.Archive.LocalPath,
			Resolve:    resolveWorldDir(ctx, cl, cfg.K8s.Namespace, worldsRoot),
		}, nil
		t := &backup.TarLocal{BackupRoot: cfg.Archive.LocalPath}
		if cl != nil {
			t.Resolve = resolveWorldDir(ctx, cl, cfg.K8s.Namespace, worldsRoot)
		} else {
			t.Resolve = func(pvc string) (string, error) {
				return "", fmt.Errorf("resolve world PVC %s: this run has no worlds root (retention only)", pvc)
			}
		}
		return t, nil
	default:
		return nil, fmt.Errorf("[archive] store %q is not implemented in this build (only tarLocal)", cfg.Archive.Store)
	}
+16 −8
Changes for deploy/bootstrap.sh: 16 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -95,10 +95,11 @@
#                     felis.toml [archive] local_path (default: /var/lib/felis/archives)
#   FELIS_WORLDS_HOST_PATH node directory holding the world volumes (on the k3s this
#                     installer provisions: /var/lib/rancher/k3s/storage). Setting it
#                     enables the daily retention reaper, which archives and then deletes
#                     worlds idle beyond the retention window; the reaper reads that
#                     lets the daily reaper also archive and then delete worlds idle
#                     for 15 days (without it the reaper only deletes backups past
#                     their expiry and leaves every world); the reaper reads that
#                     root as root, so it keeps k3s's own 0700 root:root
#                     (default: unset = no reaper)
#                     (default: unset = worlds are never reaped)
#   FELIS_REGISTRY_STORAGE / FELIS_UPLOADS_STORAGE / FELIS_BACKUP_STORAGE capacity the
#                     registry, uploads and world-archive PVCs request on first install
#                     (defaults: 10Gi, 5Gi, 10Gi). An existing claim keeps its size; on
@@ -172,8 +173,8 @@ FELIS_BACKUP_STORAGE="${FELIS_BACKUP_STORAGE:-}"
# Retention is opt-in because it DELETES worlds (after a verified archive): point this at the
# node directory the world volumes live under. On the k3s this installer provisions that is
# /var/lib/rancher/k3s/storage — the reaper resolves each PVC's local-path directory exactly
# from its volumeName. Left unset, no reaper CronJob renders and archives accumulate until
# the backup PVC fills (then backups fail loudly; nothing is deleted).
# from its volumeName. Left unset, the reaper CronJob renders retention-only: backups past
# their expiry are still deleted daily, and no world is ever archived or deleted.
FELIS_WORLDS_HOST_PATH="${FELIS_WORLDS_HOST_PATH:-}"
# k3s's local-path provisioner root. It appears with the first volume the provisioner
# creates, which on a fresh install is after the reaper's PV has been applied.
@@ -3462,8 +3463,15 @@ deploy_bundle() {
  else
    manifest_args+=(--backup-pvc=)
  fi
  # Retention renders only when the operator names where the worlds live; the archive path
  # always travels with it because it must equal the [archive] local_path written above.
  # With an archive store the reaper always renders, since backups past their expiry have
  # to leave it; it reaps idle worlds only when the operator names where the worlds live.
  # The archive path must equal the [archive] local_path written above.
  if [ -n "$FELIS_BACKUP_PVC" ]; then
    manifest_args+=(--archive-local-path "$FELIS_ARCHIVE_LOCAL_PATH")
    if [ -z "$FELIS_WORLDS_HOST_PATH" ]; then
      log "idle-world retention is off: the daily reaper deletes expired backups and keeps every world (set FELIS_WORLDS_HOST_PATH=${K3S_STORAGE_ROOT} to reap worlds idle for 15 days)"
    fi
  fi
  if [ -n "$FELIS_WORLDS_HOST_PATH" ]; then
    log "retention enabled: the daily reaper will read worlds from ${FELIS_WORLDS_HOST_PATH}"
    # The reaper reads this root as root with DAC_OVERRIDE (platform.reaperPodSecurityContext)
@@ -3478,7 +3486,7 @@ deploy_bundle() {
        warn "worlds root ${FELIS_WORLDS_HOST_PATH} does not exist yet; the reaper CronJob cannot start until it does (hostPath type Directory)"
      fi
    fi
    manifest_args+=(--worlds-host-path "$FELIS_WORLDS_HOST_PATH" --archive-local-path "$FELIS_ARCHIVE_LOCAL_PATH")
    manifest_args+=(--worlds-host-path "$FELIS_WORLDS_HOST_PATH")
  fi
  local size
  size="$(pvc_size "$CONTROL_NS" registry "$FELIS_REGISTRY_STORAGE" FELIS_REGISTRY_STORAGE)"
Loading