Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/pr-workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ jobs:
E2E_TEMPLATE_NAME: counter-microvm
E2E_TEMPLATE_READY_TIMEOUT: 600s
run: hack/run-e2e-kind.sh ./internal/e2e/suites/demo -v -args --no-color
- name: Run E2E tests (micro-VM capabilities)
# The gVisor run above covers this suite via the default target; repeat it
# here against the micro-VM class, because the capability set is applied by
# the ateom and the two ateoms reach the guest by different paths.
env:
E2E_TEMPLATE_NAMESPACE: ate-demo-counter-microvm
E2E_TEMPLATE_NAME: counter-microvm
run: hack/run-e2e-kind.sh ./internal/e2e/suites/capabilities -v -args --no-color
- name: Dump diagnostics on failure
if: failure()
run: |
Expand Down
41 changes: 36 additions & 5 deletions cmd/ateapi/internal/controlapi/workload_spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,12 @@ func workloadSpecFromActorTemplate(actorTemplate *atev1alpha1.ActorTemplate, act

for _, ctr := range actorTemplate.Spec.Containers {
ateletCtr := &ateletpb.Container{
Name: ctr.Name,
Image: ctr.Image,
Command: ctr.Command,
Args: ctr.Args,
Readyz: toAteletReadyz(ctr.Readyz),
Name: ctr.Name,
Image: ctr.Image,
Command: ctr.Command,
Args: ctr.Args,
Readyz: toAteletReadyz(ctr.Readyz),
SecurityContext: toAteletSecurityContext(ctr.SecurityContext),
}
for _, mount := range ctr.VolumeMounts {
ateletCtr.VolumeMounts = append(ateletCtr.VolumeMounts, &ateletpb.VolumeMount{
Expand Down Expand Up @@ -184,6 +185,36 @@ func toAteletReadyz(in *atev1alpha1.ContainerReadyz) *ateletpb.Readyz {
return out
}

// toAteletSecurityContext projects the CRD securityContext onto the ateletpb
// wire type. Returns nil when the source is nil or carries nothing, so
// containers that set no security settings stay unchanged on the wire.
func toAteletSecurityContext(in *atev1alpha1.SecurityContext) *ateletpb.SecurityContext {
if in == nil || in.Capabilities == nil {
return nil
}
caps := in.Capabilities
if len(caps.Add) == 0 && len(caps.Drop) == 0 {
return nil
}
return &ateletpb.SecurityContext{
Capabilities: &ateletpb.Capabilities{
Add: capabilityNames(caps.Add),
Drop: capabilityNames(caps.Drop),
},
}
}

func capabilityNames(in []atev1alpha1.Capability) []string {
if len(in) == 0 {
return nil
}
out := make([]string, 0, len(in))
for _, c := range in {
out = append(out, string(c))
}
return out
}

type envResolver struct {
kubeClient kubernetes.Interface
namespace string
Expand Down
60 changes: 60 additions & 0 deletions cmd/ateapi/internal/controlapi/workload_spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -563,3 +563,63 @@ func TestAppendExternalVolumes(t *testing.T) {
t.Errorf("appendExternalVolumes expected error for missing volume, got nil")
}
}

func TestWorkloadSpecFromActorTemplatePropagatesSecurityContext(t *testing.T) {
got, err := workloadSpecFromActorTemplate(&atev1alpha1.ActorTemplate{
ObjectMeta: metav1.ObjectMeta{Name: "tmpl-caps", Namespace: "agent-ns"},
Spec: atev1alpha1.ActorTemplateSpec{
Containers: []atev1alpha1.Container{
{
Name: "adjusted",
Image: "main",
SecurityContext: &atev1alpha1.SecurityContext{
Capabilities: &atev1alpha1.Capabilities{
Add: []atev1alpha1.Capability{"NET_ADMIN"},
Drop: []atev1alpha1.Capability{"ALL"},
},
},
},
{
Name: "unset",
Image: "side",
},
{
// An empty capabilities block asks for no adjustment, so
// nothing is put on the wire for it.
Name: "empty",
Image: "third",
SecurityContext: &atev1alpha1.SecurityContext{Capabilities: &atev1alpha1.Capabilities{}},
},
},
},
}, nil)
if err != nil {
t.Fatalf("workloadSpecFromActorTemplate failed: %v", err)
}

want := &ateletpb.WorkloadSpec{
Containers: []*ateletpb.Container{
{
Name: "adjusted",
Image: "main",
SecurityContext: &ateletpb.SecurityContext{
Capabilities: &ateletpb.Capabilities{
Add: []string{"NET_ADMIN"},
Drop: []string{"ALL"},
},
},
},
{
Name: "unset",
Image: "side",
},
{
Name: "empty",
Image: "third",
},
},
}
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
t.Errorf("WorkloadSpec mismatch (-want +got):\n%s", diff)
}
}
2 changes: 2 additions & 0 deletions cmd/atelet/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,7 @@ func (s *AteomHerder) prepareOCIBundles(
"", // pause is sandbox infra; it gets no actor identity mount.
nil,
nil,
nil, // pause only reaps; it needs no capabilities.
); err != nil {
return wrapFileSystemErr("while creating pause OCI bundle", err)
}
Expand Down Expand Up @@ -1260,6 +1261,7 @@ func (s *AteomHerder) prepareOCIBundles(
identityDir,
spec.GetVolumes(),
ctr.GetVolumeMounts(),
resolveCapabilities(ctr.GetSecurityContext().GetCapabilities()),
); err != nil {
return wrapFileSystemErr(fmt.Sprintf("while creating %q OCI bundle", ctr.GetName()), err)
}
Expand Down
76 changes: 52 additions & 24 deletions cmd/atelet/oci.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"fmt"
"os"
"path"
"sort"
"strings"

"github.com/agent-substrate/substrate/internal/ateerrors"
Expand Down Expand Up @@ -48,9 +49,50 @@ const (
// ActorIDFileName is the file inside IdentityMountPath holding the
// actor's own ID, raw with no trailing newline.
ActorIDFileName = "actor-id"

// capabilityAll is the sentinel a template may put in drop to clear the
// whole default set. It is rejected in add (see v1alpha1.Capabilities).
capabilityAll = "ALL"
)

func prepareOCIDirectory(ctx context.Context, imageCache *imagecache.Store, actorUID, containerName, ref string, command, args []string, env []string, annotations map[string]string, netns string, identityDir string, volumes []*ateletpb.Volume, volumeMounts []*ateletpb.VolumeMount) error {
// defaultCapabilities is what an actor container gets when its template asks
// for no adjustment. Names are unprefixed; resolveCapabilities adds the OCI
// "CAP_" prefix.
var defaultCapabilities = []string{
"AUDIT_WRITE",
"KILL",
"NET_BIND_SERVICE",
}

// resolveCapabilities computes a container's effective capability set as
// default - drop + add. Drop applies first, so a capability named in both is
// granted. The result is CAP_-prefixed and sorted for a stable OCI spec: the
// spec is written on every run and a reordered set would churn the bundle.
func resolveCapabilities(caps *ateletpb.Capabilities) []string {
effective := make(map[string]struct{}, len(defaultCapabilities))
for _, c := range defaultCapabilities {
effective[c] = struct{}{}
}
for _, d := range caps.GetDrop() {
if d == capabilityAll {
clear(effective)
break
}
delete(effective, d)
}
for _, a := range caps.GetAdd() {
effective[a] = struct{}{}
}

out := make([]string, 0, len(effective))
for c := range effective {
out = append(out, "CAP_"+c)
}
sort.Strings(out)
return out
}

func prepareOCIDirectory(ctx context.Context, imageCache *imagecache.Store, actorUID, containerName, ref string, command, args []string, env []string, annotations map[string]string, netns string, identityDir string, volumes []*ateletpb.Volume, volumeMounts []*ateletpb.VolumeMount, capabilities []string) error {
tracer := otel.Tracer("prepareOCIDirectory")

ctx, span := tracer.Start(ctx, "prepareOCIDirectory")
Expand Down Expand Up @@ -109,7 +151,7 @@ func prepareOCIDirectory(ctx context.Context, imageCache *imagecache.Store, acto
return fmt.Errorf("while writing overlay spec: %w", err)
}

ociSpec := buildActorOCISpec(actorUID, resolvedArgs, resolvedEnv, annotations, netns, identityDir, volumes, volumeMounts)
ociSpec := buildActorOCISpec(actorUID, resolvedArgs, resolvedEnv, annotations, netns, identityDir, volumes, volumeMounts, capabilities)
ociSpecBytes, err := json.MarshalIndent(ociSpec, "", " ")
if err != nil {
return fmt.Errorf("while marshaling OCI spec: %w", err)
Expand Down Expand Up @@ -182,11 +224,13 @@ func resolveProcessArgs(imageCfg *v1.Config, command, args []string) ([]string,
}

// buildActorOCISpec assembles the OCI runtime spec for an actor container from
// already-resolved args and env (see resolveProcessArgs and resolveActorEnv).
// already-resolved args, env and capabilities (see resolveProcessArgs,
// resolveActorEnv and resolveCapabilities). An empty capabilities set means the
// process runs with none, which is what the pause container gets.
// When identityDir is non-empty it adds a read-only bind mount of that host
// directory at IdentityMountPath so the actor can read its own ID (see
// IdentityMountPath for why this is a bind mount rather than env vars).
func buildActorOCISpec(actorUID string, args []string, env []string, annotations map[string]string, netns string, identityDir string, volumes []*ateletpb.Volume, volumeMounts []*ateletpb.VolumeMount) *specs.Spec {
func buildActorOCISpec(actorUID string, args []string, env []string, annotations map[string]string, netns string, identityDir string, volumes []*ateletpb.Volume, volumeMounts []*ateletpb.VolumeMount, capabilities []string) *specs.Spec {
mounts := []specs.Mount{
{
Destination: "/proc",
Expand Down Expand Up @@ -235,26 +279,10 @@ func buildActorOCISpec(actorUID string, args []string, env []string, annotations
Env: env,
Cwd: "/",
Capabilities: &specs.LinuxCapabilities{
Bounding: []string{
"CAP_AUDIT_WRITE",
"CAP_KILL",
"CAP_NET_BIND_SERVICE",
},
Effective: []string{
"CAP_AUDIT_WRITE",
"CAP_KILL",
"CAP_NET_BIND_SERVICE",
},
Inheritable: []string{
"CAP_AUDIT_WRITE",
"CAP_KILL",
"CAP_NET_BIND_SERVICE",
},
Permitted: []string{
"CAP_AUDIT_WRITE",
"CAP_KILL",
"CAP_NET_BIND_SERVICE",
},
Bounding: capabilities,
Effective: capabilities,
Inheritable: capabilities,
Permitted: capabilities,
// TODO(gvisor.dev/issue/3166): support ambient capabilities
},
Rlimits: []specs.POSIXRlimit{
Expand Down
Loading
Loading