A live backup drill on test-one failed: 'tar walk: open /world/world/level.dat: permission denied'. The world volume belongs to the game image's own UID (root for every Paper image we ship), and Paper saves level.dat mode 0600 — a fixed uid-1000 executor can neither read it (backup/reaper archive) nor overwrite it (restore). The same identity silently broke on-demand backups, restores, and the reaper for every server that had saved once. Run the backup Job, restore Job, file Job, and the reaper pod as root with DAC_OVERRIDE on top of drop-ALL — the same owner-matching precedent as the operator's forwarding-init container; DAC_OVERRIDE extends it to game images whose UID is neither root nor ours. FSGroup is omitted when zero so a root executor never chgrps the world volume. Shape tests updated for the new identity.
265 lines
9.4 KiB
Go
265 lines
9.4 KiB
Go
package fileedit
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
)
|
|
|
|
func testParams(op string) JobParams {
|
|
return JobParams{
|
|
Server: "survival",
|
|
OpID: "deadbeefcafe0001",
|
|
Op: op,
|
|
Path: "server.properties",
|
|
WorldPVC: "world-survival-0",
|
|
Namespace: "minecraft",
|
|
ServiceAccount: "felis-restore",
|
|
Image: "registry.example/felis:v1",
|
|
WorldsRoot: "/data",
|
|
Deadline: 2 * time.Minute,
|
|
CPULimit: "500m",
|
|
MemLimit: "256Mi",
|
|
RunAsUser: 0,
|
|
RunAsGroup: 0,
|
|
FSGroup: 0,
|
|
TTLAfterFinished: 2 * time.Minute,
|
|
}
|
|
}
|
|
|
|
// TestFilesJobIsolation asserts every isolation guarantee FilesJob documents. No
|
|
// cluster runs in this environment, so this pure-function test IS the enforcement:
|
|
// if someone loosens the Pod spec, this is what catches it.
|
|
func TestFilesJobIsolation(t *testing.T) {
|
|
job, err := FilesJob(testParams(OpRead))
|
|
if err != nil {
|
|
t.Fatalf("FilesJob: %v", err)
|
|
}
|
|
spec := job.Spec.Template.Spec
|
|
|
|
t.Run("runs under the weak SA with its token un-mounted", func(t *testing.T) {
|
|
if spec.ServiceAccountName != "felis-restore" {
|
|
t.Fatalf("SA = %q, want the weak felis-restore", spec.ServiceAccountName)
|
|
}
|
|
if spec.AutomountServiceAccountToken == nil || *spec.AutomountServiceAccountToken {
|
|
t.Fatal("the SA token MUST NOT be auto-mounted — the Pod must not reach the K8s API")
|
|
}
|
|
})
|
|
|
|
// The four-power red line: a file-editor Pod holds no credential of any kind. It
|
|
// is strictly blinder than the backup Pod, which does mount the config Secret.
|
|
t.Run("mounts exactly one volume and no credential", func(t *testing.T) {
|
|
if len(spec.Volumes) != 1 {
|
|
t.Fatalf("volumes = %d, want exactly 1 (the world PVC)", len(spec.Volumes))
|
|
}
|
|
v := spec.Volumes[0]
|
|
if v.PersistentVolumeClaim == nil || v.PersistentVolumeClaim.ClaimName != "world-survival-0" {
|
|
t.Fatalf("the sole volume must be the world PVC, got %+v", v)
|
|
}
|
|
if v.Secret != nil || v.ConfigMap != nil || v.Projected != nil {
|
|
t.Fatalf("no Secret/ConfigMap/Projected volume may be mounted, got %+v", v)
|
|
}
|
|
})
|
|
|
|
t.Run("runs as root, the owner-matching identity for game-image worlds", func(t *testing.T) {
|
|
sc := spec.SecurityContext
|
|
if sc == nil || sc.RunAsNonRoot == nil || *sc.RunAsNonRoot {
|
|
t.Fatal("RunAsNonRoot must be false: root is the owner-matching default for game-image worlds")
|
|
}
|
|
// Root because the world volume belongs to the game image's UID and Paper
|
|
// saves mode-0600 files a fixed non-root editor cannot open.
|
|
if sc.RunAsUser == nil || *sc.RunAsUser != 0 ||
|
|
sc.RunAsGroup == nil || *sc.RunAsGroup != 0 {
|
|
t.Fatalf("uid/gid must be 0:0 by default, got %+v", sc)
|
|
}
|
|
if sc.FSGroup != nil {
|
|
t.Fatalf("fsGroup must stay unset when zero, got %+v", sc.FSGroup)
|
|
}
|
|
})
|
|
|
|
t.Run("container drops every privilege", func(t *testing.T) {
|
|
if len(spec.Containers) != 1 {
|
|
t.Fatalf("containers = %d, want 1", len(spec.Containers))
|
|
}
|
|
sc := spec.Containers[0].SecurityContext
|
|
if sc == nil {
|
|
t.Fatal("the container needs a SecurityContext")
|
|
}
|
|
if sc.Privileged == nil || *sc.Privileged {
|
|
t.Fatal("Privileged must be false")
|
|
}
|
|
if sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation {
|
|
t.Fatal("AllowPrivilegeEscalation must be false")
|
|
}
|
|
if sc.ReadOnlyRootFilesystem == nil || !*sc.ReadOnlyRootFilesystem {
|
|
t.Fatal("ReadOnlyRootFilesystem must be true")
|
|
}
|
|
if sc.Capabilities == nil || len(sc.Capabilities.Drop) != 1 || sc.Capabilities.Drop[0] != "ALL" {
|
|
t.Fatalf("capabilities must drop ALL, got %+v", sc.Capabilities)
|
|
}
|
|
if len(sc.Capabilities.Add) != 1 || sc.Capabilities.Add[0] != "DAC_OVERRIDE" {
|
|
t.Fatalf("capabilities must add exactly DAC_OVERRIDE, got %+v", sc.Capabilities.Add)
|
|
}
|
|
})
|
|
|
|
t.Run("is one-shot, deadlined, and self-collecting", func(t *testing.T) {
|
|
if job.Spec.BackoffLimit == nil || *job.Spec.BackoffLimit != 0 {
|
|
t.Fatal("BackoffLimit must be 0 — a retried write is a second write")
|
|
}
|
|
if job.Spec.ActiveDeadlineSeconds == nil || *job.Spec.ActiveDeadlineSeconds != 120 {
|
|
t.Fatalf("ActiveDeadlineSeconds = %v, want 120", job.Spec.ActiveDeadlineSeconds)
|
|
}
|
|
// The TTL is the ONLY cleanup available: felis-api holds no jobs:delete.
|
|
if job.Spec.TTLSecondsAfterFinished == nil || *job.Spec.TTLSecondsAfterFinished != 120 {
|
|
t.Fatalf("TTLSecondsAfterFinished = %v, want 120", job.Spec.TTLSecondsAfterFinished)
|
|
}
|
|
if spec.RestartPolicy != corev1.RestartPolicyNever {
|
|
t.Fatalf("RestartPolicy = %q, want Never", spec.RestartPolicy)
|
|
}
|
|
})
|
|
|
|
t.Run("runs the files entrypoint with the op as arguments", func(t *testing.T) {
|
|
c := spec.Containers[0]
|
|
if len(c.Command) != 2 || c.Command[0] != felisBinaryPath || c.Command[1] != "files" {
|
|
t.Fatalf("command = %v, want [%s files]", c.Command, felisBinaryPath)
|
|
}
|
|
args := strings.Join(c.Args, " ")
|
|
for _, want := range []string{"--op read", "--path server.properties", "--worlds-root /data"} {
|
|
if !strings.Contains(args, want) {
|
|
t.Fatalf("args %q missing %q", args, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestFilesJobWorldMountIsReadOnlyExceptForWrite pins the guarantee that only a
|
|
// write can mutate a world. For list and read the kernel refuses the write, not
|
|
// merely the code — a defence that survives a bug in the entrypoint.
|
|
func TestFilesJobWorldMountIsReadOnlyExceptForWrite(t *testing.T) {
|
|
cases := []struct {
|
|
op string
|
|
wantReadOnly bool
|
|
}{
|
|
{OpList, true},
|
|
{OpRead, true},
|
|
{OpWrite, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.op, func(t *testing.T) {
|
|
job, err := FilesJob(testParams(tc.op))
|
|
if err != nil {
|
|
t.Fatalf("FilesJob: %v", err)
|
|
}
|
|
spec := job.Spec.Template.Spec
|
|
gotMount := spec.Containers[0].VolumeMounts[0].ReadOnly
|
|
gotVol := spec.Volumes[0].PersistentVolumeClaim.ReadOnly
|
|
if gotMount != tc.wantReadOnly || gotVol != tc.wantReadOnly {
|
|
t.Fatalf("op %s: mount.readOnly=%v volume.readOnly=%v, want %v",
|
|
tc.op, gotMount, gotVol, tc.wantReadOnly)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFilesJobContentEnv pins the write channel: content rides the Job spec
|
|
// base64-encoded, and ONLY for a write — a list or read Job spec must carry no
|
|
// caller content at all.
|
|
func TestFilesJobContentEnv(t *testing.T) {
|
|
t.Run("write carries base64 content", func(t *testing.T) {
|
|
p := testParams(OpWrite)
|
|
p.Content = []byte("motd=hello\n\x00\xff")
|
|
job, err := FilesJob(p)
|
|
if err != nil {
|
|
t.Fatalf("FilesJob: %v", err)
|
|
}
|
|
env := job.Spec.Template.Spec.Containers[0].Env
|
|
if len(env) != 1 || env[0].Name != ContentEnv {
|
|
t.Fatalf("env = %+v, want exactly %s", env, ContentEnv)
|
|
}
|
|
got, err := base64.StdEncoding.DecodeString(env[0].Value)
|
|
if err != nil {
|
|
t.Fatalf("env value is not base64: %v", err)
|
|
}
|
|
if string(got) != string(p.Content) {
|
|
t.Fatalf("decoded %q, want %q — arbitrary bytes must survive", got, p.Content)
|
|
}
|
|
// The content must never leak into argv, which is world-readable on the node.
|
|
if strings.Contains(strings.Join(job.Spec.Template.Spec.Containers[0].Args, " "), "motd=hello") {
|
|
t.Fatal("content must not appear in the container arguments")
|
|
}
|
|
})
|
|
|
|
for _, op := range []string{OpList, OpRead} {
|
|
t.Run(op+" carries no content env", func(t *testing.T) {
|
|
job, err := FilesJob(testParams(op))
|
|
if err != nil {
|
|
t.Fatalf("FilesJob: %v", err)
|
|
}
|
|
if env := job.Spec.Template.Spec.Containers[0].Env; len(env) != 0 {
|
|
t.Fatalf("env = %+v, want none for a %s", env, op)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFilesJobNameIsPerInvocation is the RBAC-forced property documented on
|
|
// FilesJobName. felis-api holds jobs:create and NOTHING else — no jobs:delete — so
|
|
// a deterministic name would let the first completed Job squat it for a whole TTL
|
|
// window and wedge every subsequent operation. Two operations on the same server
|
|
// must therefore never collide.
|
|
func TestFilesJobNameIsPerInvocation(t *testing.T) {
|
|
a := testParams(OpRead)
|
|
b := testParams(OpRead)
|
|
b.OpID = "deadbeefcafe0002"
|
|
|
|
ja, err := FilesJob(a)
|
|
if err != nil {
|
|
t.Fatalf("FilesJob: %v", err)
|
|
}
|
|
jb, err := FilesJob(b)
|
|
if err != nil {
|
|
t.Fatalf("FilesJob: %v", err)
|
|
}
|
|
if ja.Name == jb.Name {
|
|
t.Fatalf("two operations on one server share the Job name %q — the editor would wedge", ja.Name)
|
|
}
|
|
if !strings.Contains(ja.Name, "survival") || !strings.Contains(ja.Name, a.OpID) {
|
|
t.Fatalf("job name %q should carry the server and the op id", ja.Name)
|
|
}
|
|
// The op id must also label the Pod, or the runner could not select THIS
|
|
// operation's Pod to read its result from.
|
|
if got := ja.Spec.Template.ObjectMeta.Labels[LabelOpID]; got != a.OpID {
|
|
t.Fatalf("pod label %s = %q, want %q", LabelOpID, got, a.OpID)
|
|
}
|
|
}
|
|
|
|
// TestFilesJobRejectsBadParams checks the renderer fails loudly rather than
|
|
// producing a Job that cannot run or that would be refused by etcd.
|
|
func TestFilesJobRejectsBadParams(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
mutate func(*JobParams)
|
|
}{
|
|
{"no image", func(p *JobParams) { p.Image = "" }},
|
|
{"no world PVC", func(p *JobParams) { p.WorldPVC = "" }},
|
|
{"no op id", func(p *JobParams) { p.OpID = "" }},
|
|
{"unknown op", func(p *JobParams) { p.Op = "delete" }},
|
|
{"oversized content", func(p *JobParams) {
|
|
p.Op, p.Content = OpWrite, make([]byte, MaxWriteBytes+1)
|
|
}},
|
|
{"bad cpu limit", func(p *JobParams) { p.CPULimit = "half" }},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
p := testParams(OpRead)
|
|
tc.mutate(&p)
|
|
if _, err := FilesJob(p); err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
})
|
|
}
|
|
}
|