Skip to content

Commit 2f54f7d

Browse files
committed
Allow WorkerPools to restrict worker capabilities and seccomp
Configure worker capability drops, seccomp profiles and privilege escalation through the WorkerPool template. Preserve defaults and repair security settings changed on the managed Deployment. Adapt virtiofsd startup when the worker drops MKNOD, including on resume. Cover SYS_PTRACE and MKNOD drops through actor startup, pause and restore, and document capability defaults and override removal.
1 parent 190b956 commit 2f54f7d

16 files changed

Lines changed: 847 additions & 29 deletions

File tree

‎cmd/atecontroller/internal/controllers/workerpool_apply.go‎

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,19 @@
1515
package controllers
1616

1717
import (
18+
"fmt"
1819
"slices"
1920

2021
appsv1 "k8s.io/api/apps/v1"
2122
corev1 "k8s.io/api/core/v1"
2223
"k8s.io/apimachinery/pkg/api/resource"
24+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
25+
"k8s.io/apimachinery/pkg/runtime"
2326
"k8s.io/apimachinery/pkg/util/intstr"
2427
appsv1ac "k8s.io/client-go/applyconfigurations/apps/v1"
2528
corev1ac "k8s.io/client-go/applyconfigurations/core/v1"
2629
metav1ac "k8s.io/client-go/applyconfigurations/meta/v1"
30+
"sigs.k8s.io/controller-runtime/pkg/client"
2731

2832
"github.com/agent-substrate/substrate/internal/ateom"
2933
"github.com/agent-substrate/substrate/internal/deviceplugin"
@@ -234,6 +238,9 @@ func buildDeploymentApplyConfig(wp *atev1alpha1.WorkerPool, otel ateomOTelSettin
234238
)
235239

236240
applyWorkerPoolPodTemplate(podSpecAC, containerAC, wp.Spec.Template)
241+
if wp.Spec.Template != nil && wp.Spec.Template.SecurityContext != nil {
242+
applyWorkerSecurityContext(containerAC.SecurityContext, wp.Spec.Template.SecurityContext)
243+
}
237244
applySandboxClassToleration(podSpecAC, wp.Spec.DefaultSandboxClass())
238245
maybeApplyMicroVMPodShape(podSpecAC, containerAC, wp.Spec.DefaultSandboxClass())
239246
podSpecAC.WithContainers(containerAC)
@@ -339,27 +346,97 @@ func fieldRefEnv(name, fieldPath string) *corev1ac.EnvVarApplyConfiguration {
339346
WithFieldPath(fieldPath)))
340347
}
341348

