From c0083ef84073e68ab11d4900f646506c831b90a9 Mon Sep 17 00:00:00 2001 From: Taahir Ahmed Date: Tue, 7 Jul 2026 15:03:48 -0700 Subject: [PATCH 01/10] 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 | 241 +++++++----------- internal/proto/ateletpb/atelet.proto | 12 +- 10 files changed, 107 insertions(+), 191 deletions(-) diff --git a/cmd/ateapi/internal/controlapi/workload_spec.go b/cmd/ateapi/internal/controlapi/workload_spec.go index 4289ff2ed..e94e9875d 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec.go +++ b/cmd/ateapi/internal/controlapi/workload_spec.go @@ -33,7 +33,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{}, }, @@ -104,7 +103,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 569945713..d7e36df67 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec_test.go +++ b/cmd/ateapi/internal/controlapi/workload_spec_test.go @@ -55,7 +55,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{}}, }, }, @@ -95,7 +94,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{}}, }, }, @@ -127,7 +125,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{}}, }, }, @@ -308,7 +305,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 46de6e0d4..2e7bb3c7b 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -1401,7 +1401,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) @@ -1420,7 +1420,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()) @@ -1498,7 +1498,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 1bb492db4..700b26ddd 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -702,9 +702,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 dab3709fb..443d81e82 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 { @@ -802,7 +753,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 @@ -849,13 +799,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 @@ -886,11 +829,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() {} @@ -1973,13 +1916,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" + @@ -2054,12 +1996,7 @@ const file_atelet_proto_rawDesc = "" + "\fmemory_bytes\x18\x0f \x01(\x03R\vmemoryBytesB\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" + @@ -2090,86 +2027,84 @@ 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, 28) 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 - (*UploadPausedCheckpointRequest)(nil), // 24: atelet.UploadPausedCheckpointRequest - (*UploadPausedCheckpointResponse)(nil), // 25: atelet.UploadPausedCheckpointResponse - (*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 + (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 + (*UploadPausedCheckpointRequest)(nil), // 23: atelet.UploadPausedCheckpointRequest + (*UploadPausedCheckpointResponse)(nil), // 24: atelet.UploadPausedCheckpointResponse + (*RestoreRequest)(nil), // 25: atelet.RestoreRequest + (*RestoreResponse)(nil), // 26: atelet.RestoreResponse + nil, // 27: atelet.ArchAssets.FilesEntry + nil, // 28: atelet.SandboxAssets.AssetsEntry + nil, // 29: 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 - 28, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry - 29, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry - 15, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container - 13, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume - 30, // 7: atelet.ExternalVolumeSource.volume_context:type_name -> atelet.ExternalVolumeSource.VolumeContextEntry - 0, // 8: atelet.Volume.type:type_name -> atelet.VolumeType - 11, // 9: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume - 12, // 10: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource - 16, // 11: atelet.Container.env:type_name -> atelet.EnvEntry - 17, // 12: atelet.Container.readyz:type_name -> atelet.Readyz - 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 - 2, // 20: atelet.UploadPausedCheckpointRequest.desired_scope:type_name -> atelet.SnapshotScope - 10, // 21: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 22: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType - 20, // 23: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 21, // 24: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 25: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope - 6, // 26: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway - 7, // 27: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile - 8, // 28: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets - 3, // 29: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest - 5, // 30: atelet.AteomHerder.Run:input_type -> atelet.RunRequest - 22, // 31: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest - 26, // 32: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest - 24, // 33: atelet.AteomHerder.UploadPausedCheckpoint:input_type -> atelet.UploadPausedCheckpointRequest - 4, // 34: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse - 19, // 35: atelet.AteomHerder.Run:output_type -> atelet.RunResponse - 23, // 36: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse - 27, // 37: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse - 25, // 38: atelet.AteomHerder.UploadPausedCheckpoint:output_type -> atelet.UploadPausedCheckpointResponse - 34, // [34:39] is the sub-list for method output_type - 29, // [29:34] is the sub-list for method input_type - 29, // [29:29] is the sub-list for extension type_name - 29, // [29:29] is the sub-list for extension extendee - 0, // [0:29] 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 + 27, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 28, // 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 + 29, // 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 + 1, // 19: atelet.UploadPausedCheckpointRequest.desired_scope:type_name -> atelet.SnapshotScope + 9, // 20: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 0, // 21: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 19, // 22: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 20, // 23: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 1, // 24: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 5, // 25: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 6, // 26: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 7, // 27: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 2, // 28: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest + 4, // 29: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 21, // 30: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 25, // 31: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 23, // 32: atelet.AteomHerder.UploadPausedCheckpoint:input_type -> atelet.UploadPausedCheckpointRequest + 3, // 33: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse + 18, // 34: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 22, // 35: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 26, // 36: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 24, // 37: atelet.AteomHerder.UploadPausedCheckpoint:output_type -> atelet.UploadPausedCheckpointResponse + 33, // [33:38] is the sub-list for method output_type + 28, // [28:33] 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 } func init() { file_atelet_proto_init() } @@ -2195,7 +2130,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: 28, NumExtensions: 0, NumServices: 2, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index 6b5a68c2e..842ce2645 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -132,12 +132,6 @@ message WorkloadSpec { reserved "pause_image"; // moved to SandboxAssets } -enum VolumeType { - VOLUME_TYPE_UNSPECIFIED = 0; - VOLUME_TYPE_DURABLE_DIR = 1; - VOLUME_TYPE_EXTERNAL = 2; -} - message DurableDirVolume { } @@ -150,11 +144,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 84a843ca72f1534a5a1493b5f34fe8b9093620d6 Mon Sep 17 00:00:00 2001 From: Taahir Ahmed Date: Thu, 9 Jul 2026 14:50:22 -0700 Subject: [PATCH 02/10] 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 | 414 +++++-- 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, 2283 insertions(+), 224 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 e94e9875d..d75402bf2 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec.go +++ b/cmd/ateapi/internal/controlapi/workload_spec.go @@ -27,16 +27,43 @@ import ( func workloadSpecFromActorTemplate(actorTemplate *atev1alpha1.ActorTemplate, actor *ateapipb.Actor) (*ateletpb.WorkloadSpec, error) { workloadSpec := &ateletpb.WorkloadSpec{} - // 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 2e7bb3c7b..76440dc23 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" @@ -1389,23 +1390,43 @@ func (s *AteomHerder) prepareOCIBundles( pauseImage string, 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) + } } } @@ -1438,8 +1459,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) @@ -1470,7 +1490,6 @@ func (s *AteomHerder) prepareOCIBundles( "io.kubernetes.cri.container-name": ctr.GetName(), }, ateompath.AteomNetNSPath(targetAteomUid), - identityDir, spec.GetVolumes(), ctr.GetVolumeMounts(), ); err != nil { @@ -1848,16 +1867,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) @@ -1866,6 +1875,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 dfff194c5..ca34d920b 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -177,10 +177,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 bd715695c..5e20084c5 100644 --- a/internal/ateompath/ateompath.go +++ b/internal/ateompath/ateompath.go @@ -113,18 +113,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 @@ -211,6 +199,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 443d81e82..22e71c9b0 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -750,6 +750,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"` @@ -757,6 +916,7 @@ type Volume struct { // // *Volume_DurableDir // *Volume_External + // *Volume_SystemInfo Source isVolume_Source `protobuf_oneof:"source"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -764,7 +924,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) } @@ -776,7 +936,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 { @@ -789,7 +949,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 { @@ -824,6 +984,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() } @@ -836,10 +1005,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"` @@ -850,7 +1025,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) } @@ -862,7 +1037,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 { @@ -875,7 +1050,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 { @@ -907,7 +1082,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) } @@ -919,7 +1094,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 { @@ -932,7 +1107,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 { @@ -994,7 +1169,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) } @@ -1006,7 +1181,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 { @@ -1019,7 +1194,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 { @@ -1050,7 +1225,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) } @@ -1062,7 +1237,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 { @@ -1075,7 +1250,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 { @@ -1105,7 +1280,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) } @@ -1117,7 +1292,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 { @@ -1130,7 +1305,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 { @@ -1155,7 +1330,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) } @@ -1167,7 +1342,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 { @@ -1180,7 +1355,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 { @@ -1196,7 +1371,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) } @@ -1208,7 +1383,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 { @@ -1221,7 +1396,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) GetSnapshotName() string { @@ -1242,7 +1417,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) } @@ -1254,7 +1429,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 { @@ -1267,7 +1442,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) GetSnapshotUri() string { @@ -1305,7 +1480,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) } @@ -1317,7 +1492,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 { @@ -1330,7 +1505,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 { @@ -1445,7 +1620,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) } @@ -1457,7 +1632,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 { @@ -1470,7 +1645,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 UploadPausedCheckpointRequest struct { @@ -1498,7 +1673,7 @@ type UploadPausedCheckpointRequest struct { func (x *UploadPausedCheckpointRequest) Reset() { *x = UploadPausedCheckpointRequest{} - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1510,7 +1685,7 @@ func (x *UploadPausedCheckpointRequest) String() string { func (*UploadPausedCheckpointRequest) ProtoMessage() {} func (x *UploadPausedCheckpointRequest) 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 { @@ -1523,7 +1698,7 @@ func (x *UploadPausedCheckpointRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadPausedCheckpointRequest.ProtoReflect.Descriptor instead. func (*UploadPausedCheckpointRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{21} + return file_atelet_proto_rawDescGZIP(), []int{24} } func (x *UploadPausedCheckpointRequest) GetAtespace() string { @@ -1590,7 +1765,7 @@ type UploadPausedCheckpointResponse struct { func (x *UploadPausedCheckpointResponse) Reset() { *x = UploadPausedCheckpointResponse{} - mi := &file_atelet_proto_msgTypes[22] + mi := &file_atelet_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1602,7 +1777,7 @@ func (x *UploadPausedCheckpointResponse) String() string { func (*UploadPausedCheckpointResponse) ProtoMessage() {} func (x *UploadPausedCheckpointResponse) 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 { @@ -1615,7 +1790,7 @@ func (x *UploadPausedCheckpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadPausedCheckpointResponse.ProtoReflect.Descriptor instead. func (*UploadPausedCheckpointResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{22} + return file_atelet_proto_rawDescGZIP(), []int{25} } type RestoreRequest struct { @@ -1661,7 +1836,7 @@ type RestoreRequest struct { func (x *RestoreRequest) Reset() { *x = RestoreRequest{} - mi := &file_atelet_proto_msgTypes[23] + mi := &file_atelet_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1673,7 +1848,7 @@ func (x *RestoreRequest) String() string { func (*RestoreRequest) ProtoMessage() {} func (x *RestoreRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[23] + mi := &file_atelet_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1686,7 +1861,7 @@ func (x *RestoreRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreRequest.ProtoReflect.Descriptor instead. func (*RestoreRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{23} + return file_atelet_proto_rawDescGZIP(), []int{26} } func (x *RestoreRequest) GetTargetAteomUid() string { @@ -1829,7 +2004,7 @@ type RestoreResponse struct { func (x *RestoreResponse) Reset() { *x = RestoreResponse{} - mi := &file_atelet_proto_msgTypes[24] + mi := &file_atelet_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1841,7 +2016,7 @@ func (x *RestoreResponse) String() string { func (*RestoreResponse) ProtoMessage() {} func (x *RestoreResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[24] + mi := &file_atelet_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1854,7 +2029,7 @@ func (x *RestoreResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreResponse.ProtoReflect.Descriptor instead. func (*RestoreResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{24} + return file_atelet_proto_rawDescGZIP(), []int{27} } var File_atelet_proto protoreflect.FileDescriptor @@ -1916,12 +2091,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" + @@ -2028,7 +2212,7 @@ func file_atelet_proto_rawDescGZIP() []byte { } var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 28) +var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 31) var file_atelet_proto_goTypes = []any{ (CheckpointType)(0), // 0: atelet.CheckpointType (SnapshotScope)(0), // 1: atelet.SnapshotScope @@ -2042,69 +2226,75 @@ 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 - (*UploadPausedCheckpointRequest)(nil), // 23: atelet.UploadPausedCheckpointRequest - (*UploadPausedCheckpointResponse)(nil), // 24: atelet.UploadPausedCheckpointResponse - (*RestoreRequest)(nil), // 25: atelet.RestoreRequest - (*RestoreResponse)(nil), // 26: atelet.RestoreResponse - nil, // 27: atelet.ArchAssets.FilesEntry - nil, // 28: atelet.SandboxAssets.AssetsEntry - nil, // 29: 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 + (*UploadPausedCheckpointRequest)(nil), // 26: atelet.UploadPausedCheckpointRequest + (*UploadPausedCheckpointResponse)(nil), // 27: atelet.UploadPausedCheckpointResponse + (*RestoreRequest)(nil), // 28: atelet.RestoreRequest + (*RestoreResponse)(nil), // 29: atelet.RestoreResponse + nil, // 30: atelet.ArchAssets.FilesEntry + nil, // 31: atelet.SandboxAssets.AssetsEntry + nil, // 32: 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 - 27, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry - 28, // 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 - 29, // 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 - 1, // 19: atelet.UploadPausedCheckpointRequest.desired_scope:type_name -> atelet.SnapshotScope - 9, // 20: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec - 0, // 21: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType - 19, // 22: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 20, // 23: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 1, // 24: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope - 5, // 25: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway - 6, // 26: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile - 7, // 27: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets - 2, // 28: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest - 4, // 29: atelet.AteomHerder.Run:input_type -> atelet.RunRequest - 21, // 30: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest - 25, // 31: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest - 23, // 32: atelet.AteomHerder.UploadPausedCheckpoint:input_type -> atelet.UploadPausedCheckpointRequest - 3, // 33: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse - 18, // 34: atelet.AteomHerder.Run:output_type -> atelet.RunResponse - 22, // 35: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse - 26, // 36: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse - 24, // 37: atelet.AteomHerder.UploadPausedCheckpoint:output_type -> atelet.UploadPausedCheckpointResponse - 33, // [33:38] is the sub-list for method output_type - 28, // [28:33] 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 + 30, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 31, // 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 + 32, // 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 + 1, // 22: atelet.UploadPausedCheckpointRequest.desired_scope:type_name -> atelet.SnapshotScope + 9, // 23: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 0, // 24: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 22, // 25: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 23, // 26: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 1, // 27: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 5, // 28: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 6, // 29: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 7, // 30: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 2, // 31: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest + 4, // 32: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 24, // 33: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 28, // 34: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 26, // 35: atelet.AteomHerder.UploadPausedCheckpoint:input_type -> atelet.UploadPausedCheckpointRequest + 3, // 36: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse + 21, // 37: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 25, // 38: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 29, // 39: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 27, // 40: atelet.AteomHerder.UploadPausedCheckpoint:output_type -> atelet.UploadPausedCheckpointResponse + 36, // [36:41] is the sub-list for method output_type + 31, // [31:36] is the sub-list for method input_type + 31, // [31:31] is the sub-list for extension type_name + 31, // [31:31] is the sub-list for extension extendee + 0, // [0:31] is the sub-list for field type_name } func init() { file_atelet_proto_init() } @@ -2113,15 +2303,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[23].OneofWrappers = []any{ + file_atelet_proto_msgTypes[26].OneofWrappers = []any{ (*RestoreRequest_LocalConfig)(nil), (*RestoreRequest_ExternalConfig)(nil), } @@ -2131,7 +2325,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: 28, + NumMessages: 31, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index 842ce2645..bfeafa848 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -141,12 +141,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 165d243f9..03b9070b0 100644 --- a/manifests/ate-install/generated/ate.dev_actortemplates.yaml +++ b/manifests/ate-install/generated/ate.dev_actortemplates.yaml @@ -412,13 +412,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 1b659ee2e..8f1df70db 100644 --- a/pkg/api/v1alpha1/actortemplate_types.go +++ b/pkg/api/v1alpha1/actortemplate_types.go @@ -46,12 +46,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. @@ -63,6 +95,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 7499323d7..efa1c9e41 100644 --- a/pkg/api/v1alpha1/actortemplate_validation_test.go +++ b/pkg/api/v1alpha1/actortemplate_validation_test.go @@ -761,7 +761,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) { @@ -770,7 +770,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) { @@ -784,7 +784,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 8b54a4259..75befe656 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 @@ -477,6 +492,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 @@ -521,6 +578,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 4ccf4abcfb2fb3b0981d180433b8f3f7ccec4f18 Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Fri, 7 Aug 2026 10:27:29 -0700 Subject: [PATCH 03/10] 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 d7e36df67..70ddee254 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec_test.go +++ b/cmd/ateapi/internal/controlapi/workload_spec_test.go @@ -70,6 +70,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 76440dc23..5b0dd7b2d 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -1400,32 +1400,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) } } } @@ -1502,6 +1479,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 700b26ddd..74d532a70 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -119,6 +119,52 @@ func TestSnapshotManifestRequiresPauseImage(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 3e328b486..d056016c7 100644 --- a/cmd/ateom-microvm/spec.go +++ b/cmd/ateom-microvm/spec.go @@ -95,12 +95,13 @@ func ensureKataCompatibleSpec(bundle, id, netnsPath string, size sizing.SandboxS // 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 ec2192bea..d16d9f816 100644 --- a/internal/e2e/fixtures/probe/main.go +++ b/internal/e2e/fixtures/probe/main.go @@ -31,8 +31,8 @@ import ( "strings" ) -// 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 85ea73fd7..890fa9777 100644 --- a/internal/e2e/fixtures/probe/probe.yaml.tmpl +++ b/internal/e2e/fixtures/probe/probe.yaml.tmpl @@ -38,10 +38,19 @@ metadata: name: probe namespace: ate-e2e-probe spec: + 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 03b9070b0..916b1f311 100644 --- a/manifests/ate-install/generated/ate.dev_actortemplates.yaml +++ b/manifests/ate-install/generated/ate.dev_actortemplates.yaml @@ -434,10 +434,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 @@ -449,6 +459,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 8f1df70db..242999462 100644 --- a/pkg/api/v1alpha1/actortemplate_types.go +++ b/pkg/api/v1alpha1/actortemplate_types.go @@ -50,11 +50,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"` } @@ -75,6 +78,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 efa1c9e41..5c68b6208 100644 --- a/pkg/api/v1alpha1/actortemplate_validation_test.go +++ b/pkg/api/v1alpha1/actortemplate_validation_test.go @@ -785,6 +785,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) { From cb4bd9c119c2e9cf333f2878c4d81a0dcfe971f2 Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Mon, 10 Aug 2026 16:20:34 -0700 Subject: [PATCH 04/10] Restructure SystemInfo data sources around actorMetadata Per the API discussion on #802: substrate has no "actor ID" concept -- resource identity is (atespace, name) plus a server-generated UID. Replace the actorIdentity data source with an actorMetadata source that projects each identity field to its own file, downwardAPI-style: systemInfo: dataSources: - actorMetadata: items: - field: name # enum: name | atespace | uid path: actor-name - CRD: ActorMetadataDataSource with a field enum and per-item path; admission validation for unknown fields, duplicate fields, duplicate paths, and non-clean/absolute paths; at most one actorMetadata entry per volume keeps paths unique volume-wide. - atelet proto: ActorMetadataDataSource/ActorMetadataItem with a field enum; ateapi converts CRD items to wire items. - atelet: writeSystemInfoVolume projects name/atespace/uid from the Run/Restore request; unknown fields (newer ateapi) are skipped rather than written empty. - e2e: the identity probe projects and serves all three fields; the suite now also asserts atespace matches and the projected UID equals the control plane's authoritative UID per actor, distinct across actors seeded from the same snapshot. - docs: api-guide section rewritten for actorMetadata. This also frees the "identity" naming for the planned credential data sources (actorIdentityToken, actorIdentityCertificate), which relate to the existing ateapi.ActorIdentity service. --- .../internal/controlapi/workload_spec.go | 31 +- .../internal/controlapi/workload_spec_test.go | 26 +- cmd/atelet/main.go | 42 +- cmd/atelet/main_test.go | 35 +- cmd/ateom-microvm/spec.go | 2 +- docs/api-guide.md | 22 +- internal/e2e/fixtures/probe/main.go | 27 +- internal/e2e/fixtures/probe/probe.yaml.tmpl | 10 +- internal/e2e/suites/identity/identity_test.go | 26 +- internal/proto/ateletpb/atelet.pb.go | 461 +++++++++++------- internal/proto/ateletpb/atelet.proto | 29 +- .../generated/ate.dev_actortemplates.yaml | 87 +++- pkg/api/v1alpha1/actortemplate_types.go | 67 ++- .../v1alpha1/actortemplate_validation_test.go | 128 ++++- pkg/api/v1alpha1/zz_generated.deepcopy.go | 36 +- 15 files changed, 724 insertions(+), 305 deletions(-) diff --git a/cmd/ateapi/internal/controlapi/workload_spec.go b/cmd/ateapi/internal/controlapi/workload_spec.go index d75402bf2..b2420c368 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec.go +++ b/cmd/ateapi/internal/controlapi/workload_spec.go @@ -43,12 +43,17 @@ func workloadSpecFromActorTemplate(actorTemplate *atev1alpha1.ActorTemplate, act ateletSystemInfo := &ateletpb.SystemInfoVolume{} for _, dataSource := range vol.VolumeSource.SystemInfo.DataSources { switch { - case dataSource.ActorIdentity != nil: + case dataSource.ActorMetadata != nil: + actorMetadata := &ateletpb.ActorMetadataDataSource{} + for _, item := range dataSource.ActorMetadata.Items { + actorMetadata.Items = append(actorMetadata.Items, &ateletpb.ActorMetadataItem{ + Field: toAteletActorMetadataField(item.Field), + Path: item.Path, + }) + } ateletSystemInfo.DataSources = append(ateletSystemInfo.DataSources, &ateletpb.SystemInfoDataSource{ - DataSource: &ateletpb.SystemInfoDataSource_ActorIdentity{ - ActorIdentity: &ateletpb.ActorIdentityDataSource{ - Path: dataSource.ActorIdentity.Path, - }, + DataSource: &ateletpb.SystemInfoDataSource_ActorMetadata{ + ActorMetadata: actorMetadata, }, }) default: @@ -157,6 +162,22 @@ func isVolumeMounted(volumeName string, template *atev1alpha1.ActorTemplate) boo // toAteletReadyz projects the CRD readyz field onto the ateletpb wire type. // Returns nil when the source is nil so containers without a probe stay // unchanged on the wire. +// toAteletActorMetadataField projects the CRD field selector onto the atelet +// wire enum. Unknown values map to UNSPECIFIED, which atelet skips; CRD enum +// validation makes that unreachable for stored templates. +func toAteletActorMetadataField(in atev1alpha1.ActorMetadataField) ateletpb.ActorMetadataField { + switch in { + case atev1alpha1.ActorMetadataFieldName: + return ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME + case atev1alpha1.ActorMetadataFieldAtespace: + return ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_ATESPACE + case atev1alpha1.ActorMetadataFieldUID: + return ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_UID + default: + return ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_UNSPECIFIED + } +} + func toAteletReadyz(in *atev1alpha1.ContainerReadyz) *ateletpb.Readyz { if in == nil { return nil diff --git a/cmd/ateapi/internal/controlapi/workload_spec_test.go b/cmd/ateapi/internal/controlapi/workload_spec_test.go index 70ddee254..3e1b4a276 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec_test.go +++ b/cmd/ateapi/internal/controlapi/workload_spec_test.go @@ -71,19 +71,23 @@ func TestWorkloadSpecFromActorTemplate(t *testing.T) { }, }, { - name: "converts SystemInfo volume with ActorIdentity data sources", + name: "converts SystemInfo volume with actorMetadata items", 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"}}, + {ActorMetadata: &atev1alpha1.ActorMetadataDataSource{ + Items: []atev1alpha1.ActorMetadataItem{ + {Field: atev1alpha1.ActorMetadataFieldName, Path: "actor-name"}, + {Field: atev1alpha1.ActorMetadataFieldAtespace, Path: "atespace"}, + {Field: atev1alpha1.ActorMetadataFieldUID, Path: "identity/actor-uid"}, + }, + }}, }, }, }, @@ -101,18 +105,20 @@ func TestWorkloadSpecFromActorTemplate(t *testing.T) { }, }, 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"}, + {DataSource: &ateletpb.SystemInfoDataSource_ActorMetadata{ + ActorMetadata: &ateletpb.ActorMetadataDataSource{ + Items: []*ateletpb.ActorMetadataItem{ + {Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME, Path: "actor-name"}, + {Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_ATESPACE, Path: "atespace"}, + {Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_UID, Path: "identity/actor-uid"}, + }, + }, }}, }, }, diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 5b0dd7b2d..d933db232 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -456,7 +456,7 @@ func (s *AteomHerder) Run(ctx context.Context, req *ateletpb.RunRequest) (resp * return nil, fmt.Errorf("while recording sandbox assets: %w", err) } - if err := s.prepareOCIBundles(ctx, actorUID, actorRef.Name, + if err := s.prepareOCIBundles(ctx, actorUID, actorRef, req.GetSpec(), sandboxRec.PauseImage, req.GetTargetAteomUid(), ); err != nil { return nil, ateerrors.CrashIfReason(ctx, err, ateerrors.ReasonInvalidContainerConfig) @@ -1080,7 +1080,7 @@ func (s *AteomHerder) Restore(ctx context.Context, req *ateletpb.RestoreRequest) return ateerrors.CrashIfReason(ctx, err, ateerrors.ReasonFailedGetExternalObject, ateerrors.ReasonInvalidObjectURL, ateerrors.ReasonTerminalFileSystemError, ateerrors.ReasonInvalidSandboxAsset) } t := time.Now() - err = s.prepareOCIBundles(gctx, actorUID, actorRef.Name, req.GetSpec(), runtimeRec.PauseImage, req.GetTargetAteomUid()) + err = s.prepareOCIBundles(gctx, actorUID, actorRef, req.GetSpec(), runtimeRec.PauseImage, req.GetTargetAteomUid()) dBundles = time.Since(t) if err != nil { prepFailedPhase = ateattr.SnapshotPhaseOCIUnpack @@ -1385,7 +1385,7 @@ func (s *AteomHerder) downloadExternalCheckpoint(ctx context.Context, snapshotUR func (s *AteomHerder) prepareOCIBundles( ctx context.Context, actorUID string, - actorName string, + actorRef resources.ActorRef, spec *ateletpb.WorkloadSpec, pauseImage string, targetAteomUid string, @@ -1401,7 +1401,7 @@ func (s *AteomHerder) prepareOCIBundles( case *ateletpb.Volume_SystemInfo: volRootHostPath := ateompath.SystemInfoVolumeRoot(actorUID, vol.GetName()) - if err := writeSystemInfoVolume(ctx, volRootHostPath, actorName, volSrc.SystemInfo); err != nil { + if err := writeSystemInfoVolume(ctx, volRootHostPath, actorRef, actorUID, volSrc.SystemInfo); err != nil { return fmt.Errorf("while populating system-info volume %q: %w", vol.GetName(), err) } } @@ -1480,11 +1480,12 @@ func (s *AteomHerder) prepareOCIBundles( } // 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 { +// with one file per projected item. It runs on every Run/Restore, before the +// sandbox starts, so the files carry the values of the actor actually being +// started, no matter what checkpointed state it 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 string, actorRef resources.ActorRef, actorUID string, si *ateletpb.SystemInfoVolume) error { if err := os.MkdirAll(rootPath, 0o755); err != nil { return fmt.Errorf("while creating %q: %w", rootPath, err) } @@ -1497,10 +1498,25 @@ func writeSystemInfoVolume(ctx context.Context, rootPath, actorName string, si * 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, + case *ateletpb.SystemInfoDataSource_ActorMetadata: + for _, item := range dataSource.ActorMetadata.GetItems() { + var value string + switch item.GetField() { + case ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME: + value = actorRef.Name + case ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_ATESPACE: + value = actorRef.Atespace + case ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_UID: + value = actorUID + default: + // Unknown fields come only from a newer ateapi; skip the + // item rather than write an empty file under its path. + continue + } + contents[item.GetPath()] = atomicwriter.FileProjection{ + Data: []byte(value), + Mode: 0o644, + } } } } diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index 74d532a70..1ff751e70 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -124,35 +124,44 @@ func TestWriteSystemInfoVolume(t *testing.T) { 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"}, + {DataSource: &ateletpb.SystemInfoDataSource_ActorMetadata{ + ActorMetadata: &ateletpb.ActorMetadataDataSource{ + Items: []*ateletpb.ActorMetadataItem{ + {Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME, Path: "actor-name"}, + {Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_ATESPACE, Path: "atespace"}, + {Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_UID, Path: "identity/actor-uid"}, + }, + }, }}, }, } - if err := writeSystemInfoVolume(ctx, root, "golden-actor", si); err != nil { + golden := resources.ActorRef{Atespace: "ate-e2e-probe", Name: "golden-actor"} + if err := writeSystemInfoVolume(ctx, root, golden, "uid-golden", 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 { + // Overwrite with a different actor, as happens when a snapshot taken from + // one actor seeds another on resume: files must carry the new values. + alpha := resources.ActorRef{Atespace: "ate-e2e-probe", Name: "probe-alpha"} + if err := writeSystemInfoVolume(ctx, root, alpha, "uid-alpha", si); err != nil { t.Fatalf("writeSystemInfoVolume (rewrite): %v", err) } - for _, path := range []string{"actor-id", "identity/name"} { + // Values are written raw, no trailing newline. + for path, want := range map[string]string{ + "actor-name": "probe-alpha", + "atespace": "ate-e2e-probe", + "identity/actor-uid": "uid-alpha", + } { 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") + if string(got) != want { + t.Errorf("content = %q, want %q", got, want) } info, err := os.Stat(target) if err != nil { diff --git a/cmd/ateom-microvm/spec.go b/cmd/ateom-microvm/spec.go index d056016c7..ba1de20c1 100644 --- a/cmd/ateom-microvm/spec.go +++ b/cmd/ateom-microvm/spec.go @@ -96,7 +96,7 @@ func ensureKataCompatibleSpec(bundle, id, netnsPath string, size sizing.SandboxS // agent accepts. (Static shaper; pod DNS integration is future work.) // // KNOWN GAP vs the gVisor runtime: this also drops atelet's read-only - // systemInfo volume bind mounts (e.g. the actorIdentity data-source file). + // systemInfo volume bind mounts (e.g. the actorMetadata data-source files). // 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 diff --git a/docs/api-guide.md b/docs/api-guide.md index ca34d920b..8ecf08e74 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -151,7 +151,7 @@ The `ActorTemplate` defines the code, environment, and state-management policies | `sandboxClass` | `string` | Optional. The sandbox runtime family this template's actors require: `gvisor` (default) or `microvm`. Only `WorkerPool`s whose `sandboxClass` matches are eligible. | | `workerSelector` | `*LabelSelector` | Optional. Gates which `WorkerPool`s actors from this template may use, by matching against each pool's labels. If unset, all pools are eligible (subject to the actor's own `worker_selector`). | | `snapshotsConfig` | `SnapshotsConfig` | **Required.** The base object-storage location snapshots are written under, plus the pause/commit/resume scopes. See [Snapshot Storage Layout](#snapshot-storage-layout). | -| `volumes` | `[]Volume` | Optional. Volumes the containers may mount, each either a `durableDir` or an `externalVolumeTemplate`. Every declared volume must be mounted by at least one container. A `microvm` template may declare several `durableDir` volumes; a `gvisor` template is limited to one, and `externalVolumeTemplate` is `gvisor`-only. | +| `volumes` | `[]Volume` | Optional. Volumes the containers may mount, each a `durableDir`, an `externalVolumeTemplate`, or a `systemInfo` volume (see [SystemInfo Volumes](#systeminfo-volumes)). Every declared volume must be mounted by at least one container. A `microvm` template may declare several `durableDir` volumes; a `gvisor` template is limited to one, and `externalVolumeTemplate` is `gvisor`-only. | | `resources` | `*ResourceRequirements` | Optional. Declares each actor's compute size via `limits` — see [Sandbox Right-Sizing](#sandbox-right-sizing-specresources). Immutable, like the rest of the spec. | The sandbox itself — the binaries (e.g. the gVisor `runsc` binary) and the `pauseImage` holding the sandbox's namespaces — is **not configured on the `ActorTemplate`**. It is resolved from the referenced `WorkerPool`'s [`SandboxConfig`](#3-sandboxconfig-the-sandbox-itself) — by name (`workerPool.spec.sandboxConfigName`) or, by default, the cluster default `SandboxConfig` for the pool's `sandboxClass`. @@ -183,8 +183,8 @@ To deliver identity information, including credentials, to a running actor, you 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. +#### actorMetadata +The actorMetadata data source projects the actor's identity fields to files, one per item, analogous to the [Kubernetes downwardAPI volume](https://kubernetes.io/docs/concepts/storage/downward-api/). Each item selects a `field` — `name` (unique within an atespace), `atespace` (together with the name, the actor's full identity and DNS name), or `uid` (server-generated, distinguishes incarnations of the same name) — and the relative `path` the value is written to, raw with no trailing newline. ```yaml spec: @@ -192,17 +192,23 @@ spec: - name: system-info systemInfo: dataSources: - - actorIdentity: - path: actor-id + - actorMetadata: + items: + - field: name + path: actor-name + - field: atespace + path: atespace + - field: uid + path: actor-uid containers: - name: main # ... volumeMounts: - name: system-info - mountPath: /run/ate # the actor reads /run/ate/actor-id + mountPath: /run/ate # the actor reads e.g. /run/ate/actor-name ``` -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. +The values are delivered as files on a read-only per-actor bind mount, not environment variables, precisely so they carry the correct values after a resume from a shared snapshot — an env var (or a file baked into the image) would be frozen at the snapshot-source actor's values, since it lives in the checkpointed process memory, and would therefore be identical for every actor restored from that snapshot. The metadata fields themselves are fixed for the actor's lifetime, so workloads may cache them; future data sources that rotate (identity tokens and certificates) must be re-read at time of use. ### Container Fields @@ -413,7 +419,7 @@ Query the physical resource pool. ## 7. Advanced: Actor Identity Credentials -Workloads can exchange their ephemeral Kubernetes credentials for stable **Actor Identity** credentials that persist even as the process migrates between different physical workers. This is distinct from the `/run/ate/actor-id` bind mount described under [Actor Identity](#actor-identity), which only tells an actor its own name. +Workloads can exchange their ephemeral Kubernetes credentials for stable **Actor Identity** credentials that persist even as the process migrates between different physical workers. This is distinct from the `actorMetadata` data source described under [SystemInfo Volumes](#systeminfo-volumes), which only tells an actor its own identity fields (name, atespace, uid). ### Service: `ateapi.ActorIdentity` * **`MintJWT`:** Generates an OIDC-compatible JWT identifying the Substrate Actor. diff --git a/internal/e2e/fixtures/probe/main.go b/internal/e2e/fixtures/probe/main.go index d16d9f816..d37daf2f9 100644 --- a/internal/e2e/fixtures/probe/main.go +++ b/internal/e2e/fixtures/probe/main.go @@ -31,9 +31,13 @@ import ( "strings" ) -// 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" +// The actorMetadata data-source files of the systemInfo volume that +// probe.yaml.tmpl mounts at /run/ate. +const ( + identityFile = "/run/ate/actor-id" + atespaceFile = "/run/ate/atespace" + uidFile = "/run/ate/actor-uid" +) // whoami reports the actor's identity as observed at request time from the // bind-mounted identity file. A read failure is reported in the response @@ -42,11 +46,18 @@ func whoami(w http.ResponseWriter, _ *http.Request) { host, _ := os.Hostname() resp := map[string]string{"hostname": host} - if b, err := os.ReadFile(identityFile); err == nil { - resp["file"] = string(b) - } else { - resp["file"] = "" - resp["error"] = err.Error() + for key, path := range map[string]string{ + "file": identityFile, + "atespace": atespaceFile, + "uid": uidFile, + } { + if b, err := os.ReadFile(path); err == nil { + resp[key] = string(b) + } else { + resp[key] = "" + // Concatenate: a failed assertion should explain every missing file. + resp["error"] += err.Error() + "; " + } } writeJSON(w, resp) diff --git a/internal/e2e/fixtures/probe/probe.yaml.tmpl b/internal/e2e/fixtures/probe/probe.yaml.tmpl index 890fa9777..a0fc50c4a 100644 --- a/internal/e2e/fixtures/probe/probe.yaml.tmpl +++ b/internal/e2e/fixtures/probe/probe.yaml.tmpl @@ -42,8 +42,14 @@ spec: - name: system-info systemInfo: dataSources: - - actorIdentity: - path: actor-id + - actorMetadata: + items: + - field: name + path: actor-id + - field: atespace + path: atespace + - field: uid + path: actor-uid containers: - name: probe image: ko://github.com/agent-substrate/substrate/internal/e2e/fixtures/probe diff --git a/internal/e2e/suites/identity/identity_test.go b/internal/e2e/suites/identity/identity_test.go index abfcf7e9f..742e22013 100644 --- a/internal/e2e/suites/identity/identity_test.go +++ b/internal/e2e/suites/identity/identity_test.go @@ -39,9 +39,11 @@ const ( type whoamiResponse struct { File string `json:"file"` + Atespace string `json:"atespace"` + UID string `json:"uid"` Hostname string `json:"hostname"` - // Error is the probe's identity-file read error, if any, so a failed - // assertion explains why the ID was missing. + // Error is the probe's file read error(s), if any, so a failed assertion + // explains why a value was missing. Error string `json:"error"` } @@ -75,6 +77,7 @@ func TestActorIdentity_AfterRestore_IsOwnID_NotGolden(t *testing.T) { defer rc.Close() seen := map[string]string{} + seenUIDs := map[string]string{} for _, id := range ids { got := whoami(t, ctx, rc, id) @@ -88,6 +91,25 @@ func TestActorIdentity_AfterRestore_IsOwnID_NotGolden(t *testing.T) { t.Errorf("actor %q and %q both report identity %q — actors are not distinct", id, other, got.File) } seen[got.File] = id + + if got.Atespace != probeNamespace { + t.Errorf("actor %q: /run/ate/atespace = %q, want %q (probe read error: %q)", id, got.Atespace, probeNamespace, got.Error) + } + + // The projected UID must match the control plane's authoritative view + // of this actor, and be distinct per actor even though both actors + // were seeded from the same golden snapshot. + actor, err := clients.SubstrateAPI.GetActor(ctx, &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: probeNamespace, Name: id}}) + if err != nil { + t.Fatalf("GetActor %q: %v", id, err) + } + if wantUID := actor.GetMetadata().GetUid(); got.UID != wantUID { + t.Errorf("actor %q: /run/ate/actor-uid = %q, want %q (probe read error: %q)", id, got.UID, wantUID, got.Error) + } + if other, dup := seenUIDs[got.UID]; dup { + t.Errorf("actor %q and %q both report uid %q — actors are not distinct", id, other, got.UID) + } + seenUIDs[got.UID] = id } } diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index 22e71c9b0..154d7d816 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -35,6 +35,59 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// ActorMetadataField selects one identity field of the actor. +type ActorMetadataField int32 + +const ( + ActorMetadataField_ACTOR_METADATA_FIELD_UNSPECIFIED ActorMetadataField = 0 + ActorMetadataField_ACTOR_METADATA_FIELD_NAME ActorMetadataField = 1 + ActorMetadataField_ACTOR_METADATA_FIELD_ATESPACE ActorMetadataField = 2 + ActorMetadataField_ACTOR_METADATA_FIELD_UID ActorMetadataField = 3 +) + +// Enum value maps for ActorMetadataField. +var ( + ActorMetadataField_name = map[int32]string{ + 0: "ACTOR_METADATA_FIELD_UNSPECIFIED", + 1: "ACTOR_METADATA_FIELD_NAME", + 2: "ACTOR_METADATA_FIELD_ATESPACE", + 3: "ACTOR_METADATA_FIELD_UID", + } + ActorMetadataField_value = map[string]int32{ + "ACTOR_METADATA_FIELD_UNSPECIFIED": 0, + "ACTOR_METADATA_FIELD_NAME": 1, + "ACTOR_METADATA_FIELD_ATESPACE": 2, + "ACTOR_METADATA_FIELD_UID": 3, + } +) + +func (x ActorMetadataField) Enum() *ActorMetadataField { + p := new(ActorMetadataField) + *p = x + return p +} + +func (x ActorMetadataField) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ActorMetadataField) Descriptor() protoreflect.EnumDescriptor { + return file_atelet_proto_enumTypes[0].Descriptor() +} + +func (ActorMetadataField) Type() protoreflect.EnumType { + return &file_atelet_proto_enumTypes[0] +} + +func (x ActorMetadataField) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ActorMetadataField.Descriptor instead. +func (ActorMetadataField) EnumDescriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{0} +} + type CheckpointType int32 const ( @@ -71,11 +124,11 @@ func (x CheckpointType) String() string { } func (CheckpointType) Descriptor() protoreflect.EnumDescriptor { - return file_atelet_proto_enumTypes[0].Descriptor() + return file_atelet_proto_enumTypes[1].Descriptor() } func (CheckpointType) Type() protoreflect.EnumType { - return &file_atelet_proto_enumTypes[0] + return &file_atelet_proto_enumTypes[1] } func (x CheckpointType) Number() protoreflect.EnumNumber { @@ -84,7 +137,7 @@ func (x CheckpointType) Number() protoreflect.EnumNumber { // Deprecated: Use CheckpointType.Descriptor instead. func (CheckpointType) EnumDescriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{0} + return file_atelet_proto_rawDescGZIP(), []int{1} } type SnapshotScope int32 @@ -135,11 +188,11 @@ func (x SnapshotScope) String() string { } func (SnapshotScope) Descriptor() protoreflect.EnumDescriptor { - return file_atelet_proto_enumTypes[1].Descriptor() + return file_atelet_proto_enumTypes[2].Descriptor() } func (SnapshotScope) Type() protoreflect.EnumType { - return &file_atelet_proto_enumTypes[1] + return &file_atelet_proto_enumTypes[2] } func (x SnapshotScope) Number() protoreflect.EnumNumber { @@ -148,7 +201,7 @@ func (x SnapshotScope) Number() protoreflect.EnumNumber { // Deprecated: Use SnapshotScope.Descriptor instead. func (SnapshotScope) EnumDescriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{1} + return file_atelet_proto_rawDescGZIP(), []int{2} } type MintActorCertificateRequest struct { @@ -750,29 +803,30 @@ 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 { +// ActorMetadataItem projects one actor identity field to one file at the +// given path, relative to the root of the enclosing system-info volume. +type ActorMetadataItem struct { state protoimpl.MessageState `protogen:"open.v1"` - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Field ActorMetadataField `protobuf:"varint,1,opt,name=field,proto3,enum=atelet.ActorMetadataField" json:"field,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *ActorIdentityDataSource) Reset() { - *x = ActorIdentityDataSource{} +func (x *ActorMetadataItem) Reset() { + *x = ActorMetadataItem{} mi := &file_atelet_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ActorIdentityDataSource) String() string { +func (x *ActorMetadataItem) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ActorIdentityDataSource) ProtoMessage() {} +func (*ActorMetadataItem) ProtoMessage() {} -func (x *ActorIdentityDataSource) ProtoReflect() protoreflect.Message { +func (x *ActorMetadataItem) ProtoReflect() protoreflect.Message { mi := &file_atelet_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -784,23 +838,76 @@ func (x *ActorIdentityDataSource) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ActorIdentityDataSource.ProtoReflect.Descriptor instead. -func (*ActorIdentityDataSource) Descriptor() ([]byte, []int) { +// Deprecated: Use ActorMetadataItem.ProtoReflect.Descriptor instead. +func (*ActorMetadataItem) Descriptor() ([]byte, []int) { return file_atelet_proto_rawDescGZIP(), []int{10} } -func (x *ActorIdentityDataSource) GetPath() string { +func (x *ActorMetadataItem) GetField() ActorMetadataField { + if x != nil { + return x.Field + } + return ActorMetadataField_ACTOR_METADATA_FIELD_UNSPECIFIED +} + +func (x *ActorMetadataItem) GetPath() string { if x != nil { return x.Path } return "" } +// ActorMetadataDataSource projects the actor's identity fields to files, one +// per item. Values are written raw with no trailing newline. +type ActorMetadataDataSource struct { + state protoimpl.MessageState `protogen:"open.v1"` + Items []*ActorMetadataItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ActorMetadataDataSource) Reset() { + *x = ActorMetadataDataSource{} + mi := &file_atelet_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ActorMetadataDataSource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ActorMetadataDataSource) ProtoMessage() {} + +func (x *ActorMetadataDataSource) 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 ActorMetadataDataSource.ProtoReflect.Descriptor instead. +func (*ActorMetadataDataSource) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{11} +} + +func (x *ActorMetadataDataSource) GetItems() []*ActorMetadataItem { + if x != nil { + return x.Items + } + return nil +} + type SystemInfoDataSource struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to DataSource: // - // *SystemInfoDataSource_ActorIdentity + // *SystemInfoDataSource_ActorMetadata DataSource isSystemInfoDataSource_DataSource `protobuf_oneof:"data_source"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -808,7 +915,7 @@ type SystemInfoDataSource struct { func (x *SystemInfoDataSource) Reset() { *x = SystemInfoDataSource{} - mi := &file_atelet_proto_msgTypes[11] + mi := &file_atelet_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -820,7 +927,7 @@ func (x *SystemInfoDataSource) String() string { func (*SystemInfoDataSource) ProtoMessage() {} func (x *SystemInfoDataSource) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[11] + mi := &file_atelet_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -833,7 +940,7 @@ func (x *SystemInfoDataSource) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemInfoDataSource.ProtoReflect.Descriptor instead. func (*SystemInfoDataSource) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{11} + return file_atelet_proto_rawDescGZIP(), []int{12} } func (x *SystemInfoDataSource) GetDataSource() isSystemInfoDataSource_DataSource { @@ -843,10 +950,10 @@ func (x *SystemInfoDataSource) GetDataSource() isSystemInfoDataSource_DataSource return nil } -func (x *SystemInfoDataSource) GetActorIdentity() *ActorIdentityDataSource { +func (x *SystemInfoDataSource) GetActorMetadata() *ActorMetadataDataSource { if x != nil { - if x, ok := x.DataSource.(*SystemInfoDataSource_ActorIdentity); ok { - return x.ActorIdentity + if x, ok := x.DataSource.(*SystemInfoDataSource_ActorMetadata); ok { + return x.ActorMetadata } } return nil @@ -856,15 +963,15 @@ type isSystemInfoDataSource_DataSource interface { isSystemInfoDataSource_DataSource() } -type SystemInfoDataSource_ActorIdentity struct { - ActorIdentity *ActorIdentityDataSource `protobuf:"bytes,1,opt,name=actor_identity,json=actorIdentity,proto3,oneof"` +type SystemInfoDataSource_ActorMetadata struct { + ActorMetadata *ActorMetadataDataSource `protobuf:"bytes,1,opt,name=actor_metadata,json=actorMetadata,proto3,oneof"` } -func (*SystemInfoDataSource_ActorIdentity) isSystemInfoDataSource_DataSource() {} +func (*SystemInfoDataSource_ActorMetadata) 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. +// on every Run/Restore, so they carry the values of the actor actually being +// started, whatever checkpointed state it boots from. 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"` @@ -874,7 +981,7 @@ type SystemInfoVolume struct { func (x *SystemInfoVolume) Reset() { *x = SystemInfoVolume{} - mi := &file_atelet_proto_msgTypes[12] + mi := &file_atelet_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -886,7 +993,7 @@ func (x *SystemInfoVolume) String() string { func (*SystemInfoVolume) ProtoMessage() {} func (x *SystemInfoVolume) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[12] + mi := &file_atelet_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -899,7 +1006,7 @@ func (x *SystemInfoVolume) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemInfoVolume.ProtoReflect.Descriptor instead. func (*SystemInfoVolume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{12} + return file_atelet_proto_rawDescGZIP(), []int{13} } func (x *SystemInfoVolume) GetDataSources() []*SystemInfoDataSource { @@ -924,7 +1031,7 @@ type Volume struct { func (x *Volume) Reset() { *x = Volume{} - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -936,7 +1043,7 @@ func (x *Volume) String() string { func (*Volume) ProtoMessage() {} func (x *Volume) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -949,7 +1056,7 @@ func (x *Volume) ProtoReflect() protoreflect.Message { // Deprecated: Use Volume.ProtoReflect.Descriptor instead. func (*Volume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{13} + return file_atelet_proto_rawDescGZIP(), []int{14} } func (x *Volume) GetName() string { @@ -1025,7 +1132,7 @@ type VolumeMount struct { func (x *VolumeMount) Reset() { *x = VolumeMount{} - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1037,7 +1144,7 @@ func (x *VolumeMount) String() string { func (*VolumeMount) ProtoMessage() {} func (x *VolumeMount) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1050,7 +1157,7 @@ func (x *VolumeMount) ProtoReflect() protoreflect.Message { // Deprecated: Use VolumeMount.ProtoReflect.Descriptor instead. func (*VolumeMount) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{14} + return file_atelet_proto_rawDescGZIP(), []int{15} } func (x *VolumeMount) GetName() string { @@ -1082,7 +1189,7 @@ type Container struct { func (x *Container) Reset() { *x = Container{} - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1094,7 +1201,7 @@ func (x *Container) String() string { func (*Container) ProtoMessage() {} func (x *Container) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1107,7 +1214,7 @@ func (x *Container) ProtoReflect() protoreflect.Message { // Deprecated: Use Container.ProtoReflect.Descriptor instead. func (*Container) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{15} + return file_atelet_proto_rawDescGZIP(), []int{16} } func (x *Container) GetName() string { @@ -1169,7 +1276,7 @@ type EnvEntry struct { func (x *EnvEntry) Reset() { *x = EnvEntry{} - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1181,7 +1288,7 @@ func (x *EnvEntry) String() string { func (*EnvEntry) ProtoMessage() {} func (x *EnvEntry) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1194,7 +1301,7 @@ func (x *EnvEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvEntry.ProtoReflect.Descriptor instead. func (*EnvEntry) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{16} + return file_atelet_proto_rawDescGZIP(), []int{17} } func (x *EnvEntry) GetName() string { @@ -1225,7 +1332,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1237,7 +1344,7 @@ func (x *Readyz) String() string { func (*Readyz) ProtoMessage() {} func (x *Readyz) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1250,7 +1357,7 @@ func (x *Readyz) ProtoReflect() protoreflect.Message { // Deprecated: Use Readyz.ProtoReflect.Descriptor instead. func (*Readyz) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{17} + return file_atelet_proto_rawDescGZIP(), []int{18} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -1280,7 +1387,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1292,7 +1399,7 @@ func (x *HTTPGetAction) String() string { func (*HTTPGetAction) ProtoMessage() {} func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1305,7 +1412,7 @@ func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPGetAction.ProtoReflect.Descriptor instead. func (*HTTPGetAction) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{18} + return file_atelet_proto_rawDescGZIP(), []int{19} } func (x *HTTPGetAction) GetPath() string { @@ -1330,7 +1437,7 @@ type RunResponse struct { func (x *RunResponse) Reset() { *x = RunResponse{} - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1342,7 +1449,7 @@ func (x *RunResponse) String() string { func (*RunResponse) ProtoMessage() {} func (x *RunResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1355,7 +1462,7 @@ func (x *RunResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunResponse.ProtoReflect.Descriptor instead. func (*RunResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{19} + return file_atelet_proto_rawDescGZIP(), []int{20} } type LocalCheckpointConfiguration struct { @@ -1371,7 +1478,7 @@ type LocalCheckpointConfiguration struct { func (x *LocalCheckpointConfiguration) Reset() { *x = LocalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[20] + mi := &file_atelet_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1383,7 +1490,7 @@ func (x *LocalCheckpointConfiguration) String() string { func (*LocalCheckpointConfiguration) ProtoMessage() {} func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[20] + mi := &file_atelet_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1396,7 +1503,7 @@ func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*LocalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{20} + return file_atelet_proto_rawDescGZIP(), []int{21} } func (x *LocalCheckpointConfiguration) GetSnapshotName() string { @@ -1417,7 +1524,7 @@ type ExternalCheckpointConfiguration struct { func (x *ExternalCheckpointConfiguration) Reset() { *x = ExternalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1429,7 +1536,7 @@ func (x *ExternalCheckpointConfiguration) String() string { func (*ExternalCheckpointConfiguration) ProtoMessage() {} func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1442,7 +1549,7 @@ func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use ExternalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*ExternalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{21} + return file_atelet_proto_rawDescGZIP(), []int{22} } func (x *ExternalCheckpointConfiguration) GetSnapshotUri() string { @@ -1480,7 +1587,7 @@ type CheckpointRequest struct { func (x *CheckpointRequest) Reset() { *x = CheckpointRequest{} - mi := &file_atelet_proto_msgTypes[22] + mi := &file_atelet_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1492,7 +1599,7 @@ func (x *CheckpointRequest) String() string { func (*CheckpointRequest) ProtoMessage() {} func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[22] + mi := &file_atelet_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1505,7 +1612,7 @@ func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointRequest.ProtoReflect.Descriptor instead. func (*CheckpointRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{22} + return file_atelet_proto_rawDescGZIP(), []int{23} } func (x *CheckpointRequest) GetTargetAteomUid() string { @@ -1620,7 +1727,7 @@ type CheckpointResponse struct { func (x *CheckpointResponse) Reset() { *x = CheckpointResponse{} - mi := &file_atelet_proto_msgTypes[23] + mi := &file_atelet_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1632,7 +1739,7 @@ func (x *CheckpointResponse) String() string { func (*CheckpointResponse) ProtoMessage() {} func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[23] + mi := &file_atelet_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1645,7 +1752,7 @@ func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointResponse.ProtoReflect.Descriptor instead. func (*CheckpointResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{23} + return file_atelet_proto_rawDescGZIP(), []int{24} } type UploadPausedCheckpointRequest struct { @@ -1673,7 +1780,7 @@ type UploadPausedCheckpointRequest struct { func (x *UploadPausedCheckpointRequest) Reset() { *x = UploadPausedCheckpointRequest{} - mi := &file_atelet_proto_msgTypes[24] + mi := &file_atelet_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1685,7 +1792,7 @@ func (x *UploadPausedCheckpointRequest) String() string { func (*UploadPausedCheckpointRequest) ProtoMessage() {} func (x *UploadPausedCheckpointRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[24] + mi := &file_atelet_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1698,7 +1805,7 @@ func (x *UploadPausedCheckpointRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadPausedCheckpointRequest.ProtoReflect.Descriptor instead. func (*UploadPausedCheckpointRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{24} + return file_atelet_proto_rawDescGZIP(), []int{25} } func (x *UploadPausedCheckpointRequest) GetAtespace() string { @@ -1765,7 +1872,7 @@ type UploadPausedCheckpointResponse struct { func (x *UploadPausedCheckpointResponse) Reset() { *x = UploadPausedCheckpointResponse{} - mi := &file_atelet_proto_msgTypes[25] + mi := &file_atelet_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1777,7 +1884,7 @@ func (x *UploadPausedCheckpointResponse) String() string { func (*UploadPausedCheckpointResponse) ProtoMessage() {} func (x *UploadPausedCheckpointResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[25] + mi := &file_atelet_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1790,7 +1897,7 @@ func (x *UploadPausedCheckpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadPausedCheckpointResponse.ProtoReflect.Descriptor instead. func (*UploadPausedCheckpointResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{25} + return file_atelet_proto_rawDescGZIP(), []int{26} } type RestoreRequest struct { @@ -1836,7 +1943,7 @@ type RestoreRequest struct { func (x *RestoreRequest) Reset() { *x = RestoreRequest{} - mi := &file_atelet_proto_msgTypes[26] + mi := &file_atelet_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1848,7 +1955,7 @@ func (x *RestoreRequest) String() string { func (*RestoreRequest) ProtoMessage() {} func (x *RestoreRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[26] + mi := &file_atelet_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1861,7 +1968,7 @@ func (x *RestoreRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreRequest.ProtoReflect.Descriptor instead. func (*RestoreRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{26} + return file_atelet_proto_rawDescGZIP(), []int{27} } func (x *RestoreRequest) GetTargetAteomUid() string { @@ -2004,7 +2111,7 @@ type RestoreResponse struct { func (x *RestoreResponse) Reset() { *x = RestoreResponse{} - mi := &file_atelet_proto_msgTypes[27] + mi := &file_atelet_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2016,7 +2123,7 @@ func (x *RestoreResponse) String() string { func (*RestoreResponse) ProtoMessage() {} func (x *RestoreResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[27] + mi := &file_atelet_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2029,7 +2136,7 @@ func (x *RestoreResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreResponse.ProtoReflect.Descriptor instead. func (*RestoreResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{27} + return file_atelet_proto_rawDescGZIP(), []int{28} } var File_atelet_proto protoreflect.FileDescriptor @@ -2091,11 +2198,14 @@ 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\"-\n" + - "\x17ActorIdentityDataSource\x12\x12\n" + - "\x04path\x18\x01 \x01(\tR\x04path\"o\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"Y\n" + + "\x11ActorMetadataItem\x120\n" + + "\x05field\x18\x01 \x01(\x0e2\x1a.atelet.ActorMetadataFieldR\x05field\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\"J\n" + + "\x17ActorMetadataDataSource\x12/\n" + + "\x05items\x18\x01 \x03(\v2\x19.atelet.ActorMetadataItemR\x05items\"o\n" + "\x14SystemInfoDataSource\x12H\n" + - "\x0eactor_identity\x18\x01 \x01(\v2\x1f.atelet.ActorIdentityDataSourceH\x00R\ractorIdentityB\r\n" + + "\x0eactor_metadata\x18\x01 \x01(\v2\x1f.atelet.ActorMetadataDataSourceH\x00R\ractorMetadataB\r\n" + "\vdata_source\"S\n" + "\x10SystemInfoVolume\x12?\n" + "\fdata_sources\x18\x01 \x03(\v2\x1c.atelet.SystemInfoDataSourceR\vdataSources\"\xdc\x01\n" + @@ -2180,7 +2290,12 @@ const file_atelet_proto_rawDesc = "" + "\fmemory_bytes\x18\x0f \x01(\x03R\vmemoryBytesB\b\n" + "\x06configB\x11\n" + "\x0f_egress_gateway\"\x11\n" + - "\x0fRestoreResponse*j\n" + + "\x0fRestoreResponse*\x9a\x01\n" + + "\x12ActorMetadataField\x12$\n" + + " ACTOR_METADATA_FIELD_UNSPECIFIED\x10\x00\x12\x1d\n" + + "\x19ACTOR_METADATA_FIELD_NAME\x10\x01\x12!\n" + + "\x1dACTOR_METADATA_FIELD_ATESPACE\x10\x02\x12\x1c\n" + + "\x18ACTOR_METADATA_FIELD_UID\x10\x03*j\n" + "\x0eCheckpointType\x12\x1f\n" + "\x1bCHECKPOINT_TYPE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15CHECKPOINT_TYPE_LOCAL\x10\x01\x12\x1c\n" + @@ -2211,90 +2326,94 @@ func file_atelet_proto_rawDescGZIP() []byte { return file_atelet_proto_rawDescData } -var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 31) +var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 32) var file_atelet_proto_goTypes = []any{ - (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 - (*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 - (*UploadPausedCheckpointRequest)(nil), // 26: atelet.UploadPausedCheckpointRequest - (*UploadPausedCheckpointResponse)(nil), // 27: atelet.UploadPausedCheckpointResponse - (*RestoreRequest)(nil), // 28: atelet.RestoreRequest - (*RestoreResponse)(nil), // 29: atelet.RestoreResponse - nil, // 30: atelet.ArchAssets.FilesEntry - nil, // 31: atelet.SandboxAssets.AssetsEntry - nil, // 32: atelet.ExternalVolumeSource.VolumeContextEntry + (ActorMetadataField)(0), // 0: atelet.ActorMetadataField + (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 + (*ActorMetadataItem)(nil), // 13: atelet.ActorMetadataItem + (*ActorMetadataDataSource)(nil), // 14: atelet.ActorMetadataDataSource + (*SystemInfoDataSource)(nil), // 15: atelet.SystemInfoDataSource + (*SystemInfoVolume)(nil), // 16: atelet.SystemInfoVolume + (*Volume)(nil), // 17: atelet.Volume + (*VolumeMount)(nil), // 18: atelet.VolumeMount + (*Container)(nil), // 19: atelet.Container + (*EnvEntry)(nil), // 20: atelet.EnvEntry + (*Readyz)(nil), // 21: atelet.Readyz + (*HTTPGetAction)(nil), // 22: atelet.HTTPGetAction + (*RunResponse)(nil), // 23: atelet.RunResponse + (*LocalCheckpointConfiguration)(nil), // 24: atelet.LocalCheckpointConfiguration + (*ExternalCheckpointConfiguration)(nil), // 25: atelet.ExternalCheckpointConfiguration + (*CheckpointRequest)(nil), // 26: atelet.CheckpointRequest + (*CheckpointResponse)(nil), // 27: atelet.CheckpointResponse + (*UploadPausedCheckpointRequest)(nil), // 28: atelet.UploadPausedCheckpointRequest + (*UploadPausedCheckpointResponse)(nil), // 29: atelet.UploadPausedCheckpointResponse + (*RestoreRequest)(nil), // 30: atelet.RestoreRequest + (*RestoreResponse)(nil), // 31: atelet.RestoreResponse + nil, // 32: atelet.ArchAssets.FilesEntry + nil, // 33: atelet.SandboxAssets.AssetsEntry + nil, // 34: 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 - 30, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry - 31, // 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 - 32, // 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 - 1, // 22: atelet.UploadPausedCheckpointRequest.desired_scope:type_name -> atelet.SnapshotScope - 9, // 23: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec - 0, // 24: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType - 22, // 25: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 23, // 26: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 1, // 27: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope - 5, // 28: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway - 6, // 29: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile - 7, // 30: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets - 2, // 31: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest - 4, // 32: atelet.AteomHerder.Run:input_type -> atelet.RunRequest - 24, // 33: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest - 28, // 34: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest - 26, // 35: atelet.AteomHerder.UploadPausedCheckpoint:input_type -> atelet.UploadPausedCheckpointRequest - 3, // 36: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse - 21, // 37: atelet.AteomHerder.Run:output_type -> atelet.RunResponse - 25, // 38: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse - 29, // 39: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse - 27, // 40: atelet.AteomHerder.UploadPausedCheckpoint:output_type -> atelet.UploadPausedCheckpointResponse - 36, // [36:41] is the sub-list for method output_type - 31, // [31:36] is the sub-list for method input_type - 31, // [31:31] is the sub-list for extension type_name - 31, // [31:31] is the sub-list for extension extendee - 0, // [0:31] is the sub-list for field type_name + 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 + 32, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 33, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry + 19, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container + 17, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume + 34, // 7: atelet.ExternalVolumeSource.volume_context:type_name -> atelet.ExternalVolumeSource.VolumeContextEntry + 0, // 8: atelet.ActorMetadataItem.field:type_name -> atelet.ActorMetadataField + 13, // 9: atelet.ActorMetadataDataSource.items:type_name -> atelet.ActorMetadataItem + 14, // 10: atelet.SystemInfoDataSource.actor_metadata:type_name -> atelet.ActorMetadataDataSource + 15, // 11: atelet.SystemInfoVolume.data_sources:type_name -> atelet.SystemInfoDataSource + 11, // 12: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume + 12, // 13: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource + 16, // 14: atelet.Volume.system_info:type_name -> atelet.SystemInfoVolume + 20, // 15: atelet.Container.env:type_name -> atelet.EnvEntry + 21, // 16: atelet.Container.readyz:type_name -> atelet.Readyz + 18, // 17: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount + 22, // 18: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction + 10, // 19: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 20: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType + 24, // 21: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 25, // 22: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 23: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope + 2, // 24: atelet.UploadPausedCheckpointRequest.desired_scope:type_name -> atelet.SnapshotScope + 10, // 25: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 26: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 24, // 27: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 25, // 28: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 29: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 6, // 30: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 7, // 31: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 8, // 32: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 3, // 33: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest + 5, // 34: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 26, // 35: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 30, // 36: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 28, // 37: atelet.AteomHerder.UploadPausedCheckpoint:input_type -> atelet.UploadPausedCheckpointRequest + 4, // 38: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse + 23, // 39: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 27, // 40: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 31, // 41: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 29, // 42: atelet.AteomHerder.UploadPausedCheckpoint:output_type -> atelet.UploadPausedCheckpointResponse + 38, // [38:43] is the sub-list for method output_type + 33, // [33:38] is the sub-list for method input_type + 33, // [33:33] is the sub-list for extension type_name + 33, // [33:33] is the sub-list for extension extendee + 0, // [0:33] is the sub-list for field type_name } func init() { file_atelet_proto_init() } @@ -2303,19 +2422,19 @@ func file_atelet_proto_init() { return } file_atelet_proto_msgTypes[2].OneofWrappers = []any{} - file_atelet_proto_msgTypes[11].OneofWrappers = []any{ - (*SystemInfoDataSource_ActorIdentity)(nil), + file_atelet_proto_msgTypes[12].OneofWrappers = []any{ + (*SystemInfoDataSource_ActorMetadata)(nil), } - file_atelet_proto_msgTypes[13].OneofWrappers = []any{ + file_atelet_proto_msgTypes[14].OneofWrappers = []any{ (*Volume_DurableDir)(nil), (*Volume_External)(nil), (*Volume_SystemInfo)(nil), } - file_atelet_proto_msgTypes[22].OneofWrappers = []any{ + file_atelet_proto_msgTypes[23].OneofWrappers = []any{ (*CheckpointRequest_LocalConfig)(nil), (*CheckpointRequest_ExternalConfig)(nil), } - file_atelet_proto_msgTypes[26].OneofWrappers = []any{ + file_atelet_proto_msgTypes[27].OneofWrappers = []any{ (*RestoreRequest_LocalConfig)(nil), (*RestoreRequest_ExternalConfig)(nil), } @@ -2324,8 +2443,8 @@ 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: 2, - NumMessages: 31, + NumEnums: 3, + NumMessages: 32, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index bfeafa848..7914147c3 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -141,21 +141,36 @@ 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; +// ActorMetadataField selects one identity field of the actor. +enum ActorMetadataField { + ACTOR_METADATA_FIELD_UNSPECIFIED = 0; + ACTOR_METADATA_FIELD_NAME = 1; + ACTOR_METADATA_FIELD_ATESPACE = 2; + ACTOR_METADATA_FIELD_UID = 3; +} + +// ActorMetadataItem projects one actor identity field to one file at the +// given path, relative to the root of the enclosing system-info volume. +message ActorMetadataItem { + ActorMetadataField field = 1; + string path = 2; +} + +// ActorMetadataDataSource projects the actor's identity fields to files, one +// per item. Values are written raw with no trailing newline. +message ActorMetadataDataSource { + repeated ActorMetadataItem items = 1; } message SystemInfoDataSource { oneof data_source { - ActorIdentityDataSource actor_identity = 1; + ActorMetadataDataSource actor_metadata = 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. +// on every Run/Restore, so they carry the values of the actor actually being +// started, whatever checkpointed state it boots from. message SystemInfoVolume { repeated SystemInfoDataSource data_sources = 1; } diff --git a/manifests/ate-install/generated/ate.dev_actortemplates.yaml b/manifests/ate-install/generated/ate.dev_actortemplates.yaml index 916b1f311..d2a28e9a5 100644 --- a/manifests/ate-install/generated/ate.dev_actortemplates.yaml +++ b/manifests/ate-install/generated/ate.dev_actortemplates.yaml @@ -419,6 +419,10 @@ spec: description: |- DataSources is the list of data sources to place within the SystemInfo volume. + + At most one actorMetadata entry may appear; this is what keeps file + paths unique across the whole volume (uniqueness within the entry is + enforced on its items). items: description: |- SystemInfoDataSource is a container allowing you to pick a particular @@ -426,43 +430,78 @@ spec: Exactly one member must be set. properties: - actorIdentity: + actorMetadata: description: |- - ActorIdentityDataSource is a SystemInfo volume data source that writes the - actor's ID to a file. + ActorMetadataDataSource is a SystemInfo volume data source that projects the + actor's identity fields (name, atespace, uid) to files, one per item — + analogous to the Kubernetes downwardAPI volume. Values are written raw with + no trailing newline, and are fixed for the actor's lifetime across + suspend/resume/migration. properties: - path: + items: description: |- - Relative path from the root of the SystemInfo volume that the actor - 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 + Items is the list of fields to project and the file path each is + written to. + items: + description: ActorMetadataItem projects one + actor identity field to one file. + properties: + field: + description: Field selects which identity + field to project. + enum: + - name + - atespace + - uid + type: string + path: + description: |- + Relative path from the root of the SystemInfo volume at which the + field's value is 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: + - field + - path + type: object + maxItems: 8 + minItems: 1 + type: array 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(''(^|/)[.][.]?(/|$)'')' + - message: items must not project the same field + twice + rule: self.all(x, self.exists_one(y, y.field + == x.field)) + - message: items must not contain duplicate paths + rule: self.all(x, self.exists_one(y, y.path + == x.path)) required: - - path + - items type: object type: object x-kubernetes-validations: - - message: exactly one of the fields in [actorIdentity] + - message: exactly one of the fields in [actorMetadata] must be set - rule: '[has(self.actorIdentity)].filter(x,x==true).size() + rule: '[has(self.actorMetadata)].filter(x,x==true).size() == 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)) + - message: dataSources must contain at most one actorMetadata + entry + rule: self.filter(x, has(x.actorMetadata)).size() <= 1 type: object required: - name diff --git a/pkg/api/v1alpha1/actortemplate_types.go b/pkg/api/v1alpha1/actortemplate_types.go index 242999462..8ad39b62f 100644 --- a/pkg/api/v1alpha1/actortemplate_types.go +++ b/pkg/api/v1alpha1/actortemplate_types.go @@ -46,13 +46,34 @@ 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. Must be a clean relative Unix path: - // must not start or end with '/', and contain no ':', '..', '.', '//', - // or control characters. +// ActorMetadataField selects one identity field of the actor, following the +// resource identity model (see docs/api-style-guide.md#2-resource-naming-and-identity). +// +// +kubebuilder:validation:Enum=name;atespace;uid +type ActorMetadataField string + +const ( + // ActorMetadataFieldName is the actor's metadata.name, unique within its + // atespace. + ActorMetadataFieldName ActorMetadataField = "name" + // ActorMetadataFieldAtespace is the atespace the actor belongs to. + ActorMetadataFieldAtespace ActorMetadataField = "atespace" + // ActorMetadataFieldUID is the actor's server-generated UID, which + // distinguishes incarnations of the same (atespace, name). + ActorMetadataFieldUID ActorMetadataField = "uid" +) + +// ActorMetadataItem projects one actor identity field to one file. +type ActorMetadataItem struct { + // Field selects which identity field to project. + // + // +required + Field ActorMetadataField `json:"field"` + + // Relative path from the root of the SystemInfo volume at which the + // field's value is 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 @@ -61,24 +82,46 @@ type ActorIdentityDataSource struct { Path string `json:"path"` } +// ActorMetadataDataSource is a SystemInfo volume data source that projects the +// actor's identity fields (name, atespace, uid) to files, one per item — +// analogous to the Kubernetes downwardAPI volume. Values are written raw with +// no trailing newline, and are fixed for the actor's lifetime across +// suspend/resume/migration. +type ActorMetadataDataSource struct { + // Items is the list of fields to project and the file path each is + // written to. + // + // +required + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=8 + // +kubebuilder:validation:XValidation:rule="self.all(x, self.exists_one(y, y.field == x.field))",message="items must not project the same field twice" + // +kubebuilder:validation:XValidation:rule="self.all(x, self.exists_one(y, y.path == x.path))",message="items must not contain duplicate paths" + Items []ActorMetadataItem `json:"items"` +} + // SystemInfoDataSource is a container allowing you to pick a particular // SystemInfo data source. // // Exactly one member must be set. // -// +kubebuilder:validation:ExactlyOneOf={actorIdentity} +// +kubebuilder:validation:ExactlyOneOf={actorMetadata} type SystemInfoDataSource struct { - ActorIdentity *ActorIdentityDataSource `json:"actorIdentity,omitempty"` + ActorMetadata *ActorMetadataDataSource `json:"actorMetadata,omitempty"` } -// Represents a system information volume, which provides files containing the -// actor ID, an actor identity JWT, and an actor identity certificate. +// Represents a system information volume, which provides files containing +// substrate-generated per-actor data such as the actor's identity fields +// (and, in the future, identity JWTs and certificates). type SystemInfoVolumeSource struct { // DataSources is the list of data sources to place within the SystemInfo // volume. // + // At most one actorMetadata entry may appear; this is what keeps file + // paths unique across the whole volume (uniqueness within the entry is + // enforced on its items). + // // +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" + // +kubebuilder:validation:XValidation:rule="self.filter(x, has(x.actorMetadata)).size() <= 1",message="dataSources must contain at most one actorMetadata entry" DataSources []SystemInfoDataSource `json:"dataSources,omitempty"` } diff --git a/pkg/api/v1alpha1/actortemplate_validation_test.go b/pkg/api/v1alpha1/actortemplate_validation_test.go index 5c68b6208..7ba59c0ef 100644 --- a/pkg/api/v1alpha1/actortemplate_validation_test.go +++ b/pkg/api/v1alpha1/actortemplate_validation_test.go @@ -786,7 +786,7 @@ 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", + name: "Volumes: SystemInfo volume projecting all actor metadata fields is valid", mutate: func(at *ActorTemplate) { at.Spec.Volumes = []Volume{ { @@ -794,7 +794,13 @@ func TestActorTemplateValidation(t *testing.T) { VolumeSource: VolumeSource{ SystemInfo: &SystemInfoVolumeSource{ DataSources: []SystemInfoDataSource{ - {ActorIdentity: &ActorIdentityDataSource{Path: "actor-id"}}, + {ActorMetadata: &ActorMetadataDataSource{ + Items: []ActorMetadataItem{ + {Field: ActorMetadataFieldName, Path: "actor-name"}, + {Field: ActorMetadataFieldAtespace, Path: "atespace"}, + {Field: ActorMetadataFieldUID, Path: "identity/actor-uid"}, + }, + }}, }, }, }, @@ -806,7 +812,23 @@ func TestActorTemplateValidation(t *testing.T) { }, wantErr: false, }, { - name: "Volumes: SystemInfo data source with nested relative path is valid", + 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 [actorMetadata] must be set", + }, { + name: "Volumes: SystemInfo actorMetadata with no items is invalid", mutate: func(at *ActorTemplate) { at.Spec.Volumes = []Volume{ { @@ -814,35 +836,37 @@ func TestActorTemplateValidation(t *testing.T) { VolumeSource: VolumeSource{ SystemInfo: &SystemInfoVolumeSource{ DataSources: []SystemInfoDataSource{ - {ActorIdentity: &ActorIdentityDataSource{Path: "identity/actor-id"}}, + {ActorMetadata: &ActorMetadataDataSource{Items: []ActorMetadataItem{}}}, }, }, }, }, } - at.Spec.Containers[0].VolumeMounts = []VolumeMount{ - {Name: "system-info", MountPath: "/run/ate"}, - } }, - wantErr: false, + wantErr: true, }, { - name: "Volumes: SystemInfo data source with no member set is invalid", + name: "Volumes: SystemInfo item with unknown field is invalid", mutate: func(at *ActorTemplate) { at.Spec.Volumes = []Volume{ { Name: "system-info", VolumeSource: VolumeSource{ SystemInfo: &SystemInfoVolumeSource{ - DataSources: []SystemInfoDataSource{{}}, + DataSources: []SystemInfoDataSource{ + {ActorMetadata: &ActorMetadataDataSource{ + Items: []ActorMetadataItem{ + {Field: ActorMetadataField("hostname"), Path: "hostname"}, + }, + }}, + }, }, }, }, } }, wantErr: true, - errMsg: "exactly one of the fields in [actorIdentity] must be set", }, { - name: "Volumes: SystemInfo data source with empty path is invalid", + name: "Volumes: SystemInfo item with empty path is invalid", mutate: func(at *ActorTemplate) { at.Spec.Volumes = []Volume{ { @@ -850,7 +874,11 @@ func TestActorTemplateValidation(t *testing.T) { VolumeSource: VolumeSource{ SystemInfo: &SystemInfoVolumeSource{ DataSources: []SystemInfoDataSource{ - {ActorIdentity: &ActorIdentityDataSource{Path: ""}}, + {ActorMetadata: &ActorMetadataDataSource{ + Items: []ActorMetadataItem{ + {Field: ActorMetadataFieldName, Path: ""}, + }, + }}, }, }, }, @@ -859,7 +887,7 @@ func TestActorTemplateValidation(t *testing.T) { }, wantErr: true, }, { - name: "Volumes: SystemInfo data source with absolute path is invalid", + name: "Volumes: SystemInfo item with absolute path is invalid", mutate: func(at *ActorTemplate) { at.Spec.Volumes = []Volume{ { @@ -867,7 +895,11 @@ func TestActorTemplateValidation(t *testing.T) { VolumeSource: VolumeSource{ SystemInfo: &SystemInfoVolumeSource{ DataSources: []SystemInfoDataSource{ - {ActorIdentity: &ActorIdentityDataSource{Path: "/etc/actor-id"}}, + {ActorMetadata: &ActorMetadataDataSource{ + Items: []ActorMetadataItem{ + {Field: ActorMetadataFieldName, Path: "/etc/actor-name"}, + }, + }}, }, }, }, @@ -877,7 +909,7 @@ func TestActorTemplateValidation(t *testing.T) { wantErr: true, errMsg: "path must be a clean relative Unix path", }, { - name: "Volumes: SystemInfo data source with path traversal is invalid", + name: "Volumes: SystemInfo item with path traversal is invalid", mutate: func(at *ActorTemplate) { at.Spec.Volumes = []Volume{ { @@ -885,7 +917,11 @@ func TestActorTemplateValidation(t *testing.T) { VolumeSource: VolumeSource{ SystemInfo: &SystemInfoVolumeSource{ DataSources: []SystemInfoDataSource{ - {ActorIdentity: &ActorIdentityDataSource{Path: "../escape"}}, + {ActorMetadata: &ActorMetadataDataSource{ + Items: []ActorMetadataItem{ + {Field: ActorMetadataFieldName, Path: "../escape"}, + }, + }}, }, }, }, @@ -895,7 +931,53 @@ func TestActorTemplateValidation(t *testing.T) { wantErr: true, errMsg: "path must be a clean relative Unix path", }, { - name: "Volumes: SystemInfo data sources with duplicate paths are invalid", + name: "Volumes: SystemInfo items projecting the same field twice are invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {ActorMetadata: &ActorMetadataDataSource{ + Items: []ActorMetadataItem{ + {Field: ActorMetadataFieldName, Path: "actor-name"}, + {Field: ActorMetadataFieldName, Path: "name-again"}, + }, + }}, + }, + }, + }, + }, + } + }, + wantErr: true, + errMsg: "items must not project the same field twice", + }, { + name: "Volumes: SystemInfo items with duplicate paths are invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "system-info", + VolumeSource: VolumeSource{ + SystemInfo: &SystemInfoVolumeSource{ + DataSources: []SystemInfoDataSource{ + {ActorMetadata: &ActorMetadataDataSource{ + Items: []ActorMetadataItem{ + {Field: ActorMetadataFieldName, Path: "actor-name"}, + {Field: ActorMetadataFieldUID, Path: "actor-name"}, + }, + }}, + }, + }, + }, + }, + } + }, + wantErr: true, + errMsg: "items must not contain duplicate paths", + }, { + name: "Volumes: SystemInfo with two actorMetadata entries is invalid", mutate: func(at *ActorTemplate) { at.Spec.Volumes = []Volume{ { @@ -903,8 +985,12 @@ func TestActorTemplateValidation(t *testing.T) { VolumeSource: VolumeSource{ SystemInfo: &SystemInfoVolumeSource{ DataSources: []SystemInfoDataSource{ - {ActorIdentity: &ActorIdentityDataSource{Path: "actor-id"}}, - {ActorIdentity: &ActorIdentityDataSource{Path: "actor-id"}}, + {ActorMetadata: &ActorMetadataDataSource{ + Items: []ActorMetadataItem{{Field: ActorMetadataFieldName, Path: "actor-name"}}, + }}, + {ActorMetadata: &ActorMetadataDataSource{ + Items: []ActorMetadataItem{{Field: ActorMetadataFieldUID, Path: "actor-uid"}}, + }}, }, }, }, @@ -912,7 +998,7 @@ func TestActorTemplateValidation(t *testing.T) { } }, wantErr: true, - errMsg: "dataSources must not contain duplicate paths", + errMsg: "dataSources must contain at most one actorMetadata entry", }, { 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 75befe656..4f0caef9a 100644 --- a/pkg/api/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/api/v1alpha1/zz_generated.deepcopy.go @@ -25,16 +25,36 @@ import ( ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ActorIdentityDataSource) DeepCopyInto(out *ActorIdentityDataSource) { +func (in *ActorMetadataDataSource) DeepCopyInto(out *ActorMetadataDataSource) { *out = *in + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ActorMetadataItem, len(*in)) + copy(*out, *in) + } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActorIdentityDataSource. -func (in *ActorIdentityDataSource) DeepCopy() *ActorIdentityDataSource { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActorMetadataDataSource. +func (in *ActorMetadataDataSource) DeepCopy() *ActorMetadataDataSource { if in == nil { return nil } - out := new(ActorIdentityDataSource) + out := new(ActorMetadataDataSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ActorMetadataItem) DeepCopyInto(out *ActorMetadataItem) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActorMetadataItem. +func (in *ActorMetadataItem) DeepCopy() *ActorMetadataItem { + if in == nil { + return nil + } + out := new(ActorMetadataItem) in.DeepCopyInto(out) return out } @@ -495,10 +515,10 @@ func (in *SnapshotsConfig) DeepCopy() *SnapshotsConfig { // 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 + if in.ActorMetadata != nil { + in, out := &in.ActorMetadata, &out.ActorMetadata + *out = new(ActorMetadataDataSource) + (*in).DeepCopyInto(*out) } } From 845c5e5239725ca64a2a4bfd2f028e0af4a5e6e6 Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Mon, 10 Aug 2026 17:03:19 -0700 Subject: [PATCH 05/10] microvm: mount system-info volumes over a read-only virtio-fs share SystemInfo volumes were gVisor-only; per the #802 discussion, micro-VM support lands with Part 1 rather than as a follow-up. The mechanism mirrors the durable-dir share: - ateom proto: containers carry system_info_volume_mounts (volume name + mount path), populated by atelet's buildAteomWorkloadSpec. - ateom-microvm serves ateompath.SystemInfoVolumeRootsDir(actorUID) over a third virtiofsd (cache=auto: atelet rewrites the contents underneath the guest on every restore). The agent mounts the share at sandbox creation, and each declaring container gets a READ-ONLY bind from the share's per-volume subdirectory to its declared mount path. - Restore restarts the share's virtiofsd and rewrites its vhost-user socket in the snapshot's VM config (matched by fs tag). Nothing is restored from the snapshot itself: atelet has already regenerated the files with the resumed actor's values, which is the point of system-info volumes. - Checkpoint deliberately ignores the share: the volume roots live outside the durable-dir tree precisely so the durable tar can never capture generated identity data. - Replace the stale "KNOWN GAP" comment in spec.go: dropping host-path binds in the kata spec shaper is fine because volumes reach micro-VM containers via the shares, not spec.Mounts. --- cmd/atelet/main.go | 13 +- cmd/atelet/main_test.go | 9 +- cmd/ateom-microvm/checkpoint.go | 7 +- cmd/ateom-microvm/durable.go | 10 +- .../internal/kata/overlay_linux.go | 32 ++- cmd/ateom-microvm/internal/kata/restore.go | 6 + cmd/ateom-microvm/restore.go | 21 +- cmd/ateom-microvm/run.go | 63 +++-- cmd/ateom-microvm/spec.go | 13 +- cmd/ateom-microvm/systeminfo.go | 117 ++++++++ internal/proto/ateompb/ateom.pb.go | 251 ++++++++++++------ internal/proto/ateompb/ateom.proto | 16 ++ 12 files changed, 436 insertions(+), 122 deletions(-) create mode 100644 cmd/ateom-microvm/systeminfo.go diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index d933db232..2e7e55e49 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -1541,27 +1541,38 @@ func (s *AteomHerder) dialAteom(ctx context.Context, targetAteomUid string) (ate // the ateom-facing one. func buildAteomWorkloadSpec(spec *ateletpb.WorkloadSpec) *ateompb.WorkloadSpec { ddVolumes := make(map[string]bool) + siVolumes := make(map[string]bool) for _, vol := range spec.GetVolumes() { switch vol.GetSource().(type) { case *ateletpb.Volume_DurableDir: ddVolumes[vol.GetName()] = true + case *ateletpb.Volume_SystemInfo: + siVolumes[vol.GetName()] = true } } out := &ateompb.WorkloadSpec{} for _, ctr := range spec.GetContainers() { var ddMounts []*ateompb.DurableDirVolumeMount + var siMounts []*ateompb.SystemInfoVolumeMount for _, vm := range ctr.GetVolumeMounts() { - if ddVolumes[vm.GetName()] { + switch { + case ddVolumes[vm.GetName()]: ddMounts = append(ddMounts, &ateompb.DurableDirVolumeMount{ VolumeName: vm.GetName(), MountPath: vm.GetMountPath(), }) + case siVolumes[vm.GetName()]: + siMounts = append(siMounts, &ateompb.SystemInfoVolumeMount{ + VolumeName: vm.GetName(), + MountPath: vm.GetMountPath(), + }) } } out.Containers = append(out.Containers, &ateompb.Container{ Name: ctr.GetName(), DurableDirVolumeMounts: ddMounts, + SystemInfoVolumeMounts: siMounts, Readyz: toAteomReadyz(ctr.GetReadyz()), }) } diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index 1ff751e70..4eed97933 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -760,6 +760,7 @@ func TestBuildAteomWorkloadSpecForwardsDurableDirMounts(t *testing.T) { {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{}}}, + {Name: "system-info", Source: &ateletpb.Volume_SystemInfo{SystemInfo: &ateletpb.SystemInfoVolume{}}}, }, Containers: []*ateletpb.Container{ { @@ -767,9 +768,10 @@ func TestBuildAteomWorkloadSpecForwardsDurableDirMounts(t *testing.T) { VolumeMounts: []*ateletpb.VolumeMount{ {Name: "data", MountPath: "/home/counter"}, {Name: "cache", MountPath: "/var/cache"}, - // Only durable-dir volumes cross to ateom; other volume - // types are mounted by atelet itself. + // External volumes do not cross to ateom; they are + // mounted by atelet itself. {Name: "scratch", MountPath: "/scratch"}, + {Name: "system-info", MountPath: "/run/ate"}, }, }, { @@ -791,6 +793,9 @@ func TestBuildAteomWorkloadSpecForwardsDurableDirMounts(t *testing.T) { {VolumeName: "data", MountPath: "/home/counter"}, {VolumeName: "cache", MountPath: "/var/cache"}, }, + SystemInfoVolumeMounts: []*ateompb.SystemInfoVolumeMount{ + {VolumeName: "system-info", MountPath: "/run/ate"}, + }, }, { Name: "sidecar", diff --git a/cmd/ateom-microvm/checkpoint.go b/cmd/ateom-microvm/checkpoint.go index 78244df21..0ea41672d 100644 --- a/cmd/ateom-microvm/checkpoint.go +++ b/cmd/ateom-microvm/checkpoint.go @@ -295,9 +295,10 @@ func (s *AteomService) teardownActor(ctx context.Context, id string, ra *running _ = ra.chCmd.Process.Kill() _, _ = ra.chCmd.Process.Wait() } - // Kill the virtiofsds (after CH, their only client): the overlay RO lower's - // and, when the actor has durable-dir volumes, the writable share's. - for _, cmd := range []*exec.Cmd{ra.vfsdCmd, ra.durableVfsdCmd} { + // Kill the virtiofsds (after CH, their only client): the overlay RO + // lower's and, when the actor declares such volumes, the writable + // durable share's and the read-only system-info share's. + for _, cmd := range []*exec.Cmd{ra.vfsdCmd, ra.durableVfsdCmd, ra.systemInfoVfsdCmd} { if cmd != nil && cmd.Process != nil { _ = cmd.Process.Kill() _, _ = cmd.Process.Wait() diff --git a/cmd/ateom-microvm/durable.go b/cmd/ateom-microvm/durable.go index a28a2186a..c8d481858 100644 --- a/cmd/ateom-microvm/durable.go +++ b/cmd/ateom-microvm/durable.go @@ -88,16 +88,20 @@ func durableMounts(mounts []*ateompb.DurableDirVolumeMount) []specs.Mount { } // workloadSpec returns the OCI spec to start a container's overlay workload -// with: the prepared spec, plus a bind for each durable-dir volume it mounts. +// with: the prepared spec, plus a bind for each durable-dir volume it mounts +// (writable) and each system-info volume it mounts (read-only). // // The spec is copied rather than mutated so the bundle's on-disk config.json and // the carrier's view stay as prepared — only the workload sees the binds. func workloadSpec(c actorContainer) *specs.Spec { - if len(c.durableMounts) == 0 { + if len(c.durableMounts) == 0 && len(c.systemInfoMounts) == 0 { return c.spec } spec := *c.spec - spec.Mounts = append(append([]specs.Mount(nil), c.spec.Mounts...), durableMounts(c.durableMounts)...) + mounts := append([]specs.Mount(nil), c.spec.Mounts...) + mounts = append(mounts, durableMounts(c.durableMounts)...) + mounts = append(mounts, systemInfoMounts(c.systemInfoMounts)...) + spec.Mounts = mounts return &spec } diff --git a/cmd/ateom-microvm/internal/kata/overlay_linux.go b/cmd/ateom-microvm/internal/kata/overlay_linux.go index 741a1a446..e7022ee99 100644 --- a/cmd/ateom-microvm/internal/kata/overlay_linux.go +++ b/cmd/ateom-microvm/internal/kata/overlay_linux.go @@ -54,6 +54,16 @@ const ( // volume's contents live at / and are bind-mounted // from there into the containers that declare the volume. guestDurableDir = "/run/ateom-durable" + + // SystemInfoFsTag is the virtio-fs tag for the actor's system-info share, + // served by a third virtiofsd. Contents are generated by atelet on the + // host on every Run/Restore; containers see them read-only. + SystemInfoFsTag = "ateSystemInfo" + // guestSystemInfoDir is where the agent mounts SystemInfoFsTag in the + // guest; each volume's contents live at / + // and are bind-mounted read-only into the containers that declare the + // volume. + guestSystemInfoDir = "/run/ateom-system-info" ) // GuestDurableVolumeDir is the in-guest path holding one durable volume's @@ -62,6 +72,13 @@ func GuestDurableVolumeDir(volumeName string) string { return guestDurableDir + "/" + volumeName } +// GuestSystemInfoVolumeDir is the in-guest path holding one system-info +// volume's contents, i.e. the bind source for that volume's container mount +// points. +func GuestSystemInfoVolumeDir(volumeName string) string { + return guestSystemInfoDir + "/" + volumeName +} + // SharedDir is the host directory virtiofsd serves into the guest as the RO base. // Its layout (/rootfs) is what find-paths re-opens by path on restore. func SharedDir(id string) string { @@ -216,9 +233,10 @@ func ReconstructSharedDirFromImage(ctx context.Context, bundleRootfs, restoreID, // CreateSandboxForActor creates the guest sandbox with the kataShared virtio-fs mount // (the RO base backing every container's rootfs). Mirrors kata startSandbox. // -// withDurableShare additionally mounts the writable durable-dir share, whose -// per-volume subdirectories the containers bind-mount at their declared paths. -func (a *AgentClient) CreateSandboxForActor(ctx context.Context, sandboxID, hostname string, withDurableShare bool) error { +// withDurableShare additionally mounts the writable durable-dir share, and +// withSystemInfoShare the system-info share; the per-volume subdirectories of +// each are what the containers bind-mount at their declared paths. +func (a *AgentClient) CreateSandboxForActor(ctx context.Context, sandboxID, hostname string, withDurableShare, withSystemInfoShare bool) error { storages := []*agentpb.Storage{{ Driver: virtioFSDriver, Source: FsTag, @@ -233,6 +251,14 @@ func (a *AgentClient) CreateSandboxForActor(ctx context.Context, sandboxID, host MountPoint: guestDurableDir, }) } + if withSystemInfoShare { + storages = append(storages, &agentpb.Storage{ + Driver: virtioFSDriver, + Source: SystemInfoFsTag, + Fstype: typeVirtioFS, + MountPoint: guestSystemInfoDir, + }) + } return a.CreateSandbox(ctx, &agentpb.CreateSandboxRequest{ Hostname: hostname, SandboxId: sandboxID, diff --git a/cmd/ateom-microvm/internal/kata/restore.go b/cmd/ateom-microvm/internal/kata/restore.go index 0b71bea9d..e3d54433a 100644 --- a/cmd/ateom-microvm/internal/kata/restore.go +++ b/cmd/ateom-microvm/internal/kata/restore.go @@ -35,3 +35,9 @@ func VsockSocketPath(id string) string { return filepath.Join(VMDir(id), "clh.so func DurableVirtiofsdSocketPath(id string) string { return filepath.Join(VMDir(id), "virtiofsd-durable.sock") } + +// SystemInfoVirtiofsdSocketPath is the vhost-user-fs socket for the actor's +// system-info share, served by a third virtiofsd alongside the others. +func SystemInfoVirtiofsdSocketPath(id string) string { + return filepath.Join(VMDir(id), "virtiofsd-system-info.sock") +} diff --git a/cmd/ateom-microvm/restore.go b/cmd/ateom-microvm/restore.go index 96b4dfb31..6315a406c 100644 --- a/cmd/ateom-microvm/restore.go +++ b/cmd/ateom-microvm/restore.go @@ -245,6 +245,23 @@ func (s *AteomService) restoreFullScope(ctx context.Context, p actorBootParams, }() } + // Restart the system-info share's virtiofsd over the contents atelet + // regenerated for THIS restore: unlike the durable share, nothing is + // restored from the snapshot — the files already carry the resumed actor's + // own values, which is the point of system-info volumes. + var systemInfoVfsdCmd *exec.Cmd + if hasSystemInfoVolumes(containers) { + if systemInfoVfsdCmd, err = s.stageSystemInfoShare(ctx, rr, actorUID); err != nil { + return err + } + defer func() { + if retErr != nil && systemInfoVfsdCmd.Process != nil { + _ = systemInfoVfsdCmd.Process.Kill() + _, _ = systemInfoVfsdCmd.Process.Wait() + } + }() + } + // Networking: rebuild the per-activation veth + tap; the snapshot's virtio-net // is fd-backed, so CH needs fresh tap FDs (net_fds) on restore. if err := ateomnet.SetupActorNetwork(ctx, ateomnet.NetworkConfig{ @@ -351,7 +368,7 @@ func (s *AteomService) restoreFullScope(ctx context.Context, p actorBootParams, } ra := &runningActor{ - chCmd: chCmd, vfsdCmd: vfsdCmd, durableVfsdCmd: durableVfsdCmd, + chCmd: chCmd, vfsdCmd: vfsdCmd, durableVfsdCmd: durableVfsdCmd, systemInfoVfsdCmd: systemInfoVfsdCmd, apiSocket: apiSocket, baseID: srcID, restoreSourceDir: restoreDir, snapshotIsSelfContained: memMode == ch.MemRestoreEager, } @@ -441,6 +458,8 @@ func rewriteSnapshotSocketPaths(snapshotDir, id string) error { fm["socket"] = kata.VirtiofsdSocketPath(id) case kata.DurableFsTag: fm["socket"] = kata.DurableVirtiofsdSocketPath(id) + case kata.SystemInfoFsTag: + fm["socket"] = kata.SystemInfoVirtiofsdSocketPath(id) default: return fmt.Errorf("snapshot config %q has fs device with unknown tag %q", cfgPath, tag) } diff --git a/cmd/ateom-microvm/run.go b/cmd/ateom-microvm/run.go index c2c5ff403..9c1bd0d94 100644 --- a/cmd/ateom-microvm/run.go +++ b/cmd/ateom-microvm/run.go @@ -67,6 +67,10 @@ type runningActor struct { // durable-dir volumes. nil when the actor declares none. Owned and torn down // exactly like vfsdCmd. durableVfsdCmd *exec.Cmd + // systemInfoVfsdCmd is the third virtiofsd, serving the actor's read-only + // system-info volumes. nil when the actor declares none. Owned and torn + // down exactly like vfsdCmd. + systemInfoVfsdCmd *exec.Cmd // apiSocket is the CH api-socket for this ateom-owned VMM. apiSocket string @@ -159,6 +163,9 @@ type actorContainer struct { // durableMounts are the durable-dir volumes this container mounts, and where // (see durable.go). Empty for containers that declare none. durableMounts []*ateompb.DurableDirVolumeMount + // systemInfoMounts are the system-info volumes this container mounts, and + // where (see systeminfo.go). Empty for containers that declare none. + systemInfoMounts []*ateompb.SystemInfoVolumeMount } // resolvedRuntime holds the concrete binary/config paths for a request, taken @@ -456,6 +463,23 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re }() } + // System-info volumes (if any) share one read-only virtio-fs share, served + // by a third virtiofsd from the host directory atelet populated; each + // volume is a subdirectory of it. + systemInfo := hasSystemInfoVolumes(containers) + var systemInfoVfsdCmd *exec.Cmd + if systemInfo { + if systemInfoVfsdCmd, err = s.stageSystemInfoShare(ctx, rr, actorUID); err != nil { + return err + } + defer func() { + if retErr != nil && systemInfoVfsdCmd.Process != nil { + _ = systemInfoVfsdCmd.Process.Kill() + _, _ = systemInfoVfsdCmd.Process.Wait() + } + }() + } + // Launch a bare VMM (CH + api-socket); ateom owns this process for teardown. apiSocket := filepath.Join(kata.VMDir(actorUID), "clh-api.sock") chCmd, client, err := ch.LaunchVMM(ctx, ch.LaunchVMMOptions{ @@ -479,7 +503,7 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re // writable upper is a guest tmpfs). serialLog is also read on a failed agent dial // below, so keep it here. serialLog := filepath.Join(kata.VMDir(actorUID), "serial.log") - vmCfg := buildVMConfig(actorUID, kernel, image, kparams, serialLog, memMiB, vcpus, durable) + vmCfg := buildVMConfig(actorUID, kernel, image, kparams, serialLog, memMiB, vcpus, durable, systemInfo) if err := client.CreateVM(ctx, vmCfg); err != nil { return fmt.Errorf("while creating VM: %w", err) } @@ -533,7 +557,7 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re }() // Post-boot kata-agent setup: sandbox, guest networking, start each container. - if err := s.startActorContainers(ctx, ac, actorUID, vsockPath, ctrs, durable); err != nil { + if err := s.startActorContainers(ctx, ac, actorUID, vsockPath, ctrs, durable, systemInfo); err != nil { return err } @@ -542,7 +566,7 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re return fmt.Errorf("while waiting for container readyz: %w", err) } - ra := &runningActor{chCmd: chCmd, vfsdCmd: vfsdCmd, durableVfsdCmd: durableVfsdCmd, apiSocket: apiSocket, baseID: actorUID, guestAgent: ac} + ra := &runningActor{chCmd: chCmd, vfsdCmd: vfsdCmd, durableVfsdCmd: durableVfsdCmd, systemInfoVfsdCmd: systemInfoVfsdCmd, apiSocket: apiSocket, baseID: actorUID, guestAgent: ac} if err := s.activateActorNetworking(p.actorRef.Atespace, p.actorRef.Name, egress); err != nil { return err } @@ -602,10 +626,11 @@ func (s *AteomService) buildActorContainers(actorUID string, containers []*ateom return nil, fmt.Errorf("while writing guest resolv.conf for %q: %w", cn, err) } ctrs[i] = actorContainer{ - name: cn, - bundleRootfs: bundleRootfs, - spec: spec, - durableMounts: c.GetDurableDirVolumeMounts(), + name: cn, + bundleRootfs: bundleRootfs, + spec: spec, + durableMounts: c.GetDurableDirVolumeMounts(), + systemInfoMounts: c.GetSystemInfoVolumeMounts(), } } return ctrs, nil @@ -726,7 +751,7 @@ func (s *AteomService) guestSize(sz sizing.SandboxSize) (sizing.SandboxSize, err // // withDurable adds a second virtio-fs device for the actor's writable durable-dir // volumes (see durable.go), served by its own virtiofsd on the same PCI segment. -func buildVMConfig(id, kernel, image, kparams, serialLog string, memMiB, vcpus int, withDurable bool) ch.VmConfig { +func buildVMConfig(id, kernel, image, kparams, serialLog string, memMiB, vcpus int, withDurable, withSystemInfo bool) ch.VmConfig { console := "ttyS0" if runtime.GOARCH == "arm64" { console = "ttyAMA0" @@ -744,7 +769,7 @@ func buildVMConfig(id, kernel, image, kparams, serialLog string, memMiB, vcpus i Disks: []ch.DiskConfig{ {Path: image, Readonly: true, ImageType: "Raw", NumQueues: int32(vcpus), QueueSize: 1024}, }, - Fs: buildFsConfigs(id, withDurable), + Fs: buildFsConfigs(id, withDurable, withSystemInfo), Platform: &ch.PlatformConfig{NumPciSegments: 2}, Rng: &ch.RngConfig{Src: "/dev/urandom"}, Serial: &ch.ConsoleConfig{Mode: "File", File: serialLog}, @@ -753,9 +778,10 @@ func buildVMConfig(id, kernel, image, kparams, serialLog string, memMiB, vcpus i } // buildFsConfigs returns the VM's virtio-fs devices: the overlay RO lower's -// share, plus the writable durable-dir share when the actor has one. Both sit on -// PCI segment 1 (the segment buildVMConfig reserves for virtio-fs). -func buildFsConfigs(id string, withDurable bool) []ch.FsConfig { +// share, plus the writable durable-dir share and the read-only system-info +// share when the actor has them. All sit on PCI segment 1 (the segment +// buildVMConfig reserves for virtio-fs). +func buildFsConfigs(id string, withDurable, withSystemInfo bool) []ch.FsConfig { fs := []ch.FsConfig{{ Tag: kata.FsTag, Socket: kata.VirtiofsdSocketPath(id), NumQueues: 1, QueueSize: 1024, PciSegment: 1, @@ -766,6 +792,12 @@ func buildFsConfigs(id string, withDurable bool) []ch.FsConfig { NumQueues: 1, QueueSize: 1024, PciSegment: 1, }) } + if withSystemInfo { + fs = append(fs, ch.FsConfig{ + Tag: kata.SystemInfoFsTag, Socket: kata.SystemInfoVirtiofsdSocketPath(id), + NumQueues: 1, QueueSize: 1024, PciSegment: 1, + }) + } return fs } @@ -775,13 +807,14 @@ func buildFsConfigs(id string, withDurable bool) []ch.FsConfig { // container on its own overlay rootfs. On failure it dumps guest diagnostics. // // durable says the actor has durable-dir volumes: the sandbox then also mounts -// the writable durable share, and each container binds the volumes it declared. -func (s *AteomService) startActorContainers(ctx context.Context, ac *kata.AgentClient, id, vsockPath string, ctrs []actorContainer, durable bool) error { +// the writable durable share. systemInfo likewise mounts the read-only +// system-info share. Each container binds the volumes it declared. +func (s *AteomService) startActorContainers(ctx context.Context, ac *kata.AgentClient, id, vsockPath string, ctrs []actorContainer, durable, systemInfo bool) error { // Establish the agent sandbox + the kataShared virtio-fs mount (the RO base for // every container's overlay lower). All containers share it, so use the first // container's hostname. sbCtx, sbCancel := context.WithTimeout(ctx, 20*time.Second) - err := ac.CreateSandboxForActor(sbCtx, id, ctrs[0].spec.Hostname, durable) + err := ac.CreateSandboxForActor(sbCtx, id, ctrs[0].spec.Hostname, durable, systemInfo) sbCancel() if err != nil { return fmt.Errorf("while creating agent sandbox: %w", err) diff --git a/cmd/ateom-microvm/spec.go b/cmd/ateom-microvm/spec.go index ba1de20c1..fd879e3ac 100644 --- a/cmd/ateom-microvm/spec.go +++ b/cmd/ateom-microvm/spec.go @@ -95,13 +95,12 @@ func ensureKataCompatibleSpec(bundle, id, netnsPath string, size sizing.SandboxS // 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 - // systemInfo volume bind mounts (e.g. the actorMetadata data-source files). - // 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. + // Dropping atelet's volume bind mounts here is fine: host-path binds can't + // attach inside the guest anyway. Volumes reach micro-VM containers over + // per-actor virtio-fs shares instead — durable-dir volumes via the + // writable share (durable.go) and system-info volumes via the read-only + // share (systeminfo.go) — with the binds added to the workload specs ateom + // drives through the kata-agent (see workloadSpec). spec.Mounts = defaultKataMounts() out, err := json.MarshalIndent(&spec, "", " ") diff --git a/cmd/ateom-microvm/systeminfo.go b/cmd/ateom-microvm/systeminfo.go new file mode 100644 index 000000000..db448cdcf --- /dev/null +++ b/cmd/ateom-microvm/systeminfo.go @@ -0,0 +1,117 @@ +//go:build linux + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// System-info volume support for the micro-VM runtime. +// +// A system-info volume is a read-only directory of files generated by atelet +// on the host on every Run/Restore (e.g. the actorMetadata data-source files), +// so its contents always describe the actor actually being started, whatever +// checkpointed state it boots from. The host side is owned by atelet, which +// creates one directory per volume under +// ateompath.SystemInfoVolumeRootsDir(actorUID) and wipes/rebuilds them when +// the actor's directories are reset. +// +// ateom exposes that host directory to the guest over a THIRD virtiofsd — +// alongside the RO overlay lower (kataShared) and the writable durable-dir +// share — mounted by the agent at sandbox creation. Each volume is a +// subdirectory of the one share, at kata.GuestSystemInfoVolumeDir(volume), +// bind-mounted READ-ONLY from there into every container that declares it. +// +// Unlike durable-dir volumes, system-info volumes are deliberately absent +// from the checkpoint path: their contents must never be captured into +// snapshots (see the SystemInfo semantics in docs/api-guide.md). Like the +// durable share, this one runs with cache=auto: the host contents change +// underneath the guest whenever atelet regenerates them for a restore. + +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/agent-substrate/substrate/cmd/ateom-microvm/internal/kata" + "github.com/agent-substrate/substrate/internal/ateompath" + "github.com/agent-substrate/substrate/internal/proto/ateompb" + specs "github.com/opencontainers/runtime-spec/specs-go" +) + +// hasSystemInfoVolumes reports whether any container mounts a system-info +// volume. +func hasSystemInfoVolumes(containers []*ateompb.Container) bool { + for _, c := range containers { + if len(c.GetSystemInfoVolumeMounts()) > 0 { + return true + } + } + return false +} + +// systemInfoMounts returns the OCI mounts that expose a container's +// system-info volumes at the paths it declared, read-only. Each source is that +// volume's directory inside the guest's system-info share, which the agent +// mounts at sandbox creation. +func systemInfoMounts(mounts []*ateompb.SystemInfoVolumeMount) []specs.Mount { + out := make([]specs.Mount, 0, len(mounts)) + for _, m := range mounts { + out = append(out, specs.Mount{ + Destination: m.GetMountPath(), + Source: kata.GuestSystemInfoVolumeDir(m.GetVolumeName()), + Type: "bind", + Options: []string{"rbind", "ro"}, + }) + } + return out +} + +// systemInfoVirtiofsdLogPath is where the system-info share's virtiofsd logs, +// beside the overlay lower's and the durable share's under the actor's VM dir. +func systemInfoVirtiofsdLogPath(id string) string { + return filepath.Join(kata.VMDir(id), "virtiofsd-system-info.log") +} + +// stageSystemInfoShare starts the virtiofsd serving the actor's system-info +// volumes. +// +// It serves ateompath.SystemInfoVolumeRootsDir directly, like the durable +// share, and runs with cache=auto for the same reason: atelet rewrites the +// contents underneath the guest on every restore. Read-only enforcement +// happens at the container binds (see systemInfoMounts), not here — virtiofsd +// serves the share write-through, and the guest never gets a writable mount +// of it. +// +// The returned cmd outlives this call (CH talks to it for the VM's lifetime); +// the caller owns it (tracked on runningActor, killed in teardownActor). +func (s *AteomService) stageSystemInfoShare(ctx context.Context, rr resolvedRuntime, actorUID string) (*exec.Cmd, error) { + shared := ateompath.SystemInfoVolumeRootsDir(actorUID) + if _, err := os.Stat(shared); err != nil { + return nil, fmt.Errorf("while checking system-info volumes dir %q: %w", shared, err) + } + log, _ := os.OpenFile(systemInfoVirtiofsdLogPath(actorUID), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + cmd, err := kata.StartVirtiofsd(ctx, kata.VirtiofsdOptions{ + Binary: rr.virtiofsd, + SocketPath: kata.SystemInfoVirtiofsdSocketPath(actorUID), + SharedDir: shared, + Cache: "auto", + Log: log, + }) + if err != nil { + return nil, fmt.Errorf("while starting system-info virtiofsd: %w", err) + } + return cmd, nil +} diff --git a/internal/proto/ateompb/ateom.pb.go b/internal/proto/ateompb/ateom.pb.go index c865d0a1c..ab684180a 100644 --- a/internal/proto/ateompb/ateom.pb.go +++ b/internal/proto/ateompb/ateom.pb.go @@ -500,6 +500,10 @@ type Container struct { // durable_dir_volume_mounts are the durable-dir volumes this container // mounts, if any. DurableDirVolumeMounts []*DurableDirVolumeMount `protobuf:"bytes,4,rep,name=durable_dir_volume_mounts,json=durableDirVolumeMounts,proto3" json:"durable_dir_volume_mounts,omitempty"` + // system_info_volume_mounts are the system-info volumes this container + // mounts, if any. Contents are generated by atelet on the host; the + // container sees them read-only. + SystemInfoVolumeMounts []*SystemInfoVolumeMount `protobuf:"bytes,5,rep,name=system_info_volume_mounts,json=systemInfoVolumeMounts,proto3" json:"system_info_volume_mounts,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -555,6 +559,13 @@ func (x *Container) GetDurableDirVolumeMounts() []*DurableDirVolumeMount { return nil } +func (x *Container) GetSystemInfoVolumeMounts() []*SystemInfoVolumeMount { + if x != nil { + return x.SystemInfoVolumeMounts + } + return nil +} + // DurableDirVolumeMount is one durable-dir volume mounted into a container. type DurableDirVolumeMount struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -611,6 +622,64 @@ func (x *DurableDirVolumeMount) GetMountPath() string { return "" } +// SystemInfoVolumeMount is one system-info volume mounted (read-only) into a +// container. Unlike durable-dir volumes, system-info contents are generated +// by atelet on every Run/Restore and are never captured into snapshots. +type SystemInfoVolumeMount struct { + state protoimpl.MessageState `protogen:"open.v1"` + // volume_name is the name the ActorTemplate gave the volume. It selects the + // per-volume directory atelet prepared for the actor on the host. + VolumeName string `protobuf:"bytes,1,opt,name=volume_name,json=volumeName,proto3" json:"volume_name,omitempty"` + // mount_path is where the container sees the volume. + MountPath string `protobuf:"bytes,2,opt,name=mount_path,json=mountPath,proto3" json:"mount_path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SystemInfoVolumeMount) Reset() { + *x = SystemInfoVolumeMount{} + mi := &file_ateom_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SystemInfoVolumeMount) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SystemInfoVolumeMount) ProtoMessage() {} + +func (x *SystemInfoVolumeMount) ProtoReflect() protoreflect.Message { + mi := &file_ateom_proto_msgTypes[5] + 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 SystemInfoVolumeMount.ProtoReflect.Descriptor instead. +func (*SystemInfoVolumeMount) Descriptor() ([]byte, []int) { + return file_ateom_proto_rawDescGZIP(), []int{5} +} + +func (x *SystemInfoVolumeMount) GetVolumeName() string { + if x != nil { + return x.VolumeName + } + return "" +} + +func (x *SystemInfoVolumeMount) GetMountPath() string { + if x != nil { + return x.MountPath + } + return "" +} + // Readyz describes how to check that a container is ready to serve. // Only HTTP is supported today. type Readyz struct { @@ -625,7 +694,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_ateom_proto_msgTypes[5] + mi := &file_ateom_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -637,7 +706,7 @@ func (x *Readyz) String() string { func (*Readyz) ProtoMessage() {} func (x *Readyz) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[5] + mi := &file_ateom_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -650,7 +719,7 @@ func (x *Readyz) ProtoReflect() protoreflect.Message { // Deprecated: Use Readyz.ProtoReflect.Descriptor instead. func (*Readyz) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{5} + return file_ateom_proto_rawDescGZIP(), []int{6} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -680,7 +749,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_ateom_proto_msgTypes[6] + mi := &file_ateom_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -692,7 +761,7 @@ func (x *HTTPGetAction) String() string { func (*HTTPGetAction) ProtoMessage() {} func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[6] + mi := &file_ateom_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -705,7 +774,7 @@ func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPGetAction.ProtoReflect.Descriptor instead. func (*HTTPGetAction) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{6} + return file_ateom_proto_rawDescGZIP(), []int{7} } func (x *HTTPGetAction) GetPath() string { @@ -730,7 +799,7 @@ type RunWorkloadResponse struct { func (x *RunWorkloadResponse) Reset() { *x = RunWorkloadResponse{} - mi := &file_ateom_proto_msgTypes[7] + mi := &file_ateom_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -742,7 +811,7 @@ func (x *RunWorkloadResponse) String() string { func (*RunWorkloadResponse) ProtoMessage() {} func (x *RunWorkloadResponse) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[7] + mi := &file_ateom_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -755,7 +824,7 @@ func (x *RunWorkloadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunWorkloadResponse.ProtoReflect.Descriptor instead. func (*RunWorkloadResponse) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{7} + return file_ateom_proto_rawDescGZIP(), []int{8} } type CheckpointWorkloadRequest struct { @@ -789,7 +858,7 @@ type CheckpointWorkloadRequest struct { func (x *CheckpointWorkloadRequest) Reset() { *x = CheckpointWorkloadRequest{} - mi := &file_ateom_proto_msgTypes[8] + mi := &file_ateom_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -801,7 +870,7 @@ func (x *CheckpointWorkloadRequest) String() string { func (*CheckpointWorkloadRequest) ProtoMessage() {} func (x *CheckpointWorkloadRequest) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[8] + mi := &file_ateom_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -814,7 +883,7 @@ func (x *CheckpointWorkloadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointWorkloadRequest.ProtoReflect.Descriptor instead. func (*CheckpointWorkloadRequest) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{8} + return file_ateom_proto_rawDescGZIP(), []int{9} } func (x *CheckpointWorkloadRequest) GetAtespace() string { @@ -899,7 +968,7 @@ type CheckpointWorkloadResponse struct { func (x *CheckpointWorkloadResponse) Reset() { *x = CheckpointWorkloadResponse{} - mi := &file_ateom_proto_msgTypes[9] + mi := &file_ateom_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -911,7 +980,7 @@ func (x *CheckpointWorkloadResponse) String() string { func (*CheckpointWorkloadResponse) ProtoMessage() {} func (x *CheckpointWorkloadResponse) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[9] + mi := &file_ateom_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -924,7 +993,7 @@ func (x *CheckpointWorkloadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointWorkloadResponse.ProtoReflect.Descriptor instead. func (*CheckpointWorkloadResponse) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{9} + return file_ateom_proto_rawDescGZIP(), []int{10} } func (x *CheckpointWorkloadResponse) GetSnapshotFiles() []string { @@ -969,7 +1038,7 @@ type RestoreWorkloadRequest struct { func (x *RestoreWorkloadRequest) Reset() { *x = RestoreWorkloadRequest{} - mi := &file_ateom_proto_msgTypes[10] + mi := &file_ateom_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -981,7 +1050,7 @@ func (x *RestoreWorkloadRequest) String() string { func (*RestoreWorkloadRequest) ProtoMessage() {} func (x *RestoreWorkloadRequest) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[10] + mi := &file_ateom_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -994,7 +1063,7 @@ func (x *RestoreWorkloadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreWorkloadRequest.ProtoReflect.Descriptor instead. func (*RestoreWorkloadRequest) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{10} + return file_ateom_proto_rawDescGZIP(), []int{11} } func (x *RestoreWorkloadRequest) GetAtespace() string { @@ -1103,7 +1172,7 @@ type RestoreWorkloadResponse struct { func (x *RestoreWorkloadResponse) Reset() { *x = RestoreWorkloadResponse{} - mi := &file_ateom_proto_msgTypes[11] + mi := &file_ateom_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1115,7 +1184,7 @@ func (x *RestoreWorkloadResponse) String() string { func (*RestoreWorkloadResponse) ProtoMessage() {} func (x *RestoreWorkloadResponse) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[11] + mi := &file_ateom_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1128,7 +1197,7 @@ func (x *RestoreWorkloadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreWorkloadResponse.ProtoReflect.Descriptor instead. func (*RestoreWorkloadResponse) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{11} + return file_ateom_proto_rawDescGZIP(), []int{12} } type GetWorkloadStatsRequest struct { @@ -1144,7 +1213,7 @@ type GetWorkloadStatsRequest struct { func (x *GetWorkloadStatsRequest) Reset() { *x = GetWorkloadStatsRequest{} - mi := &file_ateom_proto_msgTypes[12] + mi := &file_ateom_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1156,7 +1225,7 @@ func (x *GetWorkloadStatsRequest) String() string { func (*GetWorkloadStatsRequest) ProtoMessage() {} func (x *GetWorkloadStatsRequest) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[12] + mi := &file_ateom_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1169,7 +1238,7 @@ func (x *GetWorkloadStatsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkloadStatsRequest.ProtoReflect.Descriptor instead. func (*GetWorkloadStatsRequest) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{12} + return file_ateom_proto_rawDescGZIP(), []int{13} } func (x *GetWorkloadStatsRequest) GetActorUid() string { @@ -1233,7 +1302,7 @@ type WorkloadStatsSample struct { func (x *WorkloadStatsSample) Reset() { *x = WorkloadStatsSample{} - mi := &file_ateom_proto_msgTypes[13] + mi := &file_ateom_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1245,7 +1314,7 @@ func (x *WorkloadStatsSample) String() string { func (*WorkloadStatsSample) ProtoMessage() {} func (x *WorkloadStatsSample) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[13] + mi := &file_ateom_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1258,7 +1327,7 @@ func (x *WorkloadStatsSample) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkloadStatsSample.ProtoReflect.Descriptor instead. func (*WorkloadStatsSample) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{13} + return file_ateom_proto_rawDescGZIP(), []int{14} } func (x *WorkloadStatsSample) GetAtespace() string { @@ -1354,7 +1423,7 @@ type GetWorkloadStatsResponse struct { func (x *GetWorkloadStatsResponse) Reset() { *x = GetWorkloadStatsResponse{} - mi := &file_ateom_proto_msgTypes[14] + mi := &file_ateom_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1366,7 +1435,7 @@ func (x *GetWorkloadStatsResponse) String() string { func (*GetWorkloadStatsResponse) ProtoMessage() {} func (x *GetWorkloadStatsResponse) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[14] + mi := &file_ateom_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1379,7 +1448,7 @@ func (x *GetWorkloadStatsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetWorkloadStatsResponse.ProtoReflect.Descriptor instead. func (*GetWorkloadStatsResponse) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{14} + return file_ateom_proto_rawDescGZIP(), []int{15} } func (x *GetWorkloadStatsResponse) GetSample() *WorkloadStatsSample { @@ -1397,7 +1466,7 @@ type GetActiveWorkloadStatsRequest struct { func (x *GetActiveWorkloadStatsRequest) Reset() { *x = GetActiveWorkloadStatsRequest{} - mi := &file_ateom_proto_msgTypes[15] + mi := &file_ateom_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1409,7 +1478,7 @@ func (x *GetActiveWorkloadStatsRequest) String() string { func (*GetActiveWorkloadStatsRequest) ProtoMessage() {} func (x *GetActiveWorkloadStatsRequest) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[15] + mi := &file_ateom_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1422,7 +1491,7 @@ func (x *GetActiveWorkloadStatsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveWorkloadStatsRequest.ProtoReflect.Descriptor instead. func (*GetActiveWorkloadStatsRequest) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{15} + return file_ateom_proto_rawDescGZIP(), []int{16} } type GetActiveWorkloadStatsResponse struct { @@ -1444,7 +1513,7 @@ type GetActiveWorkloadStatsResponse struct { func (x *GetActiveWorkloadStatsResponse) Reset() { *x = GetActiveWorkloadStatsResponse{} - mi := &file_ateom_proto_msgTypes[16] + mi := &file_ateom_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1456,7 +1525,7 @@ func (x *GetActiveWorkloadStatsResponse) String() string { func (*GetActiveWorkloadStatsResponse) ProtoMessage() {} func (x *GetActiveWorkloadStatsResponse) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[16] + mi := &file_ateom_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1469,7 +1538,7 @@ func (x *GetActiveWorkloadStatsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveWorkloadStatsResponse.ProtoReflect.Descriptor instead. func (*GetActiveWorkloadStatsResponse) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{16} + return file_ateom_proto_rawDescGZIP(), []int{17} } func (x *GetActiveWorkloadStatsResponse) GetResult() isGetActiveWorkloadStatsResponse_Result { @@ -1542,15 +1611,21 @@ const file_ateom_proto_rawDesc = "" + "\fWorkloadSpec\x120\n" + "\n" + "containers\x18\x01 \x03(\v2\x10.ateom.ContainerR\n" + - "containers\"\xba\x01\n" + + "containers\"\x93\x02\n" + "\tContainer\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12%\n" + "\x06readyz\x18\x02 \x01(\v2\r.ateom.ReadyzR\x06readyz\x12W\n" + - "\x19durable_dir_volume_mounts\x18\x04 \x03(\v2\x1c.ateom.DurableDirVolumeMountR\x16durableDirVolumeMountsJ\x04\b\x03\x10\x04R\x13durable_dir_volumes\"W\n" + + "\x19durable_dir_volume_mounts\x18\x04 \x03(\v2\x1c.ateom.DurableDirVolumeMountR\x16durableDirVolumeMounts\x12W\n" + + "\x19system_info_volume_mounts\x18\x05 \x03(\v2\x1c.ateom.SystemInfoVolumeMountR\x16systemInfoVolumeMountsJ\x04\b\x03\x10\x04R\x13durable_dir_volumes\"W\n" + "\x15DurableDirVolumeMount\x12\x1f\n" + "\vvolume_name\x18\x01 \x01(\tR\n" + "volumeName\x12\x1d\n" + "\n" + + "mount_path\x18\x02 \x01(\tR\tmountPath\"W\n" + + "\x15SystemInfoVolumeMount\x12\x1f\n" + + "\vvolume_name\x18\x01 \x01(\tR\n" + + "volumeName\x12\x1d\n" + + "\n" + "mount_path\x18\x02 \x01(\tR\tmountPath\"b\n" + "\x06Readyz\x12/\n" + "\bhttp_get\x18\x01 \x01(\v2\x14.ateom.HTTPGetActionR\ahttpGet\x12'\n" + @@ -1662,7 +1737,7 @@ func file_ateom_proto_rawDescGZIP() []byte { } var file_ateom_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_ateom_proto_msgTypes = make([]protoimpl.MessageInfo, 20) +var file_ateom_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_ateom_proto_goTypes = []any{ (SnapshotScope)(0), // 0: ateom.SnapshotScope (SandboxClass)(0), // 1: ateom.SandboxClass @@ -1673,57 +1748,59 @@ var file_ateom_proto_goTypes = []any{ (*WorkloadSpec)(nil), // 6: ateom.WorkloadSpec (*Container)(nil), // 7: ateom.Container (*DurableDirVolumeMount)(nil), // 8: ateom.DurableDirVolumeMount - (*Readyz)(nil), // 9: ateom.Readyz - (*HTTPGetAction)(nil), // 10: ateom.HTTPGetAction - (*RunWorkloadResponse)(nil), // 11: ateom.RunWorkloadResponse - (*CheckpointWorkloadRequest)(nil), // 12: ateom.CheckpointWorkloadRequest - (*CheckpointWorkloadResponse)(nil), // 13: ateom.CheckpointWorkloadResponse - (*RestoreWorkloadRequest)(nil), // 14: ateom.RestoreWorkloadRequest - (*RestoreWorkloadResponse)(nil), // 15: ateom.RestoreWorkloadResponse - (*GetWorkloadStatsRequest)(nil), // 16: ateom.GetWorkloadStatsRequest - (*WorkloadStatsSample)(nil), // 17: ateom.WorkloadStatsSample - (*GetWorkloadStatsResponse)(nil), // 18: ateom.GetWorkloadStatsResponse - (*GetActiveWorkloadStatsRequest)(nil), // 19: ateom.GetActiveWorkloadStatsRequest - (*GetActiveWorkloadStatsResponse)(nil), // 20: ateom.GetActiveWorkloadStatsResponse - nil, // 21: ateom.RunWorkloadRequest.RuntimeAssetPathsEntry - nil, // 22: ateom.CheckpointWorkloadRequest.RuntimeAssetPathsEntry - nil, // 23: ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntry + (*SystemInfoVolumeMount)(nil), // 9: ateom.SystemInfoVolumeMount + (*Readyz)(nil), // 10: ateom.Readyz + (*HTTPGetAction)(nil), // 11: ateom.HTTPGetAction + (*RunWorkloadResponse)(nil), // 12: ateom.RunWorkloadResponse + (*CheckpointWorkloadRequest)(nil), // 13: ateom.CheckpointWorkloadRequest + (*CheckpointWorkloadResponse)(nil), // 14: ateom.CheckpointWorkloadResponse + (*RestoreWorkloadRequest)(nil), // 15: ateom.RestoreWorkloadRequest + (*RestoreWorkloadResponse)(nil), // 16: ateom.RestoreWorkloadResponse + (*GetWorkloadStatsRequest)(nil), // 17: ateom.GetWorkloadStatsRequest + (*WorkloadStatsSample)(nil), // 18: ateom.WorkloadStatsSample + (*GetWorkloadStatsResponse)(nil), // 19: ateom.GetWorkloadStatsResponse + (*GetActiveWorkloadStatsRequest)(nil), // 20: ateom.GetActiveWorkloadStatsRequest + (*GetActiveWorkloadStatsResponse)(nil), // 21: ateom.GetActiveWorkloadStatsResponse + nil, // 22: ateom.RunWorkloadRequest.RuntimeAssetPathsEntry + nil, // 23: ateom.CheckpointWorkloadRequest.RuntimeAssetPathsEntry + nil, // 24: ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntry } var file_ateom_proto_depIdxs = []int32{ 6, // 0: ateom.RunWorkloadRequest.spec:type_name -> ateom.WorkloadSpec - 21, // 1: ateom.RunWorkloadRequest.runtime_asset_paths:type_name -> ateom.RunWorkloadRequest.RuntimeAssetPathsEntry + 22, // 1: ateom.RunWorkloadRequest.runtime_asset_paths:type_name -> ateom.RunWorkloadRequest.RuntimeAssetPathsEntry 5, // 2: ateom.RunWorkloadRequest.egress_gateway:type_name -> ateom.EgressGateway 7, // 3: ateom.WorkloadSpec.containers:type_name -> ateom.Container - 9, // 4: ateom.Container.readyz:type_name -> ateom.Readyz + 10, // 4: ateom.Container.readyz:type_name -> ateom.Readyz 8, // 5: ateom.Container.durable_dir_volume_mounts:type_name -> ateom.DurableDirVolumeMount - 10, // 6: ateom.Readyz.http_get:type_name -> ateom.HTTPGetAction - 6, // 7: ateom.CheckpointWorkloadRequest.spec:type_name -> ateom.WorkloadSpec - 22, // 8: ateom.CheckpointWorkloadRequest.runtime_asset_paths:type_name -> ateom.CheckpointWorkloadRequest.RuntimeAssetPathsEntry - 0, // 9: ateom.CheckpointWorkloadRequest.scope:type_name -> ateom.SnapshotScope - 6, // 10: ateom.RestoreWorkloadRequest.spec:type_name -> ateom.WorkloadSpec - 23, // 11: ateom.RestoreWorkloadRequest.runtime_asset_paths:type_name -> ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntry - 0, // 12: ateom.RestoreWorkloadRequest.scope:type_name -> ateom.SnapshotScope - 5, // 13: ateom.RestoreWorkloadRequest.egress_gateway:type_name -> ateom.EgressGateway - 1, // 14: ateom.WorkloadStatsSample.sandbox_class:type_name -> ateom.SandboxClass - 2, // 15: ateom.WorkloadStatsSample.source:type_name -> ateom.StatsSource - 17, // 16: ateom.GetWorkloadStatsResponse.sample:type_name -> ateom.WorkloadStatsSample - 17, // 17: ateom.GetActiveWorkloadStatsResponse.sample:type_name -> ateom.WorkloadStatsSample - 3, // 18: ateom.GetActiveWorkloadStatsResponse.no_sample_reason:type_name -> ateom.NoSampleReason - 4, // 19: ateom.Ateom.RunWorkload:input_type -> ateom.RunWorkloadRequest - 12, // 20: ateom.Ateom.CheckpointWorkload:input_type -> ateom.CheckpointWorkloadRequest - 14, // 21: ateom.Ateom.RestoreWorkload:input_type -> ateom.RestoreWorkloadRequest - 16, // 22: ateom.Ateom.GetWorkloadStats:input_type -> ateom.GetWorkloadStatsRequest - 19, // 23: ateom.Ateom.GetActiveWorkloadStats:input_type -> ateom.GetActiveWorkloadStatsRequest - 11, // 24: ateom.Ateom.RunWorkload:output_type -> ateom.RunWorkloadResponse - 13, // 25: ateom.Ateom.CheckpointWorkload:output_type -> ateom.CheckpointWorkloadResponse - 15, // 26: ateom.Ateom.RestoreWorkload:output_type -> ateom.RestoreWorkloadResponse - 18, // 27: ateom.Ateom.GetWorkloadStats:output_type -> ateom.GetWorkloadStatsResponse - 20, // 28: ateom.Ateom.GetActiveWorkloadStats:output_type -> ateom.GetActiveWorkloadStatsResponse - 24, // [24:29] is the sub-list for method output_type - 19, // [19:24] is the sub-list for method input_type - 19, // [19:19] is the sub-list for extension type_name - 19, // [19:19] is the sub-list for extension extendee - 0, // [0:19] is the sub-list for field type_name + 9, // 6: ateom.Container.system_info_volume_mounts:type_name -> ateom.SystemInfoVolumeMount + 11, // 7: ateom.Readyz.http_get:type_name -> ateom.HTTPGetAction + 6, // 8: ateom.CheckpointWorkloadRequest.spec:type_name -> ateom.WorkloadSpec + 23, // 9: ateom.CheckpointWorkloadRequest.runtime_asset_paths:type_name -> ateom.CheckpointWorkloadRequest.RuntimeAssetPathsEntry + 0, // 10: ateom.CheckpointWorkloadRequest.scope:type_name -> ateom.SnapshotScope + 6, // 11: ateom.RestoreWorkloadRequest.spec:type_name -> ateom.WorkloadSpec + 24, // 12: ateom.RestoreWorkloadRequest.runtime_asset_paths:type_name -> ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntry + 0, // 13: ateom.RestoreWorkloadRequest.scope:type_name -> ateom.SnapshotScope + 5, // 14: ateom.RestoreWorkloadRequest.egress_gateway:type_name -> ateom.EgressGateway + 1, // 15: ateom.WorkloadStatsSample.sandbox_class:type_name -> ateom.SandboxClass + 2, // 16: ateom.WorkloadStatsSample.source:type_name -> ateom.StatsSource + 18, // 17: ateom.GetWorkloadStatsResponse.sample:type_name -> ateom.WorkloadStatsSample + 18, // 18: ateom.GetActiveWorkloadStatsResponse.sample:type_name -> ateom.WorkloadStatsSample + 3, // 19: ateom.GetActiveWorkloadStatsResponse.no_sample_reason:type_name -> ateom.NoSampleReason + 4, // 20: ateom.Ateom.RunWorkload:input_type -> ateom.RunWorkloadRequest + 13, // 21: ateom.Ateom.CheckpointWorkload:input_type -> ateom.CheckpointWorkloadRequest + 15, // 22: ateom.Ateom.RestoreWorkload:input_type -> ateom.RestoreWorkloadRequest + 17, // 23: ateom.Ateom.GetWorkloadStats:input_type -> ateom.GetWorkloadStatsRequest + 20, // 24: ateom.Ateom.GetActiveWorkloadStats:input_type -> ateom.GetActiveWorkloadStatsRequest + 12, // 25: ateom.Ateom.RunWorkload:output_type -> ateom.RunWorkloadResponse + 14, // 26: ateom.Ateom.CheckpointWorkload:output_type -> ateom.CheckpointWorkloadResponse + 16, // 27: ateom.Ateom.RestoreWorkload:output_type -> ateom.RestoreWorkloadResponse + 19, // 28: ateom.Ateom.GetWorkloadStats:output_type -> ateom.GetWorkloadStatsResponse + 21, // 29: ateom.Ateom.GetActiveWorkloadStats:output_type -> ateom.GetActiveWorkloadStatsResponse + 25, // [25:30] is the sub-list for method output_type + 20, // [20:25] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_ateom_proto_init() } @@ -1732,8 +1809,8 @@ func file_ateom_proto_init() { return } file_ateom_proto_msgTypes[0].OneofWrappers = []any{} - file_ateom_proto_msgTypes[10].OneofWrappers = []any{} - file_ateom_proto_msgTypes[16].OneofWrappers = []any{ + file_ateom_proto_msgTypes[11].OneofWrappers = []any{} + file_ateom_proto_msgTypes[17].OneofWrappers = []any{ (*GetActiveWorkloadStatsResponse_Sample)(nil), (*GetActiveWorkloadStatsResponse_NoSampleReason)(nil), } @@ -1743,7 +1820,7 @@ func file_ateom_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ateom_proto_rawDesc), len(file_ateom_proto_rawDesc)), NumEnums: 4, - NumMessages: 20, + NumMessages: 21, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/proto/ateompb/ateom.proto b/internal/proto/ateompb/ateom.proto index 9ec80a232..c0c1279aa 100644 --- a/internal/proto/ateompb/ateom.proto +++ b/internal/proto/ateompb/ateom.proto @@ -152,6 +152,11 @@ message Container { // durable_dir_volume_mounts are the durable-dir volumes this container // mounts, if any. repeated DurableDirVolumeMount durable_dir_volume_mounts = 4; + + // system_info_volume_mounts are the system-info volumes this container + // mounts, if any. Contents are generated by atelet on the host; the + // container sees them read-only. + repeated SystemInfoVolumeMount system_info_volume_mounts = 5; } // DurableDirVolumeMount is one durable-dir volume mounted into a container. @@ -163,6 +168,17 @@ message DurableDirVolumeMount { string mount_path = 2; } +// SystemInfoVolumeMount is one system-info volume mounted (read-only) into a +// container. Unlike durable-dir volumes, system-info contents are generated +// by atelet on every Run/Restore and are never captured into snapshots. +message SystemInfoVolumeMount { + // volume_name is the name the ActorTemplate gave the volume. It selects the + // per-volume directory atelet prepared for the actor on the host. + string volume_name = 1; + // mount_path is where the container sees the volume. + string mount_path = 2; +} + // Readyz describes how to check that a container is ready to serve. // Only HTTP is supported today. message Readyz { From 249130288fadcbee98ad0798cca22b5e9859f367 Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Thu, 13 Aug 2026 10:43:26 -0700 Subject: [PATCH 06/10] third_party/atomicwriter: record provenance and delineate local changes Add a README pinning the upstream source (k8s.io/kubernetes pkg/volume/util, delta verified against kubernetes/kubernetes@52ba9013) and enumerating every class of local modification, plus maintenance rules (mechanical adaptations only in upstream-derived files; behavioral changes go in substrate-owned files) and a re-sync procedure. Mark each copied file with a greppable '// substrate:' header so the patch surface is discoverable without diffing against upstream. --- .../third_party/atomicwriter/README.md | 57 +++++++++++++++++++ .../third_party/atomicwriter/atomic_writer.go | 5 ++ .../atomicwriter/atomic_writer_linux.go | 3 + .../atomicwriter/atomic_writer_test.go | 5 ++ .../atomicwriter/atomic_writer_unsupported.go | 3 + 5 files changed, 73 insertions(+) create mode 100644 cmd/atelet/internal/third_party/atomicwriter/README.md diff --git a/cmd/atelet/internal/third_party/atomicwriter/README.md b/cmd/atelet/internal/third_party/atomicwriter/README.md new file mode 100644 index 000000000..1719030e0 --- /dev/null +++ b/cmd/atelet/internal/third_party/atomicwriter/README.md @@ -0,0 +1,57 @@ +# third_party/atomicwriter + +Kubelet's atomic writer, copied from the Kubernetes monorepo: + +- **Upstream source:** `k8s.io/kubernetes/pkg/volume/util/` + (`atomic_writer.go`, `atomic_writer_linux.go`, `atomic_writer_unsupported.go`, + `atomic_writer_test.go`) +- **Delta last verified against:** [`kubernetes/kubernetes@52ba9013`](https://github.com/kubernetes/kubernetes/commit/52ba90138eb40cab0987dac73e05c838149bdd1c) (master, 2026-08-13) +- **License:** Apache-2.0; the upstream copyright headers are retained in every file. + +It is copied, not imported, because upstream lives in the `k8s.io/kubernetes` +monorepo, which is not consumable as a Go module. Expect this to remain a +permanent fork: the algorithm has been stable upstream since ~2016, and our +adaptations below are ones upstream would not take. Occasional manual re-syncs +for upstream bugfixes (e.g. path-validation hardening) are the only planned +convergence. + +## Local modifications + +Every modified file carries a `// substrate:` marker below its license header; +`grep -rn "substrate:" .` from this directory lists the patched files. The +changes, by class: + +1. Package renamed `util` → `atomicwriter`. +2. Kubernetes-internal dependencies dropped: `k8s.io/klog/v2`, + `k8s.io/apiserver/pkg/util/feature`, and `k8s.io/kubernetes/pkg/features` + (`k8s.io/apimachinery/pkg/util/sets` is kept — substrate already vendors it). + In the test file, `k8s.io/client-go/util/testing` is replaced by a local + `mkTmpdir` helper. +3. Logging converted from klog to `log/slog`, threading a `context.Context` + through `Write`, `pathsToRemove`, and `removeUserVisiblePaths` for + `slog.*Context`. The `logContext` field/constructor parameter this obsoletes + is removed: `NewAtomicWriter(targetDir, logContext)` → + `NewAtomicWriter(targetDir)`. +4. Error handling restyled: upstream's log-then-`return err` sites return + wrapped errors (`fmt.Errorf("while ...: %w", err)`) per substrate + convention; klog error logs that accompanied a `return` are dropped in + favor of the wrapped error. +5. Upstream's `ResolvesFsUser` helper (KEP-5936, feature-gate dependent) is + omitted; substrate does not use FsUser resolution. + +## Maintenance rules + +- Only mechanical adaptations (the classes above) belong in the + upstream-derived files. Anything behavioral goes in a separate, + substrate-owned file in this package (none exist today). +- When touching these files, keep the diff against upstream minimal and update + the modification list here if a new class of change is introduced. + +## Re-syncing with upstream + +1. Fetch the files listed above from `k8s.io/kubernetes` at the new commit. +2. Diff against this copy, ignoring the modification classes above + (the delta is ~80 lines; it is meant to stay readable by hand). +3. Apply upstream's changes, re-apply our classes to any new code, run + `go test ./cmd/atelet/internal/third_party/atomicwriter/`, and update the + "Delta last verified against" commit above. diff --git a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go index d2f3a6e0b..0fb6732f0 100644 --- a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go +++ b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go @@ -14,6 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ +// substrate: modified from upstream k8s.io/kubernetes/pkg/volume/util — +// package renamed, klog→slog with context threading, errors wrapped instead +// of logged, logContext and ResolvesFsUser removed. See README.md for the +// full modification list. + package atomicwriter import ( diff --git a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_linux.go b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_linux.go index 1d5f7d34e..706f87eaf 100644 --- a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_linux.go +++ b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_linux.go @@ -16,6 +16,9 @@ See the License for the specific language governing permissions and limitations under the License. */ +// substrate: modified from upstream k8s.io/kubernetes/pkg/volume/util — +// package renamed. See README.md for the full modification list. + package atomicwriter import "os" diff --git a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_test.go b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_test.go index 09d9e5232..037b13495 100644 --- a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_test.go +++ b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_test.go @@ -16,6 +16,11 @@ See the License for the specific language governing permissions and limitations under the License. */ +// substrate: modified from upstream k8s.io/kubernetes/pkg/volume/util — +// package renamed, context threading on Write, k8s.io/client-go test dep +// replaced by a local mkTmpdir helper. See README.md for the full +// modification list. + package atomicwriter import ( diff --git a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_unsupported.go b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_unsupported.go index 2de802794..50948ec69 100644 --- a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_unsupported.go +++ b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_unsupported.go @@ -16,6 +16,9 @@ See the License for the specific language governing permissions and limitations under the License. */ +// substrate: modified from upstream k8s.io/kubernetes/pkg/volume/util — +// package renamed, klog→slog. See README.md for the full modification list. + package atomicwriter import ( From 2d2ae2456cf8ba375d676078b6eb76085e07070e Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Fri, 14 Aug 2026 15:37:45 -0700 Subject: [PATCH 07/10] Address review feedback on #803 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - third_party/atomicwriter: correct the copy-vs-import rationale in the README (upstream is importable; the dependency tree it drags in is why we copy) and trim the justification down. - atelet: TODO(#802) noting rotating data sources (JWTs, certificates) will need system-info files refreshed mid-run; actorMetadata never changes after start, so Run/Restore-time writes suffice for it. - ateompath: document how each sandbox class keeps system-info out of snapshots — the micro-VM checkpoint tars DurableDirVolumeMountsDir wholesale (capture by location), while gVisor captures durable mounts by declaration and never declares system-info mounts. - ateom-microvm: trim the teardown comment. --- .../internal/third_party/atomicwriter/README.md | 8 ++------ cmd/atelet/main.go | 4 ++++ cmd/ateom-microvm/checkpoint.go | 4 +--- internal/ateompath/ateompath.go | 17 ++++++++++++++--- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/cmd/atelet/internal/third_party/atomicwriter/README.md b/cmd/atelet/internal/third_party/atomicwriter/README.md index 1719030e0..7aeb06518 100644 --- a/cmd/atelet/internal/third_party/atomicwriter/README.md +++ b/cmd/atelet/internal/third_party/atomicwriter/README.md @@ -8,12 +8,8 @@ Kubelet's atomic writer, copied from the Kubernetes monorepo: - **Delta last verified against:** [`kubernetes/kubernetes@52ba9013`](https://github.com/kubernetes/kubernetes/commit/52ba90138eb40cab0987dac73e05c838149bdd1c) (master, 2026-08-13) - **License:** Apache-2.0; the upstream copyright headers are retained in every file. -It is copied, not imported, because upstream lives in the `k8s.io/kubernetes` -monorepo, which is not consumable as a Go module. Expect this to remain a -permanent fork: the algorithm has been stable upstream since ~2016, and our -adaptations below are ones upstream would not take. Occasional manual re-syncs -for upstream bugfixes (e.g. path-validation hardening) are the only planned -convergence. +Copied rather than imported to avoid importing the full Kubernetes dependency tree +into atelet. ## Local modifications diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 2e7e55e49..16e9d498e 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -1485,6 +1485,10 @@ func (s *AteomHerder) prepareOCIBundles( // started, no matter what checkpointed state it boots from. Files are written // with the atomic writer so a concurrent reader can never observe a partial // write. +// +// TODO(#802): rotating data sources (identity JWTs, certificates) will need +// these files refreshed while the actor runs, not just at Run/Restore. +// actorMetadata never changes after start, so writing here is enough for it. func writeSystemInfoVolume(ctx context.Context, rootPath string, actorRef resources.ActorRef, actorUID string, si *ateletpb.SystemInfoVolume) error { if err := os.MkdirAll(rootPath, 0o755); err != nil { return fmt.Errorf("while creating %q: %w", rootPath, err) diff --git a/cmd/ateom-microvm/checkpoint.go b/cmd/ateom-microvm/checkpoint.go index 0ea41672d..f0784f67e 100644 --- a/cmd/ateom-microvm/checkpoint.go +++ b/cmd/ateom-microvm/checkpoint.go @@ -295,9 +295,7 @@ func (s *AteomService) teardownActor(ctx context.Context, id string, ra *running _ = ra.chCmd.Process.Kill() _, _ = ra.chCmd.Process.Wait() } - // Kill the virtiofsds (after CH, their only client): the overlay RO - // lower's and, when the actor declares such volumes, the writable - // durable share's and the read-only system-info share's. + // Kill the virtiofsds after cloud-hypervisor for _, cmd := range []*exec.Cmd{ra.vfsdCmd, ra.durableVfsdCmd, ra.systemInfoVfsdCmd} { if cmd != nil && cmd.Process != nil { _ = cmd.Process.Kill() diff --git a/internal/ateompath/ateompath.go b/internal/ateompath/ateompath.go index 5e20084c5..ffe2e0764 100644 --- a/internal/ateompath/ateompath.go +++ b/internal/ateompath/ateompath.go @@ -200,9 +200,20 @@ 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. +// directories of system-info volumes. Snapshots must capture durable-dir +// data but never system-info contents, which atelet regenerates on every +// Run/Restore; each sandbox class excludes them differently: +// +// - micro-VM captures by location: its checkpoint tars all of +// DurableDirVolumeMountsDir (see ateom-microvm's tarDurableVolumes), so +// system-info roots are excluded by living in this separate directory. +// - gVisor captures by declaration: durable mounts are registered with +// the sandbox (mount-hint annotations for FULL checkpoints, the +// enumerated durable mount paths for DATA fscheckpoints); system-info +// mounts are plain undeclared binds, never captured regardless of host +// layout. +// +// The separate directory is therefore critical only for micro-VM. func SystemInfoVolumeRootsDir(actorUID string) string { return filepath.Join( ActorPath(actorUID), From eb041fae6e5c2973bde9190745d960fae2d1b401 Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Mon, 17 Aug 2026 10:19:01 -0700 Subject: [PATCH 08/10] atelet: materialize system-info files at stable paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #803 (find-paths safety): every virtiofsd runs with --migration-mode find-paths, which re-binds the guest's FUSE state on restore by re-opening the paths recorded at suspend — and gVisor's gofer re-opens by path the same way. The kubelet atomic writer breaks that contract: it serves files through a symlink into a timestamped payload directory, so every regeneration moves the real paths and deletes the old ones, and a restore of any guest that touched a system-info file would fail to re-bind (reproduced in TestWriteSystemInfoVolume_StableRealPaths, which fails under the old layout). Write plain files via per-file write-to-temp-and-rename instead (writeFileAtomic). Whole-set atomicity is unnecessary: generation only runs while the sandbox is down, so no reader can observe a partial write. Contents may change across a restore (that is the feature); paths never move. Path cleanliness is validated defensively in atelet since the atomic writer's checks are gone with it. Drop the now-unused third_party/atomicwriter package. The probe fixture now opens the identity file at startup and holds the fd across checkpoints, and the identity e2e asserts a post-restore read through that fd yields the restored actor's own id — the guest-handle re-binding scenario that would have caught this. --- .../third_party/atomicwriter/README.md | 53 - .../third_party/atomicwriter/atomic_writer.go | 501 -------- .../atomicwriter/atomic_writer_linux.go | 30 - .../atomicwriter/atomic_writer_test.go | 1109 ----------------- .../atomicwriter/atomic_writer_unsupported.go | 35 - cmd/atelet/main.go | 54 +- cmd/atelet/main_test.go | 66 + internal/e2e/fixtures/probe/main.go | 39 + internal/e2e/suites/identity/identity_test.go | 13 + 9 files changed, 156 insertions(+), 1744 deletions(-) delete mode 100644 cmd/atelet/internal/third_party/atomicwriter/README.md delete mode 100644 cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go delete mode 100644 cmd/atelet/internal/third_party/atomicwriter/atomic_writer_linux.go delete mode 100644 cmd/atelet/internal/third_party/atomicwriter/atomic_writer_test.go delete mode 100644 cmd/atelet/internal/third_party/atomicwriter/atomic_writer_unsupported.go diff --git a/cmd/atelet/internal/third_party/atomicwriter/README.md b/cmd/atelet/internal/third_party/atomicwriter/README.md deleted file mode 100644 index 7aeb06518..000000000 --- a/cmd/atelet/internal/third_party/atomicwriter/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# third_party/atomicwriter - -Kubelet's atomic writer, copied from the Kubernetes monorepo: - -- **Upstream source:** `k8s.io/kubernetes/pkg/volume/util/` - (`atomic_writer.go`, `atomic_writer_linux.go`, `atomic_writer_unsupported.go`, - `atomic_writer_test.go`) -- **Delta last verified against:** [`kubernetes/kubernetes@52ba9013`](https://github.com/kubernetes/kubernetes/commit/52ba90138eb40cab0987dac73e05c838149bdd1c) (master, 2026-08-13) -- **License:** Apache-2.0; the upstream copyright headers are retained in every file. - -Copied rather than imported to avoid importing the full Kubernetes dependency tree -into atelet. - -## Local modifications - -Every modified file carries a `// substrate:` marker below its license header; -`grep -rn "substrate:" .` from this directory lists the patched files. The -changes, by class: - -1. Package renamed `util` → `atomicwriter`. -2. Kubernetes-internal dependencies dropped: `k8s.io/klog/v2`, - `k8s.io/apiserver/pkg/util/feature`, and `k8s.io/kubernetes/pkg/features` - (`k8s.io/apimachinery/pkg/util/sets` is kept — substrate already vendors it). - In the test file, `k8s.io/client-go/util/testing` is replaced by a local - `mkTmpdir` helper. -3. Logging converted from klog to `log/slog`, threading a `context.Context` - through `Write`, `pathsToRemove`, and `removeUserVisiblePaths` for - `slog.*Context`. The `logContext` field/constructor parameter this obsoletes - is removed: `NewAtomicWriter(targetDir, logContext)` → - `NewAtomicWriter(targetDir)`. -4. Error handling restyled: upstream's log-then-`return err` sites return - wrapped errors (`fmt.Errorf("while ...: %w", err)`) per substrate - convention; klog error logs that accompanied a `return` are dropped in - favor of the wrapped error. -5. Upstream's `ResolvesFsUser` helper (KEP-5936, feature-gate dependent) is - omitted; substrate does not use FsUser resolution. - -## Maintenance rules - -- Only mechanical adaptations (the classes above) belong in the - upstream-derived files. Anything behavioral goes in a separate, - substrate-owned file in this package (none exist today). -- When touching these files, keep the diff against upstream minimal and update - the modification list here if a new class of change is introduced. - -## Re-syncing with upstream - -1. Fetch the files listed above from `k8s.io/kubernetes` at the new commit. -2. Diff against this copy, ignoring the modification classes above - (the delta is ~80 lines; it is meant to stay readable by hand). -3. Apply upstream's changes, re-apply our classes to any new code, run - `go test ./cmd/atelet/internal/third_party/atomicwriter/`, and update the - "Delta last verified against" commit above. diff --git a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go deleted file mode 100644 index 0fb6732f0..000000000 --- a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go +++ /dev/null @@ -1,501 +0,0 @@ -/* -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. -*/ - -// substrate: modified from upstream k8s.io/kubernetes/pkg/volume/util — -// package renamed, klog→slog with context threading, errors wrapped instead -// of logged, logContext and ResolvesFsUser removed. See README.md for the -// full modification list. - -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 deleted file mode 100644 index 706f87eaf..000000000 --- a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_linux.go +++ /dev/null @@ -1,30 +0,0 @@ -//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. -*/ - -// substrate: modified from upstream k8s.io/kubernetes/pkg/volume/util — -// package renamed. See README.md for the full modification list. - -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 deleted file mode 100644 index 037b13495..000000000 --- a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_test.go +++ /dev/null @@ -1,1109 +0,0 @@ -//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. -*/ - -// substrate: modified from upstream k8s.io/kubernetes/pkg/volume/util — -// package renamed, context threading on Write, k8s.io/client-go test dep -// replaced by a local mkTmpdir helper. See README.md for the full -// modification list. - -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 deleted file mode 100644 index 50948ec69..000000000 --- a/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_unsupported.go +++ /dev/null @@ -1,35 +0,0 @@ -//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. -*/ - -// substrate: modified from upstream k8s.io/kubernetes/pkg/volume/util — -// package renamed, klog→slog. See README.md for the full modification list. - -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 16e9d498e..b3037a88d 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -29,6 +29,7 @@ import ( "path/filepath" "slices" "strconv" + "strings" "syscall" "time" @@ -36,7 +37,6 @@ 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" @@ -1482,24 +1482,27 @@ func (s *AteomHerder) prepareOCIBundles( // writeSystemInfoVolume populates the root directory of a system-info volume // with one file per projected item. It runs on every Run/Restore, before the // sandbox starts, so the files carry the values of the actor actually being -// started, no matter what checkpointed state it boots from. Files are written -// with the atomic writer so a concurrent reader can never observe a partial -// write. +// started, no matter what checkpointed state it boots from. +// +// Every file must be a plain file at a stable real path across regenerations: +// the micro-VM virtiofsds run in find-paths migration mode, which re-binds +// the guest's FUSE state to files by the paths recorded at suspend, and +// gVisor's gofer likewise re-opens files by path on restore. Symlink-swap +// schemes (kubelet's atomic writer) move the payload files to a new +// timestamped directory on every write and delete the old one, so guest +// state from the snapshot could not re-bind. Per-file write-to-temp-and- +// rename is atomic enough: this only runs while the sandbox is down, so no +// reader can observe a partial write. // // TODO(#802): rotating data sources (identity JWTs, certificates) will need -// these files refreshed while the actor runs, not just at Run/Restore. +// these files refreshed while the actor runs, not just at Run/Restore — and +// must keep the per-file rename discipline so visible paths never move. // actorMetadata never changes after start, so writing here is enough for it. func writeSystemInfoVolume(ctx context.Context, rootPath string, actorRef resources.ActorRef, actorUID 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_ActorMetadata: @@ -1517,16 +1520,35 @@ func writeSystemInfoVolume(ctx context.Context, rootPath string, actorRef resour // item rather than write an empty file under its path. continue } - contents[item.GetPath()] = atomicwriter.FileProjection{ - Data: []byte(value), - Mode: 0o644, + if err := writeSystemInfoFile(rootPath, item.GetPath(), []byte(value)); err != nil { + return err } } } } + return nil +} - if err := aw.Write(ctx, contents, nil); err != nil { - return fmt.Errorf("while writing contents of SystemInfoVolume: %w", err) +// writeSystemInfoFile writes one projected file at relPath under rootPath via +// write-to-temp-and-rename, creating parent directories as needed. relPath is +// validated defensively even though ActorTemplate validation already rejects +// non-clean paths: atelet is the last line before the value hits the host +// filesystem. +func writeSystemInfoFile(rootPath, relPath string, data []byte) error { + if relPath == "" || strings.HasPrefix(relPath, "/") { + return fmt.Errorf("invalid system-info path %q: must be a non-empty relative path", relPath) + } + for _, seg := range strings.Split(relPath, "/") { + if seg == ".." || seg == "." || seg == "" { + return fmt.Errorf("invalid system-info path %q: must not contain empty, '.', or '..' segments", relPath) + } + } + dst := filepath.Join(rootPath, filepath.FromSlash(relPath)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return fmt.Errorf("while creating parent of %q: %w", dst, err) + } + if err := writeFileAtomic(dst, data, 0o644); err != nil { + return fmt.Errorf("while writing system-info file %q: %w", dst, err) } return nil } diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index 4eed97933..9794623d5 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -174,6 +174,72 @@ func TestWriteSystemInfoVolume(t *testing.T) { } } +// TestWriteSystemInfoVolume_StableRealPaths pins the path-stability contract +// the restore paths depend on: the micro-VM virtiofsds run in find-paths +// migration mode, which re-binds the guest's FUSE state to files by the paths +// recorded at suspend, and gVisor's gofer likewise re-opens files by path on +// restore. Projected files must therefore be plain files at stable real +// paths — no symlink indirection — and regenerating the volume must not move +// or delete a path that guest state may reference. +func TestWriteSystemInfoVolume_StableRealPaths(t *testing.T) { + ctx := context.Background() + root := filepath.Join(t.TempDir(), "system-info", "vol1") + si := &ateletpb.SystemInfoVolume{ + DataSources: []*ateletpb.SystemInfoDataSource{ + {DataSource: &ateletpb.SystemInfoDataSource_ActorMetadata{ + ActorMetadata: &ateletpb.ActorMetadataDataSource{ + Items: []*ateletpb.ActorMetadataItem{ + {Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME, Path: "actor-name"}, + {Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_UID, Path: "identity/actor-uid"}, + }, + }, + }}, + }, + } + + golden := resources.ActorRef{Atespace: "ate-e2e-probe", Name: "golden-actor"} + if err := writeSystemInfoVolume(ctx, root, golden, "uid-golden", si); err != nil { + t.Fatalf("writeSystemInfoVolume: %v", err) + } + + realBefore := map[string]string{} + for _, p := range []string{"actor-name", "identity/actor-uid"} { + visible := filepath.Join(root, p) + fi, err := os.Lstat(visible) + if err != nil { + t.Fatalf("lstat %q: %v", visible, err) + } + if !fi.Mode().IsRegular() { + t.Errorf("%q is %v, want a regular file: symlink indirection moves the real path on regeneration, which find-paths cannot re-bind", visible, fi.Mode().Type()) + } + real, err := filepath.EvalSymlinks(visible) + if err != nil { + t.Fatalf("eval symlinks %q: %v", visible, err) + } + realBefore[p] = real + } + + // Regenerate for a different actor, as a restore from a shared golden + // snapshot does. + alpha := resources.ActorRef{Atespace: "ate-e2e-probe", Name: "probe-alpha"} + if err := writeSystemInfoVolume(ctx, root, alpha, "uid-alpha", si); err != nil { + t.Fatalf("writeSystemInfoVolume (rewrite): %v", err) + } + + for _, p := range []string{"actor-name", "identity/actor-uid"} { + real, err := filepath.EvalSymlinks(filepath.Join(root, p)) + if err != nil { + t.Fatalf("eval symlinks after rewrite %q: %v", p, err) + } + if real != realBefore[p] { + t.Errorf("%q real path moved on regeneration: %q -> %q; guest state recorded at suspend cannot re-bind", p, realBefore[p], real) + } + if _, err := os.Stat(realBefore[p]); err != nil { + t.Errorf("pre-rewrite real path %q gone after regeneration: %v; find-paths re-open of a suspend-time path would fail", realBefore[p], err) + } + } +} + func TestWriteFileAtomic(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "actor-id") diff --git a/internal/e2e/fixtures/probe/main.go b/internal/e2e/fixtures/probe/main.go index d37daf2f9..6f47c1abc 100644 --- a/internal/e2e/fixtures/probe/main.go +++ b/internal/e2e/fixtures/probe/main.go @@ -23,6 +23,7 @@ package main import ( "bufio" "encoding/json" + "io" "log" "net/http" "os" @@ -39,6 +40,15 @@ const ( uidFile = "/run/ate/actor-uid" ) +// heldIdentity is identityFile opened at startup and held open for the +// probe's whole life, deliberately violating the read-at-time-of-use +// guidance. It exists so a snapshot taken after startup carries live guest +// file state for a system-info file, and a restore must re-bind it (virtiofsd +// find-paths / gofer re-open by path). whoami reads through it on every +// request; after a restore from a shared golden snapshot the read must +// succeed and yield the restored actor's own id, not the golden's. +var heldIdentity *os.File + // whoami reports the actor's identity as observed at request time from the // bind-mounted identity file. A read failure is reported in the response // rather than swallowed, so a failing e2e assertion explains itself. @@ -60,9 +70,29 @@ func whoami(w http.ResponseWriter, _ *http.Request) { } } + resp["held"] = "" + if heldIdentity == nil { + resp["error"] += "identity file was not open at startup; " + } else if b, err := readAllAt(heldIdentity); err == nil { + resp["held"] = string(b) + } else { + resp["error"] += "reading held identity fd: " + err.Error() + "; " + } + writeJSON(w, resp) } +// readAllAt reads f's full contents from offset 0 without moving its offset, +// so concurrent requests do not interleave seeks on the shared fd. +func readAllAt(f *os.File) ([]byte, error) { + buf := make([]byte, 4096) + n, err := f.ReadAt(buf, 0) + if err != nil && err != io.EOF { + return nil, err + } + return buf[:n], nil +} + // resources reports the compute envelope the actor observes from inside the // sandbox, so the sizing e2e suite can assert the actor's declared limits // actually shaped the runtime. @@ -131,6 +161,15 @@ func writeJSON(w http.ResponseWriter, v any) { } func main() { + // Hold the identity file open before serving: every snapshot of this actor + // then contains an open guest handle on a system-info file (see + // heldIdentity). + if f, err := os.Open(identityFile); err == nil { + heldIdentity = f + } else { + log.Printf("probe: opening %s at startup: %v", identityFile, err) + } + mux := http.NewServeMux() mux.HandleFunc("/whoami", whoami) mux.HandleFunc("/resources", resources) diff --git a/internal/e2e/suites/identity/identity_test.go b/internal/e2e/suites/identity/identity_test.go index 742e22013..ea38de239 100644 --- a/internal/e2e/suites/identity/identity_test.go +++ b/internal/e2e/suites/identity/identity_test.go @@ -42,6 +42,11 @@ type whoamiResponse struct { Atespace string `json:"atespace"` UID string `json:"uid"` Hostname string `json:"hostname"` + // Held is the actor id read through a file descriptor the probe opened at + // startup and holds across checkpoints — the snapshot therefore carries an + // open guest handle on a system-info file, and restore must re-bind it to + // the regenerated file (virtiofsd find-paths / gofer re-open by path). + Held string `json:"held"` // Error is the probe's file read error(s), if any, so a failed assertion // explains why a value was missing. Error string `json:"error"` @@ -92,6 +97,14 @@ func TestActorIdentity_AfterRestore_IsOwnID_NotGolden(t *testing.T) { } seen[got.File] = id + // The fd held open since before the golden snapshot must survive the + // restore and read the restored actor's OWN id: system-info files are + // regenerated at stable paths precisely so suspend-time guest handles + // re-bind (a moved or deleted path would fail the restore or the read). + if got.Held != id { + t.Errorf("actor %q: id via startup-held fd = %q, want %q (probe read error: %q)", id, got.Held, id, got.Error) + } + if got.Atespace != probeNamespace { t.Errorf("actor %q: /run/ate/atespace = %q, want %q (probe read error: %q)", id, got.Atespace, probeNamespace, got.Error) } From 53c155c0e58c4335b6e020771df5dabb5737646e Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Mon, 17 Aug 2026 13:40:54 -0700 Subject: [PATCH 09/10] e2e: micro-VM suspend/resume coverage for system-info path stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo suite exercises micro-VM suspend/resume and the identity suite exercises system-info volumes with a startup-held fd, but nothing combined them — the exact configuration where the find-paths contract bites (virtiofsd migrates with --migration-mode find-paths and its default --migration-on-error=abort hard-fails a resume whose recorded paths are missing). Add a micro-VM probe fixture in its own namespace and a gated identity test that restores an actor from the golden snapshot, asserts identity through the startup-held fd, then suspends and resumes the same actor and asserts again. The pre-suspend /whoami call deliberately seeds the guest's FUSE state (find-paths records all indexed inodes, not just open fds) so the suspend-time snapshot references every projected file. Gated on the microvm CI job's environment, same as the demo suite. --- .../fixtures/probe/probe-microvm.yaml.tmpl | 78 ++++++ internal/e2e/suites/identity/microvm_test.go | 246 ++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 internal/e2e/fixtures/probe/probe-microvm.yaml.tmpl create mode 100644 internal/e2e/suites/identity/microvm_test.go diff --git a/internal/e2e/fixtures/probe/probe-microvm.yaml.tmpl b/internal/e2e/fixtures/probe/probe-microvm.yaml.tmpl new file mode 100644 index 000000000..ec42a4582 --- /dev/null +++ b/internal/e2e/fixtures/probe/probe-microvm.yaml.tmpl @@ -0,0 +1,78 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Micro-VM variant of the probe fixture, in its own namespace so the gVisor +# probe manifest and this one can be applied and torn down independently. +# Applied only by the microvm-gated identity tests: it requires a cluster with +# the micro-VM deps installed (hack/install-microvm-deps.sh; cluster-wide +# SandboxConfig "microvm"). + +apiVersion: v1 +kind: Namespace +metadata: + name: ate-e2e-probe-mvm + +--- + +apiVersion: ate.dev/v1alpha1 +kind: WorkerPool +metadata: + name: probe-mvm + namespace: ate-e2e-probe-mvm + labels: + workload: probe-mvm +spec: + replicas: 2 + sandboxClass: microvm + sandboxConfigName: microvm + ateomImage: ko://github.com/agent-substrate/substrate/cmd/ateom-microvm + +--- + +apiVersion: ate.dev/v1alpha1 +kind: ActorTemplate +metadata: + name: probe + namespace: ate-e2e-probe-mvm +spec: + sandboxClass: microvm + volumes: + - name: system-info + systemInfo: + dataSources: + - actorMetadata: + items: + - field: name + path: actor-id + - field: atespace + path: atespace + - field: uid + path: actor-uid + 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 + readyz: + httpGet: + path: /healthz + port: 80 + timeoutSeconds: 60 + workerSelector: + matchLabels: + workload: probe-mvm + snapshotsConfig: + location: gs://${BUCKET_NAME}/ate-e2e-probe-mvm/ diff --git a/internal/e2e/suites/identity/microvm_test.go b/internal/e2e/suites/identity/microvm_test.go new file mode 100644 index 000000000..bc913d0e2 --- /dev/null +++ b/internal/e2e/suites/identity/microvm_test.go @@ -0,0 +1,246 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package identity + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/agent-substrate/substrate/internal/e2e" + "github.com/agent-substrate/substrate/internal/resources" + "github.com/agent-substrate/substrate/pkg/api/v1alpha1" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + mvmNamespace = "ate-e2e-probe-mvm" + mvmTemplate = "probe" +) + +// TestActorIdentity_Microvm_SuspendResume_StablePaths pins the system-info +// path-stability contract on the micro-VM runtime, where it is enforced the +// hardest: every virtiofsd runs with --migration-mode find-paths, so a resume +// re-binds the guest's FUSE state by re-opening the paths recorded at +// suspend, and virtiofsd's default --migration-on-error=abort hard-fails the +// restore if any recorded path is missing. atelet regenerates the system-info +// files between suspend and resume (that is the feature: the files carry the +// resuming actor's identity), so the regenerated files must land at exactly +// the paths the guest recorded — a write scheme that moves real paths (e.g. a +// timestamped-directory symlink swap) makes any actor that ever touched a +// system-info file fail to resume. +// +// The guest state is seeded two ways before each suspend: the probe holds an +// fd on /run/ate/actor-id from startup, and the pre-suspend /whoami call +// freshly indexes all three files (find-paths records all indexed inodes, not +// just open fds). +// +// Two migrations are exercised: the restore-from-golden that creates the +// actor, and a full suspend->resume cycle of the actor itself. +func TestActorIdentity_Microvm_SuspendResume_StablePaths(t *testing.T) { + // Micro-VM actors need the cluster to have the micro-VM deps installed + // (KVM, cloud-hypervisor, virtiofsd, the "microvm" SandboxConfig). The + // microvm CI job signals that the same way the demo suite detects it. + if os.Getenv("E2E_TEMPLATE_NAMESPACE") != "ate-demo-counter-microvm" { + t.Skip("micro-VM e2e deps not available (E2E_TEMPLATE_NAMESPACE != ate-demo-counter-microvm)") + } + + env, err := e2e.CheckEnv("BUCKET_NAME", "KO_DOCKER_REPO") + if err != nil { + t.Fatalf("CheckEnv failed: %v", err) + } + ctx := context.Background() + clients := e2e.GetClients() + + deployMicrovmProbe(t, env["BUCKET_NAME"]) + waitForMicrovmGolden(t, ctx, clients) + + const id = "probe-mvm-alpha" + createAndResumeMicrovmActor(t, ctx, clients, id) + waitForMicrovmActorStatus(t, ctx, clients, id, ateapipb.Actor_STATUS_RUNNING) + + rc, err := e2e.NewRouterClient(ctx) + if err != nil { + t.Fatalf("NewRouterClient: %v", err) + } + defer rc.Close() + + actor, err := clients.SubstrateAPI.GetActor(ctx, &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: mvmNamespace, Name: id}}) + if err != nil { + t.Fatalf("GetActor %q: %v", id, err) + } + wantUID := actor.GetMetadata().GetUid() + + // Migration #1: the actor was just restored from the golden snapshot, + // whose guest already held an fd on actor-id from probe startup. This + // call both asserts that re-bind worked and freshly indexes all three + // files in the guest, seeding the state the next suspend records. + assertOwnIdentity(t, "after restore-from-golden", whoamiMicrovm(t, ctx, rc, id), id, wantUID) + + // Migration #2: suspend and resume the actor itself. The suspend-time + // guest state now references the system-info files both via the held fd + // and the indexed inodes from the call above; the resume regenerates the + // files and find-paths must re-bind every recorded path. + if _, err := clients.SubstrateAPI.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: mvmNamespace, Name: id}}); err != nil { + t.Fatalf("SuspendActor %q: %v", id, err) + } + waitForMicrovmActorStatus(t, ctx, clients, id, ateapipb.Actor_STATUS_SUSPENDED) + + if _, err := clients.SubstrateAPI.ResumeActor(ctx, &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: mvmNamespace, Name: id}}); err != nil { + t.Fatalf("ResumeActor %q (after suspend): %v", id, err) + } + waitForMicrovmActorStatus(t, ctx, clients, id, ateapipb.Actor_STATUS_RUNNING) + + assertOwnIdentity(t, "after suspend/resume", whoamiMicrovm(t, ctx, rc, id), id, wantUID) +} + +// assertOwnIdentity asserts every identity channel reports the actor's own +// values: the at-request-time file reads, the projected UID, and the read +// through the fd held open since probe startup (the guest handle a restore +// must re-bind). +func assertOwnIdentity(t *testing.T, phase string, got whoamiResponse, id, wantUID string) { + t.Helper() + if got.File != id { + t.Errorf("%s: /run/ate/actor-id = %q, want %q (probe read error: %q)", phase, got.File, id, got.Error) + } + if got.Held != id { + t.Errorf("%s: id via startup-held fd = %q, want %q (probe read error: %q)", phase, got.Held, id, got.Error) + } + if got.Atespace != mvmNamespace { + t.Errorf("%s: /run/ate/atespace = %q, want %q (probe read error: %q)", phase, got.Atespace, mvmNamespace, got.Error) + } + if got.UID != wantUID { + t.Errorf("%s: /run/ate/actor-uid = %q, want %q (probe read error: %q)", phase, got.UID, wantUID, got.Error) + } +} + +// The helpers below mirror the gVisor probe's (identity_test.go) with the +// micro-VM namespace/template; kept separate rather than parameterized so the +// two fixtures stay independently deployable and deletable. + +func deployMicrovmProbe(t *testing.T, bucket string) { + t.Helper() + root, err := e2e.FindRepoRoot() + if err != nil { + t.Fatalf("FindRepoRoot: %v", err) + } + + tmpl, err := os.ReadFile(filepath.Join(root, "internal/e2e/fixtures/probe/probe-microvm.yaml.tmpl")) + if err != nil { + t.Fatalf("reading probe-microvm manifest template: %v", err) + } + manifest := filepath.Join(t.TempDir(), "probe-microvm.yaml") + rendered := strings.ReplaceAll(string(tmpl), "${BUCKET_NAME}", bucket) + if err := os.WriteFile(manifest, []byte(rendered), 0o644); err != nil { + t.Fatalf("writing rendered probe-microvm manifest: %v", err) + } + + // See deployProbe for why apply goes through the pinned ko and + // KO_CONFIG_PATH must point at the repo root. + applyArgs := []string{"ko", "apply", "-f", manifest} + if e2e.KubeContext != "" { + applyArgs = append(applyArgs, "--", "--context="+e2e.KubeContext) + } + e2e.RunCmdWithEnv(t, []string{"KO_CONFIG_PATH=" + root}, filepath.Join(root, "hack/run-tool.sh"), applyArgs...) + + t.Cleanup(func() { + delArgs := []string{"delete", "--ignore-not-found", "-f", manifest} + if e2e.KubeContext != "" { + delArgs = append([]string{"--context=" + e2e.KubeContext}, delArgs...) + } + e2e.RunCmd(t, "kubectl", delArgs...) + }) +} + +func waitForMicrovmGolden(t *testing.T, ctx context.Context, clients *e2e.Clients) { + t.Helper() + // Micro-VM golden creation cold-boots a VM (kernel + agent + virtiofsds), + // so allow longer than the gVisor probe's wait. + deadline := time.Now().Add(10 * time.Minute) + for time.Now().Before(deadline) { + at, err := clients.SubstrateK8s.ApiV1alpha1().ActorTemplates(mvmNamespace).Get(ctx, mvmTemplate, metav1.GetOptions{}) + if err == nil { + switch at.Status.Phase { + case v1alpha1.PhaseReady: + t.Logf("probe-microvm ActorTemplate ready, golden=%s", at.Status.GoldenActorID) + return + case v1alpha1.PhaseFailed: + t.Fatalf("probe-microvm ActorTemplate entered PhaseFailed") + } + } + time.Sleep(2 * time.Second) + } + t.Fatalf("timed out waiting for probe-microvm ActorTemplate to be Ready") +} + +func createAndResumeMicrovmActor(t *testing.T, ctx context.Context, clients *e2e.Clients, id string) { + t.Helper() + _, _ = clients.SubstrateAPI.CreateAtespace(ctx, &ateapipb.CreateAtespaceRequest{Atespace: &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: mvmNamespace}}}) + if _, err := clients.SubstrateAPI.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: mvmNamespace, Name: id}, + ActorTemplateNamespace: mvmNamespace, + ActorTemplateName: mvmTemplate, + }}); err != nil { + t.Fatalf("CreateActor %q: %v", id, err) + } + t.Cleanup(func() { + _, _ = clients.SubstrateAPI.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: mvmNamespace, Name: id}}) + _, _ = clients.SubstrateAPI.DeleteActor(ctx, &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Atespace: mvmNamespace, Name: id}}) + }) + + if _, err := clients.SubstrateAPI.ResumeActor(ctx, &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: mvmNamespace, Name: id}}); err != nil { + t.Fatalf("ResumeActor %q: %v", id, err) + } +} + +func waitForMicrovmActorStatus(t *testing.T, ctx context.Context, clients *e2e.Clients, id string, want ateapipb.Actor_Status) { + t.Helper() + deadline := time.Now().Add(3 * time.Minute) + for time.Now().Before(deadline) { + resp, err := clients.SubstrateAPI.GetActor(ctx, &ateapipb.GetActorRequest{ + Actor: &ateapipb.ObjectRef{Atespace: mvmNamespace, Name: id}, + }) + if err == nil && resp.GetStatus() == want { + return + } + time.Sleep(1 * time.Second) + } + t.Fatalf("timed out waiting for actor %q to reach status %v", id, want) +} + +func whoamiMicrovm(t *testing.T, ctx context.Context, rc *e2e.RouterClient, id string) whoamiResponse { + t.Helper() + resp, err := rc.Get(ctx, resources.ActorRef{Atespace: mvmNamespace, Name: id}, "/whoami") + if err != nil { + t.Fatalf("GET /whoami for %q: %v", id, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("GET /whoami for %q: status %d, body %q", id, resp.StatusCode, body) + } + var out whoamiResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decoding /whoami for %q: %v", id, err) + } + return out +} From 5a45da14470015946adcea97e8eda05a85e21e45 Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Mon, 17 Aug 2026 13:47:46 -0700 Subject: [PATCH 10/10] =?UTF-8?q?DO=20NOT=20MERGE:=20negative=20proof=20?= =?UTF-8?q?=E2=80=94=20restore=20atomicwriter=20under=20the=20e2e=20instru?= =?UTF-8?q?ments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the stable-paths materialization (eb041fae) ONLY for atelet: system-info files are again written via the kubelet atomic writer's timestamped-directory symlink swap. Deliberately KEEPS the held-fd probe, the identity-suite Held assertion, and the micro-VM suspend/resume test, and drops the stable-paths unit test so CI reaches the e2e stage. Expected result on the micro-VM e2e job: TestActorIdentity_Microvm_SuspendResume_StablePaths fails — the resume aborts (virtiofsd --migration-mode find-paths with default --migration-on-error=abort cannot re-open the suspend-time timestamped payload paths that regeneration deleted) or the held-fd read errors. The gVisor identity suite's Held assertion may fail for the analogous gofer path reason. This branch exists to document the failure mode for PR #803's review thread; it must never merge. --- .../third_party/atomicwriter/README.md | 53 + .../third_party/atomicwriter/atomic_writer.go | 501 ++++++++ .../atomicwriter/atomic_writer_linux.go | 30 + .../atomicwriter/atomic_writer_test.go | 1109 +++++++++++++++++ .../atomicwriter/atomic_writer_unsupported.go | 35 + cmd/atelet/main.go | 54 +- cmd/atelet/main_test.go | 66 - 7 files changed, 1744 insertions(+), 104 deletions(-) create mode 100644 cmd/atelet/internal/third_party/atomicwriter/README.md 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/atelet/internal/third_party/atomicwriter/README.md b/cmd/atelet/internal/third_party/atomicwriter/README.md new file mode 100644 index 000000000..7aeb06518 --- /dev/null +++ b/cmd/atelet/internal/third_party/atomicwriter/README.md @@ -0,0 +1,53 @@ +# third_party/atomicwriter + +Kubelet's atomic writer, copied from the Kubernetes monorepo: + +- **Upstream source:** `k8s.io/kubernetes/pkg/volume/util/` + (`atomic_writer.go`, `atomic_writer_linux.go`, `atomic_writer_unsupported.go`, + `atomic_writer_test.go`) +- **Delta last verified against:** [`kubernetes/kubernetes@52ba9013`](https://github.com/kubernetes/kubernetes/commit/52ba90138eb40cab0987dac73e05c838149bdd1c) (master, 2026-08-13) +- **License:** Apache-2.0; the upstream copyright headers are retained in every file. + +Copied rather than imported to avoid importing the full Kubernetes dependency tree +into atelet. + +## Local modifications + +Every modified file carries a `// substrate:` marker below its license header; +`grep -rn "substrate:" .` from this directory lists the patched files. The +changes, by class: + +1. Package renamed `util` → `atomicwriter`. +2. Kubernetes-internal dependencies dropped: `k8s.io/klog/v2`, + `k8s.io/apiserver/pkg/util/feature`, and `k8s.io/kubernetes/pkg/features` + (`k8s.io/apimachinery/pkg/util/sets` is kept — substrate already vendors it). + In the test file, `k8s.io/client-go/util/testing` is replaced by a local + `mkTmpdir` helper. +3. Logging converted from klog to `log/slog`, threading a `context.Context` + through `Write`, `pathsToRemove`, and `removeUserVisiblePaths` for + `slog.*Context`. The `logContext` field/constructor parameter this obsoletes + is removed: `NewAtomicWriter(targetDir, logContext)` → + `NewAtomicWriter(targetDir)`. +4. Error handling restyled: upstream's log-then-`return err` sites return + wrapped errors (`fmt.Errorf("while ...: %w", err)`) per substrate + convention; klog error logs that accompanied a `return` are dropped in + favor of the wrapped error. +5. Upstream's `ResolvesFsUser` helper (KEP-5936, feature-gate dependent) is + omitted; substrate does not use FsUser resolution. + +## Maintenance rules + +- Only mechanical adaptations (the classes above) belong in the + upstream-derived files. Anything behavioral goes in a separate, + substrate-owned file in this package (none exist today). +- When touching these files, keep the diff against upstream minimal and update + the modification list here if a new class of change is introduced. + +## Re-syncing with upstream + +1. Fetch the files listed above from `k8s.io/kubernetes` at the new commit. +2. Diff against this copy, ignoring the modification classes above + (the delta is ~80 lines; it is meant to stay readable by hand). +3. Apply upstream's changes, re-apply our classes to any new code, run + `go test ./cmd/atelet/internal/third_party/atomicwriter/`, and update the + "Delta last verified against" commit above. 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..0fb6732f0 --- /dev/null +++ b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer.go @@ -0,0 +1,501 @@ +/* +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. +*/ + +// substrate: modified from upstream k8s.io/kubernetes/pkg/volume/util — +// package renamed, klog→slog with context threading, errors wrapped instead +// of logged, logContext and ResolvesFsUser removed. See README.md for the +// full modification list. + +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..706f87eaf --- /dev/null +++ b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_linux.go @@ -0,0 +1,30 @@ +//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. +*/ + +// substrate: modified from upstream k8s.io/kubernetes/pkg/volume/util — +// package renamed. See README.md for the full modification list. + +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..037b13495 --- /dev/null +++ b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_test.go @@ -0,0 +1,1109 @@ +//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. +*/ + +// substrate: modified from upstream k8s.io/kubernetes/pkg/volume/util — +// package renamed, context threading on Write, k8s.io/client-go test dep +// replaced by a local mkTmpdir helper. See README.md for the full +// modification list. + +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..50948ec69 --- /dev/null +++ b/cmd/atelet/internal/third_party/atomicwriter/atomic_writer_unsupported.go @@ -0,0 +1,35 @@ +//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. +*/ + +// substrate: modified from upstream k8s.io/kubernetes/pkg/volume/util — +// package renamed, klog→slog. See README.md for the full modification list. + +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 b3037a88d..16e9d498e 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -29,7 +29,6 @@ import ( "path/filepath" "slices" "strconv" - "strings" "syscall" "time" @@ -37,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" @@ -1482,27 +1482,24 @@ func (s *AteomHerder) prepareOCIBundles( // writeSystemInfoVolume populates the root directory of a system-info volume // with one file per projected item. It runs on every Run/Restore, before the // sandbox starts, so the files carry the values of the actor actually being -// started, no matter what checkpointed state it boots from. -// -// Every file must be a plain file at a stable real path across regenerations: -// the micro-VM virtiofsds run in find-paths migration mode, which re-binds -// the guest's FUSE state to files by the paths recorded at suspend, and -// gVisor's gofer likewise re-opens files by path on restore. Symlink-swap -// schemes (kubelet's atomic writer) move the payload files to a new -// timestamped directory on every write and delete the old one, so guest -// state from the snapshot could not re-bind. Per-file write-to-temp-and- -// rename is atomic enough: this only runs while the sandbox is down, so no -// reader can observe a partial write. +// started, no matter what checkpointed state it boots from. Files are written +// with the atomic writer so a concurrent reader can never observe a partial +// write. // // TODO(#802): rotating data sources (identity JWTs, certificates) will need -// these files refreshed while the actor runs, not just at Run/Restore — and -// must keep the per-file rename discipline so visible paths never move. +// these files refreshed while the actor runs, not just at Run/Restore. // actorMetadata never changes after start, so writing here is enough for it. func writeSystemInfoVolume(ctx context.Context, rootPath string, actorRef resources.ActorRef, actorUID 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_ActorMetadata: @@ -1520,35 +1517,16 @@ func writeSystemInfoVolume(ctx context.Context, rootPath string, actorRef resour // item rather than write an empty file under its path. continue } - if err := writeSystemInfoFile(rootPath, item.GetPath(), []byte(value)); err != nil { - return err + contents[item.GetPath()] = atomicwriter.FileProjection{ + Data: []byte(value), + Mode: 0o644, } } } } - return nil -} -// writeSystemInfoFile writes one projected file at relPath under rootPath via -// write-to-temp-and-rename, creating parent directories as needed. relPath is -// validated defensively even though ActorTemplate validation already rejects -// non-clean paths: atelet is the last line before the value hits the host -// filesystem. -func writeSystemInfoFile(rootPath, relPath string, data []byte) error { - if relPath == "" || strings.HasPrefix(relPath, "/") { - return fmt.Errorf("invalid system-info path %q: must be a non-empty relative path", relPath) - } - for _, seg := range strings.Split(relPath, "/") { - if seg == ".." || seg == "." || seg == "" { - return fmt.Errorf("invalid system-info path %q: must not contain empty, '.', or '..' segments", relPath) - } - } - dst := filepath.Join(rootPath, filepath.FromSlash(relPath)) - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return fmt.Errorf("while creating parent of %q: %w", dst, err) - } - if err := writeFileAtomic(dst, data, 0o644); err != nil { - return fmt.Errorf("while writing system-info file %q: %w", dst, err) + if err := aw.Write(ctx, contents, nil); err != nil { + return fmt.Errorf("while writing contents of SystemInfoVolume: %w", err) } return nil } diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index 9794623d5..4eed97933 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -174,72 +174,6 @@ func TestWriteSystemInfoVolume(t *testing.T) { } } -// TestWriteSystemInfoVolume_StableRealPaths pins the path-stability contract -// the restore paths depend on: the micro-VM virtiofsds run in find-paths -// migration mode, which re-binds the guest's FUSE state to files by the paths -// recorded at suspend, and gVisor's gofer likewise re-opens files by path on -// restore. Projected files must therefore be plain files at stable real -// paths — no symlink indirection — and regenerating the volume must not move -// or delete a path that guest state may reference. -func TestWriteSystemInfoVolume_StableRealPaths(t *testing.T) { - ctx := context.Background() - root := filepath.Join(t.TempDir(), "system-info", "vol1") - si := &ateletpb.SystemInfoVolume{ - DataSources: []*ateletpb.SystemInfoDataSource{ - {DataSource: &ateletpb.SystemInfoDataSource_ActorMetadata{ - ActorMetadata: &ateletpb.ActorMetadataDataSource{ - Items: []*ateletpb.ActorMetadataItem{ - {Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME, Path: "actor-name"}, - {Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_UID, Path: "identity/actor-uid"}, - }, - }, - }}, - }, - } - - golden := resources.ActorRef{Atespace: "ate-e2e-probe", Name: "golden-actor"} - if err := writeSystemInfoVolume(ctx, root, golden, "uid-golden", si); err != nil { - t.Fatalf("writeSystemInfoVolume: %v", err) - } - - realBefore := map[string]string{} - for _, p := range []string{"actor-name", "identity/actor-uid"} { - visible := filepath.Join(root, p) - fi, err := os.Lstat(visible) - if err != nil { - t.Fatalf("lstat %q: %v", visible, err) - } - if !fi.Mode().IsRegular() { - t.Errorf("%q is %v, want a regular file: symlink indirection moves the real path on regeneration, which find-paths cannot re-bind", visible, fi.Mode().Type()) - } - real, err := filepath.EvalSymlinks(visible) - if err != nil { - t.Fatalf("eval symlinks %q: %v", visible, err) - } - realBefore[p] = real - } - - // Regenerate for a different actor, as a restore from a shared golden - // snapshot does. - alpha := resources.ActorRef{Atespace: "ate-e2e-probe", Name: "probe-alpha"} - if err := writeSystemInfoVolume(ctx, root, alpha, "uid-alpha", si); err != nil { - t.Fatalf("writeSystemInfoVolume (rewrite): %v", err) - } - - for _, p := range []string{"actor-name", "identity/actor-uid"} { - real, err := filepath.EvalSymlinks(filepath.Join(root, p)) - if err != nil { - t.Fatalf("eval symlinks after rewrite %q: %v", p, err) - } - if real != realBefore[p] { - t.Errorf("%q real path moved on regeneration: %q -> %q; guest state recorded at suspend cannot re-bind", p, realBefore[p], real) - } - if _, err := os.Stat(realBefore[p]); err != nil { - t.Errorf("pre-rewrite real path %q gone after regeneration: %v; find-paths re-open of a suspend-time path would fail", realBefore[p], err) - } - } -} - func TestWriteFileAtomic(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "actor-id")