fix(install): backups exist on a default install; retention resolves real world dirs (#6)
Three faces of one gap, all on the supported install path: - Backup/restore answered 503 out of the box: nothing ever rendered the archive PVC, so FELIS_BACKUP_PVC was unset. The bundle now renders the PVC (Minecraft namespace, RWO 10Gi, cluster default class) and 'felis manifests' names it by default (--backup-pvc= is the explicit no-store shape); bootstrap passes it through so the generated felis.toml [archive] local_path and the jobs' mount path come from one variable. - Retention was unreachable: bootstrap never passed the reaper flags. It now forwards FELIS_WORLDS_HOST_PATH/FELIS_ARCHIVE_LOCAL_PATH, so one env enables the daily CronJob; unset keeps today's fail-safe (no reaper, nothing deleted). - Even when enabled it could not find a world on a stock install: resolveWorldDir now also resolves the exact local-path directory <pv-name>_<ns>_<pvc-name> read from the live PVC's volumeName (never a glob, so a stale deleted PV's bytes can't be archived in place of the current world). Reaper Role gains persistentvolumeclaims:get (weaker than the delete it already held). README (zh/en) stops promising automatic/scheduled backups and states retention is opt-in. bootstrap_test covers the env->flag contract.
This commit is contained in:
14 files changed
+394
-82
No files matched your search
@@ -32,7 +32,9 @@ type Object interface {
|
||||
// Scope: this is the authorization + network fence (spec §21, §22) plus the
|
||||
// running control-plane workloads it fences — the felis-api / felis-operator
|
||||
// Deployments and the in-cluster registry (Deployment + Service + PVC), which
|
||||
// make the SAs and NetworkPolicy peers refer to something real (see workloads.go).
|
||||
// make the SAs and NetworkPolicy peers refer to something real, plus the
|
||||
// world-archive PVC that backs backup/restore when a backup PVC is named (see
|
||||
// workloads.go).
|
||||
// The reaper CronJob is also part of Workloads, rendered only when the retention
|
||||
// storage topology is supplied (WorldsHostPath + BackupPVC + ArchiveLocalPath —
|
||||
// workloads.go documents the gate and the shape-asserted hostPath caveat). The
|
||||
|
||||
@@ -107,14 +107,16 @@ type Params struct {
|
||||
FelisImage string
|
||||
// RegistryImage is the in-cluster registry image. Defaults to registry:2.
|
||||
RegistryImage string
|
||||
// BackupPVC is the name of the backup PersistentVolumeClaim the felis-api pod
|
||||
// advertises to its restore executor via FELIS_BACKUP_PVC. It is OPTIONAL: with
|
||||
// no backup PVC the restore endpoint degrades to 503 (cmd/felis/api.go), so the
|
||||
// env var is rendered only when this is set. It must name the same PVC that the
|
||||
// felis.toml archive.local_path is the mount path for, but that agreement lives
|
||||
// in the out-of-band config Secret and cannot be enforced by the manifest. The
|
||||
// reaper CronJob (when rendered) mounts this same PVC read-write to write
|
||||
// archives into it — see WorldsHostPath / ArchiveLocalPath.
|
||||
// BackupPVC is the name of the world-archive PersistentVolumeClaim. The bundle
|
||||
// RENDERS this PVC (backupPVC in workloads.go, Minecraft namespace — where every
|
||||
// pod that mounts it runs) and felis-api advertises the name to its backup/restore
|
||||
// executors via FELIS_BACKUP_PVC. An empty name renders neither: no PVC, no env,
|
||||
// and the backup/restore endpoints degrade to 503 (cmd/felis/api.go) rather than
|
||||
// enqueuing a Job that cannot mount its backup. The PVC name itself carries no
|
||||
// path meaning; the in-pod mount path is felis.toml's [archive] local_path (the
|
||||
// Jobs mount the PVC there, and tarLocal writes archive refs as absolute paths
|
||||
// under it). The reaper CronJob (when rendered) mounts this same PVC read-write
|
||||
// to write archives into it — see WorldsHostPath / ArchiveLocalPath.
|
||||
BackupPVC string
|
||||
// WorldsHostPath is the node directory under which each server's world PVC is
|
||||
// visible as <WorldsHostPath>/<pvc> — the on-disk root the reaper CronJob mounts
|
||||
@@ -128,12 +130,18 @@ type Params struct {
|
||||
// a later storage evolution. Setting it REQUIRES BackupPVC and ArchiveLocalPath
|
||||
// too — `felis manifests` enforces the trio (fail-loud).
|
||||
//
|
||||
// SHAPE-ASSERTED, runtime-unverified, and ARRANGEMENT-DEPENDENT: the reaper's
|
||||
// resolver looks for <root>/<pvc>. Stock local-path-provisioner lays volumes out
|
||||
// under PV-name paths (…/pvc-<uuid>_<ns>_<pvc>/), NOT <root>/<pvc>, so this mount
|
||||
// only finds worlds if the operator/storage is deliberately arranged to expose
|
||||
// them as <root>/<pvc>. The rendered CronJob is the correct K8s object; whether
|
||||
// the tar finds a world on a given cluster is not provable without one.
|
||||
// ARRANGEMENT: the reaper's resolver (cmd/felis/reaper.resolveWorldDir) looks
|
||||
// for <root>/<pvc> first and then for the stock local-path-provisioner layout
|
||||
// <root>/<pv-name>_<ns>_<pvc-name> — the exact directory name k3s uses under
|
||||
// its storage root (/var/lib/rancher/k3s/storage), derived from the live PVC's
|
||||
// spec.volumeName. So pointing this at the k3s storage root is the supported
|
||||
// way to enable retention on a stock install; other provisioners work if they
|
||||
// expose volumes as <root>/<pvc> or are read through the same PVC. On a
|
||||
// multi-node cluster every node HAS the root directory, but a world's directory
|
||||
// only exists on the node holding its volume: the CronJob schedules anywhere,
|
||||
// so a world found nowhere on that node fails the archive and is preserved.
|
||||
// The rendered CronJob is the correct K8s object; the actual tar depends on the
|
||||
// hosting node, which is not provable without a cluster.
|
||||
WorldsHostPath string
|
||||
// ArchiveLocalPath is the path the backup PVC is mounted at inside the reaper
|
||||
// CronJob's pod, and MUST equal felis.toml's [archive] local_path. tarLocal writes
|
||||
|
||||
@@ -162,6 +162,11 @@ func OperatorRole(p Params) *rbacv1.Role {
|
||||
// minecraftservers but cannot create them, and holds no power over StatefulSets,
|
||||
// Services, or Secrets — those belong to the operator and api.
|
||||
//
|
||||
// persistentvolumeclaims also carries get: resolving where a world lives
|
||||
// (cmd/felis/reaper.resolveWorldDir) reads the PVC's volumeName to derive the
|
||||
// stock local-path directory name. get is strictly weaker than the delete the
|
||||
// same rule already grants, so it widens nothing.
|
||||
//
|
||||
// Note no identity anywhere holds minecraftservers:delete. That is intentional, not
|
||||
// a missing grant: reaping releases a server by flipping desiredState=Stopped and
|
||||
// reclaiming the world PVC (k8scluster.go does "nothing else"), leaving the CR in
|
||||
@@ -172,7 +177,7 @@ func ReaperRole(p Params) *rbacv1.Role {
|
||||
p = p.withDefaults()
|
||||
return role(p.MinecraftNamespace, "felis-reaper", ComponentReaper, []rbacv1.PolicyRule{
|
||||
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "patch"}),
|
||||
rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"delete"}),
|
||||
rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get", "delete"}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -183,8 +183,8 @@ func TestOperatorRole_ScopeExact(t *testing.T) {
|
||||
func TestReaperRole_ScopeExact(t *testing.T) {
|
||||
rp := roleByName(t, ControlPlaneRBAC(reaperParams()).Roles, "felis-reaper")
|
||||
|
||||
if !hasRule(rp, groupCore, "persistentvolumeclaims", "delete") {
|
||||
t.Error("reaper must delete PVCs (world reclamation)")
|
||||
if !hasRule(rp, groupCore, "persistentvolumeclaims", "delete") || !hasRule(rp, groupCore, "persistentvolumeclaims", "get") {
|
||||
t.Error("reaper must get (resolve the volume) and delete (reclaim) PVCs")
|
||||
}
|
||||
if !hasRule(rp, groupFelis, "minecraftservers", "patch") || !hasRule(rp, groupFelis, "minecraftservers", "get") {
|
||||
t.Error("reaper must get+patch minecraftservers (to Stop them)")
|
||||
|
||||
@@ -32,12 +32,14 @@ import (
|
||||
// read-only — is the only one coherent with the operator's per-server
|
||||
// ReadWriteOnce world PVCs (a shared RWX worlds mount would contradict them), so
|
||||
// that is what renders; when the trio is absent no CronJob is emitted, which is
|
||||
// the fail-safe choice for a workload that deletes PVCs. SHAPE-ASSERTED and
|
||||
// runtime-unverified: the rendered CronJob is the correct K8s object, but whether
|
||||
// the tar finds a world under <WorldsHostPath>/<pvc> on a given cluster depends on
|
||||
// how that node's storage is arranged (stock local-path-provisioner uses
|
||||
// PV-name paths, not <root>/<pvc>) and is not provable without a cluster — see the
|
||||
// WorldsHostPath field doc. No nodeSelector is set: the single-node starter pins
|
||||
// the fail-safe choice for a workload that deletes PVCs. The reaper resolver
|
||||
// (cmd/felis/reaper.resolveWorldDir) finds worlds either as <WorldsHostPath>/<pvc>
|
||||
// or in the stock local-path layout k3s writes under its storage root
|
||||
// (<pv-name>_<ns>_<pvc-name>, read from the live PVC), so pointing
|
||||
// --worlds-host-path at /var/lib/rancher/k3s/storage works on a default install —
|
||||
// see the WorldsHostPath field doc. Whether the tar finds a world still depends on
|
||||
// the hosting node, and is not provable without a cluster. No nodeSelector is set:
|
||||
// the single-node starter pins
|
||||
// the worlds to one node implicitly; a multi-node deployment MUST add one (or the
|
||||
// CronJob could schedule on a node where the hostPath is empty) — a hazard left on
|
||||
// record here until multi-node retention is built.
|
||||
@@ -86,6 +88,13 @@ const (
|
||||
// local-storage install (no such Secret) still starts.
|
||||
uploadsPVCName = "felis-uploads"
|
||||
uploadsStorageSize = "5Gi"
|
||||
// backupStorageSize is the world-archive store's default capacity. It is a
|
||||
// starter-sized floor (registry 10Gi, uploads 5Gi sit beside it): archives are
|
||||
// compressed worlds and a fresh one is ~200MB, so this holds many while leaving
|
||||
// the growth knob (resize the PVC / move to a snapshot backend, spec §19) to the
|
||||
// operator. There is no storageClassName: the cluster default is the only safe
|
||||
// binding, exactly like registryPVC.
|
||||
backupStorageSize = "10Gi"
|
||||
// UploadsLocalPath is the in-pod mount of the uploads PVC; a local
|
||||
// user_uploads_context points here so the derived context ref and the on-disk
|
||||
// write location agree. Exported so the setup wizard stamps it into felis.toml.
|
||||
@@ -165,9 +174,10 @@ func InternalAPIBaseURL(controlNamespace string) string {
|
||||
|
||||
// Workloads renders the running control-plane: the felis-api Deployment, the
|
||||
// felis-operator Deployment, and the in-cluster registry (Deployment + Service +
|
||||
// PVC), plus the reaper CronJob when reaperEnabled(p). FelisImage is required —
|
||||
// `felis manifests` enforces it (fail-loud), so a rendered bundle always names a
|
||||
// concrete image.
|
||||
// PVC), the world-archive PVC when p.BackupPVC names it (it backs the
|
||||
// backup/restore Jobs and the reaper), plus the reaper CronJob when
|
||||
// reaperEnabled(p). FelisImage is required — `felis manifests` enforces it
|
||||
// (fail-loud), so a rendered bundle always names a concrete image.
|
||||
func Workloads(p Params) []Object {
|
||||
p = p.withDefaults()
|
||||
objs := []Object{
|
||||
@@ -180,6 +190,9 @@ func Workloads(p Params) []Object {
|
||||
registryPVC(p),
|
||||
uploadsPVC(p),
|
||||
}
|
||||
if p.BackupPVC != "" {
|
||||
objs = append(objs, backupPVC(p))
|
||||
}
|
||||
if reaperEnabled(p) {
|
||||
objs = append(objs, reaperCronJob(p))
|
||||
}
|
||||
@@ -656,6 +669,30 @@ func uploadsPVC(p Params) *corev1.PersistentVolumeClaim {
|
||||
}
|
||||
}
|
||||
|
||||
// backupPVC renders the world-archive store (spec §18/§19): the PVC the backup
|
||||
// and restore Jobs mount read-write, and the one the reaper CronJob writes
|
||||
// archives into. It renders ONLY when p.BackupPVC names it, so the same value
|
||||
// gates the PVC and the FELIS_BACKUP_PVC env on the felis-api Deployment — with
|
||||
// no name there is no PVC, no env, and the backup/restore endpoints keep
|
||||
// answering an honest 503 rather than enqueuing a Job that cannot mount its
|
||||
// backup. It lives in the Minecraft namespace because every pod that mounts it
|
||||
// runs there (a Pod can only mount PVCs from its own namespace; the reaper
|
||||
// CronJob is rendered there for the same reason). No storageClassName: binding
|
||||
// the cluster default is the only safe default.
|
||||
func backupPVC(p Params) *corev1.PersistentVolumeClaim {
|
||||
p = p.withDefaults()
|
||||
return &corev1.PersistentVolumeClaim{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "PersistentVolumeClaim"},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: p.BackupPVC, Namespace: p.MinecraftNamespace, Labels: controlPlanePodLabels(ComponentAPI)},
|
||||
Spec: corev1.PersistentVolumeClaimSpec{
|
||||
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
|
||||
Resources: corev1.VolumeResourceRequirements{
|
||||
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse(backupStorageSize)},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// registryLabels are the registry's recommended labels. Note the absence of
|
||||
// part-of=felis-control-plane: that is what keeps the registry out of the RCON
|
||||
// NetworkPolicy peer's reach (asserted in workloads_test.go).
|
||||
|
||||
@@ -321,6 +321,45 @@ func TestAPIDeployment_BackupPVC(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackupPVC_RendersWithTheStore proves the world-archive PVC is part of the
|
||||
// bundle exactly when a backup PVC is named, and that it lands in the Minecraft
|
||||
// namespace where every pod mounting it runs (the backup/restore Jobs and the
|
||||
// reaper CronJob) — the one property that made the reaper's first placement
|
||||
// unschedulable. Without a name the bundle must NOT create one: the api env is
|
||||
// gated on the same value, so the endpoints answer 503 instead of pointing a Job
|
||||
// at a claim nobody provisioned.
|
||||
func TestBackupPVC_RendersWithTheStore(t *testing.T) {
|
||||
p := testParams()
|
||||
p.BackupPVC = "felis-backups"
|
||||
var got *corev1.PersistentVolumeClaim
|
||||
for _, o := range Workloads(p) {
|
||||
if pvc, ok := o.(*corev1.PersistentVolumeClaim); ok && pvc.Name == "felis-backups" {
|
||||
got = pvc
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("Workloads() must render PVC %q when BackupPVC is set", p.BackupPVC)
|
||||
}
|
||||
if got.Namespace != p.MinecraftNamespace {
|
||||
t.Errorf("backup PVC namespace = %q, want %q (Pod↔PVC mounts are same-namespace only)", got.Namespace, p.MinecraftNamespace)
|
||||
}
|
||||
if len(got.Spec.AccessModes) != 1 || got.Spec.AccessModes[0] != corev1.ReadWriteOnce {
|
||||
t.Errorf("backup PVC access modes = %v, want [ReadWriteOnce]", got.Spec.AccessModes)
|
||||
}
|
||||
if q := got.Spec.Resources.Requests.Storage(); q == nil || q.String() != backupStorageSize {
|
||||
t.Errorf("backup PVC storage = %v, want %s", q, backupStorageSize)
|
||||
}
|
||||
if got.Spec.StorageClassName != nil {
|
||||
t.Errorf("backup PVC pins storageClassName %q; the cluster default is the only safe binding", *got.Spec.StorageClassName)
|
||||
}
|
||||
|
||||
for _, o := range Workloads(testParams()) {
|
||||
if pvc, ok := o.(*corev1.PersistentVolumeClaim); ok && pvc.Name == "felis-backups" {
|
||||
t.Error("no backup PVC may render when none is named")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOperatorDeployment_Wiring pins the operator entrypoint, its namespace split,
|
||||
// and its deliberately smaller surface (NO config Secret — it holds no DB URL).
|
||||
func TestOperatorDeployment_Wiring(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user