Skip to content

Commit 1dde566

Browse files
stubbiclaude
andcommitted
fix: add spec.security.seLinuxRelabel opt-out for relabel init container
The operator unconditionally adds a privileged "selinux-relabel" init container whenever persistence is enabled. On clusters where SELinux relabeling does not apply (NFS-backed storage, or nodes that are not SELinux-enforcing such as Ubuntu with AppArmor), the container's chcon fails permanently with "Operation not supported", leaving the pod stuck in Init:CrashLoopBackOff and the Service returning 503. There was no way to disable it via the Instance CRD. Add a backward-compatible opt-out field, spec.security.seLinuxRelabel (*bool, default true). The init container is now gated on both PersistenceEnabled and SELinuxRelabelEnabled. Unset preserves today's behavior so existing SELinux-enforcing clusters are unaffected; an explicit false skips the init container entirely. Closes #96 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f21a965 commit 1dde566

9 files changed

Lines changed: 175 additions & 1 deletion

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,28 @@ spec:
973973
drop: [ALL]
974974
```
975975

976+
### SELinux Relabel
977+
978+
When persistence is enabled, the operator adds a privileged `selinux-relabel`
979+
init container that runs `chcon` on the data volume so its MCS categories match
980+
the pod's SELinux level. On SELinux-enforcing nodes this is required for the pod
981+
to read its own persistent data.
982+
983+
On clusters where the relabel does not apply -- NFS-backed storage, or nodes
984+
that are not SELinux-enforcing (for example Ubuntu with AppArmor) -- `chcon`
985+
fails permanently with `Operation not supported`, leaving the pod stuck in
986+
`Init:CrashLoopBackOff`. Opt out by setting `seLinuxRelabel: false`:
987+
988+
```yaml
989+
spec:
990+
security:
991+
seLinuxRelabel: false # default: true (init container is added)
992+
```
993+
994+
The field defaults to `true`, preserving the legacy behavior, so existing
995+
SELinux-enforcing clusters are unaffected. Setting it to `false` skips the init
996+
container entirely.
997+
976998
### RBAC and ServiceAccount
977999

9781000
```yaml

