fix(workloads): world executors run as root so game-image worlds are readable
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.
This commit is contained in:
12 files changed
+223
-105
No files matched your search
@@ -112,9 +112,12 @@ type Config struct {
|
||||
// CPULimit / MemLimit cap the container.
|
||||
CPULimit string
|
||||
MemLimit string
|
||||
// RunAsUser / RunAsGroup / FSGroup are the Pod's runtime identity. FSGroup MUST
|
||||
// match the operator StatefulSet's runtime group, or a file this Pod writes
|
||||
// would be unreadable by the minecraft server that later mounts the same PVC.
|
||||
// RunAsUser / RunAsGroup / FSGroup are the Pod's runtime identity. They default
|
||||
// to ROOT (0:0): the world volume is written by the game image's own UID (root
|
||||
// for the images we ship), and Paper saves mode-0600 files a non-root editor
|
||||
// can neither read nor rewrite (level.dat). DAC_OVERRIDE on the container
|
||||
// covers images whose UID is neither root nor ours; FSGroup is omitted when
|
||||
// zero.
|
||||
RunAsUser int64
|
||||
RunAsGroup int64
|
||||
FSGroup int64
|
||||
@@ -136,7 +139,6 @@ const (
|
||||
defaultTimeout = 90 * time.Second
|
||||
defaultCPULimit = "500m"
|
||||
defaultMemLimit = "256Mi"
|
||||
defaultRunAsID = int64(1000)
|
||||
defaultTTL = 2 * time.Minute
|
||||
)
|
||||
|
||||
@@ -164,15 +166,6 @@ func (c Config) withDefaults() Config {
|
||||
if c.MemLimit == "" {
|
||||
c.MemLimit = defaultMemLimit
|
||||
}
|
||||
if c.RunAsUser == 0 {
|
||||
c.RunAsUser = defaultRunAsID
|
||||
}
|
||||
if c.RunAsGroup == 0 {
|
||||
c.RunAsGroup = defaultRunAsID
|
||||
}
|
||||
if c.FSGroup == 0 {
|
||||
c.FSGroup = defaultRunAsID
|
||||
}
|
||||
if c.TTLAfterFinished <= 0 {
|
||||
c.TTLAfterFinished = defaultTTL
|
||||
}
|
||||
|
||||
@@ -165,7 +165,13 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
|
||||
Privileged: boolPtr(false),
|
||||
AllowPrivilegeEscalation: boolPtr(false),
|
||||
ReadOnlyRootFilesystem: boolPtr(true),
|
||||
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
|
||||
// Root + DAC_OVERRIDE (see Config.RunAsUser): the file the editor is
|
||||
// asked to touch may be a mode-0600 file the game wrote as its own
|
||||
// (image) UID — level.dat — which a fixed non-root uid cannot open.
|
||||
Capabilities: &corev1.Capabilities{
|
||||
Drop: []corev1.Capability{"ALL"},
|
||||
Add: []corev1.Capability{"DAC_OVERRIDE"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -199,13 +205,8 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
|
||||
RestartPolicy: corev1.RestartPolicyNever,
|
||||
ServiceAccountName: p.ServiceAccount,
|
||||
AutomountServiceAccountToken: boolPtr(false),
|
||||
SecurityContext: &corev1.PodSecurityContext{
|
||||
RunAsNonRoot: boolPtr(true),
|
||||
RunAsUser: int64Ptr(p.RunAsUser),
|
||||
RunAsGroup: int64Ptr(p.RunAsGroup),
|
||||
FSGroup: int64Ptr(p.FSGroup),
|
||||
},
|
||||
Containers: []corev1.Container{container},
|
||||
SecurityContext: filesPodSecurityContext(p),
|
||||
Containers: []corev1.Container{container},
|
||||
Volumes: []corev1.Volume{{
|
||||
Name: worldVolume,
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
@@ -247,3 +248,19 @@ func resourceLimits(cpu, mem string) (corev1.ResourceList, error) {
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func int32Ptr(i int32) *int32 { return &i }
|
||||
func int64Ptr(i int64) *int64 { return &i }
|
||||
|
||||
// filesPodSecurityContext pins the Pod identity. Root by default: the world
|
||||
// volume belongs to the game image's UID (root for the images we ship) and its
|
||||
// mode-0600 files (level.dat) are otherwise unreadable/unwritable. FSGroup is
|
||||
// only rendered when configured so a root executor never chgrps the volume.
|
||||
func filesPodSecurityContext(p JobParams) *corev1.PodSecurityContext {
|
||||
sc := &corev1.PodSecurityContext{
|
||||
RunAsNonRoot: boolPtr(false),
|
||||
RunAsUser: int64Ptr(p.RunAsUser),
|
||||
RunAsGroup: int64Ptr(p.RunAsGroup),
|
||||
}
|
||||
if p.FSGroup > 0 {
|
||||
sc.FSGroup = int64Ptr(p.FSGroup)
|
||||
}
|
||||
return sc
|
||||
}
|
||||
@@ -23,9 +23,9 @@ func testParams(op string) JobParams {
|
||||
Deadline: 2 * time.Minute,
|
||||
CPULimit: "500m",
|
||||
MemLimit: "256Mi",
|
||||
RunAsUser: 1000,
|
||||
RunAsGroup: 1000,
|
||||
FSGroup: 1000,
|
||||
RunAsUser: 0,
|
||||
RunAsGroup: 0,
|
||||
FSGroup: 0,
|
||||
TTLAfterFinished: 2 * time.Minute,
|
||||
}
|
||||
}
|
||||
@@ -64,17 +64,19 @@ func TestFilesJobIsolation(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("runs non-root with the operator's runtime identity", func(t *testing.T) {
|
||||
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 true")
|
||||
if sc == nil || sc.RunAsNonRoot == nil || *sc.RunAsNonRoot {
|
||||
t.Fatal("RunAsNonRoot must be false: root is the owner-matching default for game-image worlds")
|
||||
}
|
||||
// FSGroup must match the minecraft server's group or a file this Pod writes
|
||||
// would be unreadable by the server that later mounts the same volume.
|
||||
if sc.RunAsUser == nil || *sc.RunAsUser != 1000 ||
|
||||
sc.RunAsGroup == nil || *sc.RunAsGroup != 1000 ||
|
||||
sc.FSGroup == nil || *sc.FSGroup != 1000 {
|
||||
t.Fatalf("uid/gid/fsGroup must all be 1000, got %+v", sc)
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -98,6 +100,9 @@ func TestFilesJobIsolation(t *testing.T) {
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user