From 0cb258d8668d1f9dcfdeda84723212b4104fd5af Mon Sep 17 00:00:00 2001 From: Nour Date: Fri, 7 Aug 2026 14:40:22 +0300 Subject: [PATCH 1/4] api: add securityContext.capabilities to ActorTemplate containers --- .../internal/controlapi/workload_spec.go | 41 ++- .../internal/controlapi/workload_spec_test.go | 60 ++++ internal/proto/ateletpb/atelet.pb.go | 299 ++++++++++++------ internal/proto/ateletpb/atelet.proto | 13 + .../generated/ate.dev_actortemplates.yaml | 59 ++++ pkg/api/v1alpha1/actortemplate_types.go | 53 ++++ .../v1alpha1/actortemplate_validation_test.go | 60 ++++ pkg/api/v1alpha1/zz_generated.deepcopy.go | 50 +++ 8 files changed, 539 insertions(+), 96 deletions(-) diff --git a/cmd/ateapi/internal/controlapi/workload_spec.go b/cmd/ateapi/internal/controlapi/workload_spec.go index 6e1b12d0d..647b7f351 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec.go +++ b/cmd/ateapi/internal/controlapi/workload_spec.go @@ -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{ @@ -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 diff --git a/cmd/ateapi/internal/controlapi/workload_spec_test.go b/cmd/ateapi/internal/controlapi/workload_spec_test.go index 0de964056..6ee149b25 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec_test.go +++ b/cmd/ateapi/internal/controlapi/workload_spec_test.go @@ -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) + } +} diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index d610bf9b7..c0d432efe 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -927,16 +927,17 @@ func (x *VolumeMount) GetMountPath() string { } type Container struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Image string `protobuf:"bytes,2,opt,name=image,proto3" json:"image,omitempty"` - Command []string `protobuf:"bytes,3,rep,name=command,proto3" json:"command,omitempty"` - Args []string `protobuf:"bytes,7,rep,name=args,proto3" json:"args,omitempty"` - Env []*EnvEntry `protobuf:"bytes,4,rep,name=env,proto3" json:"env,omitempty"` - Readyz *Readyz `protobuf:"bytes,5,opt,name=readyz,proto3" json:"readyz,omitempty"` - VolumeMounts []*VolumeMount `protobuf:"bytes,6,rep,name=volume_mounts,json=volumeMounts,proto3" json:"volume_mounts,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Image string `protobuf:"bytes,2,opt,name=image,proto3" json:"image,omitempty"` + Command []string `protobuf:"bytes,3,rep,name=command,proto3" json:"command,omitempty"` + Args []string `protobuf:"bytes,7,rep,name=args,proto3" json:"args,omitempty"` + Env []*EnvEntry `protobuf:"bytes,4,rep,name=env,proto3" json:"env,omitempty"` + Readyz *Readyz `protobuf:"bytes,5,opt,name=readyz,proto3" json:"readyz,omitempty"` + VolumeMounts []*VolumeMount `protobuf:"bytes,6,rep,name=volume_mounts,json=volumeMounts,proto3" json:"volume_mounts,omitempty"` + SecurityContext *SecurityContext `protobuf:"bytes,8,opt,name=security_context,json=securityContext,proto3" json:"security_context,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Container) Reset() { @@ -1018,6 +1019,112 @@ func (x *Container) GetVolumeMounts() []*VolumeMount { return nil } +func (x *Container) GetSecurityContext() *SecurityContext { + if x != nil { + return x.SecurityContext + } + return nil +} + +// SecurityContext holds security settings for a container's process. +type SecurityContext struct { + state protoimpl.MessageState `protogen:"open.v1"` + Capabilities *Capabilities `protobuf:"bytes,1,opt,name=capabilities,proto3" json:"capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecurityContext) Reset() { + *x = SecurityContext{} + mi := &file_atelet_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecurityContext) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecurityContext) ProtoMessage() {} + +func (x *SecurityContext) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecurityContext.ProtoReflect.Descriptor instead. +func (*SecurityContext) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{13} +} + +func (x *SecurityContext) GetCapabilities() *Capabilities { + if x != nil { + return x.Capabilities + } + return nil +} + +// Capabilities adjusts a container's Linux capabilities relative to the default +// set. Names carry no "CAP_" prefix; drop applies before add. +type Capabilities struct { + state protoimpl.MessageState `protogen:"open.v1"` + Add []string `protobuf:"bytes,1,rep,name=add,proto3" json:"add,omitempty"` + Drop []string `protobuf:"bytes,2,rep,name=drop,proto3" json:"drop,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Capabilities) Reset() { + *x = Capabilities{} + mi := &file_atelet_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Capabilities) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Capabilities) ProtoMessage() {} + +func (x *Capabilities) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Capabilities.ProtoReflect.Descriptor instead. +func (*Capabilities) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{14} +} + +func (x *Capabilities) GetAdd() []string { + if x != nil { + return x.Add + } + return nil +} + +func (x *Capabilities) GetDrop() []string { + if x != nil { + return x.Drop + } + return nil +} + type EnvEntry struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -1028,7 +1135,7 @@ type EnvEntry struct { func (x *EnvEntry) Reset() { *x = EnvEntry{} - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1040,7 +1147,7 @@ func (x *EnvEntry) String() string { func (*EnvEntry) ProtoMessage() {} func (x *EnvEntry) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1053,7 +1160,7 @@ func (x *EnvEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvEntry.ProtoReflect.Descriptor instead. func (*EnvEntry) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{13} + return file_atelet_proto_rawDescGZIP(), []int{15} } func (x *EnvEntry) GetName() string { @@ -1084,7 +1191,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1096,7 +1203,7 @@ func (x *Readyz) String() string { func (*Readyz) ProtoMessage() {} func (x *Readyz) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1109,7 +1216,7 @@ func (x *Readyz) ProtoReflect() protoreflect.Message { // Deprecated: Use Readyz.ProtoReflect.Descriptor instead. func (*Readyz) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{14} + return file_atelet_proto_rawDescGZIP(), []int{16} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -1139,7 +1246,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1151,7 +1258,7 @@ func (x *HTTPGetAction) String() string { func (*HTTPGetAction) ProtoMessage() {} func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1164,7 +1271,7 @@ func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPGetAction.ProtoReflect.Descriptor instead. func (*HTTPGetAction) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{15} + return file_atelet_proto_rawDescGZIP(), []int{17} } func (x *HTTPGetAction) GetPath() string { @@ -1189,7 +1296,7 @@ type RunResponse struct { func (x *RunResponse) Reset() { *x = RunResponse{} - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1201,7 +1308,7 @@ func (x *RunResponse) String() string { func (*RunResponse) ProtoMessage() {} func (x *RunResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1214,7 +1321,7 @@ func (x *RunResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunResponse.ProtoReflect.Descriptor instead. func (*RunResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{16} + return file_atelet_proto_rawDescGZIP(), []int{18} } type LocalCheckpointConfiguration struct { @@ -1228,7 +1335,7 @@ type LocalCheckpointConfiguration struct { func (x *LocalCheckpointConfiguration) Reset() { *x = LocalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1240,7 +1347,7 @@ func (x *LocalCheckpointConfiguration) String() string { func (*LocalCheckpointConfiguration) ProtoMessage() {} func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1253,7 +1360,7 @@ func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*LocalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{17} + return file_atelet_proto_rawDescGZIP(), []int{19} } func (x *LocalCheckpointConfiguration) GetSnapshotPrefix() string { @@ -1282,7 +1389,7 @@ type ExternalCheckpointConfiguration struct { func (x *ExternalCheckpointConfiguration) Reset() { *x = ExternalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1294,7 +1401,7 @@ func (x *ExternalCheckpointConfiguration) String() string { func (*ExternalCheckpointConfiguration) ProtoMessage() {} func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1307,7 +1414,7 @@ func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use ExternalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*ExternalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{18} + return file_atelet_proto_rawDescGZIP(), []int{20} } func (x *ExternalCheckpointConfiguration) GetSnapshotUriPrefix() string { @@ -1345,7 +1452,7 @@ type CheckpointRequest struct { func (x *CheckpointRequest) Reset() { *x = CheckpointRequest{} - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1357,7 +1464,7 @@ func (x *CheckpointRequest) String() string { func (*CheckpointRequest) ProtoMessage() {} func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1370,7 +1477,7 @@ func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointRequest.ProtoReflect.Descriptor instead. func (*CheckpointRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{19} + return file_atelet_proto_rawDescGZIP(), []int{21} } func (x *CheckpointRequest) GetTargetAteomUid() string { @@ -1485,7 +1592,7 @@ type CheckpointResponse struct { func (x *CheckpointResponse) Reset() { *x = CheckpointResponse{} - mi := &file_atelet_proto_msgTypes[20] + mi := &file_atelet_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1497,7 +1604,7 @@ func (x *CheckpointResponse) String() string { func (*CheckpointResponse) ProtoMessage() {} func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[20] + mi := &file_atelet_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1510,7 +1617,7 @@ func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointResponse.ProtoReflect.Descriptor instead. func (*CheckpointResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{20} + return file_atelet_proto_rawDescGZIP(), []int{22} } type RestoreRequest struct { @@ -1550,7 +1657,7 @@ type RestoreRequest struct { func (x *RestoreRequest) Reset() { *x = RestoreRequest{} - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1562,7 +1669,7 @@ func (x *RestoreRequest) String() string { func (*RestoreRequest) ProtoMessage() {} func (x *RestoreRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1575,7 +1682,7 @@ func (x *RestoreRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreRequest.ProtoReflect.Descriptor instead. func (*RestoreRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{21} + return file_atelet_proto_rawDescGZIP(), []int{23} } func (x *RestoreRequest) GetTargetAteomUid() string { @@ -1704,7 +1811,7 @@ type RestoreResponse struct { func (x *RestoreResponse) Reset() { *x = RestoreResponse{} - mi := &file_atelet_proto_msgTypes[22] + mi := &file_atelet_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1716,7 +1823,7 @@ func (x *RestoreResponse) String() string { func (*RestoreResponse) ProtoMessage() {} func (x *RestoreResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[22] + mi := &file_atelet_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1729,7 +1836,7 @@ func (x *RestoreResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreResponse.ProtoReflect.Descriptor instead. func (*RestoreResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{22} + return file_atelet_proto_rawDescGZIP(), []int{24} } var File_atelet_proto protoreflect.FileDescriptor @@ -1799,7 +1906,7 @@ const file_atelet_proto_rawDesc = "" + "\vVolumeMount\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + "\n" + - "mount_path\x18\x02 \x01(\tR\tmountPath\"\xe9\x01\n" + + "mount_path\x18\x02 \x01(\tR\tmountPath\"\xad\x02\n" + "\tContainer\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + "\x05image\x18\x02 \x01(\tR\x05image\x12\x18\n" + @@ -1807,7 +1914,13 @@ const file_atelet_proto_rawDesc = "" + "\x04args\x18\a \x03(\tR\x04args\x12\"\n" + "\x03env\x18\x04 \x03(\v2\x10.atelet.EnvEntryR\x03env\x12&\n" + "\x06readyz\x18\x05 \x01(\v2\x0e.atelet.ReadyzR\x06readyz\x128\n" + - "\rvolume_mounts\x18\x06 \x03(\v2\x13.atelet.VolumeMountR\fvolumeMounts\"4\n" + + "\rvolume_mounts\x18\x06 \x03(\v2\x13.atelet.VolumeMountR\fvolumeMounts\x12B\n" + + "\x10security_context\x18\b \x01(\v2\x17.atelet.SecurityContextR\x0fsecurityContext\"K\n" + + "\x0fSecurityContext\x128\n" + + "\fcapabilities\x18\x01 \x01(\v2\x14.atelet.CapabilitiesR\fcapabilities\"4\n" + + "\fCapabilities\x12\x10\n" + + "\x03add\x18\x01 \x03(\tR\x03add\x12\x12\n" + + "\x04drop\x18\x02 \x03(\tR\x04drop\"4\n" + "\bEnvEntry\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value\"c\n" + @@ -1892,7 +2005,7 @@ func file_atelet_proto_rawDescGZIP() []byte { } var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 28) var file_atelet_proto_goTypes = []any{ (VolumeType)(0), // 0: atelet.VolumeType (CheckpointType)(0), // 1: atelet.CheckpointType @@ -1910,62 +2023,66 @@ var file_atelet_proto_goTypes = []any{ (*Volume)(nil), // 13: atelet.Volume (*VolumeMount)(nil), // 14: atelet.VolumeMount (*Container)(nil), // 15: atelet.Container - (*EnvEntry)(nil), // 16: atelet.EnvEntry - (*Readyz)(nil), // 17: atelet.Readyz - (*HTTPGetAction)(nil), // 18: atelet.HTTPGetAction - (*RunResponse)(nil), // 19: atelet.RunResponse - (*LocalCheckpointConfiguration)(nil), // 20: atelet.LocalCheckpointConfiguration - (*ExternalCheckpointConfiguration)(nil), // 21: atelet.ExternalCheckpointConfiguration - (*CheckpointRequest)(nil), // 22: atelet.CheckpointRequest - (*CheckpointResponse)(nil), // 23: atelet.CheckpointResponse - (*RestoreRequest)(nil), // 24: atelet.RestoreRequest - (*RestoreResponse)(nil), // 25: atelet.RestoreResponse - nil, // 26: atelet.ArchAssets.FilesEntry - nil, // 27: atelet.SandboxAssets.AssetsEntry - nil, // 28: atelet.ExternalVolumeSource.VolumeContextEntry + (*SecurityContext)(nil), // 16: atelet.SecurityContext + (*Capabilities)(nil), // 17: atelet.Capabilities + (*EnvEntry)(nil), // 18: atelet.EnvEntry + (*Readyz)(nil), // 19: atelet.Readyz + (*HTTPGetAction)(nil), // 20: atelet.HTTPGetAction + (*RunResponse)(nil), // 21: atelet.RunResponse + (*LocalCheckpointConfiguration)(nil), // 22: atelet.LocalCheckpointConfiguration + (*ExternalCheckpointConfiguration)(nil), // 23: atelet.ExternalCheckpointConfiguration + (*CheckpointRequest)(nil), // 24: atelet.CheckpointRequest + (*CheckpointResponse)(nil), // 25: atelet.CheckpointResponse + (*RestoreRequest)(nil), // 26: atelet.RestoreRequest + (*RestoreResponse)(nil), // 27: atelet.RestoreResponse + nil, // 28: atelet.ArchAssets.FilesEntry + nil, // 29: atelet.SandboxAssets.AssetsEntry + nil, // 30: atelet.ExternalVolumeSource.VolumeContextEntry } var file_atelet_proto_depIdxs = []int32{ 10, // 0: atelet.RunRequest.spec:type_name -> atelet.WorkloadSpec 9, // 1: atelet.RunRequest.sandbox_assets:type_name -> atelet.SandboxAssets 6, // 2: atelet.RunRequest.egress_gateway:type_name -> atelet.EgressGateway - 26, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry - 27, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry + 28, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 29, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry 15, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container 13, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume - 28, // 7: atelet.ExternalVolumeSource.volume_context:type_name -> atelet.ExternalVolumeSource.VolumeContextEntry + 30, // 7: atelet.ExternalVolumeSource.volume_context:type_name -> atelet.ExternalVolumeSource.VolumeContextEntry 0, // 8: atelet.Volume.type:type_name -> atelet.VolumeType 11, // 9: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume 12, // 10: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource - 16, // 11: atelet.Container.env:type_name -> atelet.EnvEntry - 17, // 12: atelet.Container.readyz:type_name -> atelet.Readyz + 18, // 11: atelet.Container.env:type_name -> atelet.EnvEntry + 19, // 12: atelet.Container.readyz:type_name -> atelet.Readyz 14, // 13: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount - 18, // 14: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction - 10, // 15: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 16: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType - 20, // 17: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 21, // 18: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 19: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope - 10, // 20: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 21: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType - 20, // 22: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 21, // 23: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 24: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope - 6, // 25: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway - 7, // 26: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile - 8, // 27: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets - 3, // 28: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest - 5, // 29: atelet.AteomHerder.Run:input_type -> atelet.RunRequest - 22, // 30: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest - 24, // 31: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest - 4, // 32: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse - 19, // 33: atelet.AteomHerder.Run:output_type -> atelet.RunResponse - 23, // 34: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse - 25, // 35: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse - 32, // [32:36] is the sub-list for method output_type - 28, // [28:32] is the sub-list for method input_type - 28, // [28:28] is the sub-list for extension type_name - 28, // [28:28] is the sub-list for extension extendee - 0, // [0:28] is the sub-list for field type_name + 16, // 14: atelet.Container.security_context:type_name -> atelet.SecurityContext + 17, // 15: atelet.SecurityContext.capabilities:type_name -> atelet.Capabilities + 20, // 16: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction + 10, // 17: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 18: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType + 22, // 19: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 23, // 20: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 21: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope + 10, // 22: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 23: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 22, // 24: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 23, // 25: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 26: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 6, // 27: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 7, // 28: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 8, // 29: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 3, // 30: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest + 5, // 31: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 24, // 32: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 26, // 33: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 4, // 34: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse + 21, // 35: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 25, // 36: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 27, // 37: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 34, // [34:38] is the sub-list for method output_type + 30, // [30:34] is the sub-list for method input_type + 30, // [30:30] is the sub-list for extension type_name + 30, // [30:30] is the sub-list for extension extendee + 0, // [0:30] is the sub-list for field type_name } func init() { file_atelet_proto_init() } @@ -1978,11 +2095,11 @@ func file_atelet_proto_init() { (*Volume_DurableDir)(nil), (*Volume_External)(nil), } - file_atelet_proto_msgTypes[19].OneofWrappers = []any{ + file_atelet_proto_msgTypes[21].OneofWrappers = []any{ (*CheckpointRequest_LocalConfig)(nil), (*CheckpointRequest_ExternalConfig)(nil), } - file_atelet_proto_msgTypes[21].OneofWrappers = []any{ + file_atelet_proto_msgTypes[23].OneofWrappers = []any{ (*RestoreRequest_LocalConfig)(nil), (*RestoreRequest_ExternalConfig)(nil), } @@ -1992,7 +2109,7 @@ func file_atelet_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_atelet_proto_rawDesc), len(file_atelet_proto_rawDesc)), NumEnums: 3, - NumMessages: 26, + NumMessages: 28, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index 3a252928c..0039c9874 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -151,6 +151,19 @@ message Container { repeated EnvEntry env = 4; Readyz readyz = 5; repeated VolumeMount volume_mounts = 6; + SecurityContext security_context = 8; +} + +// SecurityContext holds security settings for a container's process. +message SecurityContext { + Capabilities capabilities = 1; +} + +// Capabilities adjusts a container's Linux capabilities relative to the default +// set. Names carry no "CAP_" prefix; drop applies before add. +message Capabilities { + repeated string add = 1; + repeated string drop = 2; } message EnvEntry { diff --git a/manifests/ate-install/generated/ate.dev_actortemplates.yaml b/manifests/ate-install/generated/ate.dev_actortemplates.yaml index 24f64fec0..30b1d85b5 100644 --- a/manifests/ate-install/generated/ate.dev_actortemplates.yaml +++ b/manifests/ate-install/generated/ate.dev_actortemplates.yaml @@ -223,6 +223,65 @@ spec: required: - httpGet type: object + securityContext: + description: |- + securityContext holds security settings for this container. Unset leaves + it with the default capability set. + properties: + capabilities: + description: |- + Capabilities adjusts this container's Linux capabilities relative to the + default set. + properties: + add: + description: |- + Add lists capabilities to grant on top of the default set. + + "ALL" is rejected: Kubernetes accepts it in the API and relies on + PodSecurity admission to deny it, and there is no equivalent policy layer + here yet. + items: + description: |- + Capability is a Linux capability named without the "CAP_" prefix (e.g. + "NET_BIND_SERVICE"), as in Kubernetes. The prefix is added when the OCI spec + is written, and the prefixed spelling is rejected so a manifest copied from + OCI docs fails at admission rather than granting nothing. + maxLength: 63 + pattern: ^[A-Z][A-Z0-9_]*$ + type: string + x-kubernetes-validations: + - message: Capability must be named without the 'CAP_' + prefix (e.g. 'NET_BIND_SERVICE', not 'CAP_NET_BIND_SERVICE') + rule: '!self.startsWith(''CAP_'')' + maxItems: 64 + type: array + x-kubernetes-list-type: atomic + x-kubernetes-validations: + - message: add does not accept 'ALL'; name the individual + capabilities the container needs + rule: '!self.exists(c, c == ''ALL'')' + drop: + description: |- + Drop lists capabilities to remove from the default set. "ALL" drops the + whole set, so drop+add expresses an exact set rather than a relative one. + items: + description: |- + Capability is a Linux capability named without the "CAP_" prefix (e.g. + "NET_BIND_SERVICE"), as in Kubernetes. The prefix is added when the OCI spec + is written, and the prefixed spelling is rejected so a manifest copied from + OCI docs fails at admission rather than granting nothing. + maxLength: 63 + pattern: ^[A-Z][A-Z0-9_]*$ + type: string + x-kubernetes-validations: + - message: Capability must be named without the 'CAP_' + prefix (e.g. 'NET_BIND_SERVICE', not 'CAP_NET_BIND_SERVICE') + rule: '!self.startsWith(''CAP_'')' + maxItems: 64 + type: array + x-kubernetes-list-type: atomic + type: object + type: object volumeMounts: description: volumeMounts define the volumes to mount into this container. diff --git a/pkg/api/v1alpha1/actortemplate_types.go b/pkg/api/v1alpha1/actortemplate_types.go index 17c07b29a..ca30b58cf 100644 --- a/pkg/api/v1alpha1/actortemplate_types.go +++ b/pkg/api/v1alpha1/actortemplate_types.go @@ -94,6 +94,53 @@ type VolumeMount struct { MountPath string `json:"mountPath"` } +// Capability is a Linux capability named without the "CAP_" prefix (e.g. +// "NET_BIND_SERVICE"), as in Kubernetes. The prefix is added when the OCI spec +// is written, and the prefixed spelling is rejected so a manifest copied from +// OCI docs fails at admission rather than granting nothing. +// +// +kubebuilder:validation:MaxLength=63 +// +kubebuilder:validation:Pattern=`^[A-Z][A-Z0-9_]*$` +// +kubebuilder:validation:XValidation:rule="!self.startsWith('CAP_')",message="Capability must be named without the 'CAP_' prefix (e.g. 'NET_BIND_SERVICE', not 'CAP_NET_BIND_SERVICE')" +type Capability string + +// CapabilityAll drops every default capability when used in Capabilities.Drop. +const CapabilityAll Capability = "ALL" + +// Capabilities adjusts a container's Linux capabilities relative to the default +// set. Drop applies first, then Add, so a capability in both is granted. +type Capabilities struct { + // Add lists capabilities to grant on top of the default set. + // + // "ALL" is rejected: Kubernetes accepts it in the API and relies on + // PodSecurity admission to deny it, and there is no equivalent policy layer + // here yet. + // + // +optional + // +kubebuilder:validation:MaxItems=64 + // +listType=atomic + // +kubebuilder:validation:XValidation:rule="!self.exists(c, c == 'ALL')",message="add does not accept 'ALL'; name the individual capabilities the container needs" + Add []Capability `json:"add,omitempty"` + + // Drop lists capabilities to remove from the default set. "ALL" drops the + // whole set, so drop+add expresses an exact set rather than a relative one. + // + // +optional + // +kubebuilder:validation:MaxItems=64 + // +listType=atomic + Drop []Capability `json:"drop,omitempty"` +} + +// SecurityContext holds security settings for a container's process. It models +// a subset of the Kubernetes container securityContext. +type SecurityContext struct { + // Capabilities adjusts this container's Linux capabilities relative to the + // default set. + // + // +optional + Capabilities *Capabilities `json:"capabilities,omitempty"` +} + // A single application container that you want to run within a WorkerPool. type Container struct { // Name of the container. @@ -150,6 +197,12 @@ type Container struct { // +optional // +kubebuilder:validation:MaxItems=32 VolumeMounts []VolumeMount `json:"volumeMounts,omitempty"` + + // securityContext holds security settings for this container. Unset leaves + // it with the default capability set. + // + // +optional + SecurityContext *SecurityContext `json:"securityContext,omitempty"` } // ContainerReadyz configures the readiness signal for a container. diff --git a/pkg/api/v1alpha1/actortemplate_validation_test.go b/pkg/api/v1alpha1/actortemplate_validation_test.go index 4b0a5d54c..d71465a24 100644 --- a/pkg/api/v1alpha1/actortemplate_validation_test.go +++ b/pkg/api/v1alpha1/actortemplate_validation_test.go @@ -1235,6 +1235,66 @@ func TestActorTemplateValidation(t *testing.T) { }, wantErr: true, errMsg: "All volumes defined in spec.volumes must be mounted by at least one container", + }, { + name: "capabilities add and drop", + mutate: func(at *ActorTemplate) { + at.Spec.Containers[0].SecurityContext = &SecurityContext{ + Capabilities: &Capabilities{ + Add: []Capability{"NET_ADMIN"}, + Drop: []Capability{"ALL"}, + }, + } + }, + wantErr: false, + }, { + name: "capability with CAP_ prefix", + mutate: func(at *ActorTemplate) { + at.Spec.Containers[0].SecurityContext = &SecurityContext{ + Capabilities: &Capabilities{ + Add: []Capability{"CAP_NET_ADMIN"}, + }, + } + }, + wantErr: true, + errMsg: "must be named without the 'CAP_' prefix", + }, { + name: "lower-case capability", + mutate: func(at *ActorTemplate) { + at.Spec.Containers[0].SecurityContext = &SecurityContext{ + Capabilities: &Capabilities{ + Drop: []Capability{"net_admin"}, + }, + } + }, + wantErr: true, + errMsg: "should match", + }, { + name: "ALL in add", + mutate: func(at *ActorTemplate) { + at.Spec.Containers[0].SecurityContext = &SecurityContext{ + Capabilities: &Capabilities{ + Add: []Capability{"ALL"}, + }, + } + }, + wantErr: true, + errMsg: "add does not accept 'ALL'", + }, { + name: "ALL in drop", + mutate: func(at *ActorTemplate) { + at.Spec.Containers[0].SecurityContext = &SecurityContext{ + Capabilities: &Capabilities{ + Drop: []Capability{"ALL"}, + }, + } + }, + wantErr: false, + }, { + name: "empty securityContext", + mutate: func(at *ActorTemplate) { + at.Spec.Containers[0].SecurityContext = &SecurityContext{} + }, + wantErr: false, }} for _, tt := range tests { diff --git a/pkg/api/v1alpha1/zz_generated.deepcopy.go b/pkg/api/v1alpha1/zz_generated.deepcopy.go index bdcf3505c..4b9b9ab39 100644 --- a/pkg/api/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/api/v1alpha1/zz_generated.deepcopy.go @@ -229,6 +229,31 @@ func (in *CSIDriverConfigSpec) DeepCopy() *CSIDriverConfigSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Capabilities) DeepCopyInto(out *Capabilities) { + *out = *in + if in.Add != nil { + in, out := &in.Add, &out.Add + *out = make([]Capability, len(*in)) + copy(*out, *in) + } + if in.Drop != nil { + in, out := &in.Drop, &out.Drop + *out = make([]Capability, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Capabilities. +func (in *Capabilities) DeepCopy() *Capabilities { + if in == nil { + return nil + } + out := new(Capabilities) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Container) DeepCopyInto(out *Container) { *out = *in @@ -259,6 +284,11 @@ func (in *Container) DeepCopyInto(out *Container) { *out = make([]VolumeMount, len(*in)) copy(*out, *in) } + if in.SecurityContext != nil { + in, out := &in.SecurityContext, &out.SecurityContext + *out = new(SecurityContext) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Container. @@ -508,6 +538,26 @@ func (in *SecretKeySelector) DeepCopy() *SecretKeySelector { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecurityContext) DeepCopyInto(out *SecurityContext) { + *out = *in + if in.Capabilities != nil { + in, out := &in.Capabilities, &out.Capabilities + *out = new(Capabilities) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecurityContext. +func (in *SecurityContext) DeepCopy() *SecurityContext { + if in == nil { + return nil + } + out := new(SecurityContext) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SnapshotsConfig) DeepCopyInto(out *SnapshotsConfig) { *out = *in From 9242642fb970094946091c37c8e4dc4c6bc331f3 Mon Sep 17 00:00:00 2001 From: Nour Date: Fri, 7 Aug 2026 14:40:38 +0300 Subject: [PATCH 2/4] atelet: apply per-container capabilities; pause gets none --- cmd/atelet/main.go | 2 + cmd/atelet/oci.go | 76 +++++++++++++++-------- cmd/atelet/oci_test.go | 133 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 186 insertions(+), 25 deletions(-) diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 1d49f6123..1413e1f7a 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -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) } @@ -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) } diff --git a/cmd/atelet/oci.go b/cmd/atelet/oci.go index e1476610c..bafd0025e 100644 --- a/cmd/atelet/oci.go +++ b/cmd/atelet/oci.go @@ -20,6 +20,7 @@ import ( "fmt" "os" "path" + "sort" "strings" "github.com/agent-substrate/substrate/internal/ateerrors" @@ -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") @@ -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) @@ -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", @@ -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{ diff --git a/cmd/atelet/oci_test.go b/cmd/atelet/oci_test.go index 433c082c3..6354c13e4 100644 --- a/cmd/atelet/oci_test.go +++ b/cmd/atelet/oci_test.go @@ -36,6 +36,7 @@ func TestBuildActorOCISpec_IdentityMount(t *testing.T) { "/host/actors/actor_uid/identity", nil, nil, + nil, ) found := false for _, m := range spec.Mounts { @@ -194,7 +195,7 @@ func TestResolveProcessArgs(t *testing.T) { // Without an identity dir (the pause container), no identity mount appears. func TestBuildActorOCISpec_NoIdentityMountForPause(t *testing.T) { - bare := buildActorOCISpec("actor_uid", []string{"/pause"}, nil, nil, "/run/netns/x", "", nil, nil) + bare := buildActorOCISpec("actor_uid", []string{"/pause"}, nil, nil, "/run/netns/x", "", nil, nil, nil) for _, m := range bare.Mounts { if m.Destination == IdentityMountPath { t.Errorf("identity mount must be absent when identityDir is empty") @@ -221,6 +222,7 @@ func TestBuildActorOCISpec_DurableDirVolumeMounts(t *testing.T) { "", volumes, durableDirs, + nil, ) for _, vm := range durableDirs { @@ -243,3 +245,132 @@ func TestBuildActorOCISpec_DurableDirVolumeMounts(t *testing.T) { } } } + +// wantDefaultCapabilities is the set a container gets when it asks for no +// adjustment. It is spelled out rather than derived from defaultCapabilities so +// that widening or narrowing the default is a deliberate test change. +var wantDefaultCapabilities = []string{ + "CAP_AUDIT_WRITE", + "CAP_KILL", + "CAP_NET_BIND_SERVICE", +} + +func withoutCaps(in []string, drop ...string) []string { + out := slices.Clone(in) + for _, d := range drop { + out = slices.DeleteFunc(out, func(c string) bool { return c == d }) + } + return out +} + +func withCaps(in []string, add ...string) []string { + out := append(slices.Clone(in), add...) + slices.Sort(out) + return out +} + +func TestResolveCapabilities(t *testing.T) { + tests := []struct { + name string + caps *ateletpb.Capabilities + want []string + }{{ + name: "unset keeps the default set", + caps: nil, + want: wantDefaultCapabilities, + }, { + name: "empty keeps the default set", + caps: &ateletpb.Capabilities{}, + want: wantDefaultCapabilities, + }, { + name: "drop removes from the default set", + caps: &ateletpb.Capabilities{Drop: []string{"NET_BIND_SERVICE", "AUDIT_WRITE"}}, + want: withoutCaps(wantDefaultCapabilities, "CAP_NET_BIND_SERVICE", "CAP_AUDIT_WRITE"), + }, { + name: "add grants on top of the default set", + caps: &ateletpb.Capabilities{Add: []string{"SYS_ADMIN"}}, + want: withCaps(wantDefaultCapabilities, "CAP_SYS_ADMIN"), + }, { + name: "drop ALL clears the default set", + caps: &ateletpb.Capabilities{Drop: []string{"ALL"}}, + want: nil, + }, { + name: "drop ALL with add gives an exact set", + caps: &ateletpb.Capabilities{Drop: []string{"ALL"}, Add: []string{"NET_ADMIN", "CHOWN"}}, + want: []string{"CAP_CHOWN", "CAP_NET_ADMIN"}, + }, { + // Drop applies first, so naming a capability in both grants it. + name: "add wins over drop", + caps: &ateletpb.Capabilities{Drop: []string{"KILL"}, Add: []string{"KILL"}}, + want: wantDefaultCapabilities, + }, { + name: "adding a default capability does not duplicate it", + caps: &ateletpb.Capabilities{Add: []string{"KILL"}}, + want: wantDefaultCapabilities, + }, { + name: "dropping a capability outside the default set is a no-op", + caps: &ateletpb.Capabilities{Drop: []string{"SYS_ADMIN"}}, + want: wantDefaultCapabilities, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveCapabilities(tt.caps) + if !slices.Equal(got, tt.want) { + t.Errorf("resolveCapabilities() = %v, want %v", got, tt.want) + } + }) + } +} + +// The resolved set lands in all four OCI capability sets. Ambient stays empty +// (see the gvisor#3166 TODO in buildActorOCISpec). +func TestBuildActorOCISpec_Capabilities(t *testing.T) { + want := []string{"CAP_CHOWN", "CAP_KILL"} + spec := buildActorOCISpec("actor_uid", []string{"/app"}, nil, nil, "/run/netns/x", "", nil, nil, want) + + caps := spec.Process.Capabilities + if caps == nil { + t.Fatal("spec.Process.Capabilities is nil") + } + for _, set := range []struct { + name string + got []string + }{ + {"Bounding", caps.Bounding}, + {"Effective", caps.Effective}, + {"Inheritable", caps.Inheritable}, + {"Permitted", caps.Permitted}, + } { + if !slices.Equal(set.got, want) { + t.Errorf("%s = %v, want %v", set.name, set.got, want) + } + } + if len(caps.Ambient) != 0 { + t.Errorf("Ambient = %v, want empty", caps.Ambient) + } +} + +// The pause container only reaps, so it is built with no capabilities at all. +func TestBuildActorOCISpec_NoCapabilitiesForPause(t *testing.T) { + spec := buildActorOCISpec("actor_uid", []string{"/pause"}, nil, nil, "/run/netns/x", "", nil, nil, nil) + + caps := spec.Process.Capabilities + if caps == nil { + t.Fatal("spec.Process.Capabilities is nil") + } + for _, set := range []struct { + name string + got []string + }{ + {"Bounding", caps.Bounding}, + {"Effective", caps.Effective}, + {"Inheritable", caps.Inheritable}, + {"Permitted", caps.Permitted}, + {"Ambient", caps.Ambient}, + } { + if len(set.got) != 0 { + t.Errorf("%s = %v, want empty", set.name, set.got) + } + } +} From 725cb10bf5af435c41e0edaf49cec157dce0e489 Mon Sep 17 00:00:00 2001 From: Nour Date: Fri, 7 Aug 2026 14:47:52 +0300 Subject: [PATCH 3/4] docs: document securityContext.capabilities in the API guide --- docs/api-guide.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/api-guide.md b/docs/api-guide.md index c1b5cc398..e1b84a4da 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -176,9 +176,35 @@ Each entry in `containers` describes one process to run in the actor's sandbox. | `env` | `[]EnvVar` | Optional. Literal `value` entries or `valueFrom.secretKeyRef`. | | `readyz` | `ContainerReadyz` | Optional. HTTP readiness probe — see [Container Readiness Probe](#container-readiness-probe-readyz). | | `volumeMounts` | `[]VolumeMount` | Optional. Mounts a `spec.volumes` entry (e.g. `durableDir`) into this container. | +| `securityContext` | `SecurityContext` | Optional. Security settings for the container process — see [Container Capabilities](#container-capabilities-securitycontextcapabilities). | `command` and `args` resolve against the container image's `ENTRYPOINT`/`CMD` the same way [Kubernetes Pod `command`/`args`](https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/) resolve against `ENTRYPOINT`/`CMD`. If the resolved argv is empty — the image sets neither `ENTRYPOINT` nor `CMD`, and the container sets neither `command` nor `args` — `Run`/`Restore` fails. +### Container Capabilities (`securityContext.capabilities`) + +Each container runs with a default set of Linux capabilities. `securityContext.capabilities` adjusts that set, mirroring `securityContext.capabilities` on a Kubernetes Pod container. + +| Field | Type | Description | +| :--- | :--- | :--- | +| `securityContext.capabilities.add` | `[]string` | Optional. Capabilities to grant on top of the default set. `ALL` is **not** accepted here. | +| `securityContext.capabilities.drop` | `[]string` | Optional. Capabilities to remove from the default set. `ALL` drops the whole set. | + +- **Naming.** Capabilities are named **without** the `CAP_` prefix, as in Kubernetes — `NET_BIND_SERVICE`, not `CAP_NET_BIND_SERVICE`. The prefixed spelling is rejected at admission rather than silently granting nothing. +- **Order.** `drop` is applied first, then `add`. A capability named in both is therefore **granted**. +- **Exact sets.** Because `drop: ["ALL"]` clears the default set, combining it with `add` expresses an exact capability set rather than a relative one: + + ```yaml + securityContext: + capabilities: + drop: ["ALL"] + add: ["NET_BIND_SERVICE"] + ``` + +- **`ALL` in `add` is rejected.** Kubernetes accepts it in the API and relies on PodSecurity admission to deny it; Substrate has no equivalent policy layer yet, so it is refused at admission instead. Name the capabilities the container needs. +- **Ambient capabilities are not supported** ([gvisor#3166](https://github.com/google/gvisor/issues/3166)). + +The sandbox — gVisor or micro-VM — remains the isolation boundary; capabilities constrain the workload *inside* it. + ### Container Readiness Probe (`readyz`) Each entry in `containers` may declare an optional **HTTP readiness probe** so the platform only treats the actor as "started" once the workload is actually serving traffic. This mirrors the role of `readinessProbe.httpGet` on a Kubernetes Pod container, but the gate is enforced inside ateom (the in-pod sandbox driver) rather than by the kubelet. From 16c7049c8e89e13de87dfca973d7589898105ac1 Mon Sep 17 00:00:00 2001 From: Nour Date: Sat, 8 Aug 2026 13:11:56 +0300 Subject: [PATCH 4/4] e2e: verify actor capabilities take effect inside the sandbox --- .github/workflows/pr-workflow.yaml | 8 + .../capabilities/capabilities.yaml.tmpl | 106 +++++++ internal/e2e/fixtures/probe/main.go | 114 +++++++ internal/e2e/fixtures/probe/main_test.go | 98 ++++++ .../suites/capabilities/capabilities_test.go | 293 ++++++++++++++++++ .../e2e/suites/capabilities/testmain_test.go | 24 ++ 6 files changed, 643 insertions(+) create mode 100644 internal/e2e/fixtures/capabilities/capabilities.yaml.tmpl create mode 100644 internal/e2e/fixtures/probe/main_test.go create mode 100644 internal/e2e/suites/capabilities/capabilities_test.go create mode 100644 internal/e2e/suites/capabilities/testmain_test.go diff --git a/.github/workflows/pr-workflow.yaml b/.github/workflows/pr-workflow.yaml index 14d0c1681..23a0fcd39 100644 --- a/.github/workflows/pr-workflow.yaml +++ b/.github/workflows/pr-workflow.yaml @@ -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: | diff --git a/internal/e2e/fixtures/capabilities/capabilities.yaml.tmpl b/internal/e2e/fixtures/capabilities/capabilities.yaml.tmpl new file mode 100644 index 000000000..85a5b3eb9 --- /dev/null +++ b/internal/e2e/fixtures/capabilities/capabilities.yaml.tmpl @@ -0,0 +1,106 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Fixture for the capabilities e2e suite. Two templates run the same probe +# image and differ only in securityContext.capabilities, so the suite can +# compare what the kernel reports inside the sandbox against what the template +# asked for. +# +# The sandbox-class fields are substituted by the suite from the source +# WorkerPool it was pointed at, so this one file covers both gvisor and +# micro-VM (see capabilities_test.go). + +apiVersion: v1 +kind: Namespace +metadata: + name: ${NAMESPACE} + +--- + +apiVersion: ate.dev/v1alpha1 +kind: WorkerPool +metadata: + name: caps + namespace: ${NAMESPACE} + labels: + workload: caps +spec: + # One worker per template for its golden snapshot, plus headroom for the + # actors the suite resumes. + replicas: 4 + sandboxClass: ${SANDBOX_CLASS} +${SANDBOX_CONFIG_LINE} + ateomImage: ${ATEOM_IMAGE} + +--- + +# No securityContext: pins the default capability set, so a change to the +# default is caught here and not only in atelet's unit tests. +apiVersion: ate.dev/v1alpha1 +kind: ActorTemplate +metadata: + name: caps-default + namespace: ${NAMESPACE} +spec: + sandboxClass: ${SANDBOX_CLASS} + pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" + containers: + - name: probe + image: ko://github.com/agent-substrate/substrate/internal/e2e/fixtures/probe + command: ["/ko-app/probe"] + readyz: + httpGet: + path: /healthz + port: 80 + timeoutSeconds: 60 + workerSelector: + matchLabels: + workload: caps + snapshotsConfig: + location: gs://${BUCKET_NAME}/${NAMESPACE}-default/ + +--- + +# drop ALL + add: the resulting set is exact rather than relative, so asserting +# it proves both halves at once — everything default was dropped, and only the +# named capability was granted back. +apiVersion: ate.dev/v1alpha1 +kind: ActorTemplate +metadata: + name: caps-exact + namespace: ${NAMESPACE} +spec: + sandboxClass: ${SANDBOX_CLASS} + pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" + containers: + - name: probe + image: ko://github.com/agent-substrate/substrate/internal/e2e/fixtures/probe + command: ["/ko-app/probe"] + # The probe binds :80, which is exactly what NET_BIND_SERVICE permits, so + # this template also proves the granted capability is usable and not merely + # present in the mask: without it the container could not become ready. + securityContext: + capabilities: + drop: ["ALL"] + add: ["NET_BIND_SERVICE"] + readyz: + httpGet: + path: /healthz + port: 80 + timeoutSeconds: 60 + workerSelector: + matchLabels: + workload: caps + snapshotsConfig: + location: gs://${BUCKET_NAME}/${NAMESPACE}-exact/ diff --git a/internal/e2e/fixtures/probe/main.go b/internal/e2e/fixtures/probe/main.go index 6927a1d04..73972cf07 100644 --- a/internal/e2e/fixtures/probe/main.go +++ b/internal/e2e/fixtures/probe/main.go @@ -21,16 +21,129 @@ package main import ( + "bufio" "encoding/json" + "fmt" "log" "net/http" "os" + "strconv" + "strings" ) // identityFile is the actor-id file inside the identity directory atelet // bind-mounts at IdentityMountPath. const identityFile = "/run/ate/actor-id" +// procStatus is where the kernel reports this process's capability sets. Asking +// the kernel — rather than reading back the OCI spec atelet wrote — is the whole +// point: it is what proves the sandbox actually applied the requested set. +const procStatus = "/proc/self/status" + +// capabilityNames maps a capability's bit position to its name, unprefixed to +// match how an ActorTemplate spells them. Indexed by value, so order is fixed +// by the kernel's and must not be sorted. A bit with no +// name here is reported as "CAP_" rather than dropped, so a kernel newer +// than this table still yields a readable diff instead of a silent omission. +var capabilityNames = []string{ + "CHOWN", "DAC_OVERRIDE", "DAC_READ_SEARCH", "FOWNER", "FSETID", + "KILL", "SETGID", "SETUID", "SETPCAP", "LINUX_IMMUTABLE", + "NET_BIND_SERVICE", "NET_BROADCAST", "NET_ADMIN", "NET_RAW", "IPC_LOCK", + "IPC_OWNER", "SYS_MODULE", "SYS_RAWIO", "SYS_CHROOT", "SYS_PTRACE", + "SYS_PACCT", "SYS_ADMIN", "SYS_BOOT", "SYS_NICE", "SYS_RESOURCE", + "SYS_TIME", "SYS_TTY_CONFIG", "MKNOD", "LEASE", "AUDIT_WRITE", + "AUDIT_CONTROL", "SETFCAP", "MAC_OVERRIDE", "MAC_ADMIN", "SYSLOG", + "WAKE_ALARM", "BLOCK_SUSPEND", "AUDIT_READ", "PERFMON", "BPF", + "CHECKPOINT_RESTORE", +} + +// capabilitiesResponse reports each of the process's capability sets by name. +// Sets are returned in bit order, which is stable, so a test can compare +// against an expected slice without sorting. +type capabilitiesResponse struct { + Bounding []string `json:"bounding"` + Effective []string `json:"effective"` + Permitted []string `json:"permitted"` + Inheritable []string `json:"inheritable"` + Ambient []string `json:"ambient"` + // Error carries a read/parse failure so a failing assertion explains itself + // instead of just showing empty sets. + Error string `json:"error,omitempty"` +} + +// decodeCapMask turns a /proc/self/status Cap* hex mask into capability names. +func decodeCapMask(hex string) ([]string, error) { + mask, err := strconv.ParseUint(strings.TrimSpace(hex), 16, 64) + if err != nil { + return nil, fmt.Errorf("parsing capability mask %q: %w", hex, err) + } + // Non-nil empty rather than nil: "no capabilities" is a real, assertable + // result here, and JSON-encoding nil as null muddies that. + names := []string{} + for bit := range 64 { + if mask&(1< and every decoded name past the +// gap would be wrong. +func TestCapabilityNamesTable(t *testing.T) { + const wantLen = 41 // CAP_CHOWN (0) .. CAP_CHECKPOINT_RESTORE (40) + if len(capabilityNames) != wantLen { + t.Errorf("len(capabilityNames) = %d, want %d", len(capabilityNames), wantLen) + } + for i, name := range capabilityNames { + if name == "" { + t.Errorf("capabilityNames[%d] is empty", i) + } + } +} diff --git a/internal/e2e/suites/capabilities/capabilities_test.go b/internal/e2e/suites/capabilities/capabilities_test.go new file mode 100644 index 000000000..e507bddb9 --- /dev/null +++ b/internal/e2e/suites/capabilities/capabilities_test.go @@ -0,0 +1,293 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package capabilities + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/agent-substrate/substrate/internal/e2e" + "github.com/agent-substrate/substrate/internal/resources" + "github.com/agent-substrate/substrate/pkg/api/v1alpha1" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// defaultCapabilities mirrors atelet's default set (cmd/atelet/oci.go). It is +// written out rather than imported so that changing the default is a deliberate +// two-place edit, and so this suite fails if the default silently drifts. +var defaultCapabilities = []string{"KILL", "NET_BIND_SERVICE", "AUDIT_WRITE"} + +// capabilitiesResponse mirrors the probe's /capabilities payload. +type capabilitiesResponse struct { + Bounding []string `json:"bounding"` + Effective []string `json:"effective"` + Permitted []string `json:"permitted"` + Inheritable []string `json:"inheritable"` + Ambient []string `json:"ambient"` + Error string `json:"error"` +} + +// TestActorCapabilities asserts that an ActorTemplate's +// securityContext.capabilities is actually in force inside the sandbox, as the +// kernel reports it — not merely present in the OCI spec atelet writes. atelet +// does not spawn containers, so the spec being right and the sandbox applying +// it are separate claims; only this test covers the second. +// +// It runs against whichever sandbox class the source template names, so CI +// covers both gvisor and micro-VM from one suite (see sourceRuntime). +func TestActorCapabilities(t *testing.T) { + env, err := e2e.CheckEnv("BUCKET_NAME", "KO_DOCKER_REPO") + if err != nil { + t.Fatalf("CheckEnv failed: %v", err) + } + ctx := context.Background() + clients := e2e.GetClients() + + rt := sourceRuntime(t, ctx, clients) + t.Logf("running against sandboxClass=%q ateomImage=%q", rt.sandboxClass, rt.ateomImage) + + namespace := deployFixture(t, env["BUCKET_NAME"], rt) + + tests := []struct { + name string + template string + // want is the exact bounding set, in kernel bit order as the probe + // reports it. + want []string + }{{ + name: "no securityContext keeps the default set", + template: "caps-default", + want: defaultCapabilities, + }, { + // Proves both halves at once: everything default was dropped, and only + // the named capability came back. + name: "drop ALL plus add yields exactly the added capability", + template: "caps-exact", + want: []string{"NET_BIND_SERVICE"}, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + waitForGolden(t, ctx, clients, namespace, tt.template) + actor := tt.template + "-actor" + createAndResumeActor(t, ctx, clients, namespace, tt.template, actor) + + rc, err := e2e.NewRouterClient(ctx) + if err != nil { + t.Fatalf("NewRouterClient: %v", err) + } + defer rc.Close() + + got := probeCapabilities(t, ctx, rc, namespace, actor) + if got.Error != "" { + t.Fatalf("probe reported an error reading its capabilities: %s", got.Error) + } + + // Bounding is the ceiling: no set can exceed it, so asserting it + // exactly is what proves a dropped capability is truly gone rather + // than merely inactive. + assertSameCapabilities(t, "bounding", got.Bounding, tt.want) + assertSameCapabilities(t, "effective", got.Effective, tt.want) + assertSameCapabilities(t, "permitted", got.Permitted, tt.want) + + if len(got.Ambient) != 0 { + t.Errorf("ambient = %v, want empty (ambient capabilities are not supported)", got.Ambient) + } + }) + } +} + +// assertSameCapabilities compares two capability sets irrespective of order. +func assertSameCapabilities(t *testing.T, set string, got, want []string) { + t.Helper() + g := slices.Clone(got) + w := slices.Clone(want) + slices.Sort(g) + slices.Sort(w) + if !slices.Equal(g, w) { + t.Errorf("%s capability set = %v, want %v", set, g, w) + } +} + +// runtime is the sandbox-class-specific configuration copied from an existing +// WorkerPool so this suite runs unchanged on gvisor and micro-VM. +type runtime struct { + sandboxClass string + sandboxConfigName string + ateomImage string +} + +// sourceRuntime reads the WorkerPool this suite should imitate. It defaults to +// the gVisor counter demo; CI overrides the env to point the same suite at the +// micro-VM demo, mirroring how the demo suite covers both classes. +func sourceRuntime(t *testing.T, ctx context.Context, clients *e2e.Clients) runtime { + t.Helper() + ns := "ate-demo-counter" + if v := os.Getenv("E2E_TEMPLATE_NAMESPACE"); v != "" { + ns = v + } + name := "counter" + if v := os.Getenv("E2E_TEMPLATE_NAME"); v != "" { + name = v + } + + wp, err := clients.SubstrateK8s.ApiV1alpha1().WorkerPools(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("getting source WorkerPool %s/%s: %v", ns, name, err) + } + + sandboxClass := string(wp.Spec.SandboxClass) + if sandboxClass == "" { + sandboxClass = string(v1alpha1.SandboxClassGvisor) + } + return runtime{ + sandboxClass: sandboxClass, + sandboxConfigName: wp.Spec.SandboxConfigName, + ateomImage: wp.Spec.AteomImage, + } +} + +// deployFixture renders and applies the fixture for the given runtime and +// returns the namespace it created. The namespace is suffixed with the sandbox +// class so a gvisor run and a micro-VM run against the same cluster do not +// collide. +func deployFixture(t *testing.T, bucket string, rt runtime) string { + t.Helper() + root, err := e2e.FindRepoRoot() + if err != nil { + t.Fatalf("FindRepoRoot: %v", err) + } + + namespace := "ate-e2e-caps-" + rt.sandboxClass + + tmpl, err := os.ReadFile(filepath.Join(root, "internal/e2e/fixtures/capabilities/capabilities.yaml.tmpl")) + if err != nil { + t.Fatalf("reading capabilities manifest template: %v", err) + } + + // Only a micro-VM pool names a SandboxConfig; for gvisor the line is + // omitted entirely so the pool falls back to the cluster default. + sandboxConfigLine := "" + if rt.sandboxConfigName != "" { + sandboxConfigLine = " sandboxConfigName: " + rt.sandboxConfigName + } + + rendered := strings.NewReplacer( + "${BUCKET_NAME}", bucket, + "${NAMESPACE}", namespace, + "${SANDBOX_CLASS}", rt.sandboxClass, + "${SANDBOX_CONFIG_LINE}", sandboxConfigLine, + "${ATEOM_IMAGE}", rt.ateomImage, + ).Replace(string(tmpl)) + + manifest := filepath.Join(t.TempDir(), "capabilities.yaml") + if err := os.WriteFile(manifest, []byte(rendered), 0o644); err != nil { + t.Fatalf("writing rendered capabilities manifest: %v", err) + } + + // Build/push the probe image and apply through the repo's pinned ko, as the + // identity suite does; CI does not install ko on PATH, and KO_CONFIG_PATH is + // required because ko resolves .ko.yaml from its working directory. + applyArgs := []string{"ko", "apply", "-f", manifest} + if e2e.KubeContext != "" { + applyArgs = append(applyArgs, "--", "--context="+e2e.KubeContext) + } + e2e.RunCmdWithEnv(t, []string{"KO_CONFIG_PATH=" + root}, filepath.Join(root, "hack/run-tool.sh"), applyArgs...) + + t.Cleanup(func() { + delArgs := []string{"delete", "--ignore-not-found", "-f", manifest} + if e2e.KubeContext != "" { + delArgs = append([]string{"--context=" + e2e.KubeContext}, delArgs...) + } + e2e.RunCmd(t, "kubectl", delArgs...) + }) + + return namespace +} + +func waitForGolden(t *testing.T, ctx context.Context, clients *e2e.Clients, namespace, template string) { + t.Helper() + deadline := time.Now().Add(10 * time.Minute) + for time.Now().Before(deadline) { + at, err := clients.SubstrateK8s.ApiV1alpha1().ActorTemplates(namespace).Get(ctx, template, metav1.GetOptions{}) + if err == nil { + switch at.Status.Phase { + case v1alpha1.PhaseReady: + t.Logf("ActorTemplate %s ready, golden=%s", template, at.Status.GoldenActorID) + return + case v1alpha1.PhaseFailed: + // A template whose container cannot start — for example because + // a needed capability was dropped — lands here rather than + // timing out, so say so plainly. + t.Fatalf("ActorTemplate %s entered PhaseFailed; its container never became ready", template) + } + } + time.Sleep(2 * time.Second) + } + t.Fatalf("timed out waiting for ActorTemplate %s to be Ready", template) +} + +func createAndResumeActor(t *testing.T, ctx context.Context, clients *e2e.Clients, namespace, template, id string) { + t.Helper() + // CreateActor requires the atespace to exist first. + _, _ = clients.SubstrateAPI.CreateAtespace(ctx, &ateapipb.CreateAtespaceRequest{ + Atespace: &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: namespace}}, + }) + if _, err := clients.SubstrateAPI.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: namespace, Name: id}, + ActorTemplateNamespace: namespace, + ActorTemplateName: template, + }}); err != nil { + t.Fatalf("CreateActor %q: %v", id, err) + } + t.Cleanup(func() { + // DeleteActor requires the actor to be suspended. + _, _ = clients.SubstrateAPI.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: namespace, Name: id}}) + _, _ = clients.SubstrateAPI.DeleteActor(ctx, &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Atespace: namespace, Name: id}}) + }) + + if _, err := clients.SubstrateAPI.ResumeActor(ctx, &ateapipb.ResumeActorRequest{ + Actor: &ateapipb.ObjectRef{Atespace: namespace, Name: id}, + }); err != nil { + t.Fatalf("ResumeActor %q: %v", id, err) + } +} + +func probeCapabilities(t *testing.T, ctx context.Context, rc *e2e.RouterClient, namespace, id string) capabilitiesResponse { + t.Helper() + resp, err := rc.Get(ctx, resources.ActorRef{Atespace: namespace, Name: id}, "/capabilities") + if err != nil { + t.Fatalf("GET /capabilities for %q: %v", id, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("GET /capabilities for %q: status %d, body %q", id, resp.StatusCode, body) + } + var out capabilitiesResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decoding /capabilities for %q: %v", id, err) + } + return out +} diff --git a/internal/e2e/suites/capabilities/testmain_test.go b/internal/e2e/suites/capabilities/testmain_test.go new file mode 100644 index 000000000..34b1c0f0a --- /dev/null +++ b/internal/e2e/suites/capabilities/testmain_test.go @@ -0,0 +1,24 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package capabilities + +import ( + "os" + "testing" + + "github.com/agent-substrate/substrate/internal/e2e" +) + +func TestMain(m *testing.M) { os.Exit(e2e.RunTestMain(m)) }