342-
// ateomGvisorCapabilities is the capability set an unprivileged gVisor worker
343-
// needs. runsc's gofer maps a full-range identity in a user namespace
349+
// deploymentApplyConfiguration preserves explicitly empty capability lists.
350+
// The generated apply types omit empty slices, which leaves another field
351+
// manager's additions intact even when the worker must drop every capability.
352+
func deploymentApplyConfiguration(dep *appsv1ac.DeploymentApplyConfiguration) (runtime.ApplyConfiguration, error) {
353+
if dep == nil || dep.Spec == nil || dep.Spec.Template == nil || dep.Spec.Template.Spec == nil {
354+
return nil, fmt.Errorf("Deployment apply configuration must include a pod spec")
355+
}
356+
typedContainers := dep.Spec.Template.Spec.Containers
357+
needsEmptyCapabilities := false
358+
for _, container := range typedContainers {
359+
sc := container.SecurityContext
360+
if sc != nil && sc.Capabilities != nil && len(sc.Capabilities.Add) == 0 {
361+
needsEmptyCapabilities = true
362+
break
363+
}
364+
}
365+
if !needsEmptyCapabilities {
366+
return dep, nil
367+
}
368+
369+
obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(dep)
370+
if err != nil {
371+
return nil, fmt.Errorf("convert Deployment apply configuration: %w", err)
372+
}
373+
containers, found, err := unstructured.NestedSlice(obj, "spec", "template", "spec", "containers")
374+
if err != nil {
375+
return nil, fmt.Errorf("read Deployment apply containers: %w", err)
376+
}
377+
if !found || len(containers) != len(typedContainers) {
378+
return nil, fmt.Errorf("Deployment apply containers missing or inconsistent with typed configuration")
379+
}
380+
for i, container := range typedContainers {
381+
sc := container.SecurityContext
382+
if sc == nil || sc.Capabilities == nil || len(sc.Capabilities.Add) != 0 {
383+
continue
384+
}
385+
containerObj, ok := containers[i].(map[string]interface{})
386+
if !ok {
387+
return nil, fmt.Errorf("Deployment apply container %d: expected an object, got %T", i, containers[i])
388+
}
389+
if err := unstructured.SetNestedSlice(containerObj, []interface{}{}, "securityContext", "capabilities", "add"); err != nil {
390+
return nil, fmt.Errorf("set empty worker capabilities: %w", err)
391+
}
392+
}
393+
if err := unstructured.SetNestedSlice(obj, containers, "spec", "template", "spec", "containers"); err != nil {
394+
return nil, fmt.Errorf("set Deployment apply containers: %w", err)
395+
}
396+
return client.ApplyConfigurationFromUnstructured(&unstructured.Unstructured{Object: obj}), nil
397+
}
398+
399+
func applyWorkerSecurityContext(sc *corev1ac.SecurityContextApplyConfiguration, settings *atev1alpha1.WorkerPoolSecurityContext) {
400+
if slices.Contains(settings.DropCapabilities, "ALL") {
401+
sc.Capabilities.Add = nil
402+
} else {
403+
sc.Capabilities.Add = slices.DeleteFunc(sc.Capabilities.Add, func(capability corev1.Capability) bool {
404+
return slices.Contains(settings.DropCapabilities, capability)
405+
})
406+
}
407+
if settings.SeccompProfile != nil {
408+
profile := corev1ac.SeccompProfile().WithType(settings.SeccompProfile.Type)
409+
if settings.SeccompProfile.LocalhostProfile != nil {
410+
profile.WithLocalhostProfile(*settings.SeccompProfile.LocalhostProfile)
411+
}
412+
sc.WithSeccompProfile(profile)
413+
}
414+
if settings.AllowPrivilegeEscalation != nil {
415+
sc.WithAllowPrivilegeEscalation(*settings.AllowPrivilegeEscalation)
416+
}
417+
}
418+
419+
// ateomGvisorCapabilities is the default capability set for gVisor workers.
420+
// runsc's gofer maps a full-range identity in a user namespace
344421
// (SETUID/SETGID/SETPCAP/SETFCAP), the sandbox pivots root and traces the
345422
// application (SYS_ADMIN/SYS_CHROOT/SYS_PTRACE), actor networking programs the
346-
// veth and nftables rules (NET_ADMIN/NET_RAW), and the OCI rootfs is unpacked
347-
// and device nodes created as root over image-owned trees
348-
// (DAC_OVERRIDE/FOWNER/CHOWN/MKNOD). This replaces the former privileged worker;
349-
// seccomp stays at the runtime default, but AppArmor must be Unconfined (see
350-
// ateomSecurityContext) since runsc's own mounts trip the default profile.
423+
// veth and nftables rules (NET_ADMIN/NET_RAW), and rootfs trees are managed as
424+
// root (DAC_OVERRIDE/FOWNER/CHOWN). MKNOD remains in the default set; Linux's
425+
// 0:0 overlay whiteouts do not require it on the tested kernels.
426+
// Workers use Unconfined seccomp and AppArmor profiles by default so the sandbox
427+
// runtime can set up its mounts (see ateomSecurityContext).
351428
var ateomGvisorCapabilities = []corev1.Capability{
352429
"NET_ADMIN", "SYS_ADMIN", "SYS_CHROOT", "SYS_PTRACE",
353430
"SETUID", "SETGID", "SETPCAP", "DAC_OVERRIDE",
354431
"FOWNER", "CHOWN", "MKNOD", "NET_RAW", "SETFCAP",
355432
}
356433

357-
// ateomMicroVMCapabilities is the capability set an unprivileged micro-VM worker
358-
// needs: the gVisor set, which covers the same worker-side work, plus FSETID and
359-
// DAC_READ_SEARCH for virtiofsd. virtiofsd re-applies a fixed set to its
360-
// sandboxed child, and the kernel refuses to raise a capability the bounding set
361-
// omits, so without those two it exits with "can't apply the child capabilities"
362-
// and the VM never gets its virtio-fs device.
434+
// ateomMicroVMCapabilities is the default capability set for micro-VM workers:
435+
// the gVisor set, which covers the same worker-side work, plus FSETID and
436+
// DAC_READ_SEARCH for virtiofsd. Its default sandboxed child set includes those
437+
// two capabilities and MKNOD. Missing capabilities make child setup fail, not
438+
// whiteout preparation. When the worker drops MKNOD, StartVirtiofsd excludes it
439+
// from the child's set with --modcaps=-mknod.
363440
//
364441
// The hypervisor devices are not capabilities: they come from atelet's device
365442
// plugin (see maybeApplyMicroVMPodShape).