api/v1alpha1/paperclipinstance_types.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,27 @@ type SecuritySpec struct {
934934
// +optional
935935
ContainerSecurityContext *corev1.SecurityContext `json:"containerSecurityContext,omitempty"`
936936

937+
// SELinuxRelabel controls whether the operator adds a privileged
938+
// "selinux-relabel" init container that runs chcon on the data volume when
939+
// persistence is enabled. This relabel is required on SELinux-enforcing
940+
// nodes so the data directory's MCS categories match the pod's SELinux
941+
// level; without it the pod cannot read its own persistent data.
942+
//
943+
// It defaults to true to preserve the legacy behavior (the init container is
944+
// always added when persistence is enabled). Set it to false on clusters
945+
// where the relabel does not apply and would fail permanently, e.g. NFS-
946+
// backed storage or nodes that are not SELinux-enforcing (Ubuntu with
947+
// AppArmor). On those clusters chcon returns "Operation not supported",
948+
// leaving the pod stuck in Init:CrashLoopBackOff.
949+
//
950+
// A pointer is required so an explicit `false` survives marshaling: a plain
951+
// bool with omitempty is dropped on marshal and the API server re-defaults
952+
// it to true on every controller update (same bug class as
953+
// PersistenceSpec.Enabled and NetworkPolicySpec.Enabled).
954+
// +kubebuilder:default=true
955+
// +optional
956+
SELinuxRelabel *bool `json:"seLinuxRelabel,omitempty"`
957+
937958
// NetworkPolicy configures network isolation.
938959
// +optional
939960
NetworkPolicy NetworkPolicySpec `json:"networkPolicy,omitempty"`

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

charts/paperclip-operator/templates/crds/paperclip.inc_instances.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6933,6 +6933,27 @@ spec:
69336933
annotations for the ServiceAccount.
69346934
type: object
69356935
type: object
6936+
seLinuxRelabel:
6937+
default: true
6938+
description: |-
6939+
SELinuxRelabel controls whether the operator adds a privileged
6940+
"selinux-relabel" init container that runs chcon on the data volume when
6941+
persistence is enabled. This relabel is required on SELinux-enforcing
6942+
nodes so the data directory's MCS categories match the pod's SELinux
6943+
level; without it the pod cannot read its own persistent data.
6944+
6945+
It defaults to true to preserve the legacy behavior (the init container is
6946+
always added when persistence is enabled). Set it to false on clusters
6947+
where the relabel does not apply and would fail permanently, e.g. NFS-
6948+
backed storage or nodes that are not SELinux-enforcing (Ubuntu with
6949+
AppArmor). On those clusters chcon returns "Operation not supported",
6950+
leaving the pod stuck in Init:CrashLoopBackOff.
6951+
6952+
A pointer is required so an explicit `false` survives marshaling: a plain
6953+
bool with omitempty is dropped on marshal and the API server re-defaults
6954+
it to true on every controller update (same bug class as
6955+
PersistenceSpec.Enabled and NetworkPolicySpec.Enabled).
6956+
type: boolean
69366957
type: object
69376958
selfConfigure:
69386959
description: |-

config/crd/bases/paperclip.inc_instances.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6927,6 +6927,27 @@ spec:
69276927
annotations for the ServiceAccount.
69286928
type: object
69296929
type: object
6930+
seLinuxRelabel:
6931+
default: true
6932+
description: |-
6933+
SELinuxRelabel controls whether the operator adds a privileged
6934+
"selinux-relabel" init container that runs chcon on the data volume when
6935+
persistence is enabled. This relabel is required on SELinux-enforcing
6936+
nodes so the data directory's MCS categories match the pod's SELinux
6937+
level; without it the pod cannot read its own persistent data.
6938+
6939+
It defaults to true to preserve the legacy behavior (the init container is
6940+
always added when persistence is enabled). Set it to false on clusters
6941+
where the relabel does not apply and would fail permanently, e.g. NFS-
6942+
backed storage or nodes that are not SELinux-enforcing (Ubuntu with
6943+
AppArmor). On those clusters chcon returns "Operation not supported",
6944+
leaving the pod stuck in Init:CrashLoopBackOff.
6945+
6946+
A pointer is required so an explicit `false` survives marshaling: a plain
6947+
bool with omitempty is dropped on marshal and the API server re-defaults
6948+
it to true on every controller update (same bug class as
6949+
PersistenceSpec.Enabled and NetworkPolicySpec.Enabled).
6950+
type: boolean
69306951
type: object
69316952
selfConfigure:
69326953
description: |-

internal/resources/common.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ func PersistenceEnabled(instance *paperclipv1alpha1.Instance) bool {
120120
return *instance.Spec.Storage.Persistence.Enabled
121121
}
122122

123+
// SELinuxRelabelEnabled resolves spec.security.seLinuxRelabel (*bool, nil =
124+
// default true). When true (or unset) and persistence is enabled the operator
125+
// adds the privileged selinux-relabel init container. An explicit false lets
126+
// operators opt out on clusters where chcon fails permanently (NFS storage or
127+
// non-SELinux-enforcing nodes).
128+
func SELinuxRelabelEnabled(instance *paperclipv1alpha1.Instance) bool {
129+
if instance.Spec.Security.SELinuxRelabel == nil {
130+
return true
131+
}
132+
return *instance.Spec.Security.SELinuxRelabel
133+
}
134+
123135
// SchedulerGatingMode resolves spec.heartbeat.schedulerGating to the mode the
124136
// operator actually applies: "ordinal" or "lease". "auto" currently resolves
125137
// to "ordinal" (it will flip to "lease" once the minimum supported app version

internal/resources/podtemplate.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,12 @@ func BuildServerPodTemplate(instance *paperclipv1alpha1.Instance, extraPodAnnota
7373
// the pod's SELinux context. Required because Kubernetes may assign MCS
7474
// categories to the volume that differ from the pod's level, making
7575
// the data inaccessible. Runs as privileged to perform chcon.
76-
if PersistenceEnabled(instance) {
76+
//
77+
// Gated on spec.security.seLinuxRelabel (default true) so operators can opt
78+
// out on clusters where chcon fails permanently, e.g. NFS-backed storage or
79+
// non-SELinux-enforcing nodes. Leaving it unset preserves the legacy
80+
// behavior for existing SELinux-enforcing clusters.
81+
if PersistenceEnabled(instance) && SELinuxRelabelEnabled(instance) {
7782
seLevel := "s0"
7883
if instance.Spec.Security.PodSecurityContext != nil &&
7984
instance.Spec.Security.PodSecurityContext.SELinuxOptions != nil &&

internal/resources/resources_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1227,6 +1227,61 @@ func findInitContainer(sts *appsv1.StatefulSet, name string) (*corev1.Container,
12271227
return nil, -1
12281228
}
12291229

1230+
func TestBuildStatefulSetSELinuxRelabelOptOut(t *testing.T) {
1231+
tests := []struct {
1232+
name string
1233+
persistence *bool
1234+
seLinuxLabel *bool
1235+
wantContainer bool
1236+
}{
1237+
{
1238+
// Legacy behavior preserved: persistence on, field unset => present.
1239+
name: "persistence enabled, seLinuxRelabel unset",
1240+
persistence: Ptr(true),
1241+
seLinuxLabel: nil,
1242+
wantContainer: true,
1243+
},
1244+
{
1245+
// Explicit opt-out: persistence on, field false => absent.
1246+
name: "persistence enabled, seLinuxRelabel false",
1247+
persistence: Ptr(true),
1248+
seLinuxLabel: Ptr(false),
1249+
wantContainer: false,
1250+
},
1251+
{
1252+
// Explicit opt-in: persistence on, field true => present.
1253+
name: "persistence enabled, seLinuxRelabel true",
1254+
persistence: Ptr(true),
1255+
seLinuxLabel: Ptr(true),
1256+
wantContainer: true,
1257+
},
1258+
{
1259+
// No PVC to relabel: persistence off => absent regardless of field.
1260+
name: "persistence disabled, seLinuxRelabel unset",
1261+
persistence: Ptr(false),
1262+
seLinuxLabel: nil,
1263+
wantContainer: false,
1264+
},
1265+
}
1266+
1267+
for _, tt := range tests {
1268+
t.Run(tt.name, func(t *testing.T) {
1269+
instance := newTestInstance("my-paperclip")
1270+
instance.Spec.Storage.Persistence.Enabled = tt.persistence
1271+
instance.Spec.Security.SELinuxRelabel = tt.seLinuxLabel
1272+
1273+
sts := BuildStatefulSet(instance, nil)
1274+
c, _ := findInitContainer(sts, "selinux-relabel")
1275+
if tt.wantContainer && c == nil {
1276+
t.Fatal("expected selinux-relabel init container to be present")
1277+
}
1278+
if !tt.wantContainer && c != nil {
1279+
t.Fatal("expected selinux-relabel init container to be absent")
1280+
}
1281+
})
1282+
}
1283+
}
1284+
12301285
func TestBuildStatefulSetNoSeedInstanceAdminWhenNil(t *testing.T) {
12311286
instance := newTestInstance("my-paperclip")
12321287
// PlatformAdmin defaults to nil.

test/e2e/e2e_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,9 @@ spec:
356356
Expect(env).NotTo(ContainSubstring("PAPERCLIP_RATE_LIMIT_REDIS_URL"))
357357
Expect(env).NotTo(ContainSubstring("PAPERCLIP_MANAGED_"))
358358

359+
By("asserting the selinux-relabel init container is present (persistence on, seLinuxRelabel unset => legacy behavior)")
360+
Expect(stsInitContainerNames(instNS, "e2e-boot")).To(ContainSubstring("selinux-relabel"))
361+
359362
By("asserting no managed Redis resources were created")
360363
_, err := utils.Run(exec.Command("kubectl", "get", "statefulset", "e2e-boot-redis", "-n", instNS))
361364
Expect(err).To(HaveOccurred(), "no Redis StatefulSet should exist")
@@ -692,6 +695,15 @@ func stsEnvNames(ns, name string) string {
692695
return out
693696
}
694697

698+
// stsInitContainerNames returns the space-separated names of the StatefulSet's
699+
// init containers, used to assert presence/absence of the selinux-relabel init
700+
// container.
701+
func stsInitContainerNames(ns, name string) string {
702+
out, _ := utils.Run(exec.Command("kubectl", "get", "statefulset", name, "-n", ns,
703+
"-o", "jsonpath={.spec.template.spec.initContainers[*].name}"))
704+
return out
705+
}
706+
695707
// serviceAccountToken returns a token for the specified service account in the given namespace.
696708
// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request
697709
// and parsing the resulting token from the API response.

0 commit comments

Comments
 (0)