From b81b306e8e89f0987772d02b23250662f2507f09 Mon Sep 17 00:00:00 2001 From: Taahir Ahmed Date: Tue, 7 Jul 2026 15:03:48 -0700 Subject: [PATCH 1/3] Remove volume type enum --- .../internal/controlapi/workload_spec.go | 2 - .../internal/controlapi/workload_spec_test.go | 4 - cmd/atelet/main.go | 7 +- cmd/atelet/main_test.go | 6 +- cmd/atelet/oci.go | 10 +- cmd/atelet/oci_test.go | 4 +- cmd/atelet/volumes.go | 6 - cmd/atelet/volumes_test.go | 6 +- internal/proto/ateletpb/atelet.pb.go | 231 +++++++----------- internal/proto/ateletpb/atelet.proto | 12 +- 10 files changed, 102 insertions(+), 186 deletions(-) diff --git a/cmd/ateapi/internal/controlapi/workload_spec.go b/cmd/ateapi/internal/controlapi/workload_spec.go index 6e1b12d0d..a330b1525 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec.go +++ b/cmd/ateapi/internal/controlapi/workload_spec.go @@ -47,7 +47,6 @@ func workloadSpecFromActorTemplate(actorTemplate *atev1alpha1.ActorTemplate, act if vol.VolumeSource.DurableDir != nil { workloadSpec.Volumes = append(workloadSpec.Volumes, &ateletpb.Volume{ Name: vol.Name, - Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR, Source: &ateletpb.Volume_DurableDir{ DurableDir: &ateletpb.DurableDirVolume{}, }, @@ -142,7 +141,6 @@ func appendExternalVolumes(workloadSpec *ateletpb.WorkloadSpec, template *atev1a } workloadSpec.Volumes = append(workloadSpec.Volumes, &ateletpb.Volume{ Name: vol.Name, - Type: ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL, Source: &ateletpb.Volume_External{ External: &ateletpb.ExternalVolumeSource{ StorageVolumeId: storageVolID, diff --git a/cmd/ateapi/internal/controlapi/workload_spec_test.go b/cmd/ateapi/internal/controlapi/workload_spec_test.go index 0de964056..b567bef52 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec_test.go +++ b/cmd/ateapi/internal/controlapi/workload_spec_test.go @@ -64,7 +64,6 @@ func TestWorkloadSpecFromActorTemplate(t *testing.T) { Volumes: []*ateletpb.Volume{ { Name: "home", - Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR, Source: &ateletpb.Volume_DurableDir{DurableDir: &ateletpb.DurableDirVolume{}}, }, }, @@ -104,7 +103,6 @@ func TestWorkloadSpecFromActorTemplate(t *testing.T) { Volumes: []*ateletpb.Volume{ { Name: "home", - Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR, Source: &ateletpb.Volume_DurableDir{DurableDir: &ateletpb.DurableDirVolume{}}, }, }, @@ -136,7 +134,6 @@ func TestWorkloadSpecFromActorTemplate(t *testing.T) { Volumes: []*ateletpb.Volume{ { Name: "home", - Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR, Source: &ateletpb.Volume_DurableDir{DurableDir: &ateletpb.DurableDirVolume{}}, }, }, @@ -535,7 +532,6 @@ func TestAppendExternalVolumes(t *testing.T) { Volumes: []*ateletpb.Volume{ { Name: "vol-1", - Type: ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL, Source: &ateletpb.Volume_External{ External: &ateletpb.ExternalVolumeSource{ StorageVolumeId: "vol-gce-pd-123", diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 60cca4c3c..4d98c8802 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -1061,7 +1061,7 @@ func (s *AteomHerder) prepareOCIBundles( } // make directories for all durable-dir volumes for _, vol := range spec.GetVolumes() { - if vol.GetType() == ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR { + if vol.GetDurableDir() != nil { volPath := ateompath.DurableDirVolumeMountPoint(actorUID, vol.GetName()) if err := os.MkdirAll(volPath, 0o700); err != nil { return fmt.Errorf("while creating %q: %w", volPath, err) @@ -1080,7 +1080,7 @@ func (s *AteomHerder) prepareOCIBundles( // Declare durable-dir volumes to gVisor. We use the volume name as the // mount hint name to support multiple durable-dir volumes. for _, vol := range spec.GetVolumes() { - if vol.GetType() == ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR { + if vol.GetDurableDir() != nil { annotations[fmt.Sprintf("dev.gvisor.spec.mount.%s.type", vol.GetName())] = "bind" annotations[fmt.Sprintf("dev.gvisor.spec.mount.%s.share", vol.GetName())] = "container" annotations[fmt.Sprintf("dev.gvisor.spec.mount.%s.source", vol.GetName())] = ateompath.DurableDirVolumeMountPoint(actorUID, vol.GetName()) @@ -1158,7 +1158,8 @@ func (s *AteomHerder) dialAteom(ctx context.Context, targetAteomUid string) (ate func buildAteomWorkloadSpec(spec *ateletpb.WorkloadSpec) *ateompb.WorkloadSpec { ddVolumes := make(map[string]bool) for _, vol := range spec.GetVolumes() { - if vol.GetType() == ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR { + switch vol.GetSource().(type) { + case *ateletpb.Volume_DurableDir: ddVolumes[vol.GetName()] = true } } diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index ddda9a68c..1abe3a8e7 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -640,9 +640,9 @@ func TestBuildAteomWorkloadSpecForwardsReadyz(t *testing.T) { func TestBuildAteomWorkloadSpecForwardsDurableDirMounts(t *testing.T) { in := &ateletpb.WorkloadSpec{ Volumes: []*ateletpb.Volume{ - {Name: "data", Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR}, - {Name: "cache", Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR}, - {Name: "scratch", Type: ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL}, + {Name: "data", Source: &ateletpb.Volume_DurableDir{DurableDir: &ateletpb.DurableDirVolume{}}}, + {Name: "cache", Source: &ateletpb.Volume_DurableDir{DurableDir: &ateletpb.DurableDirVolume{}}}, + {Name: "scratch", Source: &ateletpb.Volume_External{External: &ateletpb.ExternalVolumeSource{}}}, }, Containers: []*ateletpb.Container{ { diff --git a/cmd/atelet/oci.go b/cmd/atelet/oci.go index e1476610c..74fb8f93a 100644 --- a/cmd/atelet/oci.go +++ b/cmd/atelet/oci.go @@ -295,17 +295,17 @@ func buildActorOCISpec(actorUID string, args []string, env []string, annotations } // Prepare and mount all volumes. - volumeTypes := make(map[string]ateletpb.VolumeType) + volumesByName := make(map[string]*ateletpb.Volume) for _, vol := range volumes { - volumeTypes[vol.GetName()] = vol.GetType() + volumesByName[vol.GetName()] = vol } for _, vm := range volumeMounts { var srcPath string - switch volumeTypes[vm.GetName()] { - case ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR: + switch volumesByName[vm.GetName()].GetSource().(type) { + case *ateletpb.Volume_DurableDir: srcPath = ateompath.DurableDirVolumeMountPoint(actorUID, vm.GetName()) - case ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL: + case *ateletpb.Volume_External: srcPath = ateompath.VolumeHostPath(actorUID, vm.GetName()) default: continue diff --git a/cmd/atelet/oci_test.go b/cmd/atelet/oci_test.go index 433c082c3..1f492092a 100644 --- a/cmd/atelet/oci_test.go +++ b/cmd/atelet/oci_test.go @@ -211,8 +211,8 @@ func TestBuildActorOCISpec_DurableDirVolumeMounts(t *testing.T) { {Name: "cache", MountPath: "/var/cache"}, } volumes := []*ateletpb.Volume{ - {Name: "data", Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR}, - {Name: "cache", Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR}, + {Name: "data", Source: &ateletpb.Volume_DurableDir{DurableDir: &ateletpb.DurableDirVolume{}}}, + {Name: "cache", Source: &ateletpb.Volume_DurableDir{DurableDir: &ateletpb.DurableDirVolume{}}}, } spec := buildActorOCISpec( actorUID, diff --git a/cmd/atelet/volumes.go b/cmd/atelet/volumes.go index 639bb6e75..492aadc10 100644 --- a/cmd/atelet/volumes.go +++ b/cmd/atelet/volumes.go @@ -31,9 +31,6 @@ import ( func (s *AteomHerder) mountExternalVolumes(ctx context.Context, actorUID string, volumes []*ateletpb.Volume) error { for _, vol := range volumes { - if vol.GetType() != ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL { - continue - } ext := vol.GetExternal() if ext == nil { continue @@ -57,9 +54,6 @@ func (s *AteomHerder) mountExternalVolumes(ctx context.Context, actorUID string, func (s *AteomHerder) unmountExternalVolumes(ctx context.Context, actorUID string, volumes []*ateletpb.Volume) error { var errs []error for _, vol := range volumes { - if vol.GetType() != ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL { - continue - } ext := vol.GetExternal() if ext == nil { continue diff --git a/cmd/atelet/volumes_test.go b/cmd/atelet/volumes_test.go index 6d009a53c..588d1f637 100644 --- a/cmd/atelet/volumes_test.go +++ b/cmd/atelet/volumes_test.go @@ -47,7 +47,6 @@ func TestUnmountExternalVolumes(t *testing.T) { extVol1 := &ateletpb.Volume{ Name: "vol-1", - Type: ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL, Source: &ateletpb.Volume_External{ External: &ateletpb.ExternalVolumeSource{ StorageVolumeId: "mock-vol-1", @@ -57,7 +56,6 @@ func TestUnmountExternalVolumes(t *testing.T) { } extVol2 := &ateletpb.Volume{ Name: "vol-2", - Type: ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL, Source: &ateletpb.Volume_External{ External: &ateletpb.ExternalVolumeSource{ StorageVolumeId: "mock-vol-2", @@ -67,7 +65,9 @@ func TestUnmountExternalVolumes(t *testing.T) { } durableVol := &ateletpb.Volume{ Name: "durable-1", - Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR, + Source: &ateletpb.Volume_DurableDir{ + DurableDir: &ateletpb.DurableDirVolume{}, + }, } t.Run("success", func(t *testing.T) { diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index d610bf9b7..f97ff7074 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -35,55 +35,6 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -type VolumeType int32 - -const ( - VolumeType_VOLUME_TYPE_UNSPECIFIED VolumeType = 0 - VolumeType_VOLUME_TYPE_DURABLE_DIR VolumeType = 1 - VolumeType_VOLUME_TYPE_EXTERNAL VolumeType = 2 -) - -// Enum value maps for VolumeType. -var ( - VolumeType_name = map[int32]string{ - 0: "VOLUME_TYPE_UNSPECIFIED", - 1: "VOLUME_TYPE_DURABLE_DIR", - 2: "VOLUME_TYPE_EXTERNAL", - } - VolumeType_value = map[string]int32{ - "VOLUME_TYPE_UNSPECIFIED": 0, - "VOLUME_TYPE_DURABLE_DIR": 1, - "VOLUME_TYPE_EXTERNAL": 2, - } -) - -func (x VolumeType) Enum() *VolumeType { - p := new(VolumeType) - *p = x - return p -} - -func (x VolumeType) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (VolumeType) Descriptor() protoreflect.EnumDescriptor { - return file_atelet_proto_enumTypes[0].Descriptor() -} - -func (VolumeType) Type() protoreflect.EnumType { - return &file_atelet_proto_enumTypes[0] -} - -func (x VolumeType) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use VolumeType.Descriptor instead. -func (VolumeType) EnumDescriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{0} -} - type CheckpointType int32 const ( @@ -120,11 +71,11 @@ func (x CheckpointType) String() string { } func (CheckpointType) Descriptor() protoreflect.EnumDescriptor { - return file_atelet_proto_enumTypes[1].Descriptor() + return file_atelet_proto_enumTypes[0].Descriptor() } func (CheckpointType) Type() protoreflect.EnumType { - return &file_atelet_proto_enumTypes[1] + return &file_atelet_proto_enumTypes[0] } func (x CheckpointType) Number() protoreflect.EnumNumber { @@ -133,7 +84,7 @@ func (x CheckpointType) Number() protoreflect.EnumNumber { // Deprecated: Use CheckpointType.Descriptor instead. func (CheckpointType) EnumDescriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{1} + return file_atelet_proto_rawDescGZIP(), []int{0} } type SnapshotScope int32 @@ -184,11 +135,11 @@ func (x SnapshotScope) String() string { } func (SnapshotScope) Descriptor() protoreflect.EnumDescriptor { - return file_atelet_proto_enumTypes[2].Descriptor() + return file_atelet_proto_enumTypes[1].Descriptor() } func (SnapshotScope) Type() protoreflect.EnumType { - return &file_atelet_proto_enumTypes[2] + return &file_atelet_proto_enumTypes[1] } func (x SnapshotScope) Number() protoreflect.EnumNumber { @@ -197,7 +148,7 @@ func (x SnapshotScope) Number() protoreflect.EnumNumber { // Deprecated: Use SnapshotScope.Descriptor instead. func (SnapshotScope) EnumDescriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{2} + return file_atelet_proto_rawDescGZIP(), []int{1} } type MintActorCertificateRequest struct { @@ -779,7 +730,6 @@ func (x *ExternalVolumeSource) GetVolumeContext() map[string]string { type Volume struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Type VolumeType `protobuf:"varint,2,opt,name=type,proto3,enum=atelet.VolumeType" json:"type,omitempty"` // Types that are valid to be assigned to Source: // // *Volume_DurableDir @@ -826,13 +776,6 @@ func (x *Volume) GetName() string { return "" } -func (x *Volume) GetType() VolumeType { - if x != nil { - return x.Type - } - return VolumeType_VOLUME_TYPE_UNSPECIFIED -} - func (x *Volume) GetSource() isVolume_Source { if x != nil { return x.Source @@ -863,11 +806,11 @@ type isVolume_Source interface { } type Volume_DurableDir struct { - DurableDir *DurableDirVolume `protobuf:"bytes,3,opt,name=durable_dir,json=durableDir,proto3,oneof"` + DurableDir *DurableDirVolume `protobuf:"bytes,2,opt,name=durable_dir,json=durableDir,proto3,oneof"` } type Volume_External struct { - External *ExternalVolumeSource `protobuf:"bytes,4,opt,name=external,proto3,oneof"` + External *ExternalVolumeSource `protobuf:"bytes,3,opt,name=external,proto3,oneof"` } func (*Volume_DurableDir) isVolume_Source() {} @@ -1788,13 +1731,12 @@ const file_atelet_proto_rawDesc = "" + "\x0evolume_context\x18\x03 \x03(\v2/.atelet.ExternalVolumeSource.VolumeContextEntryR\rvolumeContext\x1a@\n" + "\x12VolumeContextEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xc7\x01\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x9f\x01\n" + "\x06Volume\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12&\n" + - "\x04type\x18\x02 \x01(\x0e2\x12.atelet.VolumeTypeR\x04type\x12;\n" + - "\vdurable_dir\x18\x03 \x01(\v2\x18.atelet.DurableDirVolumeH\x00R\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12;\n" + + "\vdurable_dir\x18\x02 \x01(\v2\x18.atelet.DurableDirVolumeH\x00R\n" + "durableDir\x12:\n" + - "\bexternal\x18\x04 \x01(\v2\x1c.atelet.ExternalVolumeSourceH\x00R\bexternalB\b\n" + + "\bexternal\x18\x03 \x01(\v2\x1c.atelet.ExternalVolumeSourceH\x00R\bexternalB\b\n" + "\x06source\"@\n" + "\vVolumeMount\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + @@ -1856,12 +1798,7 @@ const file_atelet_proto_rawDesc = "" + "\x0eegress_gateway\x18\r \x01(\v2\x15.atelet.EgressGatewayH\x01R\regressGateway\x88\x01\x01B\b\n" + "\x06configB\x11\n" + "\x0f_egress_gateway\"\x11\n" + - "\x0fRestoreResponse*`\n" + - "\n" + - "VolumeType\x12\x1b\n" + - "\x17VOLUME_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n" + - "\x17VOLUME_TYPE_DURABLE_DIR\x10\x01\x12\x18\n" + - "\x14VOLUME_TYPE_EXTERNAL\x10\x02*j\n" + + "\x0fRestoreResponse*j\n" + "\x0eCheckpointType\x12\x1f\n" + "\x1bCHECKPOINT_TYPE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15CHECKPOINT_TYPE_LOCAL\x10\x01\x12\x1c\n" + @@ -1891,81 +1828,79 @@ func file_atelet_proto_rawDescGZIP() []byte { return file_atelet_proto_rawDescData } -var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 2) var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 26) var file_atelet_proto_goTypes = []any{ - (VolumeType)(0), // 0: atelet.VolumeType - (CheckpointType)(0), // 1: atelet.CheckpointType - (SnapshotScope)(0), // 2: atelet.SnapshotScope - (*MintActorCertificateRequest)(nil), // 3: atelet.MintActorCertificateRequest - (*MintActorCertificateResponse)(nil), // 4: atelet.MintActorCertificateResponse - (*RunRequest)(nil), // 5: atelet.RunRequest - (*EgressGateway)(nil), // 6: atelet.EgressGateway - (*AssetFile)(nil), // 7: atelet.AssetFile - (*ArchAssets)(nil), // 8: atelet.ArchAssets - (*SandboxAssets)(nil), // 9: atelet.SandboxAssets - (*WorkloadSpec)(nil), // 10: atelet.WorkloadSpec - (*DurableDirVolume)(nil), // 11: atelet.DurableDirVolume - (*ExternalVolumeSource)(nil), // 12: atelet.ExternalVolumeSource - (*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 + (CheckpointType)(0), // 0: atelet.CheckpointType + (SnapshotScope)(0), // 1: atelet.SnapshotScope + (*MintActorCertificateRequest)(nil), // 2: atelet.MintActorCertificateRequest + (*MintActorCertificateResponse)(nil), // 3: atelet.MintActorCertificateResponse + (*RunRequest)(nil), // 4: atelet.RunRequest + (*EgressGateway)(nil), // 5: atelet.EgressGateway + (*AssetFile)(nil), // 6: atelet.AssetFile + (*ArchAssets)(nil), // 7: atelet.ArchAssets + (*SandboxAssets)(nil), // 8: atelet.SandboxAssets + (*WorkloadSpec)(nil), // 9: atelet.WorkloadSpec + (*DurableDirVolume)(nil), // 10: atelet.DurableDirVolume + (*ExternalVolumeSource)(nil), // 11: atelet.ExternalVolumeSource + (*Volume)(nil), // 12: atelet.Volume + (*VolumeMount)(nil), // 13: atelet.VolumeMount + (*Container)(nil), // 14: atelet.Container + (*EnvEntry)(nil), // 15: atelet.EnvEntry + (*Readyz)(nil), // 16: atelet.Readyz + (*HTTPGetAction)(nil), // 17: atelet.HTTPGetAction + (*RunResponse)(nil), // 18: atelet.RunResponse + (*LocalCheckpointConfiguration)(nil), // 19: atelet.LocalCheckpointConfiguration + (*ExternalCheckpointConfiguration)(nil), // 20: atelet.ExternalCheckpointConfiguration + (*CheckpointRequest)(nil), // 21: atelet.CheckpointRequest + (*CheckpointResponse)(nil), // 22: atelet.CheckpointResponse + (*RestoreRequest)(nil), // 23: atelet.RestoreRequest + (*RestoreResponse)(nil), // 24: atelet.RestoreResponse + nil, // 25: atelet.ArchAssets.FilesEntry + nil, // 26: atelet.SandboxAssets.AssetsEntry + nil, // 27: 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 - 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 - 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 - 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 + 9, // 0: atelet.RunRequest.spec:type_name -> atelet.WorkloadSpec + 8, // 1: atelet.RunRequest.sandbox_assets:type_name -> atelet.SandboxAssets + 5, // 2: atelet.RunRequest.egress_gateway:type_name -> atelet.EgressGateway + 25, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 26, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry + 14, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container + 12, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume + 27, // 7: atelet.ExternalVolumeSource.volume_context:type_name -> atelet.ExternalVolumeSource.VolumeContextEntry + 10, // 8: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume + 11, // 9: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource + 15, // 10: atelet.Container.env:type_name -> atelet.EnvEntry + 16, // 11: atelet.Container.readyz:type_name -> atelet.Readyz + 13, // 12: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount + 17, // 13: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction + 9, // 14: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec + 0, // 15: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType + 19, // 16: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 20, // 17: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 1, // 18: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope + 9, // 19: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 0, // 20: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 19, // 21: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 20, // 22: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 1, // 23: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 5, // 24: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 6, // 25: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 7, // 26: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 2, // 27: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest + 4, // 28: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 21, // 29: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 23, // 30: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 3, // 31: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse + 18, // 32: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 22, // 33: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 24, // 34: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 31, // [31:35] is the sub-list for method output_type + 27, // [27:31] is the sub-list for method input_type + 27, // [27:27] is the sub-list for extension type_name + 27, // [27:27] is the sub-list for extension extendee + 0, // [0:27] is the sub-list for field type_name } func init() { file_atelet_proto_init() } @@ -1991,7 +1926,7 @@ func file_atelet_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_atelet_proto_rawDesc), len(file_atelet_proto_rawDesc)), - NumEnums: 3, + NumEnums: 2, NumMessages: 26, NumExtensions: 0, NumServices: 2, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index 3a252928c..ca45b7d88 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -112,12 +112,6 @@ message WorkloadSpec { repeated Volume volumes = 3; } -enum VolumeType { - VOLUME_TYPE_UNSPECIFIED = 0; - VOLUME_TYPE_DURABLE_DIR = 1; - VOLUME_TYPE_EXTERNAL = 2; -} - message DurableDirVolume { } @@ -130,11 +124,9 @@ message ExternalVolumeSource { message Volume { string name = 1; - VolumeType type = 2; - oneof source { - DurableDirVolume durable_dir = 3; - ExternalVolumeSource external = 4; + DurableDirVolume durable_dir = 2; + ExternalVolumeSource external = 3; } } From 511c4776d3de0144b9138ab1a1300619ae4b247e Mon Sep 17 00:00:00 2001 From: Taahir Ahmed Date: Thu, 9 Jul 2026 14:50:22 -0700 Subject: [PATCH 2/3] System Information Volumes: Part 1 (Actor Identity) This commit defines a new volume type, SystemInfoVolume, that will serve a similar purpose as Projected volumes in Kubernetes. It will support writing information from multiple sources to automatically-updating files in the Actor's filesystem. For a first pass, I have converted the existing hardcoded Actor ID file to be one of the available information sources in a SystemInfoVolume. Further work will add Actor Identity JWTs and Actor Identity certificates. --- .../internal/controlapi/workload_spec.go | 33 +- .../third_party/atomicwriter/atomic_writer.go | 496 ++++++++ .../atomicwriter/atomic_writer_linux.go | 27 + .../atomicwriter/atomic_writer_test.go | 1104 +++++++++++++++++ .../atomicwriter/atomic_writer_unsupported.go | 32 + cmd/atelet/main.go | 69 +- cmd/atelet/oci.go | 55 +- cmd/atelet/oci_test.go | 46 +- docs/api-guide.md | 28 +- internal/ateompath/ateompath.go | 32 +- internal/proto/ateletpb/atelet.pb.go | 392 ++++-- internal/proto/ateletpb/atelet.proto | 20 + .../generated/ate.dev_actortemplates.yaml | 44 +- pkg/api/v1alpha1/actortemplate_types.go | 39 +- .../v1alpha1/actortemplate_validation_test.go | 6 +- pkg/api/v1alpha1/zz_generated.deepcopy.go | 62 + 16 files changed, 2272 insertions(+), 213 deletions(-) create mode 100644 cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go create mode 100644 cmd/atelet/internal/third_party/atomicwriter/atomic_writer_linux.go create mode 100644 cmd/atelet/internal/third_party/atomicwriter/atomic_writer_test.go create mode 100644 cmd/atelet/internal/third_party/atomicwriter/atomic_writer_unsupported.go diff --git a/cmd/ateapi/internal/controlapi/workload_spec.go b/cmd/ateapi/internal/controlapi/workload_spec.go index a330b1525..c7465db39 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec.go +++ b/cmd/ateapi/internal/controlapi/workload_spec.go @@ -41,16 +41,43 @@ func workloadSpecFromActorTemplate(actorTemplate *atev1alpha1.ActorTemplate, act PauseImage: actorTemplate.Spec.PauseImage, } - // add volumes + // Convert volumes to atelet's representation. ActorTemplate validation has + // already ensured that only one source is set. for _, vol := range actorTemplate.Spec.Volumes { - // volume is durable-dir type - if vol.VolumeSource.DurableDir != nil { + switch { + case vol.VolumeSource.DurableDir != nil: workloadSpec.Volumes = append(workloadSpec.Volumes, &ateletpb.Volume{ Name: vol.Name, Source: &ateletpb.Volume_DurableDir{ DurableDir: &ateletpb.DurableDirVolume{}, }, }) + + case vol.VolumeSource.SystemInfo != nil: + ateletSystemInfo := &ateletpb.SystemInfoVolume{} + for _, dataSource := range vol.VolumeSource.SystemInfo.DataSources { + switch { + case dataSource.ActorIdentity != nil: + ateletSystemInfo.DataSources = append(ateletSystemInfo.DataSources, &ateletpb.SystemInfoDataSource{ + DataSource: &ateletpb.SystemInfoDataSource_ActorIdentity{ + ActorIdentity: &ateletpb.ActorIdentityDataSource{ + Path: dataSource.ActorIdentity.Path, + }, + }, + }) + default: + continue // Drop unrecognized data sources + } + } + workloadSpec.Volumes = append(workloadSpec.Volumes, &ateletpb.Volume{ + Name: vol.Name, + Source: &ateletpb.Volume_SystemInfo{ + SystemInfo: ateletSystemInfo, + }, + }) + + default: + continue // Drop unrecognized volumes. } } diff --git a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go new file mode 100644 index 000000000..d2f3a6e0b --- /dev/null +++ b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go @@ -0,0 +1,496 @@ +/* +Copyright 2016 The Kubernetes Authors. + +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 atomicwriter + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + "path" + "path/filepath" + "runtime" + "strings" + "time" + + "k8s.io/apimachinery/pkg/util/sets" +) + +const ( + maxFileNameLength = 255 + maxPathLength = 4096 +) + +// AtomicWriter handles atomically projecting content for a set of files into +// a target directory. +// +// Note: +// +// 1. AtomicWriter reserves the set of pathnames starting with `..`. +// 2. AtomicWriter offers no concurrency guarantees and must be synchronized +// by the caller. +// +// The visible files in this volume are symlinks to files in the writer's data +// directory. Actual files are stored in a hidden timestamped directory which +// is symlinked to by the data directory. The timestamped directory and +// data directory symlink are created in the writer's target dir.  This scheme +// allows the files to be atomically updated by changing the target of the +// data directory symlink. +// +// Consumers of the target directory can monitor the ..data symlink using +// inotify or fanotify to receive events when the content in the volume is +// updated. +type AtomicWriter struct { + targetDir string +} + +// FileProjection contains file Data and access Mode +type FileProjection struct { + Data []byte + Mode int32 + FsUser *int64 +} + +// NewAtomicWriter creates a new AtomicWriter configured to write to the given +// target directory, or returns an error if the target directory does not exist. +func NewAtomicWriter(targetDir string) (*AtomicWriter, error) { + _, err := os.Stat(targetDir) + if os.IsNotExist(err) { + return nil, err + } + + return &AtomicWriter{targetDir: targetDir}, nil +} + +const ( + dataDirName = "..data" + newDataDirName = "..data_tmp" +) + +// Write does an atomic projection of the given payload into the writer's target +// directory. Input paths must not begin with '..'. +// setPerms is an optional pointer to a function that caller can provide to set the +// permissions of the newly created files before they are published. The function is +// passed subPath which is the name of the timestamped directory that was created +// under target directory. +// +// The Write algorithm is: +// +// 1. The payload is validated; if the payload is invalid, the function returns +// +// 2. The current timestamped directory is detected by reading the data directory +// symlink +// +// 3. The old version of the volume is walked to determine whether any +// portion of the payload was deleted and is still present on disk. +// +// 4. The data in the current timestamped directory is compared to the projected +// data to determine if an update to data directory is required. +// +// 5. A new timestamped dir is created if an update is required. +// +// 6. The payload is written to the new timestamped directory. +// +// 7. Permissions are set (if setPerms is not nil) on the new timestamped directory and files. +// +// 8. A symlink to the new timestamped directory ..data_tmp is created that will +// become the new data directory. +// +// 9. The new data directory symlink is renamed to the data directory; rename is atomic. +// +// 10. Symlinks and directory for new user-visible files are created (if needed). +// +// For example, consider the files: +// /podName +// /user/labels +// /k8s/annotations +// +// The user visible files are symbolic links into the internal data directory: +// /podName -> ..data/podName +// /usr -> ..data/usr +// /k8s -> ..data/k8s +// +// The data directory itself is a link to a timestamped directory with +// the real data: +// /..data -> ..2016_02_01_15_04_05.12345678/ +// NOTE(claudiub): We need to create these symlinks AFTER we've finished creating and +// linking everything else. On Windows, if a target does not exist, the created symlink +// will not work properly if the target ends up being a directory. +// +// 11. Old paths are removed from the user-visible portion of the target directory. +// +// 12. The previous timestamped directory is removed, if it exists. +func (w *AtomicWriter) Write(ctx context.Context, payload map[string]FileProjection, setPerms func(subPath string) error) error { + // (1) + cleanPayload, err := validatePayload(payload) + if err != nil { + return fmt.Errorf("while validating payload: %w", err) + } + + // (2) + dataDirPath := filepath.Join(w.targetDir, dataDirName) + oldTsDir, err := os.Readlink(dataDirPath) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("while reading link for data directory: %w", err) + } + // although Readlink() returns "" on err, don't be fragile by relying on it (since it's not specified in docs) + // empty oldTsDir indicates that it didn't exist + oldTsDir = "" + } + oldTsPath := filepath.Join(w.targetDir, oldTsDir) + + var pathsToRemove sets.Set[string] + shouldWrite := true + // if there was no old version, there's nothing to remove + if len(oldTsDir) != 0 { + // (3) + pathsToRemove, err = w.pathsToRemove(ctx, cleanPayload, oldTsPath) + if err != nil { + return fmt.Errorf("while determining user-visible files to remove: %w", err) + } + + // (4) + if should, err := shouldWritePayload(cleanPayload, oldTsPath); err != nil { + return fmt.Errorf("while determining whether payload should be written to disk: %w", err) + } else if !should && len(pathsToRemove) == 0 { + slog.InfoContext(ctx, "write not required for data directory", slog.String("dir", oldTsDir)) + // data directory is already up to date, but we need to make sure that + // the user-visible symlinks are created. + // See https://github.com/kubernetes/kubernetes/issues/121472 for more details. + // Reset oldTsDir to empty string to avoid removing the data directory. + shouldWrite = false + oldTsDir = "" + } else { + slog.InfoContext(ctx, "write required for target directory", slog.String("dir", w.targetDir)) + } + } + + if shouldWrite { + // (5) + tsDir, err := w.newTimestampDir() + if err != nil { + return fmt.Errorf("while creating new ts data directory: %w", err) + } + tsDirName := filepath.Base(tsDir) + + // (6) + if err = w.writePayloadToDir(cleanPayload, tsDir); err != nil { + return fmt.Errorf("while writing payload to ts data directory %s: %w", tsDir, err) + } + + slog.InfoContext(ctx, "performed write of new data to ts data directory", slog.String("dir", tsDir)) + + // (7) + if setPerms != nil { + if err := setPerms(tsDirName); err != nil { + return fmt.Errorf("while applying ownership settings: %w", err) + } + } + + // (8) + newDataDirPath := filepath.Join(w.targetDir, newDataDirName) + if err = os.Symlink(tsDirName, newDataDirPath); err != nil { + if err := os.RemoveAll(tsDir); err != nil { + return fmt.Errorf("while removing new ts directory %s: %w", tsDir, err) + } + } + + // (9) + if runtime.GOOS == "windows" { + if err := os.Remove(dataDirPath); err != nil { + slog.ErrorContext(ctx, "Error removing data dir directory", slog.Any("err", err), slog.String("dir", dataDirPath)) + } + err = os.Symlink(tsDirName, dataDirPath) + if err := os.Remove(newDataDirPath); err != nil { + slog.ErrorContext(ctx, "Error removing new data dir directory", slog.Any("err", err), slog.String("dir", newDataDirPath)) + } + } else { + err = os.Rename(newDataDirPath, dataDirPath) + } + if err != nil { + if err := os.Remove(newDataDirPath); err != nil && err != os.ErrNotExist { + slog.ErrorContext(ctx, "Error removing new data dir directory", slog.Any("err", err), slog.String("dir", newDataDirPath)) + } + if err := os.RemoveAll(tsDir); err != nil { + slog.ErrorContext(ctx, "Error removing new ts directory", slog.Any("err", err), slog.String("dir", tsDir)) + } + return fmt.Errorf("while renaming symbolic link for data directory: %s: %w", newDataDirPath, err) + } + } + + // (10) + if err = w.createUserVisibleFiles(cleanPayload); err != nil { + return fmt.Errorf("while creating visible symlinks in %s: %w", w.targetDir, err) + } + + // (11) + if err = w.removeUserVisiblePaths(ctx, pathsToRemove); err != nil { + return fmt.Errorf("while removing old visible symlinks: %w", err) + } + + // (12) + if len(oldTsDir) > 0 { + if err = os.RemoveAll(oldTsPath); err != nil { + return fmt.Errorf("while removing old data directory %s: %w", oldTsDir, err) + } + } + + return nil +} + +// validatePayload returns an error if any path in the payload returns a copy of the payload with the paths cleaned. +func validatePayload(payload map[string]FileProjection) (map[string]FileProjection, error) { + cleanPayload := make(map[string]FileProjection) + for k, content := range payload { + if err := validatePath(k); err != nil { + return nil, err + } + + cleanPayload[filepath.Clean(k)] = content + } + + return cleanPayload, nil +} + +// validatePath validates a single path, returning an error if the path is +// invalid. paths may not: +// +// 1. be absolute +// 2. contain '..' as an element +// 3. start with '..' +// 4. contain filenames larger than 255 characters +// 5. be longer than 4096 characters +func validatePath(targetPath string) error { + // TODO: somehow unify this with the similar api validation, + // validateVolumeSourcePath; the error semantics are just different enough + // from this that it was time-prohibitive trying to find the right + // refactoring to re-use. + if targetPath == "" { + return fmt.Errorf("invalid path: must not be empty: %q", targetPath) + } + if path.IsAbs(targetPath) { + return fmt.Errorf("invalid path: must be relative path: %s", targetPath) + } + + if len(targetPath) > maxPathLength { + return fmt.Errorf("invalid path: must be less than or equal to %d characters", maxPathLength) + } + + items := strings.Split(targetPath, string(os.PathSeparator)) + for _, item := range items { + if item == ".." { + return fmt.Errorf("invalid path: must not contain '..': %s", targetPath) + } + if len(item) > maxFileNameLength { + return fmt.Errorf("invalid path: filenames must be less than or equal to %d characters", maxFileNameLength) + } + } + if strings.HasPrefix(items[0], "..") && len(items[0]) > 2 { + return fmt.Errorf("invalid path: must not start with '..': %s", targetPath) + } + + return nil +} + +// shouldWritePayload returns whether the payload should be written to disk. +func shouldWritePayload(payload map[string]FileProjection, oldTsDir string) (bool, error) { + for userVisiblePath, fileProjection := range payload { + shouldWrite, err := shouldWriteFile(filepath.Join(oldTsDir, userVisiblePath), fileProjection.Data) + if err != nil { + return false, err + } + + if shouldWrite { + return true, nil + } + } + + return false, nil +} + +// shouldWriteFile returns whether a new version of a file should be written to disk. +func shouldWriteFile(path string, content []byte) (bool, error) { + _, err := os.Lstat(path) + if os.IsNotExist(err) { + return true, nil + } + + contentOnFs, err := os.ReadFile(path) + if err != nil { + return false, err + } + + return !bytes.Equal(content, contentOnFs), nil +} + +// pathsToRemove walks the current version of the data directory and +// determines which paths should be removed (if any) after the payload is +// written to the target directory. +func (w *AtomicWriter) pathsToRemove(ctx context.Context, payload map[string]FileProjection, oldTSDir string) (sets.Set[string], error) { + paths := sets.New[string]() + visitor := func(path string, info os.FileInfo, err error) error { + relativePath := strings.TrimPrefix(path, oldTSDir) + relativePath = strings.TrimPrefix(relativePath, string(os.PathSeparator)) + if relativePath == "" { + return nil + } + + paths.Insert(relativePath) + return nil + } + + err := filepath.Walk(oldTSDir, visitor) + if os.IsNotExist(err) { + return nil, nil + } else if err != nil { + return nil, err + } + + slog.DebugContext(ctx, "current paths", slog.String("targetDir", w.targetDir), slog.Any("paths", sets.List(paths))) + + newPaths := sets.New[string]() + for file := range payload { + // add all subpaths for the payload to the set of new paths + // to avoid attempting to remove non-empty dirs + for subPath := file; subPath != ""; { + newPaths.Insert(subPath) + subPath, _ = filepath.Split(subPath) + subPath = strings.TrimSuffix(subPath, string(os.PathSeparator)) + } + } + slog.DebugContext(ctx, "new paths", slog.String("targetDir", w.targetDir), slog.Any("paths", sets.List(newPaths))) + + result := paths.Difference(newPaths) + slog.DebugContext(ctx, "paths to remove", slog.String("targetDir", w.targetDir), slog.Any("paths", result)) + + return result, nil +} + +// newTimestampDir creates a new timestamp directory +func (w *AtomicWriter) newTimestampDir() (string, error) { + tsDir, err := os.MkdirTemp(w.targetDir, time.Now().UTC().Format("..2006_01_02_15_04_05.")) + if err != nil { + return "", fmt.Errorf("while creating new temp directory: %w", err) + } + + // 0755 permissions are needed to allow 'group' and 'other' to recurse the + // directory tree. do a chmod here to ensure that permissions are set correctly + // regardless of the process' umask. + err = os.Chmod(tsDir, 0755) + if err != nil { + return "", fmt.Errorf("while setting mode on new temp directory: %w", err) + } + + return tsDir, nil +} + +// writePayloadToDir writes the given payload to the given directory. The +// directory must exist. +func (w *AtomicWriter) writePayloadToDir(payload map[string]FileProjection, dir string) error { + for userVisiblePath, fileProjection := range payload { + content := fileProjection.Data + mode := os.FileMode(fileProjection.Mode) + fullPath := filepath.Join(dir, userVisiblePath) + baseDir, _ := filepath.Split(fullPath) + + if err := os.MkdirAll(baseDir, os.ModePerm); err != nil { + return fmt.Errorf("while creating directory %s: %w", baseDir, err) + } + + if err := os.WriteFile(fullPath, content, mode); err != nil { + return fmt.Errorf("while writing file %s with mode %v: %w", fullPath, mode, err) + } + // Chmod is needed because os.WriteFile() ends up calling + // open(2) to create the file, so the final mode used is "mode & + // ~umask". But we want to make sure the specified mode is used + // in the file no matter what the umask is. + if err := os.Chmod(fullPath, mode); err != nil { + return fmt.Errorf("while changing file %s with mode %v: %w", fullPath, mode, err) + } + + if fileProjection.FsUser == nil { + continue + } + + if err := w.lchown(fullPath, int(*fileProjection.FsUser), -1); err != nil { + return fmt.Errorf("while changing file %s to owner %v: %w", fullPath, int(*fileProjection.FsUser), err) + } + } + + return nil +} + +// createUserVisibleFiles creates the relative symlinks for all the +// files configured in the payload. If the directory in a file path does not +// exist, it is created. +// +// Viz: +// For files: "bar", "foo/bar", "baz/bar", "foo/baz/blah" +// the following symlinks are created: +// bar -> ..data/bar +// foo -> ..data/foo +// baz -> ..data/baz +func (w *AtomicWriter) createUserVisibleFiles(payload map[string]FileProjection) error { + for userVisiblePath, fileProjection := range payload { + slashpos := strings.Index(userVisiblePath, string(os.PathSeparator)) + if slashpos == -1 { + slashpos = len(userVisiblePath) + } + linkname := userVisiblePath[:slashpos] + _, err := os.Readlink(filepath.Join(w.targetDir, linkname)) + if err != nil && os.IsNotExist(err) { + // The link into the data directory for this path doesn't exist; create it + visibleFile := filepath.Join(w.targetDir, linkname) + dataDirFile := filepath.Join(dataDirName, linkname) + + err = os.Symlink(dataDirFile, visibleFile) + if err != nil { + return err + } + + if fileProjection.FsUser == nil { + continue + } + + if err := w.lchown(visibleFile, int(*fileProjection.FsUser), -1); err != nil { + return fmt.Errorf("while changing file %s to owner %v: %w", visibleFile, int(*fileProjection.FsUser), err) + } + } + } + return nil +} + +// removeUserVisiblePaths removes the set of paths from the user-visible +// portion of the writer's target directory. +func (w *AtomicWriter) removeUserVisiblePaths(ctx context.Context, paths sets.Set[string]) error { + ps := string(os.PathSeparator) + var lasterr error + for p := range paths { + // only remove symlinks from the volume root directory (i.e. items that don't contain '/') + if strings.Contains(p, ps) { + continue + } + if err := os.Remove(filepath.Join(w.targetDir, p)); err != nil { + slog.ErrorContext(ctx, "Error pruning old user-visible path", slog.String("path", p), slog.Any("err", err)) + lasterr = err + } + } + + return lasterr +} diff --git a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_linux.go b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_linux.go new file mode 100644 index 000000000..1d5f7d34e --- /dev/null +++ b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_linux.go @@ -0,0 +1,27 @@ +//go:build linux + +/* +Copyright 2024 The Kubernetes Authors. + +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 atomicwriter + +import "os" + +// lchown changes the numeric uid and gid of the named file. +// If the file is a symbolic link, it changes the uid and gid of the link itself. +func (w *AtomicWriter) lchown(name string, uid, gid int) error { + return os.Lchown(name, uid, gid) +} diff --git a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_test.go b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_test.go new file mode 100644 index 000000000..09d9e5232 --- /dev/null +++ b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_test.go @@ -0,0 +1,1104 @@ +//go:build linux + +/* +Copyright 2016 The Kubernetes Authors. + +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 atomicwriter + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/util/sets" +) + +// mkTmpdir creates a temporary directory based upon the prefix passed in. +// If successful, it returns the temporary directory path. The directory can be +// deleted with a call to "os.RemoveAll(...)". +// In case of error, it'll return an empty string and the error. +func mkTmpdir(prefix string) (string, error) { + tmpDir, err := os.MkdirTemp(os.TempDir(), prefix) + if err != nil { + return "", err + } + return tmpDir, nil +} + +func TestNewAtomicWriter(t *testing.T) { + targetDir, err := mkTmpdir("atomic-write") + if err != nil { + t.Fatalf("unexpected error creating tmp dir: %v", err) + } + defer os.RemoveAll(targetDir) + + _, err = NewAtomicWriter(targetDir) + if err != nil { + t.Fatalf("unexpected error creating writer for existing target dir: %v", err) + } + + nonExistentDir, err := mkTmpdir("atomic-write") + if err != nil { + t.Fatalf("unexpected error creating tmp dir: %v", err) + } + err = os.Remove(nonExistentDir) + if err != nil { + t.Fatalf("unexpected error ensuring dir %v does not exist: %v", nonExistentDir, err) + } + + _, err = NewAtomicWriter(nonExistentDir) + if err == nil { + t.Fatalf("unexpected success creating writer for nonexistent target dir: %v", err) + } +} + +func TestValidatePath(t *testing.T) { + maxPath := strings.Repeat("a", maxPathLength+1) + maxFile := strings.Repeat("a", maxFileNameLength+1) + + cases := []struct { + name string + path string + valid bool + }{ + { + name: "valid 1", + path: "i/am/well/behaved.txt", + valid: true, + }, + { + name: "valid 2", + path: "keepyourheaddownandfollowtherules.txt", + valid: true, + }, + { + name: "max path length", + path: maxPath, + valid: false, + }, + { + name: "max file length", + path: maxFile, + valid: false, + }, + { + name: "absolute failure", + path: "/dev/null", + valid: false, + }, + { + name: "reserved path", + path: "..sneaky.txt", + valid: false, + }, + { + name: "contains doubledot 1", + path: "hello/there/../../../../../../etc/passwd", + valid: false, + }, + { + name: "contains doubledot 2", + path: "hello/../etc/somethingbad", + valid: false, + }, + { + name: "empty", + path: "", + valid: false, + }, + } + + for _, tc := range cases { + err := validatePath(tc.path) + if tc.valid && err != nil { + t.Errorf("%v: unexpected failure: %v", tc.name, err) + continue + } + + if !tc.valid && err == nil { + t.Errorf("%v: unexpected success", tc.name) + } + } +} + +func TestPathsToRemove(t *testing.T) { + cases := []struct { + name string + payload1 map[string]FileProjection + payload2 map[string]FileProjection + expected sets.Set[string] + }{ + { + name: "simple", + payload1: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + "bar.txt": {Mode: 0644, Data: []byte("bar")}, + }, + payload2: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + }, + expected: sets.New[string]("bar.txt"), + }, + { + name: "simple 2", + payload1: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + "zip/bar.txt": {Mode: 0644, Data: []byte("zip/b}ar")}, + }, + payload2: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + }, + expected: sets.New[string]("zip/bar.txt", "zip"), + }, + { + name: "subdirs 1", + payload1: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + "zip/zap/bar.txt": {Mode: 0644, Data: []byte("zip/bar")}, + }, + payload2: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + }, + expected: sets.New[string]("zip/zap/bar.txt", "zip", "zip/zap"), + }, + { + name: "subdirs 2", + payload1: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + "zip/1/2/3/4/bar.txt": {Mode: 0644, Data: []byte("zip/b}ar")}, + }, + payload2: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + }, + expected: sets.New[string]("zip/1/2/3/4/bar.txt", "zip", "zip/1", "zip/1/2", "zip/1/2/3", "zip/1/2/3/4"), + }, + { + name: "subdirs 3", + payload1: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + "zip/1/2/3/4/bar.txt": {Mode: 0644, Data: []byte("zip/b}ar")}, + "zap/a/b/c/bar.txt": {Mode: 0644, Data: []byte("zap/bar")}, + }, + payload2: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + }, + expected: sets.New[string]("zip/1/2/3/4/bar.txt", "zip", "zip/1", "zip/1/2", "zip/1/2/3", "zip/1/2/3/4", "zap", "zap/a", "zap/a/b", "zap/a/b/c", "zap/a/b/c/bar.txt"), + }, + { + name: "subdirs 4", + payload1: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + "zap/1/2/3/4/bar.txt": {Mode: 0644, Data: []byte("zip/bar")}, + "zap/1/2/c/bar.txt": {Mode: 0644, Data: []byte("zap/bar")}, + "zap/1/2/magic.txt": {Mode: 0644, Data: []byte("indigo")}, + }, + payload2: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + "zap/1/2/magic.txt": {Mode: 0644, Data: []byte("indigo")}, + }, + expected: sets.New[string]("zap/1/2/3/4/bar.txt", "zap/1/2/3", "zap/1/2/3/4", "zap/1/2/3/4/bar.txt", "zap/1/2/c", "zap/1/2/c/bar.txt"), + }, + { + name: "subdirs 5", + payload1: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + "zap/1/2/3/4/bar.txt": {Mode: 0644, Data: []byte("zip/bar")}, + "zap/1/2/c/bar.txt": {Mode: 0644, Data: []byte("zap/bar")}, + }, + payload2: map[string]FileProjection{ + "foo.txt": {Mode: 0644, Data: []byte("foo")}, + "zap/1/2/magic.txt": {Mode: 0644, Data: []byte("indigo")}, + }, + expected: sets.New[string]("zap/1/2/3/4/bar.txt", "zap/1/2/3", "zap/1/2/3/4", "zap/1/2/3/4/bar.txt", "zap/1/2/c", "zap/1/2/c/bar.txt"), + }, + } + + for _, tc := range cases { + targetDir, err := mkTmpdir("atomic-write") + if err != nil { + t.Errorf("%v: unexpected error creating tmp dir: %v", tc.name, err) + continue + } + defer os.RemoveAll(targetDir) + + writer := &AtomicWriter{targetDir: targetDir} + err = writer.Write(t.Context(), tc.payload1, nil) + if err != nil { + t.Errorf("%v: unexpected error writing: %v", tc.name, err) + continue + } + + dataDirPath := filepath.Join(targetDir, dataDirName) + oldTsDir, err := os.Readlink(dataDirPath) + if err != nil && os.IsNotExist(err) { + t.Errorf("Data symlink does not exist: %v", dataDirPath) + continue + } else if err != nil { + t.Errorf("Unable to read symlink %v: %v", dataDirPath, err) + continue + } + + actual, err := writer.pathsToRemove(t.Context(), tc.payload2, filepath.Join(targetDir, oldTsDir)) + if err != nil { + t.Errorf("%v: unexpected error determining paths to remove: %v", tc.name, err) + continue + } + + if e, a := tc.expected, actual; !e.Equal(a) { + t.Errorf("%v: unexpected paths to remove:\nexpected: %v\n got: %v", tc.name, e, a) + } + } +} + +func TestWriteOnce(t *testing.T) { + // $1 if you can tell me what this binary is + encodedMysteryBinary := `f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAeABAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAEAAOAAB +AAAAAAAAAAEAAAAFAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAfQAAAAAAAAB9AAAAAAAAAAAA +IAAAAAAAsDyZDwU=` + + mysteryBinaryBytes := make([]byte, base64.StdEncoding.DecodedLen(len(encodedMysteryBinary))) + numBytes, err := base64.StdEncoding.Decode(mysteryBinaryBytes, []byte(encodedMysteryBinary)) + if err != nil { + t.Fatalf("Unexpected error decoding binary payload: %v", err) + } + + if numBytes != 125 { + t.Fatalf("Unexpected decoded binary size: expected 125, got %v", numBytes) + } + + cases := []struct { + name string + payload map[string]FileProjection + success bool + }{ + { + name: "invalid payload 1", + payload: map[string]FileProjection{ + "foo": {Mode: 0644, Data: []byte("foo")}, + "..bar": {Mode: 0644, Data: []byte("bar")}, + "binary.bin": {Mode: 0644, Data: mysteryBinaryBytes}, + }, + success: false, + }, + { + name: "invalid payload 2", + payload: map[string]FileProjection{ + "foo/../bar": {Mode: 0644, Data: []byte("foo")}, + }, + success: false, + }, + { + name: "basic 1", + payload: map[string]FileProjection{ + "foo": {Mode: 0644, Data: []byte("foo")}, + "bar": {Mode: 0644, Data: []byte("bar")}, + }, + success: true, + }, + { + name: "basic 2", + payload: map[string]FileProjection{ + "binary.bin": {Mode: 0644, Data: mysteryBinaryBytes}, + ".binary.bin": {Mode: 0644, Data: mysteryBinaryBytes}, + }, + success: true, + }, + { + name: "basic mode 1", + payload: map[string]FileProjection{ + "foo": {Mode: 0777, Data: []byte("foo")}, + "bar": {Mode: 0400, Data: []byte("bar")}, + }, + success: true, + }, + { + name: "dotfiles", + payload: map[string]FileProjection{ + "foo": {Mode: 0644, Data: []byte("foo")}, + "bar": {Mode: 0644, Data: []byte("bar")}, + ".dotfile": {Mode: 0644, Data: []byte("dotfile")}, + ".dotfile.file": {Mode: 0644, Data: []byte("dotfile.file")}, + }, + success: true, + }, + { + name: "dotfiles mode", + payload: map[string]FileProjection{ + "foo": {Mode: 0407, Data: []byte("foo")}, + "bar": {Mode: 0440, Data: []byte("bar")}, + ".dotfile": {Mode: 0777, Data: []byte("dotfile")}, + ".dotfile.file": {Mode: 0666, Data: []byte("dotfile.file")}, + }, + success: true, + }, + { + name: "subdirectories 1", + payload: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt")}, + }, + success: true, + }, + { + name: "subdirectories mode 1", + payload: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0400, Data: []byte("foo/bar")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt")}, + }, + success: true, + }, + { + name: "subdirectories 2", + payload: map[string]FileProjection{ + "foo//bar.txt": {Mode: 0644, Data: []byte("foo//bar")}, + "bar///bar/zab.txt": {Mode: 0644, Data: []byte("bar/../bar/zab.txt")}, + }, + success: true, + }, + { + name: "subdirectories 3", + payload: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt")}, + "foo/blaz/bar.txt": {Mode: 0644, Data: []byte("foo/blaz/bar")}, + "bar/zib/zab.txt": {Mode: 0644, Data: []byte("bar/zib/zab.txt")}, + }, + success: true, + }, + { + name: "kitchen sink", + payload: map[string]FileProjection{ + "foo.log": {Mode: 0644, Data: []byte("foo")}, + "bar.zap": {Mode: 0644, Data: []byte("bar")}, + ".dotfile": {Mode: 0644, Data: []byte("dotfile")}, + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt")}, + "foo/blaz/bar.txt": {Mode: 0644, Data: []byte("foo/blaz/bar")}, + "bar/zib/zab.txt": {Mode: 0400, Data: []byte("bar/zib/zab.txt")}, + "1/2/3/4/5/6/7/8/9/10/.dotfile.lib": {Mode: 0777, Data: []byte("1-2-3-dotfile")}, + }, + success: true, + }, + } + + for _, tc := range cases { + targetDir, err := mkTmpdir("atomic-write") + if err != nil { + t.Errorf("%v: unexpected error creating tmp dir: %v", tc.name, err) + continue + } + defer os.RemoveAll(targetDir) + + writer := &AtomicWriter{targetDir: targetDir} + err = writer.Write(t.Context(), tc.payload, nil) + if err != nil && tc.success { + t.Errorf("%v: unexpected error writing payload: %v", tc.name, err) + continue + } else if err == nil && !tc.success { + t.Errorf("%v: unexpected success", tc.name) + continue + } else if err != nil { + continue + } + + checkVolumeContents(targetDir, tc.name, tc.payload, t) + } +} + +func TestUpdate(t *testing.T) { + cases := []struct { + name string + first map[string]FileProjection + next map[string]FileProjection + shouldWrite bool + }{ + { + name: "update", + first: map[string]FileProjection{ + "foo": {Mode: 0644, Data: []byte("foo")}, + "bar": {Mode: 0644, Data: []byte("bar")}, + }, + next: map[string]FileProjection{ + "foo": {Mode: 0644, Data: []byte("foo2")}, + "bar": {Mode: 0640, Data: []byte("bar2")}, + }, + shouldWrite: true, + }, + { + name: "no update", + first: map[string]FileProjection{ + "foo": {Mode: 0644, Data: []byte("foo")}, + "bar": {Mode: 0644, Data: []byte("bar")}, + }, + next: map[string]FileProjection{ + "foo": {Mode: 0644, Data: []byte("foo")}, + "bar": {Mode: 0644, Data: []byte("bar")}, + }, + shouldWrite: false, + }, + { + name: "no update 2", + first: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar")}, + }, + next: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar")}, + }, + shouldWrite: false, + }, + { + name: "add 1", + first: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar")}, + }, + next: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar")}, + "blu/zip.txt": {Mode: 0644, Data: []byte("zip")}, + }, + shouldWrite: true, + }, + { + name: "add 2", + first: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar")}, + }, + next: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar")}, + "blu/two/2/3/4/5/zip.txt": {Mode: 0644, Data: []byte("zip")}, + }, + shouldWrite: true, + }, + { + name: "add 3", + first: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar")}, + }, + next: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar")}, + "bar/2/3/4/5/zip.txt": {Mode: 0644, Data: []byte("zip")}, + }, + shouldWrite: true, + }, + { + name: "delete 1", + first: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar")}, + }, + next: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + }, + shouldWrite: true, + }, + { + name: "delete 2", + first: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/1/2/3/zab.txt": {Mode: 0644, Data: []byte("bar")}, + }, + next: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + }, + shouldWrite: true, + }, + { + name: "delete 3", + first: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/1/2/sip.txt": {Mode: 0644, Data: []byte("sip")}, + "bar/1/2/3/zab.txt": {Mode: 0644, Data: []byte("bar")}, + }, + next: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/1/2/sip.txt": {Mode: 0644, Data: []byte("sip")}, + }, + shouldWrite: true, + }, + { + name: "delete 4", + first: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/1/2/sip.txt": {Mode: 0644, Data: []byte("sip")}, + "bar/1/2/3/4/5/6zab.txt": {Mode: 0644, Data: []byte("bar")}, + }, + next: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/1/2/sip.txt": {Mode: 0644, Data: []byte("sip")}, + }, + shouldWrite: true, + }, + { + name: "delete all", + first: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/1/2/sip.txt": {Mode: 0644, Data: []byte("sip")}, + "bar/1/2/3/4/5/6zab.txt": {Mode: 0644, Data: []byte("bar")}, + }, + next: map[string]FileProjection{}, + shouldWrite: true, + }, + { + name: "add and delete 1", + first: map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + }, + next: map[string]FileProjection{ + "bar/baz.txt": {Mode: 0644, Data: []byte("baz")}, + }, + shouldWrite: true, + }, + } + + for _, tc := range cases { + targetDir, err := mkTmpdir("atomic-write") + if err != nil { + t.Errorf("%v: unexpected error creating tmp dir: %v", tc.name, err) + continue + } + defer os.RemoveAll(targetDir) + + writer := &AtomicWriter{targetDir: targetDir} + + err = writer.Write(t.Context(), tc.first, nil) + if err != nil { + t.Errorf("%v: unexpected error writing: %v", tc.name, err) + continue + } + + checkVolumeContents(targetDir, tc.name, tc.first, t) + if !tc.shouldWrite { + continue + } + + err = writer.Write(t.Context(), tc.next, nil) + if err != nil { + if tc.shouldWrite { + t.Errorf("%v: unexpected error writing: %v", tc.name, err) + continue + } + } else if !tc.shouldWrite { + t.Errorf("%v: unexpected success", tc.name) + continue + } + + checkVolumeContents(targetDir, tc.name, tc.next, t) + } +} + +func TestMultipleUpdates(t *testing.T) { + cases := []struct { + name string + payloads []map[string]FileProjection + }{ + { + name: "update 1", + payloads: []map[string]FileProjection{ + { + "foo": {Mode: 0644, Data: []byte("foo")}, + "bar": {Mode: 0644, Data: []byte("bar")}, + }, + { + "foo": {Mode: 0400, Data: []byte("foo2")}, + "bar": {Mode: 0400, Data: []byte("bar2")}, + }, + { + "foo": {Mode: 0600, Data: []byte("foo3")}, + "bar": {Mode: 0600, Data: []byte("bar3")}, + }, + }, + }, + { + name: "update 2", + payloads: []map[string]FileProjection{ + { + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt")}, + }, + { + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar2")}, + "bar/zab.txt": {Mode: 0400, Data: []byte("bar/zab.txt2")}, + }, + }, + }, + { + name: "clear sentinel", + payloads: []map[string]FileProjection{ + { + "foo": {Mode: 0644, Data: []byte("foo")}, + "bar": {Mode: 0644, Data: []byte("bar")}, + }, + { + "foo": {Mode: 0644, Data: []byte("foo2")}, + "bar": {Mode: 0644, Data: []byte("bar2")}, + }, + { + "foo": {Mode: 0644, Data: []byte("foo3")}, + "bar": {Mode: 0644, Data: []byte("bar3")}, + }, + { + "foo": {Mode: 0644, Data: []byte("foo4")}, + "bar": {Mode: 0644, Data: []byte("bar4")}, + }, + }, + }, + { + name: "subdirectories 2", + payloads: []map[string]FileProjection{ + { + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt")}, + "foo/blaz/bar.txt": {Mode: 0644, Data: []byte("foo/blaz/bar")}, + "bar/zib/zab.txt": {Mode: 0644, Data: []byte("bar/zib/zab.txt")}, + }, + { + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar2")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt2")}, + "foo/blaz/bar.txt": {Mode: 0644, Data: []byte("foo/blaz/bar2")}, + "bar/zib/zab.txt": {Mode: 0644, Data: []byte("bar/zib/zab.txt2")}, + }, + }, + }, + { + name: "add 1", + payloads: []map[string]FileProjection{ + { + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar")}, + "bar//zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt")}, + "foo/blaz/bar.txt": {Mode: 0644, Data: []byte("foo/blaz/bar")}, + "bar/zib////zib/zab.txt": {Mode: 0644, Data: []byte("bar/zib/zab.txt")}, + }, + { + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar2")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt2")}, + "foo/blaz/bar.txt": {Mode: 0644, Data: []byte("foo/blaz/bar2")}, + "bar/zib/zab.txt": {Mode: 0644, Data: []byte("bar/zib/zab.txt2")}, + "add/new/keys.txt": {Mode: 0644, Data: []byte("addNewKeys")}, + }, + }, + }, + { + name: "add 2", + payloads: []map[string]FileProjection{ + { + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar2")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt2")}, + "foo/blaz/bar.txt": {Mode: 0644, Data: []byte("foo/blaz/bar2")}, + "bar/zib/zab.txt": {Mode: 0644, Data: []byte("bar/zib/zab.txt2")}, + "add/new/keys.txt": {Mode: 0644, Data: []byte("addNewKeys")}, + }, + { + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar2")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt2")}, + "foo/blaz/bar.txt": {Mode: 0644, Data: []byte("foo/blaz/bar2")}, + "bar/zib/zab.txt": {Mode: 0644, Data: []byte("bar/zib/zab.txt2")}, + "add/new/keys.txt": {Mode: 0644, Data: []byte("addNewKeys")}, + "add/new/keys2.txt": {Mode: 0644, Data: []byte("addNewKeys2")}, + "add/new/keys3.txt": {Mode: 0644, Data: []byte("addNewKeys3")}, + }, + }, + }, + { + name: "remove 1", + payloads: []map[string]FileProjection{ + { + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar")}, + "bar//zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt")}, + "foo/blaz/bar.txt": {Mode: 0644, Data: []byte("foo/blaz/bar")}, + "zip/zap/zup/fop.txt": {Mode: 0644, Data: []byte("zip/zap/zup/fop.txt")}, + }, + { + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar2")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar/zab.txt2")}, + }, + { + "foo/bar.txt": {Mode: 0644, Data: []byte("foo/bar")}, + }, + }, + }, + } + + for _, tc := range cases { + targetDir, err := mkTmpdir("atomic-write") + if err != nil { + t.Errorf("%v: unexpected error creating tmp dir: %v", tc.name, err) + continue + } + defer os.RemoveAll(targetDir) + + writer := &AtomicWriter{targetDir: targetDir} + + for _, payload := range tc.payloads { + writer.Write(t.Context(), payload, nil) + + checkVolumeContents(targetDir, tc.name, payload, t) + } + } +} + +func checkVolumeContents(targetDir, tcName string, payload map[string]FileProjection, t *testing.T) { + dataDirPath := filepath.Join(targetDir, dataDirName) + // use filepath.Walk to reconstruct the payload, then deep equal + observedPayload := make(map[string]FileProjection) + visitor := func(path string, info os.FileInfo, _ error) error { + if info.IsDir() { + return nil + } + + relativePath := strings.TrimPrefix(path, dataDirPath) + relativePath = strings.TrimPrefix(relativePath, "/") + if strings.HasPrefix(relativePath, "..") { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return err + } + fileInfo, err := os.Stat(path) + if err != nil { + return err + } + mode := int32(fileInfo.Mode()) + + observedPayload[relativePath] = FileProjection{Data: content, Mode: mode} + + return nil + } + + d, err := os.ReadDir(targetDir) + if err != nil { + t.Errorf("Unable to read dir %v: %v", targetDir, err) + return + } + for _, info := range d { + if strings.HasPrefix(info.Name(), "..") { + continue + } + if info.Type()&os.ModeSymlink != 0 { + p := filepath.Join(targetDir, info.Name()) + actual, err := os.Readlink(p) + if err != nil { + t.Errorf("Unable to read symlink %v: %v", p, err) + continue + } + if err := filepath.Walk(filepath.Join(targetDir, actual), visitor); err != nil { + t.Errorf("%v: unexpected error walking directory: %v", tcName, err) + } + } + } + + cleanPathPayload := make(map[string]FileProjection, len(payload)) + for k, v := range payload { + cleanPathPayload[filepath.Clean(k)] = v + } + + if !reflect.DeepEqual(cleanPathPayload, observedPayload) { + t.Errorf("%v: payload and observed payload do not match.", tcName) + } +} + +func TestValidatePayload(t *testing.T) { + maxPath := strings.Repeat("a", maxPathLength+1) + + cases := []struct { + name string + payload map[string]FileProjection + expected sets.Set[string] + valid bool + }{ + { + name: "valid payload", + payload: map[string]FileProjection{ + "foo": {}, + "bar": {}, + }, + valid: true, + expected: sets.New[string]("foo", "bar"), + }, + { + name: "payload with path length > 4096 is invalid", + payload: map[string]FileProjection{ + maxPath: {}, + }, + valid: false, + }, + { + name: "payload with absolute path is invalid", + payload: map[string]FileProjection{ + "/dev/null": {}, + }, + valid: false, + }, + { + name: "payload with reserved path is invalid", + payload: map[string]FileProjection{ + "..sneaky.txt": {}, + }, + valid: false, + }, + { + name: "payload with doubledot path is invalid", + payload: map[string]FileProjection{ + "foo/../etc/password": {}, + }, + valid: false, + }, + { + name: "payload with empty path is invalid", + payload: map[string]FileProjection{ + "": {}, + }, + valid: false, + }, + { + name: "payload with unclean path should be cleaned", + payload: map[string]FileProjection{ + "foo////bar": {}, + }, + valid: true, + expected: sets.New[string]("foo/bar"), + }, + } + getPayloadPaths := func(payload map[string]FileProjection) sets.Set[string] { + paths := sets.New[string]() + for path := range payload { + paths.Insert(path) + } + return paths + } + + for _, tc := range cases { + real, err := validatePayload(tc.payload) + if !tc.valid && err == nil { + t.Errorf("%v: unexpected success", tc.name) + } + + if tc.valid { + if err != nil { + t.Errorf("%v: unexpected failure: %v", tc.name, err) + continue + } + + realPaths := getPayloadPaths(real) + if !realPaths.Equal(tc.expected) { + t.Errorf("%v: unexpected payload paths: %v is not equal to %v", tc.name, realPaths, tc.expected) + } + } + + } +} + +func TestCreateUserVisibleFiles(t *testing.T) { + cases := []struct { + name string + payload map[string]FileProjection + expected map[string]string + }{ + { + name: "simple path", + payload: map[string]FileProjection{ + "foo": {}, + "bar": {}, + }, + expected: map[string]string{ + "foo": "..data/foo", + "bar": "..data/bar", + }, + }, + { + name: "simple nested path", + payload: map[string]FileProjection{ + "foo/bar": {}, + "foo/bar/txt": {}, + "bar/txt": {}, + }, + expected: map[string]string{ + "foo": "..data/foo", + "bar": "..data/bar", + }, + }, + { + name: "unclean nested path", + payload: map[string]FileProjection{ + "./bar": {}, + "foo///bar": {}, + }, + expected: map[string]string{ + "bar": "..data/bar", + "foo": "..data/foo", + }, + }, + } + + for _, tc := range cases { + targetDir, err := mkTmpdir("atomic-write") + if err != nil { + t.Errorf("%v: unexpected error creating tmp dir: %v", tc.name, err) + continue + } + defer os.RemoveAll(targetDir) + + dataDirPath := filepath.Join(targetDir, dataDirName) + err = os.MkdirAll(dataDirPath, 0755) + if err != nil { + t.Fatalf("%v: unexpected error creating data path: %v", tc.name, err) + } + + writer := &AtomicWriter{targetDir: targetDir} + payload, err := validatePayload(tc.payload) + if err != nil { + t.Fatalf("%v: unexpected error validating payload: %v", tc.name, err) + } + err = writer.createUserVisibleFiles(payload) + if err != nil { + t.Fatalf("%v: unexpected error creating visible files: %v", tc.name, err) + } + + for subpath, expectedDest := range tc.expected { + visiblePath := filepath.Join(targetDir, subpath) + destination, err := os.Readlink(visiblePath) + if err != nil && os.IsNotExist(err) { + t.Fatalf("%v: visible symlink does not exist: %v", tc.name, visiblePath) + } else if err != nil { + t.Fatalf("%v: unable to read symlink %v: %v", tc.name, dataDirPath, err) + } + + if expectedDest != destination { + t.Fatalf("%v: symlink destination %q not same with expected data dir %q", tc.name, destination, expectedDest) + } + } + } +} + +func TestSetPerms(t *testing.T) { + targetDir, err := mkTmpdir("atomic-write") + if err != nil { + t.Fatalf("unexpected error creating tmp dir: %v", err) + } + defer os.RemoveAll(targetDir) + + // Test that setPerms() is called once and with valid timestamp directory. + payload1 := map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar")}, + } + + var setPermsCalled int + writer := &AtomicWriter{targetDir: targetDir} + err = writer.Write(t.Context(), payload1, func(subPath string) error { + fileInfo, err := os.Stat(filepath.Join(targetDir, subPath)) + if err != nil { + t.Fatalf("unexpected error getting file info: %v", err) + } + // Ensure that given timestamp directory really exists. + if !fileInfo.IsDir() { + t.Fatalf("subPath is not a directory: %v", subPath) + } + setPermsCalled++ + return nil + }) + if err != nil { + t.Fatalf("unexpected error writing: %v", err) + } + if setPermsCalled != 1 { + t.Fatalf("unexpected number of calls to setPerms: %v", setPermsCalled) + } + + // Test that errors from setPerms() are propagated. + payload2 := map[string]FileProjection{ + "foo/bar.txt": {Mode: 0644, Data: []byte("foo2")}, + "bar/zab.txt": {Mode: 0644, Data: []byte("bar2")}, + } + + err = writer.Write(t.Context(), payload2, func(_ string) error { + return fmt.Errorf("error in setPerms") + }) + if err == nil { + t.Fatalf("expected error while writing but got nil") + } + if !strings.Contains(err.Error(), "error in setPerms") { + t.Fatalf("unexpected error while writing: %v", err) + } +} + +func TestWriteAgainAfterUnexpectedExit(t *testing.T) { + testCases := []struct { + name string + payload map[string]FileProjection + simulateFn func(targetDir string, payload map[string]FileProjection) error + }{ + { + name: "process killed before creating user visible files", + payload: map[string]FileProjection{ + "foo": {Mode: 0644, Data: []byte("foo")}, + "bar": {Mode: 0644, Data: []byte("bar")}, + }, + simulateFn: func(targetDir string, payload map[string]FileProjection) error { + for filename := range payload { + path := filepath.Join(targetDir, filename) + if err := os.RemoveAll(path); err != nil { + return err + } + } + return nil + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + targetDir, err := mkTmpdir("atomic-write") + if err != nil { + t.Fatalf("unexpected error creating tmp dir: %v", err) + } + defer func() { + err := os.RemoveAll(targetDir) + if err != nil { + t.Errorf("%v: unexpected error removing tmp dir: %v", tc.name, err) + } + }() + + writer := &AtomicWriter{targetDir: targetDir} + err = writer.Write(t.Context(), tc.payload, nil) + if err != nil { + t.Fatalf("unexpected error writing payload: %v", err) + } + + err = tc.simulateFn(targetDir, tc.payload) + if err != nil { + t.Fatalf("failed to simulate the unexpected exit: %v", err) + } + + err = writer.Write(t.Context(), tc.payload, nil) + if err != nil { + t.Fatalf("unexpected error writing payload again: %v", err) + } + checkVolumeContents(targetDir, tc.name, tc.payload, t) + }) + } +} diff --git a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_unsupported.go b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_unsupported.go new file mode 100644 index 000000000..2de802794 --- /dev/null +++ b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_unsupported.go @@ -0,0 +1,32 @@ +//go:build !linux + +/* +Copyright 2024 The Kubernetes Authors. + +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 atomicwriter + +import ( + "log/slog" + "runtime" +) + +// lchown changes the numeric uid and gid of the named file. +// If the file is a symbolic link, it changes the uid and gid of the link itself. +// This is a no-op on unsupported platforms. +func (w *AtomicWriter) lchown(name string, uid, _ /* gid */ int) error { + slog.Warn("skipping change of Linux owner; unsupported on this platform", slog.Int("uid", uid), slog.String("name", name), slog.String("goos", runtime.GOOS)) + return nil +} diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 4d98c8802..c0e748ddf 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -36,6 +36,7 @@ import ( "cloud.google.com/go/storage" "github.com/agent-substrate/substrate/cmd/atelet/internal/ategcs" + "github.com/agent-substrate/substrate/cmd/atelet/internal/third_party/atomicwriter" "github.com/agent-substrate/substrate/internal/ateapiauth" "github.com/agent-substrate/substrate/internal/ateattr" "github.com/agent-substrate/substrate/internal/ateerrors" @@ -1049,23 +1050,43 @@ func (s *AteomHerder) prepareOCIBundles( spec *ateletpb.WorkloadSpec, targetAteomUid string, ) error { - // Populate the per-actor identity directory that gets bind-mounted into - // the application containers. Regenerated on every resume, so it carries - // the correct per-actor name even when restoring from the golden snapshot. - identityDir := ateompath.ActorIdentityDirPath(actorUID) - if err := os.MkdirAll(identityDir, 0o755); err != nil { - return fmt.Errorf("while creating actor identity dir: %w", err) - } - if err := writeFileAtomic(filepath.Join(identityDir, ActorIDFileName), []byte(actorName), 0o644); err != nil { - return fmt.Errorf("while writing actor identity file: %w", err) - } - // make directories for all durable-dir volumes + // Prepare host folders for volume types that need them. for _, vol := range spec.GetVolumes() { - if vol.GetDurableDir() != nil { + switch volSrc := vol.GetSource().(type) { + case *ateletpb.Volume_DurableDir: volPath := ateompath.DurableDirVolumeMountPoint(actorUID, vol.GetName()) if err := os.MkdirAll(volPath, 0o700); err != nil { return fmt.Errorf("while creating %q: %w", volPath, err) } + + case *ateletpb.Volume_SystemInfo: + // Populated on every Run/Restore, so the contents carry the + // correct per-actor values even when restoring from the golden + // snapshot. + volRootHostPath := ateompath.SystemInfoVolumeRoot(actorUID, vol.GetName()) + if err := os.MkdirAll(volRootHostPath, 0o755); err != nil { + return fmt.Errorf("while creating %q: %w", volRootHostPath, err) + } + + aw, err := atomicwriter.NewAtomicWriter(volRootHostPath) + if err != nil { + return fmt.Errorf("while creating atomicwriter: %w", err) + } + + contents := map[string]atomicwriter.FileProjection{} + for _, dataSourceAny := range volSrc.SystemInfo.GetDataSources() { + switch dataSource := dataSourceAny.GetDataSource().(type) { + case *ateletpb.SystemInfoDataSource_ActorIdentity: + contents[dataSource.ActorIdentity.GetPath()] = atomicwriter.FileProjection{ + Data: []byte(actorName), + Mode: 0o644, + } + } + } + + if err := aw.Write(ctx, contents, nil); err != nil { + return fmt.Errorf("while writing contents of SystemInfoVolume: %w", err) + } } } @@ -1098,8 +1119,7 @@ func (s *AteomHerder) prepareOCIBundles( nil, annotations, ateompath.AteomNetNSPath(targetAteomUid), - "", // pause is sandbox infra; it gets no actor identity mount. - nil, + nil, // pause is sandbox infra; it mounts no volumes. nil, ); err != nil { return wrapFileSystemErr("while creating pause OCI bundle", err) @@ -1130,7 +1150,6 @@ func (s *AteomHerder) prepareOCIBundles( "io.kubernetes.cri.container-name": ctr.GetName(), }, ateompath.AteomNetNSPath(targetAteomUid), - identityDir, spec.GetVolumes(), ctr.GetVolumeMounts(), ); err != nil { @@ -1477,16 +1496,6 @@ func resetActorDirs(actorUID string) error { return wrapFileSystemErr("while creating restore-state dir: %w", err) } - // World-readable (0o755): bind-mounted into the actor, whose workload - // reads it through the gofer. - identityDir := ateompath.ActorIdentityDirPath(actorUID) - if err := os.RemoveAll(identityDir); err != nil { - return wrapFileSystemErr("while deleting actor identity dir: %w", err) - } - if err := os.MkdirAll(identityDir, 0o755); err != nil { - return wrapFileSystemErr("while creating actor identity dir: %w", err) - } - durableDirVolumesMountDir := ateompath.DurableDirVolumeMountsDir(actorUID) if err := os.RemoveAll(durableDirVolumesMountDir); err != nil { return wrapFileSystemErr("while deleting durable-dir volumes mount dir: %w", err) @@ -1495,6 +1504,16 @@ func resetActorDirs(actorUID string) error { return wrapFileSystemErr("while creating durable-dir volumes mount dir: %w", err) } + // World-readable (0o755): bind-mounted read-only into the actor, whose + // workload reads it through the gofer. + systemInfoVolumeRootsDir := ateompath.SystemInfoVolumeRootsDir(actorUID) + if err := os.RemoveAll(systemInfoVolumeRootsDir); err != nil { + return wrapFileSystemErr("while deleting system-info volume roots dir: %w", err) + } + if err := os.MkdirAll(systemInfoVolumeRootsDir, 0o755); err != nil { + return wrapFileSystemErr("while creating system-info volume roots dir: %w", err) + } + // Do not call RemoveAll on volume directories in case the unmount failed. // We do not want to delete mount content. volumesDir := ateompath.VolumesDir(actorUID) diff --git a/cmd/atelet/oci.go b/cmd/atelet/oci.go index 74fb8f93a..94a4274ea 100644 --- a/cmd/atelet/oci.go +++ b/cmd/atelet/oci.go @@ -25,32 +25,14 @@ import ( "github.com/agent-substrate/substrate/internal/ateerrors" "github.com/agent-substrate/substrate/internal/ateompath" "github.com/agent-substrate/substrate/internal/imagecache" + "github.com/agent-substrate/substrate/internal/proto/ateletpb" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/opencontainers/runtime-spec/specs-go" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" - - "github.com/agent-substrate/substrate/internal/proto/ateletpb" -) - -const ( - // IdentityMountPath is the in-actor directory at which atelet bind-mounts - // the actor's identity data. Workloads read the files inside it (at - // request time, not cached at startup) to learn about themselves. It is - // delivered as a per-actor bind mount rather than environment variables - // because env lives in the checkpointed process memory and would be - // frozen at the golden snapshot's values after a restore; a bind mount is - // re-attached per-actor on every resume. A directory (rather than a - // single-file mount) so further identity data can be added without - // changing the mount shape. - IdentityMountPath = "/run/ate" - - // ActorIDFileName is the file inside IdentityMountPath holding the - // actor's own ID, raw with no trailing newline. - ActorIDFileName = "actor-id" ) -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 { +func prepareOCIDirectory(ctx context.Context, imageCache *imagecache.Store, actorUID, containerName, ref string, command, args []string, env []string, annotations map[string]string, netns string, volumes []*ateletpb.Volume, volumeMounts []*ateletpb.VolumeMount) error { tracer := otel.Tracer("prepareOCIDirectory") ctx, span := tracer.Start(ctx, "prepareOCIDirectory") @@ -90,14 +72,10 @@ func prepareOCIDirectory(ctx context.Context, imageCache *imagecache.Store, acto } resolvedEnv := resolveActorEnv(&img.Config, env) - // The identity bind target must exist in the rootfs for the mount to - // attach; ateom creates it through the mounted overlay (it lands in the - // actor's upper) so the workload can read its own name at - // IdentityMountPath/ActorIDFileName. + // Every bind target must exist in the rootfs for the mount to attach; + // ateom creates them through the mounted overlay (they land in the + // actor's upper). var extraDirs []string - if identityDir != "" { - extraDirs = append(extraDirs, IdentityMountPath) - } for _, vm := range volumeMounts { extraDirs = append(extraDirs, vm.GetMountPath()) } @@ -109,7 +87,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, volumes, volumeMounts) ociSpecBytes, err := json.MarshalIndent(ociSpec, "", " ") if err != nil { return fmt.Errorf("while marshaling OCI spec: %w", err) @@ -183,10 +161,7 @@ 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). -// 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, volumes []*ateletpb.Volume, volumeMounts []*ateletpb.VolumeMount) *specs.Spec { mounts := []specs.Mount{ { Destination: "/proc", @@ -216,14 +191,6 @@ func buildActorOCISpec(actorUID string, args []string, env []string, annotations Options: []string{"ro"}, }, } - if identityDir != "" { - mounts = append(mounts, specs.Mount{ - Destination: IdentityMountPath, - Type: "bind", - Source: identityDir, - Options: []string{"ro"}, - }) - } spec := &specs.Spec{ Process: &specs.Process{ @@ -302,11 +269,17 @@ func buildActorOCISpec(actorUID string, args []string, env []string, annotations for _, vm := range volumeMounts { var srcPath string + options := []string{"bind", "rw"} switch volumesByName[vm.GetName()].GetSource().(type) { case *ateletpb.Volume_DurableDir: srcPath = ateompath.DurableDirVolumeMountPoint(actorUID, vm.GetName()) case *ateletpb.Volume_External: srcPath = ateompath.VolumeHostPath(actorUID, vm.GetName()) + case *ateletpb.Volume_SystemInfo: + // System-info contents are generated by atelet; the workload only + // reads them. + srcPath = ateompath.SystemInfoVolumeRoot(actorUID, vm.GetName()) + options = []string{"bind", "ro"} default: continue } @@ -314,7 +287,7 @@ func buildActorOCISpec(actorUID string, args []string, env []string, annotations Destination: vm.GetMountPath(), Type: "bind", Source: srcPath, - Options: []string{"bind", "rw"}, + Options: options, }) } diff --git a/cmd/atelet/oci_test.go b/cmd/atelet/oci_test.go index 1f492092a..64d7aedc5 100644 --- a/cmd/atelet/oci_test.go +++ b/cmd/atelet/oci_test.go @@ -25,36 +25,47 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" ) -// With an identity dir, a read-only bind mount appears at IdentityMountPath. -func TestBuildActorOCISpec_IdentityMount(t *testing.T) { +// Each system-info volume mount becomes a read-only bind mount whose source +// is the per-actor on-host SystemInfoVolumeRoot for that volume name. It is +// delivered as a bind mount rather than environment variables because env +// lives in the checkpointed process memory and would be frozen at the golden +// snapshot's values after a restore; a bind mount is re-attached per-actor on +// every resume. +func TestBuildActorOCISpec_SystemInfoVolumeMounts(t *testing.T) { + const actorUID = "actor_uid" + volumeMounts := []*ateletpb.VolumeMount{ + {Name: "sysinfo", MountPath: "/run/ate"}, + } + volumes := []*ateletpb.Volume{ + {Name: "sysinfo", Source: &ateletpb.Volume_SystemInfo{SystemInfo: &ateletpb.SystemInfoVolume{}}}, + } spec := buildActorOCISpec( - "actor_uid", + actorUID, []string{"/app"}, []string{"FOO=bar"}, map[string]string{"k": "v"}, "/run/netns/x", - "/host/actors/actor_uid/identity", - nil, - nil, + volumes, + volumeMounts, ) found := false for _, m := range spec.Mounts { - if m.Destination != IdentityMountPath { + if m.Destination != "/run/ate" { continue } found = true - if m.Source != "/host/actors/actor_uid/identity" { - t.Errorf("identity mount source = %q, want the per-actor identity dir", m.Source) + if want := ateompath.SystemInfoVolumeRoot(actorUID, "sysinfo"); m.Source != want { + t.Errorf("system-info mount source = %q, want %q", m.Source, want) } if m.Type != "bind" { - t.Errorf("identity mount type = %q, want bind", m.Type) + t.Errorf("system-info mount type = %q, want bind", m.Type) } if !slices.Contains(m.Options, "ro") { - t.Errorf("identity mount must be read-only, options=%v", m.Options) + t.Errorf("system-info mount must be read-only, options=%v", m.Options) } } if !found { - t.Fatalf("identity mount %q missing; mounts=%v", IdentityMountPath, spec.Mounts) + t.Fatalf("system-info mount %q missing; mounts=%v", "/run/ate", spec.Mounts) } } @@ -192,16 +203,6 @@ 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) - for _, m := range bare.Mounts { - if m.Destination == IdentityMountPath { - t.Errorf("identity mount must be absent when identityDir is empty") - } - } -} - // Each durable-dir volume mount becomes a bind mount whose source is the // per-actor on-host DurableDirVolumeMountPoint for that volume name. func TestBuildActorOCISpec_DurableDirVolumeMounts(t *testing.T) { @@ -218,7 +219,6 @@ func TestBuildActorOCISpec_DurableDirVolumeMounts(t *testing.T) { actorUID, []string{"/app"}, nil, nil, "/run/netns/x", - "", volumes, durableDirs, ) diff --git a/docs/api-guide.md b/docs/api-guide.md index c1b5cc398..9692ada96 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -158,10 +158,32 @@ Substrate uses a **Uniform DNS Mesh**: every actor created from a template is au **Format:** `..actors.resources.substrate.ate.dev` -### Actor Identity -Substrate bind-mounts a read-only, per-actor identity directory at **`/run/ate`** into each of the actor's containers. An actor can learn its own name without parsing the `Host` header by reading the file **`/run/ate/actor-id`** inside it, which contains the raw actor name with no trailing newline. Further identity and configuration data may appear in this directory over time. +### SystemInfo Volumes -Read it fresh rather than caching it at process start. It is delivered as a per-actor bind mount, not an environment variable, precisely so it carries the correct name after a resume from the golden snapshot — an env var (or a file baked into the image) would be frozen at the *golden* actor's name, since it lives in the checkpointed process memory, and would therefore be identical for every actor of the template. +To deliver identity information, including credentials, to a running actor, you can use a SystemInfo volume. Define it in `spec.volumes`, and mount it into each container that needs it. + +Available information sources: + +#### ActorIdentity +The ActorIdentity data source places a file that contains the actor's name (raw, with no trailing newline) at a configurable relative path in the volume. + +```yaml +spec: + volumes: + - name: system-info + systemInfo: + dataSources: + - actorIdentity: + path: actor-id + containers: + - name: main + # ... + volumeMounts: + - name: system-info + mountPath: /run/ate # the actor reads /run/ate/actor-id +``` + +Read it fresh rather than caching it at process start. It is delivered as a file on a read-only per-actor bind mount, not an environment variable, precisely so it carries the correct name after a resume from the golden snapshot — an env var (or a file baked into the image) would be frozen at the *golden* actor's name, since it lives in the checkpointed process memory, and would therefore be identical for every actor of the template. ### Container Fields diff --git a/internal/ateompath/ateompath.go b/internal/ateompath/ateompath.go index 680e329c1..aacce601c 100644 --- a/internal/ateompath/ateompath.go +++ b/internal/ateompath/ateompath.go @@ -87,18 +87,6 @@ func ActorPath(actorUID string) string { ) } -// ActorIdentityDirPath is the host directory atelet populates with the -// actor's identity data (currently the single file "actor-id") and -// bind-mounts read-only into the actor. It is per-actor and regenerated on -// every resume, so (unlike the checkpointed process environment) it reflects -// the correct ID after a restore from the golden snapshot. -func ActorIdentityDirPath(actorUID string) string { - return filepath.Join( - ActorPath(actorUID), - "identity", - ) -} - // ActorSandboxAssetsFile is the per-actor file where atelet records the sandbox // binaries (class + content-addressed asset set, for this node's architecture) // the actor is currently running. It is written at Run/Restore and read at @@ -172,6 +160,26 @@ func DurableDirVolumeMountPoint(actorUID, volumeName string) string { ) } +// SystemInfoVolumeRootsDir is the directory containing the per-volume root +// directories of system-info volumes. It is deliberately separate from +// DurableDirVolumeMountsDir: system-info contents are regenerated by atelet +// on every Run/Restore and must never be captured into durable snapshots. +func SystemInfoVolumeRootsDir(actorUID string) string { + return filepath.Join( + ActorPath(actorUID), + "system-info", + ) +} + +// SystemInfoVolumeRoot returns the host path of the root directory for a +// specific system-info volume. +func SystemInfoVolumeRoot(actorUID, volumeName string) string { + return filepath.Join( + SystemInfoVolumeRootsDir(actorUID), + volumeName, + ) +} + // RestoreStateDir is the local directory to use to restore an actor from a // checkpoint downloaded from GCS. // diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index f97ff7074..f06558c73 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -727,6 +727,165 @@ func (x *ExternalVolumeSource) GetVolumeContext() map[string]string { return nil } +// ActorIdentityDataSource writes the actor's name to a file at the given +// path, relative to the root of the enclosing system-info volume. +type ActorIdentityDataSource struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ActorIdentityDataSource) Reset() { + *x = ActorIdentityDataSource{} + mi := &file_atelet_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ActorIdentityDataSource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ActorIdentityDataSource) ProtoMessage() {} + +func (x *ActorIdentityDataSource) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[10] + 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 ActorIdentityDataSource.ProtoReflect.Descriptor instead. +func (*ActorIdentityDataSource) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{10} +} + +func (x *ActorIdentityDataSource) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type SystemInfoDataSource struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to DataSource: + // + // *SystemInfoDataSource_ActorIdentity + DataSource isSystemInfoDataSource_DataSource `protobuf_oneof:"data_source"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SystemInfoDataSource) Reset() { + *x = SystemInfoDataSource{} + mi := &file_atelet_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SystemInfoDataSource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SystemInfoDataSource) ProtoMessage() {} + +func (x *SystemInfoDataSource) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[11] + 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 SystemInfoDataSource.ProtoReflect.Descriptor instead. +func (*SystemInfoDataSource) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{11} +} + +func (x *SystemInfoDataSource) GetDataSource() isSystemInfoDataSource_DataSource { + if x != nil { + return x.DataSource + } + return nil +} + +func (x *SystemInfoDataSource) GetActorIdentity() *ActorIdentityDataSource { + if x != nil { + if x, ok := x.DataSource.(*SystemInfoDataSource_ActorIdentity); ok { + return x.ActorIdentity + } + } + return nil +} + +type isSystemInfoDataSource_DataSource interface { + isSystemInfoDataSource_DataSource() +} + +type SystemInfoDataSource_ActorIdentity struct { + ActorIdentity *ActorIdentityDataSource `protobuf:"bytes,1,opt,name=actor_identity,json=actorIdentity,proto3,oneof"` +} + +func (*SystemInfoDataSource_ActorIdentity) isSystemInfoDataSource_DataSource() {} + +// SystemInfoVolume is a read-only volume whose files are generated by atelet +// on every Run/Restore, so they carry per-actor values even after a restore +// from the golden snapshot. +type SystemInfoVolume struct { + state protoimpl.MessageState `protogen:"open.v1"` + DataSources []*SystemInfoDataSource `protobuf:"bytes,1,rep,name=data_sources,json=dataSources,proto3" json:"data_sources,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SystemInfoVolume) Reset() { + *x = SystemInfoVolume{} + mi := &file_atelet_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SystemInfoVolume) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SystemInfoVolume) ProtoMessage() {} + +func (x *SystemInfoVolume) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[12] + 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 SystemInfoVolume.ProtoReflect.Descriptor instead. +func (*SystemInfoVolume) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{12} +} + +func (x *SystemInfoVolume) GetDataSources() []*SystemInfoDataSource { + if x != nil { + return x.DataSources + } + return nil +} + type Volume struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -734,6 +893,7 @@ type Volume struct { // // *Volume_DurableDir // *Volume_External + // *Volume_SystemInfo Source isVolume_Source `protobuf_oneof:"source"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -741,7 +901,7 @@ type Volume struct { func (x *Volume) Reset() { *x = Volume{} - mi := &file_atelet_proto_msgTypes[10] + mi := &file_atelet_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -753,7 +913,7 @@ func (x *Volume) String() string { func (*Volume) ProtoMessage() {} func (x *Volume) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[10] + mi := &file_atelet_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -766,7 +926,7 @@ func (x *Volume) ProtoReflect() protoreflect.Message { // Deprecated: Use Volume.ProtoReflect.Descriptor instead. func (*Volume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{10} + return file_atelet_proto_rawDescGZIP(), []int{13} } func (x *Volume) GetName() string { @@ -801,6 +961,15 @@ func (x *Volume) GetExternal() *ExternalVolumeSource { return nil } +func (x *Volume) GetSystemInfo() *SystemInfoVolume { + if x != nil { + if x, ok := x.Source.(*Volume_SystemInfo); ok { + return x.SystemInfo + } + } + return nil +} + type isVolume_Source interface { isVolume_Source() } @@ -813,10 +982,16 @@ type Volume_External struct { External *ExternalVolumeSource `protobuf:"bytes,3,opt,name=external,proto3,oneof"` } +type Volume_SystemInfo struct { + SystemInfo *SystemInfoVolume `protobuf:"bytes,4,opt,name=system_info,json=systemInfo,proto3,oneof"` +} + func (*Volume_DurableDir) isVolume_Source() {} func (*Volume_External) isVolume_Source() {} +func (*Volume_SystemInfo) isVolume_Source() {} + type VolumeMount struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -827,7 +1002,7 @@ type VolumeMount struct { func (x *VolumeMount) Reset() { *x = VolumeMount{} - mi := &file_atelet_proto_msgTypes[11] + mi := &file_atelet_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -839,7 +1014,7 @@ func (x *VolumeMount) String() string { func (*VolumeMount) ProtoMessage() {} func (x *VolumeMount) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[11] + mi := &file_atelet_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -852,7 +1027,7 @@ func (x *VolumeMount) ProtoReflect() protoreflect.Message { // Deprecated: Use VolumeMount.ProtoReflect.Descriptor instead. func (*VolumeMount) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{11} + return file_atelet_proto_rawDescGZIP(), []int{14} } func (x *VolumeMount) GetName() string { @@ -884,7 +1059,7 @@ type Container struct { func (x *Container) Reset() { *x = Container{} - mi := &file_atelet_proto_msgTypes[12] + mi := &file_atelet_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -896,7 +1071,7 @@ func (x *Container) String() string { func (*Container) ProtoMessage() {} func (x *Container) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[12] + mi := &file_atelet_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -909,7 +1084,7 @@ func (x *Container) ProtoReflect() protoreflect.Message { // Deprecated: Use Container.ProtoReflect.Descriptor instead. func (*Container) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{12} + return file_atelet_proto_rawDescGZIP(), []int{15} } func (x *Container) GetName() string { @@ -971,7 +1146,7 @@ type EnvEntry struct { func (x *EnvEntry) Reset() { *x = EnvEntry{} - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -983,7 +1158,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[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -996,7 +1171,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{16} } func (x *EnvEntry) GetName() string { @@ -1027,7 +1202,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1039,7 +1214,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[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1052,7 +1227,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{17} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -1082,7 +1257,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1094,7 +1269,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[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1107,7 +1282,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{18} } func (x *HTTPGetAction) GetPath() string { @@ -1132,7 +1307,7 @@ type RunResponse struct { func (x *RunResponse) Reset() { *x = RunResponse{} - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1144,7 +1319,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[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1157,7 +1332,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{19} } type LocalCheckpointConfiguration struct { @@ -1171,7 +1346,7 @@ type LocalCheckpointConfiguration struct { func (x *LocalCheckpointConfiguration) Reset() { *x = LocalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1183,7 +1358,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[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1196,7 +1371,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{20} } func (x *LocalCheckpointConfiguration) GetSnapshotPrefix() string { @@ -1225,7 +1400,7 @@ type ExternalCheckpointConfiguration struct { func (x *ExternalCheckpointConfiguration) Reset() { *x = ExternalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1237,7 +1412,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[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1250,7 +1425,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{21} } func (x *ExternalCheckpointConfiguration) GetSnapshotUriPrefix() string { @@ -1288,7 +1463,7 @@ type CheckpointRequest struct { func (x *CheckpointRequest) Reset() { *x = CheckpointRequest{} - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1300,7 +1475,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[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1313,7 +1488,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{22} } func (x *CheckpointRequest) GetTargetAteomUid() string { @@ -1428,7 +1603,7 @@ type CheckpointResponse struct { func (x *CheckpointResponse) Reset() { *x = CheckpointResponse{} - mi := &file_atelet_proto_msgTypes[20] + mi := &file_atelet_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1440,7 +1615,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[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1453,7 +1628,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{23} } type RestoreRequest struct { @@ -1493,7 +1668,7 @@ type RestoreRequest struct { func (x *RestoreRequest) Reset() { *x = RestoreRequest{} - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1505,7 +1680,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[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1518,7 +1693,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{24} } func (x *RestoreRequest) GetTargetAteomUid() string { @@ -1647,7 +1822,7 @@ type RestoreResponse struct { func (x *RestoreResponse) Reset() { *x = RestoreResponse{} - mi := &file_atelet_proto_msgTypes[22] + mi := &file_atelet_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1659,7 +1834,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[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1672,7 +1847,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{25} } var File_atelet_proto protoreflect.FileDescriptor @@ -1731,12 +1906,21 @@ const file_atelet_proto_rawDesc = "" + "\x0evolume_context\x18\x03 \x03(\v2/.atelet.ExternalVolumeSource.VolumeContextEntryR\rvolumeContext\x1a@\n" + "\x12VolumeContextEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x9f\x01\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"-\n" + + "\x17ActorIdentityDataSource\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"o\n" + + "\x14SystemInfoDataSource\x12H\n" + + "\x0eactor_identity\x18\x01 \x01(\v2\x1f.atelet.ActorIdentityDataSourceH\x00R\ractorIdentityB\r\n" + + "\vdata_source\"S\n" + + "\x10SystemInfoVolume\x12?\n" + + "\fdata_sources\x18\x01 \x03(\v2\x1c.atelet.SystemInfoDataSourceR\vdataSources\"\xdc\x01\n" + "\x06Volume\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12;\n" + "\vdurable_dir\x18\x02 \x01(\v2\x18.atelet.DurableDirVolumeH\x00R\n" + "durableDir\x12:\n" + - "\bexternal\x18\x03 \x01(\v2\x1c.atelet.ExternalVolumeSourceH\x00R\bexternalB\b\n" + + "\bexternal\x18\x03 \x01(\v2\x1c.atelet.ExternalVolumeSourceH\x00R\bexternal\x12;\n" + + "\vsystem_info\x18\x04 \x01(\v2\x18.atelet.SystemInfoVolumeH\x00R\n" + + "systemInfoB\b\n" + "\x06source\"@\n" + "\vVolumeMount\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + @@ -1829,7 +2013,7 @@ func file_atelet_proto_rawDescGZIP() []byte { } var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 29) var file_atelet_proto_goTypes = []any{ (CheckpointType)(0), // 0: atelet.CheckpointType (SnapshotScope)(0), // 1: atelet.SnapshotScope @@ -1843,64 +2027,70 @@ var file_atelet_proto_goTypes = []any{ (*WorkloadSpec)(nil), // 9: atelet.WorkloadSpec (*DurableDirVolume)(nil), // 10: atelet.DurableDirVolume (*ExternalVolumeSource)(nil), // 11: atelet.ExternalVolumeSource - (*Volume)(nil), // 12: atelet.Volume - (*VolumeMount)(nil), // 13: atelet.VolumeMount - (*Container)(nil), // 14: atelet.Container - (*EnvEntry)(nil), // 15: atelet.EnvEntry - (*Readyz)(nil), // 16: atelet.Readyz - (*HTTPGetAction)(nil), // 17: atelet.HTTPGetAction - (*RunResponse)(nil), // 18: atelet.RunResponse - (*LocalCheckpointConfiguration)(nil), // 19: atelet.LocalCheckpointConfiguration - (*ExternalCheckpointConfiguration)(nil), // 20: atelet.ExternalCheckpointConfiguration - (*CheckpointRequest)(nil), // 21: atelet.CheckpointRequest - (*CheckpointResponse)(nil), // 22: atelet.CheckpointResponse - (*RestoreRequest)(nil), // 23: atelet.RestoreRequest - (*RestoreResponse)(nil), // 24: atelet.RestoreResponse - nil, // 25: atelet.ArchAssets.FilesEntry - nil, // 26: atelet.SandboxAssets.AssetsEntry - nil, // 27: atelet.ExternalVolumeSource.VolumeContextEntry + (*ActorIdentityDataSource)(nil), // 12: atelet.ActorIdentityDataSource + (*SystemInfoDataSource)(nil), // 13: atelet.SystemInfoDataSource + (*SystemInfoVolume)(nil), // 14: atelet.SystemInfoVolume + (*Volume)(nil), // 15: atelet.Volume + (*VolumeMount)(nil), // 16: atelet.VolumeMount + (*Container)(nil), // 17: atelet.Container + (*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{ 9, // 0: atelet.RunRequest.spec:type_name -> atelet.WorkloadSpec 8, // 1: atelet.RunRequest.sandbox_assets:type_name -> atelet.SandboxAssets 5, // 2: atelet.RunRequest.egress_gateway:type_name -> atelet.EgressGateway - 25, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry - 26, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry - 14, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container - 12, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume - 27, // 7: atelet.ExternalVolumeSource.volume_context:type_name -> atelet.ExternalVolumeSource.VolumeContextEntry - 10, // 8: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume - 11, // 9: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource - 15, // 10: atelet.Container.env:type_name -> atelet.EnvEntry - 16, // 11: atelet.Container.readyz:type_name -> atelet.Readyz - 13, // 12: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount - 17, // 13: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction - 9, // 14: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec - 0, // 15: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType - 19, // 16: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 20, // 17: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 1, // 18: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope - 9, // 19: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec - 0, // 20: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType - 19, // 21: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 20, // 22: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 1, // 23: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope - 5, // 24: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway - 6, // 25: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile - 7, // 26: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets - 2, // 27: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest - 4, // 28: atelet.AteomHerder.Run:input_type -> atelet.RunRequest - 21, // 29: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest - 23, // 30: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest - 3, // 31: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse - 18, // 32: atelet.AteomHerder.Run:output_type -> atelet.RunResponse - 22, // 33: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse - 24, // 34: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse - 31, // [31:35] is the sub-list for method output_type - 27, // [27:31] is the sub-list for method input_type - 27, // [27:27] is the sub-list for extension type_name - 27, // [27:27] is the sub-list for extension extendee - 0, // [0:27] is the sub-list for field type_name + 28, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 29, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry + 17, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container + 15, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume + 30, // 7: atelet.ExternalVolumeSource.volume_context:type_name -> atelet.ExternalVolumeSource.VolumeContextEntry + 12, // 8: atelet.SystemInfoDataSource.actor_identity:type_name -> atelet.ActorIdentityDataSource + 13, // 9: atelet.SystemInfoVolume.data_sources:type_name -> atelet.SystemInfoDataSource + 10, // 10: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume + 11, // 11: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource + 14, // 12: atelet.Volume.system_info:type_name -> atelet.SystemInfoVolume + 18, // 13: atelet.Container.env:type_name -> atelet.EnvEntry + 19, // 14: atelet.Container.readyz:type_name -> atelet.Readyz + 16, // 15: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount + 20, // 16: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction + 9, // 17: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec + 0, // 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 + 1, // 21: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope + 9, // 22: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 0, // 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 + 1, // 26: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 5, // 27: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 6, // 28: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 7, // 29: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 2, // 30: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest + 4, // 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 + 3, // 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() } @@ -1909,15 +2099,19 @@ func file_atelet_proto_init() { return } file_atelet_proto_msgTypes[2].OneofWrappers = []any{} - file_atelet_proto_msgTypes[10].OneofWrappers = []any{ + file_atelet_proto_msgTypes[11].OneofWrappers = []any{ + (*SystemInfoDataSource_ActorIdentity)(nil), + } + file_atelet_proto_msgTypes[13].OneofWrappers = []any{ (*Volume_DurableDir)(nil), (*Volume_External)(nil), + (*Volume_SystemInfo)(nil), } - file_atelet_proto_msgTypes[19].OneofWrappers = []any{ + file_atelet_proto_msgTypes[22].OneofWrappers = []any{ (*CheckpointRequest_LocalConfig)(nil), (*CheckpointRequest_ExternalConfig)(nil), } - file_atelet_proto_msgTypes[21].OneofWrappers = []any{ + file_atelet_proto_msgTypes[24].OneofWrappers = []any{ (*RestoreRequest_LocalConfig)(nil), (*RestoreRequest_ExternalConfig)(nil), } @@ -1927,7 +2121,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: 2, - NumMessages: 26, + NumMessages: 29, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index ca45b7d88..92a032316 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -121,12 +121,32 @@ message ExternalVolumeSource { map volume_context = 3; } +// ActorIdentityDataSource writes the actor's name to a file at the given +// path, relative to the root of the enclosing system-info volume. +message ActorIdentityDataSource { + string path = 1; +} + +message SystemInfoDataSource { + oneof data_source { + ActorIdentityDataSource actor_identity = 1; + } +} + +// SystemInfoVolume is a read-only volume whose files are generated by atelet +// on every Run/Restore, so they carry per-actor values even after a restore +// from the golden snapshot. +message SystemInfoVolume { + repeated SystemInfoDataSource data_sources = 1; +} + message Volume { string name = 1; oneof source { DurableDirVolume durable_dir = 2; ExternalVolumeSource external = 3; + SystemInfoVolume system_info = 4; } } diff --git a/manifests/ate-install/generated/ate.dev_actortemplates.yaml b/manifests/ate-install/generated/ate.dev_actortemplates.yaml index 24f64fec0..5f2b65cd2 100644 --- a/manifests/ate-install/generated/ate.dev_actortemplates.yaml +++ b/manifests/ate-install/generated/ate.dev_actortemplates.yaml @@ -392,13 +392,51 @@ spec: x-kubernetes-validations: - message: Name must be a valid DNS label rule: '!format.dns1123Label().validate(self).hasValue()' + systemInfo: + description: systemInfo configures a system information volume. + properties: + dataSources: + description: |- + DataSources is the list of data sources to place within the SystemInfo + volume. + items: + description: |- + SystemInfoDataSource is a container allowing you to pick a particular + SystemInfo data source. + + Exactly one member must be set. + properties: + actorIdentity: + description: |- + ActorIdentityDataSource is a SystemInfo volume data source that writes the + actor's ID to a file. + properties: + path: + description: |- + Relative path from the root of the SystemInfo volume that the actor + identity file should be written. + maxLength: 1024 + minLength: 1 + type: string + required: + - path + type: object + type: object + x-kubernetes-validations: + - message: exactly one of the fields in [actorIdentity] + must be set + rule: '[has(self.actorIdentity)].filter(x,x==true).size() + == 1' + maxItems: 32 + type: array + type: object required: - name type: object x-kubernetes-validations: - - message: exactly one of the fields in [durableDir externalVolumeTemplate] - must be set - rule: '[has(self.durableDir),has(self.externalVolumeTemplate)].filter(x,x==true).size() + - message: exactly one of the fields in [durableDir externalVolumeTemplate + systemInfo] must be set + rule: '[has(self.durableDir),has(self.externalVolumeTemplate),has(self.systemInfo)].filter(x,x==true).size() == 1' maxItems: 32 type: array diff --git a/pkg/api/v1alpha1/actortemplate_types.go b/pkg/api/v1alpha1/actortemplate_types.go index 17c07b29a..170afe2fc 100644 --- a/pkg/api/v1alpha1/actortemplate_types.go +++ b/pkg/api/v1alpha1/actortemplate_types.go @@ -45,12 +45,44 @@ type ExternalVolumeTemplate struct { StorageClassName string `json:"storageClassName"` } +// ActorIdentityDataSource is a SystemInfo volume data source that writes the +// actor's ID to a file. +type ActorIdentityDataSource struct { + // Relative path from the root of the SystemInfo volume that the actor + // identity file should be written. + // + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + Path string `json:"path"` +} + +// SystemInfoDataSource is a container allowing you to pick a particular +// SystemInfo data source. +// +// Exactly one member must be set. +// +// +kubebuilder:validation:ExactlyOneOf={actorIdentity} +type SystemInfoDataSource struct { + ActorIdentity *ActorIdentityDataSource `json:"actorIdentity,omitempty"` +} + +// Represents a system information volume, which provides files containing the +// actor ID, an actor identity JWT, and an actor identity certificate. +type SystemInfoVolumeSource struct { + // DataSources is the list of data sources to place within the SystemInfo + // volume. + // + // +kubebuilder:validation:MaxItems=32 + DataSources []SystemInfoDataSource `json:"dataSources,omitempty"` +} + // Represents the source of a volume to mount. // Exactly one of its members must be specified. // // When adding a new source type, list it in the ExactlyOneOf marker below. // -// +kubebuilder:validation:ExactlyOneOf={durableDir,externalVolumeTemplate} +// +kubebuilder:validation:ExactlyOneOf={durableDir,externalVolumeTemplate,systemInfo} type VolumeSource struct { // durableDir represents a durable directory on rootfs that persists across // resumes and participates in snapshots. @@ -62,6 +94,11 @@ type VolumeSource struct { // when the actor is deleted. // +optional ExternalVolumeTemplate *ExternalVolumeTemplate `json:"externalVolumeTemplate,omitempty"` + + // systemInfo configures a system information volume. + // + // +optional + SystemInfo *SystemInfoVolumeSource `json:"systemInfo,omitempty"` } type Volume struct { diff --git a/pkg/api/v1alpha1/actortemplate_validation_test.go b/pkg/api/v1alpha1/actortemplate_validation_test.go index 4b0a5d54c..86c0ae20a 100644 --- a/pkg/api/v1alpha1/actortemplate_validation_test.go +++ b/pkg/api/v1alpha1/actortemplate_validation_test.go @@ -807,7 +807,7 @@ func TestActorTemplateValidation(t *testing.T) { } }, wantErr: true, - errMsg: "exactly one of the fields in [durableDir externalVolumeTemplate] must be set", + errMsg: "exactly one of the fields in [durableDir externalVolumeTemplate systemInfo] must be set", }, { name: "Volumes: VolumeSource with no source set is invalid", mutate: func(at *ActorTemplate) { @@ -816,7 +816,7 @@ func TestActorTemplateValidation(t *testing.T) { } }, wantErr: true, - errMsg: "exactly one of the fields in [durableDir externalVolumeTemplate] must be set", + errMsg: "exactly one of the fields in [durableDir externalVolumeTemplate systemInfo] must be set", }, { name: "Volumes: VolumeSource with no source set is invalid (mixed with a valid DurableDir volume)", mutate: func(at *ActorTemplate) { @@ -830,7 +830,7 @@ func TestActorTemplateValidation(t *testing.T) { } }, wantErr: true, - errMsg: "exactly one of the fields in [durableDir externalVolumeTemplate] must be set", + errMsg: "exactly one of the fields in [durableDir externalVolumeTemplate systemInfo] must be set", }, { name: "Volumes: DurableDir MountPath with nested absolute path is valid", mutate: func(at *ActorTemplate) { diff --git a/pkg/api/v1alpha1/zz_generated.deepcopy.go b/pkg/api/v1alpha1/zz_generated.deepcopy.go index bdcf3505c..fb19b77c1 100644 --- a/pkg/api/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/api/v1alpha1/zz_generated.deepcopy.go @@ -24,6 +24,21 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ActorIdentityDataSource) DeepCopyInto(out *ActorIdentityDataSource) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActorIdentityDataSource. +func (in *ActorIdentityDataSource) DeepCopy() *ActorIdentityDataSource { + if in == nil { + return nil + } + out := new(ActorIdentityDataSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ActorTemplate) DeepCopyInto(out *ActorTemplate) { *out = *in @@ -524,6 +539,48 @@ func (in *SnapshotsConfig) DeepCopy() *SnapshotsConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SystemInfoDataSource) DeepCopyInto(out *SystemInfoDataSource) { + *out = *in + if in.ActorIdentity != nil { + in, out := &in.ActorIdentity, &out.ActorIdentity + *out = new(ActorIdentityDataSource) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SystemInfoDataSource. +func (in *SystemInfoDataSource) DeepCopy() *SystemInfoDataSource { + if in == nil { + return nil + } + out := new(SystemInfoDataSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SystemInfoVolumeSource) DeepCopyInto(out *SystemInfoVolumeSource) { + *out = *in + if in.DataSources != nil { + in, out := &in.DataSources, &out.DataSources + *out = make([]SystemInfoDataSource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SystemInfoVolumeSource. +func (in *SystemInfoVolumeSource) DeepCopy() *SystemInfoVolumeSource { + if in == nil { + return nil + } + out := new(SystemInfoVolumeSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Volume) DeepCopyInto(out *Volume) { *out = *in @@ -568,6 +625,11 @@ func (in *VolumeSource) DeepCopyInto(out *VolumeSource) { *out = new(ExternalVolumeTemplate) (*in).DeepCopyInto(*out) } + if in.SystemInfo != nil { + in, out := &in.SystemInfo, &out.SystemInfo + *out = new(SystemInfoVolumeSource) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VolumeSource. From 8a20a2a69da126135fb17787232b6749ae0c77fd Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Fri, 7 Aug 2026 10:27:29 -0700 Subject: [PATCH 3/3] System Information Volumes: Part 1 finishing touches Complete the initial actorIdentity data source support: - e2e: declare a systemInfo volume in the identity probe's ActorTemplate, mounted at /run/ate, replacing the removed automatic identity mount so the restore-identity regression gate exercises the new API. - Validate actorIdentity paths at admission: must be a clean relative Unix path (no absolute paths, '..', '.', '//', ':', or control characters), and paths must be unique within a volume. Previously bad paths were only rejected by the atomic writer at Run/Restore time. - Unit tests for the ateapi systemInfo conversion and for atelet's system-info volume population (extracted into writeSystemInfoVolume). - Update the stale micro-VM known-gap comment to reference systemInfo volumes instead of the removed /run/ate identity mount. --- .../internal/controlapi/workload_spec_test.go | 60 ++++++++ cmd/atelet/main.go | 59 ++++---- cmd/atelet/main_test.go | 46 +++++++ cmd/ateom-microvm/spec.go | 13 +- internal/e2e/fixtures/probe/main.go | 4 +- internal/e2e/fixtures/probe/probe.yaml.tmpl | 9 ++ .../generated/ate.dev_actortemplates.yaml | 18 ++- pkg/api/v1alpha1/actortemplate_types.go | 8 +- .../v1alpha1/actortemplate_validation_test.go | 128 ++++++++++++++++++ 9 files changed, 308 insertions(+), 37 deletions(-) diff --git a/cmd/ateapi/internal/controlapi/workload_spec_test.go b/cmd/ateapi/internal/controlapi/workload_spec_test.go index b567bef52..811881e42 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec_test.go +++ b/cmd/ateapi/internal/controlapi/workload_spec_test.go @@ -79,6 +79,66 @@ func TestWorkloadSpecFromActorTemplate(t *testing.T) { }, }, }, + { + name: "converts SystemInfo volume with ActorIdentity data sources", + template: &atev1alpha1.ActorTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "tmpl1", Namespace: "agent-ns"}, + Spec: atev1alpha1.ActorTemplateSpec{ + PauseImage: "pause", + Volumes: []atev1alpha1.Volume{ + { + Name: "system-info", + VolumeSource: atev1alpha1.VolumeSource{ + SystemInfo: &atev1alpha1.SystemInfoVolumeSource{ + DataSources: []atev1alpha1.SystemInfoDataSource{ + {ActorIdentity: &atev1alpha1.ActorIdentityDataSource{Path: "actor-id"}}, + {ActorIdentity: &atev1alpha1.ActorIdentityDataSource{Path: "identity/name"}}, + }, + }, + }, + }, + }, + Containers: []atev1alpha1.Container{ + { + Name: "main", + Image: "main", + VolumeMounts: []atev1alpha1.VolumeMount{ + {Name: "system-info", MountPath: "/run/ate"}, + }, + }, + }, + }, + }, + want: &ateletpb.WorkloadSpec{ + PauseImage: "pause", + Volumes: []*ateletpb.Volume{ + { + Name: "system-info", + Source: &ateletpb.Volume_SystemInfo{ + SystemInfo: &ateletpb.SystemInfoVolume{ + DataSources: []*ateletpb.SystemInfoDataSource{ + {DataSource: &ateletpb.SystemInfoDataSource_ActorIdentity{ + ActorIdentity: &ateletpb.ActorIdentityDataSource{Path: "actor-id"}, + }}, + {DataSource: &ateletpb.SystemInfoDataSource_ActorIdentity{ + ActorIdentity: &ateletpb.ActorIdentityDataSource{Path: "identity/name"}, + }}, + }, + }, + }, + }, + }, + Containers: []*ateletpb.Container{ + { + Name: "main", + Image: "main", + VolumeMounts: []*ateletpb.VolumeMount{ + {Name: "system-info", MountPath: "/run/ate"}, + }, + }, + }, + }, + }, { name: "skips non-DurableDir volumes", template: &atev1alpha1.ActorTemplate{ diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index c0e748ddf..74814ac4a 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -1060,32 +1060,9 @@ func (s *AteomHerder) prepareOCIBundles( } case *ateletpb.Volume_SystemInfo: - // Populated on every Run/Restore, so the contents carry the - // correct per-actor values even when restoring from the golden - // snapshot. volRootHostPath := ateompath.SystemInfoVolumeRoot(actorUID, vol.GetName()) - if err := os.MkdirAll(volRootHostPath, 0o755); err != nil { - return fmt.Errorf("while creating %q: %w", volRootHostPath, err) - } - - aw, err := atomicwriter.NewAtomicWriter(volRootHostPath) - if err != nil { - return fmt.Errorf("while creating atomicwriter: %w", err) - } - - contents := map[string]atomicwriter.FileProjection{} - for _, dataSourceAny := range volSrc.SystemInfo.GetDataSources() { - switch dataSource := dataSourceAny.GetDataSource().(type) { - case *ateletpb.SystemInfoDataSource_ActorIdentity: - contents[dataSource.ActorIdentity.GetPath()] = atomicwriter.FileProjection{ - Data: []byte(actorName), - Mode: 0o644, - } - } - } - - if err := aw.Write(ctx, contents, nil); err != nil { - return fmt.Errorf("while writing contents of SystemInfoVolume: %w", err) + if err := writeSystemInfoVolume(ctx, volRootHostPath, actorName, volSrc.SystemInfo); err != nil { + return fmt.Errorf("while populating system-info volume %q: %w", vol.GetName(), err) } } } @@ -1162,6 +1139,38 @@ func (s *AteomHerder) prepareOCIBundles( return g.Wait() } +// writeSystemInfoVolume populates the root directory of a system-info volume +// with one file per data source. It runs on every Run/Restore, before the +// sandbox starts, so the files carry the resumed actor's own values no matter +// what checkpointed state the actor boots from. Files are written with the +// atomic writer so a concurrent reader can never observe a partial write. +func writeSystemInfoVolume(ctx context.Context, rootPath, actorName string, si *ateletpb.SystemInfoVolume) error { + if err := os.MkdirAll(rootPath, 0o755); err != nil { + return fmt.Errorf("while creating %q: %w", rootPath, err) + } + + aw, err := atomicwriter.NewAtomicWriter(rootPath) + if err != nil { + return fmt.Errorf("while creating atomicwriter: %w", err) + } + + contents := map[string]atomicwriter.FileProjection{} + for _, dataSourceAny := range si.GetDataSources() { + switch dataSource := dataSourceAny.GetDataSource().(type) { + case *ateletpb.SystemInfoDataSource_ActorIdentity: + contents[dataSource.ActorIdentity.GetPath()] = atomicwriter.FileProjection{ + Data: []byte(actorName), + Mode: 0o644, + } + } + } + + if err := aw.Write(ctx, contents, nil); err != nil { + return fmt.Errorf("while writing contents of SystemInfoVolume: %w", err) + } + return nil +} + // dialAteom opens (or reuses) the gRPC connection to the target ateom // pod and returns an ateom client. func (s *AteomHerder) dialAteom(ctx context.Context, targetAteomUid string) (ateompb.AteomClient, error) { diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index 1abe3a8e7..3b879b6f2 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -64,6 +64,52 @@ func TestSnapshotManifestActorMetadata(t *testing.T) { } } +func TestWriteSystemInfoVolume(t *testing.T) { + ctx := context.Background() + root := filepath.Join(t.TempDir(), "system-info", "vol1") + si := &ateletpb.SystemInfoVolume{ + DataSources: []*ateletpb.SystemInfoDataSource{ + {DataSource: &ateletpb.SystemInfoDataSource_ActorIdentity{ + ActorIdentity: &ateletpb.ActorIdentityDataSource{Path: "actor-id"}, + }}, + {DataSource: &ateletpb.SystemInfoDataSource_ActorIdentity{ + ActorIdentity: &ateletpb.ActorIdentityDataSource{Path: "identity/name"}, + }}, + }, + } + + if err := writeSystemInfoVolume(ctx, root, "golden-actor", si); err != nil { + t.Fatalf("writeSystemInfoVolume: %v", err) + } + + // Overwrite with a different actor name, as happens when a snapshot taken + // from one actor seeds another on resume: files must carry the new value. + if err := writeSystemInfoVolume(ctx, root, "probe-alpha", si); err != nil { + t.Fatalf("writeSystemInfoVolume (rewrite): %v", err) + } + + for _, path := range []string{"actor-id", "identity/name"} { + t.Run(path, func(t *testing.T) { + target := filepath.Join(root, path) + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("reading %q: %v", target, err) + } + // Raw actor name, no trailing newline. + if string(got) != "probe-alpha" { + t.Errorf("content = %q, want %q", got, "probe-alpha") + } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat %q: %v", target, err) + } + if perm := info.Mode().Perm(); perm != 0o644 { + t.Errorf("perm = %o, want 644", perm) + } + }) + } +} + func TestWriteFileAtomic(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "actor-id") diff --git a/cmd/ateom-microvm/spec.go b/cmd/ateom-microvm/spec.go index 7962bc5aa..1bb015751 100644 --- a/cmd/ateom-microvm/spec.go +++ b/cmd/ateom-microvm/spec.go @@ -88,12 +88,13 @@ func ensureKataCompatibleSpec(bundle, id, netnsPath string) (*specs.Spec, error) // the exact set `ctr run --runtime io.containerd.kata.v2` emits, which kata's // agent accepts. (Static shaper; pod DNS integration is future work.) // - // KNOWN GAP vs the gVisor runtime: this also drops atelet's read-only actor - // identity bind mount (/run/ate/actor-id). The micro-VM guest can't see host - // paths (the rootfs is an overlay of a virtio-fs base + a guest-RAM upper, not a - // host bind), so atelet's host-path identity mount has nothing to bind to. - // Exposing the identity needs a per-actor volume plumbed into the guest; not yet - // implemented. No micro-VM workload depends on it today. + // KNOWN GAP vs the gVisor runtime: this also drops atelet's read-only + // systemInfo volume bind mounts (e.g. the actorIdentity data-source file). + // The micro-VM guest can't see host paths (the rootfs is an overlay of a + // virtio-fs base + a guest-RAM upper, not a host bind), so atelet's + // host-path volume roots have nothing to bind to. Exposing them needs a + // per-actor volume plumbed into the guest; not yet implemented. No + // micro-VM workload depends on it today. spec.Mounts = defaultKataMounts() out, err := json.MarshalIndent(&spec, "", " ") diff --git a/internal/e2e/fixtures/probe/main.go b/internal/e2e/fixtures/probe/main.go index 6927a1d04..89fe88ed5 100644 --- a/internal/e2e/fixtures/probe/main.go +++ b/internal/e2e/fixtures/probe/main.go @@ -27,8 +27,8 @@ import ( "os" ) -// identityFile is the actor-id file inside the identity directory atelet -// bind-mounts at IdentityMountPath. +// identityFile is the actorIdentity data-source file of the systemInfo +// volume that probe.yaml.tmpl mounts at /run/ate. const identityFile = "/run/ate/actor-id" // whoami reports the actor's identity as observed at request time from the diff --git a/internal/e2e/fixtures/probe/probe.yaml.tmpl b/internal/e2e/fixtures/probe/probe.yaml.tmpl index f79e0cdfe..f2eb34769 100644 --- a/internal/e2e/fixtures/probe/probe.yaml.tmpl +++ b/internal/e2e/fixtures/probe/probe.yaml.tmpl @@ -39,10 +39,19 @@ metadata: namespace: ate-e2e-probe spec: pauseImage: "registry.k8s.io/pause:3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4" + volumes: + - name: system-info + systemInfo: + dataSources: + - actorIdentity: + path: actor-id containers: - name: probe image: ko://github.com/agent-substrate/substrate/internal/e2e/fixtures/probe command: ["/ko-app/probe"] + volumeMounts: + - name: system-info + mountPath: /run/ate # the probe reads /run/ate/actor-id # The probe binary binds :80 immediately, so this gates actor start on a # readiness signal rather than a guess, and carries a non-default # timeoutSeconds so e2e covers the value crossing ateapi -> atelet -> ateom diff --git a/manifests/ate-install/generated/ate.dev_actortemplates.yaml b/manifests/ate-install/generated/ate.dev_actortemplates.yaml index 5f2b65cd2..d471e9555 100644 --- a/manifests/ate-install/generated/ate.dev_actortemplates.yaml +++ b/manifests/ate-install/generated/ate.dev_actortemplates.yaml @@ -414,10 +414,20 @@ spec: path: description: |- Relative path from the root of the SystemInfo volume that the actor - identity file should be written. - maxLength: 1024 + identity file should be written. Must be a clean relative Unix path: + must not start or end with '/', and contain no ':', '..', '.', '//', + or control characters. + maxLength: 255 minLength: 1 type: string + x-kubernetes-validations: + - message: 'path must be a clean relative Unix + path: must not start or end with ''/'', and + contain no '':'', ''..'', ''.'', ''//'', or + control characters' + rule: '!self.startsWith(''/'') && !self.endsWith(''/'') + && !self.contains(''//'') && !self.contains('':'') + && !self.matches(''[\x00-\x1f\x7f]'') && !self.matches(''(^|/)[.][.]?(/|$)'')' required: - path type: object @@ -429,6 +439,10 @@ spec: == 1' maxItems: 32 type: array + x-kubernetes-validations: + - message: dataSources must not contain duplicate paths + rule: self.all(x, !has(x.actorIdentity) || self.exists_one(y, + has(y.actorIdentity) && y.actorIdentity.path == x.actorIdentity.path)) type: object required: - name diff --git a/pkg/api/v1alpha1/actortemplate_types.go b/pkg/api/v1alpha1/actortemplate_types.go index 170afe2fc..77cb04ab9 100644 --- a/pkg/api/v1alpha1/actortemplate_types.go +++ b/pkg/api/v1alpha1/actortemplate_types.go @@ -49,11 +49,14 @@ type ExternalVolumeTemplate struct { // actor's ID to a file. type ActorIdentityDataSource struct { // Relative path from the root of the SystemInfo volume that the actor - // identity file should be written. + // identity file should be written. Must be a clean relative Unix path: + // must not start or end with '/', and contain no ':', '..', '.', '//', + // or control characters. // // +required // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=1024 + // +kubebuilder:validation:MaxLength=255 + // +kubebuilder:validation:XValidation:rule="!self.startsWith('/') && !self.endsWith('/') && !self.contains('//') && !self.contains(':') && !self.matches('[\\x00-\\x1f\\x7f]') && !self.matches('(^|/)[.][.]?(/|$)')",message="path must be a clean relative Unix path: must not start or end with '/', and contain no ':', '..', '.', '//', or control characters" Path string `json:"path"` } @@ -74,6 +77,7 @@ type SystemInfoVolumeSource struct { // volume. // // +kubebuilder:validation:MaxItems=32 + // +kubebuilder:validation:XValidation:rule="self.all(x, !has(x.actorIdentity) || self.exists_one(y, has(y.actorIdentity) && y.actorIdentity.path == x.actorIdentity.path))",message="dataSources must not contain duplicate paths" DataSources []SystemInfoDataSource `json:"dataSources,omitempty"` } diff --git a/pkg/api/v1alpha1/actortemplate_validation_test.go b/pkg/api/v1alpha1/actortemplate_validation_test.go index 86c0ae20a..b8a7fe844 100644 --- a/pkg/api/v1alpha1/actortemplate_validation_test.go +++ b/pkg/api/v1alpha1/actortemplate_validation_test.go @@ -831,6 +831,134 @@ func TestActorTemplateValidation(t *testing.T) { }, wantErr: true, errMsg: "exactly one of the fields in [durableDir externalVolumeTemplate systemInfo] must be set", + }, { + name: "Volumes: SystemInfo volume with an ActorIdentity data source is valid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {ActorIdentity: &ActorIdentityDataSource{Path: "actor-id"}}, + }, + }, + }, + }, + } + at.Spec.Containers[0].VolumeMounts = []VolumeMount{ + {Name: "system-info", MountPath: "/run/ate"}, + } + }, + wantErr: false, + }, { + name: "Volumes: SystemInfo data source with nested relative path is valid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {ActorIdentity: &ActorIdentityDataSource{Path: "identity/actor-id"}}, + }, + }, + }, + }, + } + at.Spec.Containers[0].VolumeMounts = []VolumeMount{ + {Name: "system-info", MountPath: "/run/ate"}, + } + }, + wantErr: false, + }, { + name: "Volumes: SystemInfo data source with no member set is invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{{}}, + }, + }, + }, + } + }, + wantErr: true, + errMsg: "exactly one of the fields in [actorIdentity] must be set", + }, { + name: "Volumes: SystemInfo data source with empty path is invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {ActorIdentity: &ActorIdentityDataSource{Path: ""}}, + }, + }, + }, + }, + } + }, + wantErr: true, + }, { + name: "Volumes: SystemInfo data source with absolute path is invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {ActorIdentity: &ActorIdentityDataSource{Path: "/etc/actor-id"}}, + }, + }, + }, + }, + } + }, + wantErr: true, + errMsg: "path must be a clean relative Unix path", + }, { + name: "Volumes: SystemInfo data source with path traversal is invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {ActorIdentity: &ActorIdentityDataSource{Path: "../escape"}}, + }, + }, + }, + }, + } + }, + wantErr: true, + errMsg: "path must be a clean relative Unix path", + }, { + name: "Volumes: SystemInfo data sources with duplicate paths are invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {ActorIdentity: &ActorIdentityDataSource{Path: "actor-id"}}, + {ActorIdentity: &ActorIdentityDataSource{Path: "actor-id"}}, + }, + }, + }, + }, + } + }, + wantErr: true, + errMsg: "dataSources must not contain duplicate paths", }, { name: "Volumes: DurableDir MountPath with nested absolute path is valid", mutate: func(at *ActorTemplate) {