‎cmd/atecontroller/internal/controllers/workerpool_apply_test.go‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
package controllers
1616

1717
import (
18+
"encoding/json"
1819
"regexp"
20+
"slices"
1921
"strings"
2022
"testing"
2123

@@ -497,6 +499,181 @@ func TestAteomSecurityContextByClass(t *testing.T) {
497499
}
498500
}
499501

502+
func TestDeploymentApplyConfiguration(t *testing.T) {
503+
for _, class := range []atev1alpha1.SandboxClass{atev1alpha1.SandboxClassGvisor, atev1alpha1.SandboxClassMicroVM} {
504+
defaults := slices.Clone(ateomGvisorCapabilities)
505+
if class == atev1alpha1.SandboxClassMicroVM {
506+
defaults = slices.Clone(ateomMicroVMCapabilities)
507+
}
508+
for _, tt := range []struct {
509+
name string
510+
drop []corev1.Capability
511+
wantEmptyAdd bool
512+
}{
513+
{name: "defaults"},
514+
{name: "partial drop", drop: []corev1.Capability{"SYS_PTRACE"}},
515+
{name: "drop all", drop: []corev1.Capability{"ALL"}, wantEmptyAdd: true},
516+
{name: "drop individually", drop: defaults, wantEmptyAdd: true},
517+
} {
518+
t.Run(string(class)+"/"+tt.name, func(t *testing.T) {
519+
wp := testWorkerPoolApplyConfig(&atev1alpha1.WorkerPoolPodTemplate{
520+
NodeSelector: map[string]string{"workload": "substrate"},
521+
SecurityContext: &atev1alpha1.WorkerPoolSecurityContext{
522+
DropCapabilities: tt.drop,
523+
},
524+
})
525+
wp.Spec.SandboxClasses = []atev1alpha1.WorkerPoolSandboxClass{{Name: class}}
526+
dep := buildDeploymentApplyConfig(wp, ateomOTelSettings{}, installdefaults.SystemNamespace, installdefaults.AteletServiceAccount, installdefaults.RouterServiceAccount)
527+
dep.Spec.Template.Spec.WithContainers(
528+
corev1ac.Container().WithName("metrics").WithImage("metrics:v1").
529+
WithSecurityContext(corev1ac.SecurityContext().
530+
WithCapabilities(corev1ac.Capabilities().WithAdd("NET_BIND_SERVICE"))),
531+
corev1ac.Container().WithName("helper").WithImage("helper:v1"),
532+
)
533+
before, err := json.Marshal(dep)
534+
if err != nil {
535+
t.Fatal(err)
536+
}
537+
var want appsv1ac.DeploymentApplyConfiguration
538+
if err := json.Unmarshal(before, &want); err != nil {
539+
t.Fatal(err)
540+
}
541+
if tt.wantEmptyAdd {
542+
want.Spec.Template.Spec.Containers[0].SecurityContext.Capabilities.Add = []corev1.Capability{}
543+
}
544+
applied, err := deploymentApplyConfiguration(dep)
545+
if err != nil {
546+
t.Fatal(err)
547+
}
548+
if !tt.wantEmptyAdd {
549+
if typed, ok := applied.(*appsv1ac.DeploymentApplyConfiguration); !ok || typed != dep {
550+
t.Fatalf("non-empty capabilities must keep the original typed apply configuration, got %T", applied)
551+
}
552+
}
553+
payload, err := json.Marshal(applied)
554+
if err != nil {
555+
t.Fatal(err)
556+
}
557+
var got appsv1ac.DeploymentApplyConfiguration
558+
if err := json.Unmarshal(payload, &got); err != nil {
559+
t.Fatal(err)
560+
}
561+
// An explicit [] decodes as a non-nil empty slice. An omitted
562+
// add field decodes as nil, so cmp detects the SSA regression.
563+
if diff := cmp.Diff(&want, &got); diff != "" {
564+
t.Errorf("serialized Deployment (-want +got):\n%s", diff)
565+
}
566+
after, err := json.Marshal(dep)
567+
if err != nil {
568+
t.Fatal(err)
569+
}
570+
if diff := cmp.Diff(string(before), string(after)); diff != "" {
571+
t.Errorf("input apply configuration was mutated (-before +after):\n%s", diff)
572+
}
573+
})
574+
}
575+
}
576+
}
577+
578+
func TestDeploymentApplyConfigurationRequiresPodSpec(t *testing.T) {
579+
for _, tt := range []struct {
580+
name string
581+
dep *appsv1ac.DeploymentApplyConfiguration
582+
}{
583+
{name: "nil deployment"},
584+
{name: "missing spec", dep: appsv1ac.Deployment("worker", "default")},
585+
{name: "missing template", dep: appsv1ac.Deployment("worker", "default").WithSpec(appsv1ac.DeploymentSpec())},
586+
{name: "missing pod spec", dep: appsv1ac.Deployment("worker", "default").WithSpec(appsv1ac.DeploymentSpec().WithTemplate(corev1ac.PodTemplateSpec()))},
587+
} {
588+
t.Run(tt.name, func(t *testing.T) {
589+
if _, err := deploymentApplyConfiguration(tt.dep); err == nil || !strings.Contains(err.Error(), "must include a pod spec") {
590+
t.Fatalf("got %v, want missing pod spec error", err)
591+
}
592+
})
593+
}
594+
}
595+
596+
func TestWorkerSecurityContext(t *testing.T) {
597+
gvisorDefaults := slices.Clone(ateomGvisorCapabilities)
598+
microVMDefaults := slices.Clone(ateomMicroVMCapabilities)
599+
tests := []struct {
600+
name string
601+
class atev1alpha1.SandboxClass
602+
settings *atev1alpha1.WorkerPoolSecurityContext
603+
wantCaps []corev1.Capability
604+
}{
605+
{"omitted settings", atev1alpha1.SandboxClassGvisor, nil, gvisorDefaults},
606+
{"empty settings", atev1alpha1.SandboxClassGvisor, &atev1alpha1.WorkerPoolSecurityContext{}, gvisorDefaults},
607+
{"empty microvm settings", atev1alpha1.SandboxClassMicroVM, &atev1alpha1.WorkerPoolSecurityContext{}, microVMDefaults},
608+
{"drop gvisor capabilities", atev1alpha1.SandboxClassGvisor, &atev1alpha1.WorkerPoolSecurityContext{DropCapabilities: []corev1.Capability{"SYS_PTRACE", "MKNOD"}}, []corev1.Capability{"NET_ADMIN", "SYS_ADMIN", "SYS_CHROOT", "SETUID", "SETGID", "SETPCAP", "DAC_OVERRIDE", "FOWNER", "CHOWN", "NET_RAW", "SETFCAP"}},
609+
{"drop microvm capabilities", atev1alpha1.SandboxClassMicroVM, &atev1alpha1.WorkerPoolSecurityContext{DropCapabilities: []corev1.Capability{"FSETID", "DAC_READ_SEARCH"}}, gvisorDefaults},
610+
{"capability absent from class", atev1alpha1.SandboxClassGvisor, &atev1alpha1.WorkerPoolSecurityContext{DropCapabilities: []corev1.Capability{"FSETID"}}, gvisorDefaults},
611+
{"drop all", atev1alpha1.SandboxClassGvisor, &atev1alpha1.WorkerPoolSecurityContext{DropCapabilities: []corev1.Capability{"ALL"}}, nil},
612+
}
613+
for _, tt := range tests {
614+
t.Run(tt.name, func(t *testing.T) {
615+
wp := testWorkerPoolApplyConfig(&atev1alpha1.WorkerPoolPodTemplate{SecurityContext: tt.settings})
616+
wp.Spec.SandboxClasses = []atev1alpha1.WorkerPoolSandboxClass{{Name: tt.class}}
617+
sc := buildDeploymentApplyConfig(wp, ateomOTelSettings{}, installdefaults.SystemNamespace, installdefaults.AteletServiceAccount, installdefaults.RouterServiceAccount).Spec.Template.Spec.Containers[0].SecurityContext
618+
if sc == nil || sc.Capabilities == nil {
619+
t.Fatal("worker security context and capabilities must be set")
620+
}
621+
if diff := cmp.Diff(tt.wantCaps, sc.Capabilities.Add); diff != "" {
622+
t.Fatalf("capabilities (-want +got):\n%s", diff)
623+
}
624+
if !ptr.Equal(sc.Privileged, ptr.To(false)) || !ptr.Equal(sc.RunAsUser, ptr.To(int64(0))) || !ptr.Equal(sc.RunAsGroup, ptr.To(int64(0))) || !slices.Equal(sc.Capabilities.Drop, []corev1.Capability{"ALL"}) {
625+
t.Fatal("worker identity, privileged mode or capability default changed")
626+
}
627+
if diff := cmp.Diff(corev1ac.SeccompProfile().WithType(corev1.SeccompProfileTypeUnconfined), sc.SeccompProfile); diff != "" {
628+
t.Errorf("default seccomp profile (-want +got):\n%s", diff)
629+
}
630+
if sc.AllowPrivilegeEscalation != nil {
631+
t.Errorf("AllowPrivilegeEscalation = %v, want nil", *sc.AllowPrivilegeEscalation)
632+
}
633+
// Compare against copies taken before any overrides, not the globals
634+
// that a slice-aliasing regression could have mutated.
635+
for _, defaults := range []struct {
636+
class atev1alpha1.SandboxClass
637+
want []corev1.Capability
638+
got []corev1.Capability
639+
}{
640+
{atev1alpha1.SandboxClassGvisor, gvisorDefaults, ateomGvisorCapabilities},
641+
{atev1alpha1.SandboxClassMicroVM, microVMDefaults, ateomMicroVMCapabilities},
642+
} {
643+
if diff := cmp.Diff(defaults.want, defaults.got); diff != "" {
644+
t.Errorf("%s global defaults mutated (-want +got):\n%s", defaults.class, diff)
645+
}
646+
otherPool := testWorkerPoolApplyConfig(nil)
647+
otherPool.Spec.SandboxClasses = []atev1alpha1.WorkerPoolSandboxClass{{Name: defaults.class}}
648+
otherSC := buildDeploymentApplyConfig(otherPool, ateomOTelSettings{}, installdefaults.SystemNamespace, installdefaults.AteletServiceAccount, installdefaults.RouterServiceAccount).Spec.Template.Spec.Containers[0].SecurityContext
649+
if diff := cmp.Diff(defaults.want, otherSC.Capabilities.Add); diff != "" {
650+
t.Errorf("%s subsequent pool capabilities (-want +got):\n%s", defaults.class, diff)
651+
}
652+
}
653+
})
654+
}
655+
for _, profile := range []*corev1.SeccompProfile{
656+
{Type: corev1.SeccompProfileTypeLocalhost, LocalhostProfile: ptr.To("substrate-worker.json")},
657+
{Type: corev1.SeccompProfileTypeRuntimeDefault},
658+
{Type: corev1.SeccompProfileTypeUnconfined},
659+
} {
660+
t.Run(string(profile.Type), func(t *testing.T) {
661+
wp := testWorkerPoolApplyConfig(&atev1alpha1.WorkerPoolPodTemplate{SecurityContext: &atev1alpha1.WorkerPoolSecurityContext{SeccompProfile: profile, AllowPrivilegeEscalation: ptr.To(false)}})
662+
sc := buildDeploymentApplyConfig(wp, ateomOTelSettings{}, installdefaults.SystemNamespace, installdefaults.AteletServiceAccount, installdefaults.RouterServiceAccount).Spec.Template.Spec.Containers[0].SecurityContext
663+
want := corev1ac.SeccompProfile().WithType(profile.Type)
664+
if profile.LocalhostProfile != nil {
665+
want.WithLocalhostProfile(*profile.LocalhostProfile)
666+
}
667+
if diff := cmp.Diff(want, sc.SeccompProfile); diff != "" {
668+
t.Errorf("seccomp profile (-want +got):\n%s", diff)
669+
}
670+
if sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation {
671+
t.Fatal("privilege escalation was not disabled")
672+
}
673+
})
674+
}
675+
}
676+
500677
// TestTerminationGracePeriodSeconds asserts the pod's grace period is hardcoded to 3600s.
501678
func TestTerminationGracePeriodSeconds(t *testing.T) {
502679
wp := testWorkerPoolApplyConfig(nil)

‎cmd/atecontroller/internal/controllers/workerpool_controller.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,11 @@ func (r *WorkerPoolReconciler) applyDeployment(ctx context.Context, wp *atev1alp
130130
TracesSamplerArg: r.OTelTracesSamplerArg,
131131
LogsExporter: r.OTelLogsExporter,
132132
}, r.SystemNamespace, r.AteletServiceAccount, r.RouterServiceAccount)
133-
if err := r.Apply(ctx, depAC, client.FieldOwner(workerPoolFieldOwner), client.ForceOwnership); err != nil {
133+
applyConfig, err := deploymentApplyConfiguration(depAC)
134+
if err != nil {
135+
return err
136+
}
137+
if err := r.Apply(ctx, applyConfig, client.FieldOwner(workerPoolFieldOwner), client.ForceOwnership); err != nil {
134138
return fmt.Errorf("failed to apply Deployment: %w", err)
135139
}
136140
return nil

0 commit comments

Comments
 (0)