diff --git a/.github/workflows/pr-workflow.yaml b/.github/workflows/pr-workflow.yaml index 586f53d829..efcfd120b1 100644 --- a/.github/workflows/pr-workflow.yaml +++ b/.github/workflows/pr-workflow.yaml @@ -103,6 +103,12 @@ jobs: E2E_TEMPLATE_NAME: counter-microvm E2E_TEMPLATE_READY_TIMEOUT: 600s run: hack/run-e2e-kind.sh ./internal/e2e/suites/demo -v -args --no-color + - name: Run E2E tests (micro-VM image volumes) + # Same image volume e2e suite on the micro-VM runtime. + env: + E2E_SANDBOX_CLASS: microvm + E2E_TEMPLATE_READY_TIMEOUT: 600s + run: hack/run-e2e-kind.sh ./internal/e2e/suites/imagevolume -v -args --no-color - name: Dump diagnostics on failure if: failure() run: | diff --git a/cmd/ateapi/internal/controlapi/workload_spec.go b/cmd/ateapi/internal/controlapi/workload_spec.go index 4289ff2ed2..5785313837 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec.go +++ b/cmd/ateapi/internal/controlapi/workload_spec.go @@ -39,6 +39,19 @@ func workloadSpecFromActorTemplate(actorTemplate *atev1alpha1.ActorTemplate, act }, }) } + + // volume is image type + if vol.VolumeSource.Image != nil { + workloadSpec.Volumes = append(workloadSpec.Volumes, &ateletpb.Volume{ + Name: vol.Name, + Type: ateletpb.VolumeType_VOLUME_TYPE_IMAGE, + Source: &ateletpb.Volume_Image{ + Image: &ateletpb.ImageVolumeSource{ + Reference: vol.VolumeSource.Image.Reference, + }, + }, + }) + } } // TODO: order may be important for nested mounts. Also need to think about diff --git a/cmd/ateapi/internal/controlapi/workload_spec_test.go b/cmd/ateapi/internal/controlapi/workload_spec_test.go index 569945713a..5743def51a 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec_test.go +++ b/cmd/ateapi/internal/controlapi/workload_spec_test.go @@ -71,6 +71,52 @@ func TestWorkloadSpecFromActorTemplate(t *testing.T) { }, }, }, + { + name: "converts Image volume and mounts", + template: &atev1alpha1.ActorTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "tmpl1", Namespace: "agent-ns"}, + Spec: atev1alpha1.ActorTemplateSpec{ + Volumes: []atev1alpha1.Volume{ + {Name: "home", VolumeSource: atev1alpha1.VolumeSource{DurableDir: &atev1alpha1.DurableDirVolumeSource{}}}, + {Name: "agent", VolumeSource: atev1alpha1.VolumeSource{Image: &atev1alpha1.ImageVolumeSource{Reference: "example.com/agent@sha256:abc"}}}, + }, + Containers: []atev1alpha1.Container{ + { + Name: "main", + Image: "main", + VolumeMounts: []atev1alpha1.VolumeMount{ + {Name: "home", MountPath: "/home/user"}, + {Name: "agent", MountPath: "/ate"}, + }, + }, + }, + }, + }, + want: &ateletpb.WorkloadSpec{ + Volumes: []*ateletpb.Volume{ + { + Name: "home", + Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR, + Source: &ateletpb.Volume_DurableDir{DurableDir: &ateletpb.DurableDirVolume{}}, + }, + { + Name: "agent", + Type: ateletpb.VolumeType_VOLUME_TYPE_IMAGE, + Source: &ateletpb.Volume_Image{Image: &ateletpb.ImageVolumeSource{Reference: "example.com/agent@sha256:abc"}}, + }, + }, + Containers: []*ateletpb.Container{ + { + Name: "main", + Image: "main", + VolumeMounts: []*ateletpb.VolumeMount{ + {Name: "home", MountPath: "/home/user"}, + {Name: "agent", MountPath: "/ate"}, + }, + }, + }, + }, + }, { name: "skips non-DurableDir volumes", template: &atev1alpha1.ActorTemplate{ diff --git a/cmd/atelet/imagevolume_test.go b/cmd/atelet/imagevolume_test.go new file mode 100644 index 0000000000..a439f21cb9 --- /dev/null +++ b/cmd/atelet/imagevolume_test.go @@ -0,0 +1,178 @@ +// 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 main + +import ( + "archive/tar" + "bytes" + "io" + "log" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/agent-substrate/substrate/internal/imagecache" + "github.com/agent-substrate/substrate/internal/proto/ateletpb" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/tarball" +) + +// imageVolumeTestRegistry starts an in-memory OCI registry. Its 127.0.0.1 host +// makes the image cache treat it as a local registry and pull over plain HTTP. +func imageVolumeTestRegistry(t *testing.T) string { + t.Helper() + srv := httptest.NewServer(registry.New(registry.Logger(log.New(io.Discard, "", 0)))) + t.Cleanup(srv.Close) + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parsing registry URL: %v", err) + } + return u.Host +} + +func singleFileLayer(t *testing.T, path, body string) v1.Layer { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + if err := tw.WriteHeader(&tar.Header{Name: path, Mode: 0o755, Size: int64(len(body))}); err != nil { + t.Fatalf("tar.WriteHeader: %v", err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatalf("tar.Write: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("tar.Close: %v", err) + } + l, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(buf.Bytes())), nil + }) + if err != nil { + t.Fatalf("tarball.LayerFromOpener: %v", err) + } + return l +} + +func pushTestImage(t *testing.T, ref string, layers ...v1.Layer) { + t.Helper() + img, err := mutate.AppendLayers(empty.Image, layers...) + if err != nil { + t.Fatalf("mutate.AppendLayers: %v", err) + } + tag, err := name.ParseReference(ref, name.Insecure) + if err != nil { + t.Fatalf("name.ParseReference(%q): %v", ref, err) + } + if err := remote.Write(tag, img); err != nil { + t.Fatalf("remote.Write(%q): %v", ref, err) + } +} + +func newImageVolumeStore(t *testing.T) *imagecache.Store { + t.Helper() + s, err := imagecache.New(t.TempDir()) + if err != nil { + t.Fatalf("imagecache.New: %v", err) + } + return s +} + +// A mounted image volume records its layers for ateom to compose, and its +// digest so the cache GC can protect them. +func TestResolveImageVolumes_RecordsLayersAndDigest(t *testing.T) { + host := imageVolumeTestRegistry(t) + ref := host + "/agent:v1" + pushTestImage(t, ref, singleFileLayer(t, "payload-binary", "binary")) + + volumes := []*ateletpb.Volume{{ + Name: "agent", + Type: ateletpb.VolumeType_VOLUME_TYPE_IMAGE, + Source: &ateletpb.Volume_Image{Image: &ateletpb.ImageVolumeSource{Reference: ref}}, + }} + mounts := []*ateletpb.VolumeMount{{Name: "agent", MountPath: "/ate"}} + + got, err := resolveImageVolumes(t.Context(), newImageVolumeStore(t), volumes, mounts) + if err != nil { + t.Fatalf("resolveImageVolumes: %v", err) + } + if len(got) != 1 || got[0].Name != "agent" { + t.Fatalf("resolveImageVolumes = %+v, want one entry named %q", got, "agent") + } + if len(got[0].Layers) != 1 { + t.Errorf("layers = %v, want 1", got[0].Layers) + } + if !strings.HasPrefix(got[0].ImageDigest, "sha256:") { + t.Errorf("image digest = %q, want a sha256 digest", got[0].ImageDigest) + } + // The returned path is a layer directory; the binary lives under its fs/ subtree. + if _, err := os.Stat(filepath.Join(got[0].Layers[0], "fs", "payload-binary")); err != nil { + t.Errorf("recorded path is not a layer directory: %v", err) + } +} + +// Multi-layer image volumes produce one entry with layers in bottom-most-first order. +func TestResolveImageVolumes_MultiLayer(t *testing.T) { + host := imageVolumeTestRegistry(t) + ref := host + "/agent:multi" + pushTestImage(t, ref, + singleFileLayer(t, "base", "one"), + singleFileLayer(t, "payload-binary", "binary"), + ) + + volumes := []*ateletpb.Volume{{ + Name: "agent", + Type: ateletpb.VolumeType_VOLUME_TYPE_IMAGE, + Source: &ateletpb.Volume_Image{Image: &ateletpb.ImageVolumeSource{Reference: ref}}, + }} + mounts := []*ateletpb.VolumeMount{{Name: "agent", MountPath: "/ate"}} + + got, err := resolveImageVolumes(t.Context(), newImageVolumeStore(t), volumes, mounts) + if err != nil { + t.Fatalf("resolveImageVolumes: %v", err) + } + if len(got) != 1 || len(got[0].Layers) != 2 { + t.Fatalf("resolveImageVolumes = %+v, want one entry with 2 layers", got) + } + for i, want := range []string{"base", "payload-binary"} { + if _, err := os.Stat(filepath.Join(got[0].Layers[i], "fs", want)); err != nil { + t.Errorf("layer %d does not hold %q: %v", i, want, err) + } + } +} + +// An image volume no container mounts is never pulled, so a bad reference on an +// unused volume cannot fail the actor. +func TestResolveImageVolumes_UnmountedVolumeNotPulled(t *testing.T) { + volumes := []*ateletpb.Volume{{ + Name: "agent", + Type: ateletpb.VolumeType_VOLUME_TYPE_IMAGE, + Source: &ateletpb.Volume_Image{Image: &ateletpb.ImageVolumeSource{Reference: "127.0.0.1:1/nope@sha256:abc"}}, + }} + + got, err := resolveImageVolumes(t.Context(), newImageVolumeStore(t), volumes, nil) + if err != nil { + t.Fatalf("resolveImageVolumes: %v", err) + } + if len(got) != 0 { + t.Errorf("resolveImageVolumes = %+v, want empty", got) + } +} diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 46de6e0d4e..d9fcb03293 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -1497,26 +1497,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) + imgVolumes := make(map[string]bool) for _, vol := range spec.GetVolumes() { - if vol.GetType() == ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR { + switch vol.GetType() { + case ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR: ddVolumes[vol.GetName()] = true + case ateletpb.VolumeType_VOLUME_TYPE_IMAGE: + imgVolumes[vol.GetName()] = true } } out := &ateompb.WorkloadSpec{} for _, ctr := range spec.GetContainers() { var ddMounts []*ateompb.DurableDirVolumeMount + var imgMounts []*ateompb.ImageVolumeMount 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 imgVolumes[vm.GetName()]: + imgMounts = append(imgMounts, &ateompb.ImageVolumeMount{ + VolumeName: vm.GetName(), + MountPath: vm.GetMountPath(), + }) } } out.Containers = append(out.Containers, &ateompb.Container{ Name: ctr.GetName(), DurableDirVolumeMounts: ddMounts, + ImageVolumeMounts: imgMounts, Readyz: toAteomReadyz(ctr.GetReadyz()), }) } diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index 1bb492db44..f3d8b6b519 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -1078,6 +1078,41 @@ func TestDrainOnShutdownForceStopsAfterTimeout(t *testing.T) { } } +// Image volumes appear on their own ImageVolumeMounts field, separate from durable-dir mounts. +func TestBuildAteomWorkloadSpec_ImageVolumeMounts(t *testing.T) { + spec := &ateletpb.WorkloadSpec{ + Volumes: []*ateletpb.Volume{ + {Name: "agent", Type: ateletpb.VolumeType_VOLUME_TYPE_IMAGE}, + {Name: "data", Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR}, + {Name: "ext", Type: ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL}, + }, + Containers: []*ateletpb.Container{{ + Name: "app", + VolumeMounts: []*ateletpb.VolumeMount{ + {Name: "agent", MountPath: "/ate"}, + {Name: "data", MountPath: "/var/data"}, + {Name: "ext", MountPath: "/mnt/ext"}, + }, + }}, + } + + got := buildAteomWorkloadSpec(spec) + if len(got.GetContainers()) != 1 { + t.Fatalf("containers = %d, want 1", len(got.GetContainers())) + } + ctr := got.GetContainers()[0] + + if len(ctr.GetImageVolumeMounts()) != 1 { + t.Fatalf("image volume mounts = %v, want 1", ctr.GetImageVolumeMounts()) + } + if name, path := ctr.GetImageVolumeMounts()[0].GetVolumeName(), ctr.GetImageVolumeMounts()[0].GetMountPath(); name != "agent" || path != "/ate" { + t.Errorf("image volume mount = (%q, %q), want (agent, /ate)", name, path) + } + if len(ctr.GetDurableDirVolumeMounts()) != 1 || ctr.GetDurableDirVolumeMounts()[0].GetVolumeName() != "data" { + t.Errorf("durable mounts = %v, want just data", ctr.GetDurableDirVolumeMounts()) + } +} + // allocatedBytes reports how much disk a file actually occupies, which is less than its // size when it has holes. func allocatedBytes(t *testing.T, path string) int64 { diff --git a/cmd/atelet/oci.go b/cmd/atelet/oci.go index e1476610c5..2628164a66 100644 --- a/cmd/atelet/oci.go +++ b/cmd/atelet/oci.go @@ -29,6 +29,7 @@ import ( "github.com/opencontainers/runtime-spec/specs-go" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" + "golang.org/x/sync/errgroup" "github.com/agent-substrate/substrate/internal/proto/ateletpb" ) @@ -77,9 +78,25 @@ func prepareOCIDirectory(ctx context.Context, imageCache *imagecache.Store, acto } } - img, err := imageCache.EnsureImage(ctx, ref) - if err != nil { - return fmt.Errorf("in imageCache.EnsureImage: %w", err) + var ( + img *imagecache.Image + imageVolumes []imagecache.ImageVolumeOverlay + ) + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + var err error + if img, err = imageCache.EnsureImage(gctx, ref); err != nil { + return fmt.Errorf("in imageCache.EnsureImage: %w", err) + } + return nil + }) + g.Go(func() error { + var err error + imageVolumes, err = resolveImageVolumes(gctx, imageCache, volumes, volumeMounts) + return err + }) + if err := g.Wait(); err != nil { + return err } // Argv and env need only the image config; resolve them before writing @@ -102,14 +119,15 @@ func prepareOCIDirectory(ctx context.Context, imageCache *imagecache.Store, acto extraDirs = append(extraDirs, vm.GetMountPath()) } if err := imagecache.WriteSpec(bundlePath, &imagecache.OverlaySpec{ - ImageDigest: img.Digest.String(), - Layers: img.LayerDirs, - ExtraDirs: extraDirs, + ImageDigest: img.Digest.String(), + Layers: img.LayerDirs, + ExtraDirs: extraDirs, + ImageVolumes: imageVolumes, }); err != nil { return fmt.Errorf("while writing overlay spec: %w", err) } - ociSpec := buildActorOCISpec(actorUID, resolvedArgs, resolvedEnv, annotations, netns, identityDir, volumes, volumeMounts) + ociSpec := buildActorOCISpec(actorUID, containerName, resolvedArgs, resolvedEnv, annotations, netns, identityDir, volumes, volumeMounts) ociSpecBytes, err := json.MarshalIndent(ociSpec, "", " ") if err != nil { return fmt.Errorf("while marshaling OCI spec: %w", err) @@ -122,6 +140,46 @@ func prepareOCIDirectory(ctx context.Context, imageCache *imagecache.Store, acto return nil } +// resolveImageVolumes pulls the image behind every image-typed volume this +// container mounts and returns what the overlay spec needs to compose each. +func resolveImageVolumes(ctx context.Context, imageCache *imagecache.Store, volumes []*ateletpb.Volume, volumeMounts []*ateletpb.VolumeMount) ([]imagecache.ImageVolumeOverlay, error) { + mounted := make(map[string]bool, len(volumeMounts)) + for _, vm := range volumeMounts { + mounted[vm.GetName()] = true + } + + var wanted []*ateletpb.Volume + for _, vol := range volumes { + if vol.GetType() != ateletpb.VolumeType_VOLUME_TYPE_IMAGE || !mounted[vol.GetName()] { + continue + } + wanted = append(wanted, vol) + } + + // Pull the volumes concurrently; each entry lands at its own index so the + // spec order stays the template order. + out := make([]imagecache.ImageVolumeOverlay, len(wanted)) + g, gctx := errgroup.WithContext(ctx) + for i, vol := range wanted { + g.Go(func() error { + img, err := imageCache.EnsureImage(gctx, vol.GetImage().GetReference()) + if err != nil { + return fmt.Errorf("in imageCache.EnsureImage for volume %q: %w", vol.GetName(), err) + } + out[i] = imagecache.ImageVolumeOverlay{ + Name: vol.GetName(), + ImageDigest: img.Digest.String(), + Layers: img.LayerDirs, + } + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + return out, nil +} + // resolveActorEnv computes the final container environment from the image's ENV // and the ActorTemplate env, with the template taking precedence. Duplicate keys // are removed in favor of template env > image env, and a default PATH stands in @@ -186,7 +244,7 @@ func resolveProcessArgs(imageCfg *v1.Config, command, args []string) ([]string, // 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, containerName string, args []string, env []string, annotations map[string]string, netns string, identityDir string, volumes []*ateletpb.Volume, volumeMounts []*ateletpb.VolumeMount) *specs.Spec { mounts := []specs.Mount{ { Destination: "/proc", @@ -302,11 +360,15 @@ func buildActorOCISpec(actorUID string, args []string, env []string, annotations for _, vm := range volumeMounts { var srcPath string + access := "rw" switch volumeTypes[vm.GetName()] { case ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR: srcPath = ateompath.DurableDirVolumeMountPoint(actorUID, vm.GetName()) case ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL: srcPath = ateompath.VolumeHostPath(actorUID, vm.GetName()) + case ateletpb.VolumeType_VOLUME_TYPE_IMAGE: + srcPath = ateompath.ImageVolumeMountPath(actorUID, containerName, vm.GetName()) + access = "ro" default: continue } @@ -314,7 +376,7 @@ func buildActorOCISpec(actorUID string, args []string, env []string, annotations Destination: vm.GetMountPath(), Type: "bind", Source: srcPath, - Options: []string{"bind", "rw"}, + Options: []string{"bind", access}, }) } diff --git a/cmd/atelet/oci_test.go b/cmd/atelet/oci_test.go index 433c082c37..9ac71e1c74 100644 --- a/cmd/atelet/oci_test.go +++ b/cmd/atelet/oci_test.go @@ -23,12 +23,13 @@ import ( "github.com/agent-substrate/substrate/internal/ateompath" "github.com/agent-substrate/substrate/internal/proto/ateletpb" v1 "github.com/google/go-containerregistry/pkg/v1" + specs "github.com/opencontainers/runtime-spec/specs-go" ) // With an identity dir, a read-only bind mount appears at IdentityMountPath. func TestBuildActorOCISpec_IdentityMount(t *testing.T) { spec := buildActorOCISpec( - "actor_uid", + "actor_uid", "app", []string{"/app"}, []string{"FOO=bar"}, map[string]string{"k": "v"}, @@ -194,7 +195,7 @@ func TestResolveProcessArgs(t *testing.T) { // Without an identity dir (the pause container), no identity mount appears. func TestBuildActorOCISpec_NoIdentityMountForPause(t *testing.T) { - bare := buildActorOCISpec("actor_uid", []string{"/pause"}, nil, nil, "/run/netns/x", "", nil, nil) + bare := buildActorOCISpec("actor_uid", "app", []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") @@ -215,7 +216,7 @@ func TestBuildActorOCISpec_DurableDirVolumeMounts(t *testing.T) { {Name: "cache", Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR}, } spec := buildActorOCISpec( - actorUID, + actorUID, "app", []string{"/app"}, nil, nil, "/run/netns/x", "", @@ -243,3 +244,42 @@ func TestBuildActorOCISpec_DurableDirVolumeMounts(t *testing.T) { } } } + +// An image volume binds the layer directory resolved for it, read-only. +func TestBuildActorOCISpec_ImageVolumeMounts(t *testing.T) { + volumes := []*ateletpb.Volume{ + {Name: "agent", Type: ateletpb.VolumeType_VOLUME_TYPE_IMAGE}, + {Name: "data", Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR}, + } + mounts := []*ateletpb.VolumeMount{ + {Name: "agent", MountPath: "/ate"}, + {Name: "data", MountPath: "/var/data"}, + } + spec := buildActorOCISpec( + "actor_uid", "app", + []string{"/ate/payload-binary"}, nil, nil, + "/run/netns/x", + "", + volumes, + mounts, + ) + + var got *specs.Mount + for i, m := range spec.Mounts { + if m.Destination == "/ate" { + got = &spec.Mounts[i] + } + } + if got == nil { + t.Fatalf("image volume mount for /ate missing; mounts=%v", spec.Mounts) + } + if want := ateompath.ImageVolumeMountPath("actor_uid", "app", "agent"); got.Source != want { + t.Errorf("image volume source = %q, want %q", got.Source, want) + } + if got.Type != "bind" { + t.Errorf("image volume type = %q, want bind", got.Type) + } + if want := []string{"bind", "ro"}; !slices.Equal(got.Options, want) { + t.Errorf("image volume options = %v, want %v", got.Options, want) + } +} diff --git a/cmd/ateom-microvm/durable.go b/cmd/ateom-microvm/durable.go index 46088de05d..ce0aeb61bf 100644 --- a/cmd/ateom-microvm/durable.go +++ b/cmd/ateom-microvm/durable.go @@ -90,16 +90,17 @@ func durableMounts(mounts []*ateompb.DurableDirVolumeMount) []specs.Mount { } // workloadSpec returns the OCI spec to start a container with: the prepared -// spec, plus a bind for each durable-dir volume it mounts. +// spec, plus a bind for each durable-dir and image volume it mounts. // // The spec is copied rather than mutated so the bundle's on-disk config.json // stays as prepared — only the started container sees the binds. func workloadSpec(c actorContainer) *specs.Spec { - if len(c.durableMounts) == 0 { + if len(c.durableMounts) == 0 && len(c.imageMounts) == 0 { return c.spec } spec := *c.spec spec.Mounts = append(append([]specs.Mount(nil), c.spec.Mounts...), durableMounts(c.durableMounts)...) + spec.Mounts = append(spec.Mounts, imageVolumeMounts(c.imageMounts, c.name)...) return &spec } diff --git a/cmd/ateom-microvm/imagevolume.go b/cmd/ateom-microvm/imagevolume.go new file mode 100644 index 0000000000..9890062c02 --- /dev/null +++ b/cmd/ateom-microvm/imagevolume.go @@ -0,0 +1,36 @@ +//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. + +package main + +import ( + "github.com/agent-substrate/substrate/cmd/ateom-microvm/internal/kata" + "github.com/agent-substrate/substrate/internal/proto/ateompb" + specs "github.com/opencontainers/runtime-spec/specs-go" +) + +func imageVolumeMounts(mounts []*ateompb.ImageVolumeMount, cid string) []specs.Mount { + out := make([]specs.Mount, 0, len(mounts)) + for _, m := range mounts { + out = append(out, specs.Mount{ + Destination: m.GetMountPath(), + Source: kata.GuestSharedVolumeDir(cid, m.GetVolumeName()), + Type: "bind", + Options: []string{"rbind", "ro"}, + }) + } + return out +} diff --git a/cmd/ateom-microvm/internal/kata/overlay_linux.go b/cmd/ateom-microvm/internal/kata/overlay_linux.go index fabf4071b8..3a07f100cf 100644 --- a/cmd/ateom-microvm/internal/kata/overlay_linux.go +++ b/cmd/ateom-microvm/internal/kata/overlay_linux.go @@ -92,6 +92,18 @@ func UpperWorkDirs(upperBase, containerID string) (upper, work string) { // stock kata flow. func GuestSharedRootfs(containerID string) string { return guestSharedDir + containerID + "/rootfs" } +// GuestSharedVolumeDir is the in-guest path one image volume's contents appear +// at, beside the container's rootfs in the same kataShared tree. +func GuestSharedVolumeDir(containerID, volumeName string) string { + return filepath.Join(guestSharedDir, containerID, "volumes", volumeName) +} + +// SharedVolumeDir is the host path under virtiofsd's served tree that +// GuestSharedVolumeDir resolves to. +func SharedVolumeDir(id, containerID, volumeName string) string { + return filepath.Join(SharedDir(id), containerID, "volumes", volumeName) +} + // VirtiofsdOptions configures StartVirtiofsd. type VirtiofsdOptions struct { Binary string // virtiofsd executable; defaults to "virtiofsd" @@ -168,6 +180,35 @@ func waitForSocket(ctx context.Context, path string, timeout time.Duration) erro } } +// StageImageVolume bind-mounts one composed image volume read-only at +// /volumes/ under SharedDir(id), so virtiofsd exposes it to the +// guest. +func StageImageVolume(ctx context.Context, src, id, cid, volumeName string) error { + if cid == "" || volumeName == "" { + return fmt.Errorf("StageImageVolume: empty container id or volume name") + } + dst := SharedVolumeDir(id, cid, volumeName) + if err := reaper.Run(exec.Command("umount", dst)); err != nil { + _ = reaper.Run(exec.Command("umount", "-l", dst)) + } + if err := os.MkdirAll(dst, 0o755); err != nil { + return fmt.Errorf("creating shared volume dir %q: %w", dst, err) + } + cmd := exec.CommandContext(ctx, "mount", "--rbind", src, dst) + var stderr strings.Builder + cmd.Stderr = &stderr + if err := reaper.Run(cmd); err != nil { + return fmt.Errorf("bind-mounting image volume %q -> %q: %w (%s)", src, dst, err, strings.TrimSpace(stderr.String())) + } + ro := exec.CommandContext(ctx, "mount", "-o", "remount,bind,ro", dst) + var roErr strings.Builder + ro.Stderr = &roErr + if err := reaper.Run(ro); err != nil { + return fmt.Errorf("remounting image volume %q read-only: %w (%s)", dst, err, strings.TrimSpace(roErr.String())) + } + return nil +} + // StageMergedRootfs mounts overlay(lower = the OCI image bundle rootfs, upper/work = // the actor's host rootfs-upper dirs for cid) at SharedDir(restoreID)//rootfs — // the merged tree the ONE virtiofsd serves and the guest runs the container on diff --git a/cmd/ateom-microvm/run.go b/cmd/ateom-microvm/run.go index f38f6b51fd..5145dc641f 100644 --- a/cmd/ateom-microvm/run.go +++ b/cmd/ateom-microvm/run.go @@ -187,6 +187,8 @@ 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 + // imageMounts are the image volumes this container mounts, and where. + imageMounts []*ateompb.ImageVolumeMount } // resolvedRuntime holds the concrete binary/config paths for a request, taken @@ -665,6 +667,7 @@ func (s *AteomService) buildActorContainers(actorUID string, containers []*ateom bundleRootfs: bundleRootfs, spec: spec, durableMounts: c.GetDurableDirVolumeMounts(), + imageMounts: c.GetImageVolumeMounts(), } } return ctrs, nil @@ -684,6 +687,12 @@ func (s *AteomService) stageMergedRootfs(ctx context.Context, rr resolvedRuntime if err := kata.StageMergedRootfs(ctx, c.bundleRootfs, upperBase, id, c.name); err != nil { return nil, fmt.Errorf("while staging merged rootfs for %q: %w", c.name, err) } + for _, vm := range c.imageMounts { + src := ateompath.ImageVolumeMountPath(id, c.name, vm.GetVolumeName()) + if err := kata.StageImageVolume(ctx, src, id, c.name, vm.GetVolumeName()); err != nil { + return nil, fmt.Errorf("while staging image volume %q for %q: %w", vm.GetVolumeName(), c.name, err) + } + } } vfsdLog, _ := os.OpenFile(virtiofsdLogPath(id), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) vfsdCmd, err := kata.StartVirtiofsd(ctx, kata.VirtiofsdOptions{ diff --git a/internal/ateompath/ateompath.go b/internal/ateompath/ateompath.go index bd715695c0..4f77d0e6b0 100644 --- a/internal/ateompath/ateompath.go +++ b/internal/ateompath/ateompath.go @@ -159,6 +159,19 @@ func OCIBundlePath(actorUID, containerName string) string { ) } +// ImageVolumeMountPath returns where ateom composes one image volume for a +// container. The path is per-container: containers of one actor may mount the +// same volume, and each needs its own mount point inside its own bundle. +func ImageVolumeMountPath(actorUID, containerName, volumeName string) string { + return ImageVolumeMountPathInBundle(OCIBundlePath(actorUID, containerName), volumeName) +} + +// ImageVolumeMountPathInBundle returns the image volume mount path inside a +// bundle path. +func ImageVolumeMountPathInBundle(bundlePath, volumeName string) string { + return filepath.Join(bundlePath, "volumes", volumeName) +} + func RunscDebugLogDir(actorUID, containerName string) string { return filepath.Join( ActorPath(actorUID), diff --git a/internal/e2e/fixtures/probe/main.go b/internal/e2e/fixtures/probe/main.go index ec2192beaa..2ad513cf8f 100644 --- a/internal/e2e/fixtures/probe/main.go +++ b/internal/e2e/fixtures/probe/main.go @@ -52,6 +52,30 @@ func whoami(w http.ResponseWriter, _ *http.Request) { writeJSON(w, resp) } +// readfile reports the contents of a path inside the actor, so a test can +// assert on what a volume actually delivered. +func readfile(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + resp := map[string]string{"path": path} + if b, err := os.ReadFile(path); err == nil { + resp["content"] = string(b) + } else { + resp["error"] = err.Error() + } + writeJSON(w, resp) +} + +func writefile(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + resp := map[string]string{"path": path} + if err := os.WriteFile(path, []byte("written by probe"), 0o644); err != nil { + resp["error"] = err.Error() + } else { + resp["ok"] = "true" + } + writeJSON(w, resp) +} + // 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. @@ -122,6 +146,8 @@ func writeJSON(w http.ResponseWriter, v any) { func main() { mux := http.NewServeMux() mux.HandleFunc("/whoami", whoami) + mux.HandleFunc("/readfile", readfile) + mux.HandleFunc("/writefile", writefile) mux.HandleFunc("/resources", resources) mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) 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 0000000000..a2c538fc69 --- /dev/null +++ b/internal/e2e/fixtures/probe/probe-microvm.yaml.tmpl @@ -0,0 +1,64 @@ +# 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. + +# The micro-VM variant of probe.yaml.tmpl. Requires the cluster to hold a +# SandboxConfig named "microvm" with the kata assets and pause image. + +apiVersion: v1 +kind: Namespace +metadata: + name: ate-e2e-probe + +--- + +apiVersion: ate.dev/v1alpha1 +kind: WorkerPool +metadata: + name: probe + namespace: ate-e2e-probe + labels: + workload: probe +spec: + replicas: 3 + 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 +spec: + sandboxClass: microvm + containers: + - name: probe + image: ko://github.com/agent-substrate/substrate/internal/e2e/fixtures/probe + command: ["/ko-app/probe"] + # 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 + # instead of only the ateom's built-in default. + readyz: + httpGet: + path: /healthz + port: 80 + timeoutSeconds: 60 + workerSelector: + matchLabels: + workload: probe + snapshotsConfig: + location: gs://${BUCKET_NAME}/ate-e2e-probe/ diff --git a/internal/e2e/probe.go b/internal/e2e/probe.go new file mode 100644 index 0000000000..98a421a0f3 --- /dev/null +++ b/internal/e2e/probe.go @@ -0,0 +1,84 @@ +// 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 e2e + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const ( + // ProbeNamespace and ProbeName identify the shared probe fixture's + // WorkerPool and ActorTemplate. + ProbeNamespace = "ate-e2e-probe" + ProbeName = "probe" +) + +// DeployProbe builds the probe fixture image and applies its manifests, +// removing them when the test ends. A suite that needs the probe calls this +// rather than assuming a previous run left it behind. +func DeployProbe(t *testing.T, bucket string) { + t.Helper() + + root, err := FindRepoRoot() + if err != nil { + t.Fatalf("FindRepoRoot: %v", err) + } + + // E2E_SANDBOX_CLASS selects the probe manifest variant; suites copy the + // probe's runtime, so this is what runs a suite on gVisor or micro-VM. + tmplName := "probe.yaml.tmpl" + if os.Getenv("E2E_SANDBOX_CLASS") == "microvm" { + tmplName = "probe-microvm.yaml.tmpl" + } + + // Render the manifest to a file so both apply and delete can consume it + // without any shell involved. + tmpl, err := os.ReadFile(filepath.Join(root, "internal/e2e/fixtures/probe", tmplName)) + if err != nil { + t.Fatalf("reading probe manifest template: %v", err) + } + manifest := filepath.Join(t.TempDir(), "probe.yaml") + rendered := strings.ReplaceAll(string(tmpl), "${BUCKET_NAME}", bucket) + if err := os.WriteFile(manifest, []byte(rendered), 0o644); err != nil { + t.Fatalf("writing rendered probe manifest: %v", err) + } + + // Build/push the probe image and apply through the repo's pinned ko; CI + // does not install ko on PATH. The trailing `-- --context=...` mirrors + // run_ko in hack/install-ate.sh: ko's apply subcommand forwards args after + // `--` to kubectl. KO_CONFIG_PATH is required because ko resolves .ko.yaml + // from its working directory, which is the test's package dir rather than + // the repo root; without it the build silently loses defaultPlatforms and + // produces images that cannot run on the cluster's nodes. + applyArgs := []string{"ko", "apply", "-f", manifest} + if KubeContext != "" { + applyArgs = append(applyArgs, "--", "--context="+KubeContext) + } + RunCmdWithEnv(t, []string{"KO_CONFIG_PATH=" + root}, filepath.Join(root, "hack/run-tool.sh"), applyArgs...) + + t.Cleanup(func() { + // Deletion needs no image build, so go straight to kubectl. `ko delete` + // rejects this arg shape ("you may not specify resource arguments as + // well"). + delArgs := []string{"delete", "--ignore-not-found", "-f", manifest} + if KubeContext != "" { + delArgs = append([]string{"--context=" + KubeContext}, delArgs...) + } + RunCmd(t, "kubectl", delArgs...) + }) +} diff --git a/internal/e2e/suites/identity/identity_test.go b/internal/e2e/suites/identity/identity_test.go index abfcf7e9f5..2a08fa780f 100644 --- a/internal/e2e/suites/identity/identity_test.go +++ b/internal/e2e/suites/identity/identity_test.go @@ -19,9 +19,6 @@ import ( "encoding/json" "io" "net/http" - "os" - "path/filepath" - "strings" "testing" "time" @@ -59,7 +56,7 @@ func TestActorIdentity_AfterRestore_IsOwnID_NotGolden(t *testing.T) { ctx := context.Background() clients := e2e.GetClients() - deployProbe(t, env["BUCKET_NAME"]) + e2e.DeployProbe(t, env["BUCKET_NAME"]) golden := waitForGolden(t, ctx, clients) // Two distinct actors from the same golden snapshot. @@ -91,53 +88,6 @@ func TestActorIdentity_AfterRestore_IsOwnID_NotGolden(t *testing.T) { } } -func deployProbe(t *testing.T, bucket string) { - t.Helper() - root, err := e2e.FindRepoRoot() - if err != nil { - t.Fatalf("FindRepoRoot: %v", err) - } - - // Render the manifest template to a file so both apply and delete can - // consume it without any shell involved. - tmpl, err := os.ReadFile(filepath.Join(root, "internal/e2e/fixtures/probe/probe.yaml.tmpl")) - if err != nil { - t.Fatalf("reading probe manifest template: %v", err) - } - manifest := filepath.Join(t.TempDir(), "probe.yaml") - rendered := strings.ReplaceAll(string(tmpl), "${BUCKET_NAME}", bucket) - if err := os.WriteFile(manifest, []byte(rendered), 0o644); err != nil { - t.Fatalf("writing rendered probe manifest: %v", err) - } - - // Build/push the probe image and apply the manifest through the repo's - // pinned ko (hack/run-tool.sh ko); CI does not install ko on PATH, and every - // other deploy in this repo goes through this wrapper. The trailing - // `-- --context=...` mirrors run_ko in hack/install-ate.sh: ko's apply - // subcommand forwards args after `--` to kubectl. KO_CONFIG_PATH is - // required because ko resolves .ko.yaml from its working directory, which - // is the test's package dir, not the repo root; without it the build - // silently loses defaultPlatforms (and produces amd64-only images that - // cannot run on arm64 nodes). - 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() { - // Deletion needs no image build, so go straight to kubectl (matching - // demo-counter_delete in hack/install-demo-counter.sh). `ko delete` - // rejects this arg shape ("you may not specify resource arguments as - // well"). - delArgs := []string{"delete", "--ignore-not-found", "-f", manifest} - if e2e.KubeContext != "" { - delArgs = append([]string{"--context=" + e2e.KubeContext}, delArgs...) - } - e2e.RunCmd(t, "kubectl", delArgs...) - }) -} - func waitForGolden(t *testing.T, ctx context.Context, clients *e2e.Clients) string { t.Helper() deadline := time.Now().Add(5 * time.Minute) diff --git a/internal/e2e/suites/imagevolume/imagevolume_test.go b/internal/e2e/suites/imagevolume/imagevolume_test.go new file mode 100644 index 0000000000..b3db097ed8 --- /dev/null +++ b/internal/e2e/suites/imagevolume/imagevolume_test.go @@ -0,0 +1,316 @@ +// 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 imagevolume exercises image volumes against a live cluster. +package imagevolume + +import ( + "archive/tar" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "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" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/tarball" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + atespace = "imagevolume" + + // mountPath must not collide with anything the probe's own image ships. + mountPath = "/mnt/ate-image-volume" + + payloadName = "payload.txt" + payloadContent = "delivered by an image volume" + // shadowedName is written by two layers; the upper one must win. + shadowedName = "shadowed.txt" + shadowedContent = "from the middle layer" + // deletedName is shipped by the bottom layer and whited out by the top. + deletedName = "deleted.txt" +) + +const ( + probeNamespace = e2e.ProbeNamespace + probeName = e2e.ProbeName +) + +// tarLayer builds a layer from a set of paths to contents. A path whose base +// name starts with ".wh." is an OCI whiteout for the same-named lower path. +func tarLayer(t *testing.T, files map[string]string) v1.Layer { + t.Helper() + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for name, body := range files { + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o444, Size: int64(len(body))}); err != nil { + t.Fatalf("writing tar header for %q: %v", name, err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatalf("writing tar body for %q: %v", name, err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("closing tar: %v", err) + } + + layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(buf.Bytes())), nil + }) + if err != nil { + t.Fatalf("building layer: %v", err) + } + return layer +} + +// buildFixtureImage pushes a three-layer image and returns its digest-pinned +// reference. +func buildFixtureImage(t *testing.T, repo string) string { + t.Helper() + + img, err := mutate.AppendLayers(empty.Image, + tarLayer(t, map[string]string{ + payloadName: "from-the-bottom-layer", + shadowedName: "from-the-bottom-layer", + deletedName: "should-not-survive", + }), + tarLayer(t, map[string]string{shadowedName: shadowedContent}), + tarLayer(t, map[string]string{ + payloadName: payloadContent, + ".wh." + deletedName: "", + }), + ) + if err != nil { + t.Fatalf("appending layers: %v", err) + } + + // A unique tag per run: some registries refuse to overwrite an existing + // tag. The returned reference is digest-pinned, so the tag itself is + // throwaway. + ref := fmt.Sprintf("%s/e2e-imagevolume-fixture:%d", strings.TrimSuffix(repo, "/"), time.Now().UnixNano()) + tag, err := name.ParseReference(ref, name.Insecure) + if err != nil { + t.Fatalf("parsing %q: %v", ref, err) + } + if err := remote.Write(tag, img); err != nil { + t.Fatalf("pushing %q: %v", ref, err) + } + + digest, err := img.Digest() + if err != nil { + t.Fatalf("computing digest: %v", err) + } + return fmt.Sprintf("%s@%s", tag.Context().Name(), digest) +} + +// createTemplate builds a probe ActorTemplate with the fixture attached as an +// image volume, copying the resolved runtime from the shared probe template. +func createTemplate(ctx context.Context, t *testing.T, clients *e2e.Clients, ns *e2e.Namespace, fixtureImage string) *v1alpha1.ActorTemplate { + t.Helper() + + env, err := e2e.CheckEnv("BUCKET_NAME") + if err != nil { + t.Fatalf("CheckEnv: %v", err) + } + + // The probe supplies this suite's container image and resolved runtime. + e2e.DeployProbe(t, env["BUCKET_NAME"]) + + srcPool, err := clients.SubstrateK8s.ApiV1alpha1().WorkerPools(probeNamespace).Get(ctx, probeName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("getting WorkerPool %s/%s: %v", probeNamespace, probeName, err) + } + srcTemplate, err := clients.SubstrateK8s.ApiV1alpha1().ActorTemplates(probeNamespace).Get(ctx, probeName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("getting ActorTemplate %s/%s: %v", probeNamespace, probeName, err) + } + + // The pool is labeled uniquely to this namespace so the cluster-wide + // scheduler cannot hand its workers to another suite's actors. + poolLabels := map[string]string{"imagevolume": ns.Name} + pool := &v1alpha1.WorkerPool{ + ObjectMeta: metav1.ObjectMeta{Name: probeName, Namespace: ns.Name, Labels: poolLabels}, + Spec: v1alpha1.WorkerPoolSpec{ + Replicas: 2, + AteomImage: srcPool.Spec.AteomImage, + SandboxClass: srcPool.Spec.SandboxClass, + SandboxConfigName: srcPool.Spec.SandboxConfigName, + }, + } + if _, err := clients.SubstrateK8s.ApiV1alpha1().WorkerPools(ns.Name).Create(ctx, pool, metav1.CreateOptions{}); err != nil { + t.Fatalf("creating WorkerPool: %v", err) + } + + container := srcTemplate.Spec.Containers[0] + container.VolumeMounts = append(container.VolumeMounts, v1alpha1.VolumeMount{Name: "fixture", MountPath: mountPath}) + + at := &v1alpha1.ActorTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: probeName, Namespace: ns.Name}, + Spec: v1alpha1.ActorTemplateSpec{ + Containers: []v1alpha1.Container{container}, + WorkerSelector: &metav1.LabelSelector{MatchLabels: poolLabels}, + SandboxClass: srcTemplate.Spec.SandboxClass, + Volumes: []v1alpha1.Volume{{ + Name: "fixture", + VolumeSource: v1alpha1.VolumeSource{Image: &v1alpha1.ImageVolumeSource{Reference: fixtureImage}}, + }}, + SnapshotsConfig: v1alpha1.SnapshotsConfig{ + Location: fmt.Sprintf("gs://%s/%s/", env["BUCKET_NAME"], ns.Name), + }, + }, + } + created, err := clients.SubstrateK8s.ApiV1alpha1().ActorTemplates(ns.Name).Create(ctx, at, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("creating ActorTemplate: %v", err) + } + + e2e.WaitForTemplateReady(ctx, t, clients, ns.Name, probeName) + return created +} + +// probeJSON calls a probe endpoint through the router and decodes its reply. +func probeJSON(ctx context.Context, t *testing.T, router *e2e.RouterClient, actorRef resources.ActorRef, path string) map[string]string { + t.Helper() + + resp, err := router.Get(ctx, actorRef, path) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("GET %s: status %d: %s", path, resp.StatusCode, body) + } + + var out map[string]string + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decoding %s: %v", path, err) + } + return out +} + +func TestImageVolume(t *testing.T) { + repo := os.Getenv("KO_DOCKER_REPO") + if repo == "" { + t.Skip("KO_DOCKER_REPO is unset; it names the registry both this host and the cluster can reach") + } + + ctx := context.Background() + clients := e2e.GetClients() + ns := e2e.CreateNamespace(t) + + fixtureImage := buildFixtureImage(t, repo) + t.Logf("fixture image: %s", fixtureImage) + createTemplate(ctx, t, clients, ns, fixtureImage) + + if _, err := clients.SubstrateAPI.CreateAtespace(ctx, &ateapipb.CreateAtespaceRequest{ + Atespace: &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: atespace}}, + }); err != nil { + t.Logf("CreateAtespace (may already exist): %v", err) + } + + actorRef := resources.ActorRef{Atespace: atespace, Name: "iv-" + ns.Name} + if _, err := clients.SubstrateAPI.CreateActor(ctx, &ateapipb.CreateActorRequest{ + Actor: &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: actorRef.Atespace, Name: actorRef.Name}, + ActorTemplateNamespace: ns.Name, + ActorTemplateName: probeName, + }, + }); err != nil { + t.Fatalf("CreateActor: %v", err) + } + t.Cleanup(func() { + cleanupCtx := context.Background() + _, _ = clients.SubstrateAPI.SuspendActor(cleanupCtx, &ateapipb.SuspendActorRequest{Actor: actorRef.ToObjectRef()}) + _, _ = clients.SubstrateAPI.DeleteActor(cleanupCtx, &ateapipb.DeleteActorRequest{Actor: actorRef.ToObjectRef()}) + }) + + if _, err := clients.SubstrateAPI.ResumeActor(ctx, &ateapipb.ResumeActorRequest{Actor: actorRef.ToObjectRef()}); err != nil { + t.Fatalf("ResumeActor: %v", err) + } + + router, err := e2e.NewRouterClient(ctx) + if err != nil { + t.Fatalf("NewRouterClient: %v", err) + } + defer router.Close() + + payloadPath := mountPath + "/" + payloadName + + t.Run("DeliversImageContents", func(t *testing.T) { + got := probeJSON(ctx, t, router, actorRef, "/readfile?path="+payloadPath) + if got["error"] != "" { + t.Fatalf("reading %s: %s", payloadPath, got["error"]) + } + if got["content"] != payloadContent { + t.Errorf("content = %q, want %q", got["content"], payloadContent) + } + }) + + t.Run("UpperLayerWins", func(t *testing.T) { + got := probeJSON(ctx, t, router, actorRef, "/readfile?path="+mountPath+"/"+shadowedName) + if got["error"] != "" { + t.Fatalf("reading %s: %s", shadowedName, got["error"]) + } + if got["content"] != shadowedContent { + t.Errorf("content = %q, want %q from the upper layer", got["content"], shadowedContent) + } + }) + + t.Run("WhiteoutHidesLowerLayerFile", func(t *testing.T) { + got := probeJSON(ctx, t, router, actorRef, "/readfile?path="+mountPath+"/"+deletedName) + if got["error"] == "" { + t.Errorf("%s is readable (%q), want it hidden by the whiteout", deletedName, got["content"]) + } + }) + + t.Run("MountIsReadOnly", func(t *testing.T) { + got := probeJSON(ctx, t, router, actorRef, "/writefile?path="+mountPath+"/should-not-exist") + if got["error"] == "" { + t.Errorf("write to the image volume succeeded, want it rejected as read-only") + } + }) + + t.Run("SurvivesSuspendResume", func(t *testing.T) { + if _, err := clients.SubstrateAPI.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: actorRef.ToObjectRef()}); err != nil { + t.Fatalf("SuspendActor: %v", err) + } + + // No explicit resume: routing to the actor is what wakes it. + resumeCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + got := probeJSON(resumeCtx, t, router, actorRef, "/readfile?path="+payloadPath) + if got["error"] != "" { + t.Fatalf("reading %s after resume: %s", payloadPath, got["error"]) + } + if got["content"] != payloadContent { + t.Errorf("content after resume = %q, want %q", got["content"], payloadContent) + } + }) +} diff --git a/internal/e2e/suites/imagevolume/testmain_test.go b/internal/e2e/suites/imagevolume/testmain_test.go new file mode 100644 index 0000000000..cda783977c --- /dev/null +++ b/internal/e2e/suites/imagevolume/testmain_test.go @@ -0,0 +1,24 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package imagevolume + +import ( + "os" + "testing" + + "github.com/agent-substrate/substrate/internal/e2e" +) + +func TestMain(m *testing.M) { os.Exit(e2e.RunTestMain(m)) } diff --git a/internal/e2e/template.go b/internal/e2e/template.go new file mode 100644 index 0000000000..e77987895a --- /dev/null +++ b/internal/e2e/template.go @@ -0,0 +1,62 @@ +// 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 e2e + +import ( + "context" + "os" + "testing" + "time" + + "github.com/agent-substrate/substrate/pkg/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// WaitForTemplateReady blocks until the ActorTemplate's golden actor has +// booted and been snapshotted. The default 5 minute timeout can be +// overridden with E2E_TEMPLATE_READY_TIMEOUT. +func WaitForTemplateReady(ctx context.Context, t *testing.T, clients *Clients, namespace, name string) { + t.Helper() + + timeout := 5 * time.Minute + if v := os.Getenv("E2E_TEMPLATE_READY_TIMEOUT"); v != "" { + d, err := time.ParseDuration(v) + if err != nil { + t.Fatalf("invalid E2E_TEMPLATE_READY_TIMEOUT %q: %v", v, err) + } + timeout = d + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var lastPhase v1alpha1.PhaseType + for { + at, err := clients.SubstrateK8s.ApiV1alpha1().ActorTemplates(namespace).Get(ctx, name, metav1.GetOptions{}) + if err == nil { + lastPhase = at.Status.Phase + if lastPhase == v1alpha1.PhaseReady { + return + } + if lastPhase == v1alpha1.PhaseFailed { + t.Fatalf("ActorTemplate %s/%s transitioned to Failed", namespace, name) + } + } + select { + case <-ctx.Done(): + t.Fatalf("timed out after %v waiting for ActorTemplate %s/%s to be Ready (last phase %q, err %v)", timeout, namespace, name, lastPhase, err) + case <-time.After(time.Second): + } + } +} diff --git a/internal/imagecache/bundle_linux.go b/internal/imagecache/bundle_linux.go index dc15a1460b..d89b12f0fb 100644 --- a/internal/imagecache/bundle_linux.go +++ b/internal/imagecache/bundle_linux.go @@ -28,6 +28,8 @@ import ( "strings" "golang.org/x/sys/unix" + + "github.com/agent-substrate/substrate/internal/ateompath" ) // SetupBundleRootfs composes the bundle's rootfs from cached layers per the @@ -94,15 +96,63 @@ func SetupBundleRootfs(bundlePath string) error { if err := applyDirFixups(rootfs, fixups); err != nil { return fmt.Errorf("while repairing implicit dir metadata: %w", err) } + + if err := setupImageVolumes(bundlePath, spec.ImageVolumes); err != nil { + return fmt.Errorf("while setting up image volumes: %w", err) + } return nil } +// setupImageVolumes exposes each image volume's contents read-only at its +// bundle-local mount point, for the OCI spec to bind into the container. +func setupImageVolumes(bundlePath string, volumes []ImageVolumeOverlay) error { + for _, vol := range volumes { + for _, layerDir := range vol.Layers { + if err := FinalizeLayer(layerDir); err != nil { + return fmt.Errorf("while finalizing layer %q of image volume %q: %w", layerDir, vol.Name, err) + } + } + + mountpoint := ateompath.ImageVolumeMountPathInBundle(bundlePath, vol.Name) + if err := os.MkdirAll(mountpoint, 0o700); err != nil { + return fmt.Errorf("while creating image volume mount point %q: %w", mountpoint, err) + } + _ = unix.Unmount(mountpoint, unix.MNT_DETACH) + + if err := mountImageVolume(mountpoint, vol.Layers); err != nil { + return fmt.Errorf("while mounting image volume %q: %w", vol.Name, err) + } + } + return nil +} + +// mountImageVolume attaches layers read-only at mountpoint. +func mountImageVolume(mountpoint string, layers []string) error { + if len(layers) == 1 { + // The kernel rejects a single lowerdir without an upperdir, so bind + // the layer's fs/ tree directly for the single-layer case. + fsDir := filepath.Join(layers[0], layerFSDirName) + if err := unix.Mount(fsDir, mountpoint, "", unix.MS_BIND|unix.MS_REC, ""); err != nil { + return fmt.Errorf("while binding %q: %w", fsDir, err) + } + // MS_BIND and MS_RDONLY cannot be combined; a second call applies read-only. + if err := unix.Mount("", mountpoint, "", unix.MS_REMOUNT|unix.MS_BIND|unix.MS_RDONLY, ""); err != nil { + return fmt.Errorf("while remounting %q read-only: %w", mountpoint, err) + } + return nil + } + return mountOverlay(mountpoint, overlayLowerDirs(layers), "", "") +} + // mountOverlay attaches an overlay of lowers (top-most first) with the given // upper/work dirs at mountpoint, using the new mount API rather than // mount(2): appending lowerdirs one fsconfig(2) call at a time sidesteps // mount(2)'s single-page option-string cap, which digest-derived layer paths // (~114 bytes each) would hit at roughly 34 layers. // +// An empty upper mounts the overlay without a writable layer, which overlayfs +// makes read-only. +// // Minimum supported kernel: Linux 6.5, where overlayfs gained the // incremental "lowerdir+" option. Every current GKE channel is at or above // it (Stable runs COS 121 LTS on kernel 6.6; Regular and Rapid run COS @@ -125,11 +175,13 @@ func mountOverlay(mountpoint string, lowers []string, upper, work string) error return err } } - if err := set("upperdir", upper); err != nil { - return err - } - if err := set("workdir", work); err != nil { - return err + if upper != "" { + if err := set("upperdir", upper); err != nil { + return err + } + if err := set("workdir", work); err != nil { + return err + } } if err := unix.FsconfigCreate(fsfd); err != nil { diff --git a/internal/imagecache/bundle_linux_test.go b/internal/imagecache/bundle_linux_test.go index 8a3d88c502..3d35206047 100644 --- a/internal/imagecache/bundle_linux_test.go +++ b/internal/imagecache/bundle_linux_test.go @@ -18,6 +18,7 @@ package imagecache import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -289,3 +290,70 @@ func TestSetupBundleRootfs_ManyLayers(t *testing.T) { t.Fatalf("UnmountAllUnder: %v", err) } } + +// Image volumes reach identical content through both arms: one layer binds, +// several overlay. +func TestSetupBundleRootfs_ImageVolumes(t *testing.T) { + roottest.Require(t, "mount/unmount") + + for _, tc := range []struct { + name string + layers int + }{ + {"one layer binds", 1}, + {"three layers overlay", 3}, + } { + t.Run(tc.name, func(t *testing.T) { + var layers []string + for i := range tc.layers { + dir := t.TempDir() + writeLayer(t, dir, map[string]string{ + fmt.Sprintf("layer%d.txt", i): "content", + "shadowed.txt": fmt.Sprintf("from-layer-%d", i), + }, nil) + layers = append(layers, dir) + } + + bundle := t.TempDir() + if err := WriteSpec(bundle, &OverlaySpec{ + Layers: []string{layers[0]}, + ImageVolumes: []ImageVolumeOverlay{{Name: "agent", Layers: layers}}, + }); err != nil { + t.Fatalf("WriteSpec: %v", err) + } + if err := SetupBundleRootfs(bundle); err != nil { + t.Fatalf("SetupBundleRootfs: %v", err) + } + t.Cleanup(func() { _ = UnmountAllUnder(bundle) }) + + mnt := filepath.Join(bundle, "volumes", "agent") + for i := range tc.layers { + if _, err := os.Stat(filepath.Join(mnt, fmt.Sprintf("layer%d.txt", i))); err != nil { + t.Errorf("layer %d not visible in the volume: %v", i, err) + } + } + // Later layers win, same as the rootfs overlay. + want := fmt.Sprintf("from-layer-%d", tc.layers-1) + if got, err := os.ReadFile(filepath.Join(mnt, "shadowed.txt")); err != nil || string(got) != want { + t.Errorf("shadowed.txt = %q (%v), want %q", got, err, want) + } + // No upper on either arm, so there is nowhere for a write to go. + if err := os.WriteFile(filepath.Join(mnt, "nope.txt"), []byte("x"), 0o644); err == nil { + t.Error("write succeeded through a read-only image volume") + } else if !errors.Is(err, unix.EROFS) { + t.Errorf("write failed with %v, want EROFS", err) + } + // The shared pool must never see the attempt. + if _, err := os.Stat(filepath.Join(layers[tc.layers-1], layerFSDirName, "nope.txt")); err == nil { + t.Error("write leaked into the shared layer pool") + } + + if err := UnmountAllUnder(bundle); err != nil { + t.Fatalf("UnmountAllUnder: %v", err) + } + if _, err := os.Stat(filepath.Join(mnt, "layer0.txt")); err == nil { + t.Error("volume still shows content after unmount") + } + }) + } +} diff --git a/internal/imagecache/gc.go b/internal/imagecache/gc.go index 8a27804fb7..bdc1d6d192 100644 --- a/internal/imagecache/gc.go +++ b/internal/imagecache/gc.go @@ -166,24 +166,33 @@ func (s *Store) InUse() (RootSet, error) { return rs, errors.Join(errs...) } -// addSpecRoots roots one bundle spec's image digest, layers, and exact -// layer-set signature. +// addSpecRoots roots one bundle spec's image digests (including image volume +// images), layers, and exact layer-set signatures. func addSpecRoots(rs *RootSet, spec *OverlaySpec, bundle string, dbg bool) { - if spec.ImageDigest != "" { - rs.ImageDigests[spec.ImageDigest] = true + addImageRoots(rs, spec.ImageDigest, spec.Layers, bundle, dbg) + for _, vol := range spec.ImageVolumes { + addImageRoots(rs, vol.ImageDigest, vol.Layers, bundle+"/volumes/"+vol.Name, dbg) + } +} + +// addImageRoots roots one image's digest, layer hexes, and exact layer-set +// signature. +func addImageRoots(rs *RootSet, digest string, layers []string, bundle string, dbg bool) { + if digest != "" { + rs.ImageDigests[digest] = true if dbg { slog.Debug("Image cache root-set: bundle roots image", slog.String("bundle", bundle), - slog.String("digest", spec.ImageDigest), - slog.Int("layers", len(spec.Layers))) + slog.String("digest", digest), + slog.Int("layers", len(layers))) } - } else if len(spec.Layers) > 0 && dbg { + } else if len(layers) > 0 && dbg { slog.Debug("Image cache root-set: digestless bundle roots layers only", slog.String("bundle", bundle), - slog.Int("layers", len(spec.Layers))) + slog.Int("layers", len(layers))) } - hexes := make([]string, 0, len(spec.Layers)) - for _, layerDir := range spec.Layers { + hexes := make([]string, 0, len(layers)) + for _, layerDir := range layers { hex := filepath.Base(layerDir) rs.LayerHexes[hex] = true hexes = append(hexes, hex) diff --git a/internal/imagecache/gc_test.go b/internal/imagecache/gc_test.go index a4fae11606..8b0b6c8b90 100644 --- a/internal/imagecache/gc_test.go +++ b/internal/imagecache/gc_test.go @@ -233,6 +233,62 @@ func TestEvictUnusedRootSet(t *testing.T) { } } +// A bundle's image volumes root their images exactly like its rootfs does: +// the volume's layers are bind-mounted for as long as the bundle exists. +func TestEvictUnusedRootSetImageVolumes(t *testing.T) { + _, host := newTestRegistry(t) + refRootfs := host + "/test/rootfs:latest" + refVolume := host + "/test/volume:latest" + for _, r := range []string{refRootfs, refVolume} { + pushImage(t, r, v1.Config{}, layerFromEntries(t, []tarEntry{ + {name: "f-" + r[len(r)-8:], typeflag: tar.TypeReg, mode: 0o644, body: r}, + })) + } + + actorsDir := t.TempDir() + store := newTestStore(t, WithActorsDir(actorsDir)) + imgRootfs := mustEnsure(t, store, refRootfs) + imgVolume := mustEnsure(t, store, refVolume) + + bundle := filepath.Join(actorsDir, "actor-1", "bundles", "main") + if err := os.MkdirAll(bundle, 0o700); err != nil { + t.Fatal(err) + } + if err := WriteSpec(bundle, &OverlaySpec{ + ImageDigest: imgRootfs.Digest.String(), + Layers: imgRootfs.LayerDirs, + ImageVolumes: []ImageVolumeOverlay{{ + Name: "agent", + ImageDigest: imgVolume.Digest.String(), + Layers: imgVolume.LayerDirs, + }}, + }); err != nil { + t.Fatal(err) + } + + rs, err := store.InUse() + if err != nil { + t.Fatalf("InUse: %v", err) + } + if !rs.ImageDigests[imgVolume.Digest.String()] { + t.Error("InUse missing image volume digest") + } + if !rs.LayerHexes[filepath.Base(imgVolume.LayerDirs[0])] { + t.Error("InUse missing image volume layer") + } + + backdateStore(t, store, 3*time.Hour) + if _, err := store.EvictUnused(context.Background(), math.MaxInt64, false); err != nil { + t.Fatalf("EvictUnused: %v", err) + } + if _, err := os.Stat(store.recordPath(imgVolume.Digest)); err != nil { + t.Errorf("image volume record evicted while its bundle exists: %v", err) + } + if _, err := os.Stat(imgVolume.LayerDirs[0]); err != nil { + t.Errorf("image volume layer evicted while its bundle exists: %v", err) + } +} + // An unreadable record must gate the whole pass: its refcounts are // invisible, so a layer it shares with a readable candidate would hit // zero and be retired while the unreadable record still names it. diff --git a/internal/imagecache/spec.go b/internal/imagecache/spec.go index 1cba5b1fbf..f8670bd709 100644 --- a/internal/imagecache/spec.go +++ b/internal/imagecache/spec.go @@ -53,6 +53,23 @@ type OverlaySpec struct { // that must exist for the runtime to attach them, e.g. the actor identity // mount. ExtraDirs []string `json:"extraDirs,omitempty"` + // ImageVolumes are read-only image contents to expose beside the rootfs, + // one per image-typed volume the container mounts. The consumer composes + // each at the volume's bundle-local mount point, which the OCI spec binds + // into the container. + ImageVolumes []ImageVolumeOverlay `json:"imageVolumes,omitempty"` +} + +// ImageVolumeOverlay is one image volume's contents. +type ImageVolumeOverlay struct { + // Name is the ActorTemplate's name for the volume. + Name string `json:"name"` + // ImageDigest is the manifest digest the volume's ref resolved to, in the + // same form and for the same reason as OverlaySpec.ImageDigest: the GC's + // root-set scan protects an image by digest. + ImageDigest string `json:"imageDigest,omitempty"` + // Layers are the cached layer directories, bottom-most first. + Layers []string `json:"layers"` } // WriteSpec writes spec into the bundle at bundlePath. diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index dab3709fbd..4f867d2367 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -41,6 +41,7 @@ const ( VolumeType_VOLUME_TYPE_UNSPECIFIED VolumeType = 0 VolumeType_VOLUME_TYPE_DURABLE_DIR VolumeType = 1 VolumeType_VOLUME_TYPE_EXTERNAL VolumeType = 2 + VolumeType_VOLUME_TYPE_IMAGE VolumeType = 3 ) // Enum value maps for VolumeType. @@ -49,11 +50,13 @@ var ( 0: "VOLUME_TYPE_UNSPECIFIED", 1: "VOLUME_TYPE_DURABLE_DIR", 2: "VOLUME_TYPE_EXTERNAL", + 3: "VOLUME_TYPE_IMAGE", } VolumeType_value = map[string]int32{ "VOLUME_TYPE_UNSPECIFIED": 0, "VOLUME_TYPE_DURABLE_DIR": 1, "VOLUME_TYPE_EXTERNAL": 2, + "VOLUME_TYPE_IMAGE": 3, } ) @@ -799,6 +802,50 @@ func (x *ExternalVolumeSource) GetVolumeContext() map[string]string { return nil } +type ImageVolumeSource struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reference string `protobuf:"bytes,1,opt,name=reference,proto3" json:"reference,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ImageVolumeSource) Reset() { + *x = ImageVolumeSource{} + mi := &file_atelet_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ImageVolumeSource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ImageVolumeSource) ProtoMessage() {} + +func (x *ImageVolumeSource) 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 ImageVolumeSource.ProtoReflect.Descriptor instead. +func (*ImageVolumeSource) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{10} +} + +func (x *ImageVolumeSource) GetReference() string { + if x != nil { + return x.Reference + } + return "" +} + type Volume struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -807,6 +854,7 @@ type Volume struct { // // *Volume_DurableDir // *Volume_External + // *Volume_Image Source isVolume_Source `protobuf_oneof:"source"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -814,7 +862,7 @@ type Volume struct { func (x *Volume) Reset() { *x = Volume{} - mi := &file_atelet_proto_msgTypes[10] + mi := &file_atelet_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -826,7 +874,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[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -839,7 +887,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{11} } func (x *Volume) GetName() string { @@ -881,6 +929,15 @@ func (x *Volume) GetExternal() *ExternalVolumeSource { return nil } +func (x *Volume) GetImage() *ImageVolumeSource { + if x != nil { + if x, ok := x.Source.(*Volume_Image); ok { + return x.Image + } + } + return nil +} + type isVolume_Source interface { isVolume_Source() } @@ -893,10 +950,16 @@ type Volume_External struct { External *ExternalVolumeSource `protobuf:"bytes,4,opt,name=external,proto3,oneof"` } +type Volume_Image struct { + Image *ImageVolumeSource `protobuf:"bytes,5,opt,name=image,proto3,oneof"` +} + func (*Volume_DurableDir) isVolume_Source() {} func (*Volume_External) isVolume_Source() {} +func (*Volume_Image) isVolume_Source() {} + type VolumeMount struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -907,7 +970,7 @@ type VolumeMount struct { func (x *VolumeMount) Reset() { *x = VolumeMount{} - mi := &file_atelet_proto_msgTypes[11] + mi := &file_atelet_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -919,7 +982,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[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -932,7 +995,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{12} } func (x *VolumeMount) GetName() string { @@ -964,7 +1027,7 @@ type Container struct { func (x *Container) Reset() { *x = Container{} - mi := &file_atelet_proto_msgTypes[12] + mi := &file_atelet_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -976,7 +1039,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[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -989,7 +1052,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{13} } func (x *Container) GetName() string { @@ -1051,7 +1114,7 @@ type EnvEntry struct { func (x *EnvEntry) Reset() { *x = EnvEntry{} - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1063,7 +1126,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[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1076,7 +1139,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{14} } func (x *EnvEntry) GetName() string { @@ -1107,7 +1170,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1119,7 +1182,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[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1132,7 +1195,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{15} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -1162,7 +1225,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1174,7 +1237,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[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1187,7 +1250,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{16} } func (x *HTTPGetAction) GetPath() string { @@ -1212,7 +1275,7 @@ type RunResponse struct { func (x *RunResponse) Reset() { *x = RunResponse{} - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1224,7 +1287,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[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1237,7 +1300,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{17} } type LocalCheckpointConfiguration struct { @@ -1253,7 +1316,7 @@ type LocalCheckpointConfiguration struct { func (x *LocalCheckpointConfiguration) Reset() { *x = LocalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1265,7 +1328,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[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1278,7 +1341,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{18} } func (x *LocalCheckpointConfiguration) GetSnapshotName() string { @@ -1299,7 +1362,7 @@ type ExternalCheckpointConfiguration struct { func (x *ExternalCheckpointConfiguration) Reset() { *x = ExternalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1311,7 +1374,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[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1324,7 +1387,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{19} } func (x *ExternalCheckpointConfiguration) GetSnapshotUri() string { @@ -1362,7 +1425,7 @@ type CheckpointRequest struct { func (x *CheckpointRequest) Reset() { *x = CheckpointRequest{} - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1374,7 +1437,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[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1387,7 +1450,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{20} } func (x *CheckpointRequest) GetTargetAteomUid() string { @@ -1502,7 +1565,7 @@ type CheckpointResponse struct { func (x *CheckpointResponse) Reset() { *x = CheckpointResponse{} - mi := &file_atelet_proto_msgTypes[20] + mi := &file_atelet_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1514,7 +1577,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[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1527,7 +1590,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{21} } type UploadPausedCheckpointRequest struct { @@ -1555,7 +1618,7 @@ type UploadPausedCheckpointRequest struct { func (x *UploadPausedCheckpointRequest) Reset() { *x = UploadPausedCheckpointRequest{} - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1567,7 +1630,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[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1580,7 +1643,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{22} } func (x *UploadPausedCheckpointRequest) GetAtespace() string { @@ -1647,7 +1710,7 @@ type UploadPausedCheckpointResponse struct { func (x *UploadPausedCheckpointResponse) Reset() { *x = UploadPausedCheckpointResponse{} - mi := &file_atelet_proto_msgTypes[22] + mi := &file_atelet_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1659,7 +1722,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[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1672,7 +1735,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{23} } type RestoreRequest struct { @@ -1718,7 +1781,7 @@ type RestoreRequest struct { func (x *RestoreRequest) Reset() { *x = RestoreRequest{} - mi := &file_atelet_proto_msgTypes[23] + mi := &file_atelet_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1730,7 +1793,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[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1743,7 +1806,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{24} } func (x *RestoreRequest) GetTargetAteomUid() string { @@ -1886,7 +1949,7 @@ type RestoreResponse struct { func (x *RestoreResponse) Reset() { *x = RestoreResponse{} - mi := &file_atelet_proto_msgTypes[24] + mi := &file_atelet_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1898,7 +1961,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[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1911,7 +1974,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{25} } var File_atelet_proto protoreflect.FileDescriptor @@ -1973,13 +2036,16 @@ 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\"1\n" + + "\x11ImageVolumeSource\x12\x1c\n" + + "\treference\x18\x01 \x01(\tR\treference\"\xfa\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" + "durableDir\x12:\n" + - "\bexternal\x18\x04 \x01(\v2\x1c.atelet.ExternalVolumeSourceH\x00R\bexternalB\b\n" + + "\bexternal\x18\x04 \x01(\v2\x1c.atelet.ExternalVolumeSourceH\x00R\bexternal\x121\n" + + "\x05image\x18\x05 \x01(\v2\x19.atelet.ImageVolumeSourceH\x00R\x05imageB\b\n" + "\x06source\"@\n" + "\vVolumeMount\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + @@ -2054,12 +2120,13 @@ const file_atelet_proto_rawDesc = "" + "\fmemory_bytes\x18\x0f \x01(\x03R\vmemoryBytesB\b\n" + "\x06configB\x11\n" + "\x0f_egress_gateway\"\x11\n" + - "\x0fRestoreResponse*`\n" + + "\x0fRestoreResponse*w\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" + + "\x14VOLUME_TYPE_EXTERNAL\x10\x02\x12\x15\n" + + "\x11VOLUME_TYPE_IMAGE\x10\x03*j\n" + "\x0eCheckpointType\x12\x1f\n" + "\x1bCHECKPOINT_TYPE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15CHECKPOINT_TYPE_LOCAL\x10\x01\x12\x1c\n" + @@ -2091,7 +2158,7 @@ func file_atelet_proto_rawDescGZIP() []byte { } var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 28) +var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 29) var file_atelet_proto_goTypes = []any{ (VolumeType)(0), // 0: atelet.VolumeType (CheckpointType)(0), // 1: atelet.CheckpointType @@ -2106,70 +2173,72 @@ var file_atelet_proto_goTypes = []any{ (*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 + (*ImageVolumeSource)(nil), // 13: atelet.ImageVolumeSource + (*Volume)(nil), // 14: atelet.Volume + (*VolumeMount)(nil), // 15: atelet.VolumeMount + (*Container)(nil), // 16: atelet.Container + (*EnvEntry)(nil), // 17: atelet.EnvEntry + (*Readyz)(nil), // 18: atelet.Readyz + (*HTTPGetAction)(nil), // 19: atelet.HTTPGetAction + (*RunResponse)(nil), // 20: atelet.RunResponse + (*LocalCheckpointConfiguration)(nil), // 21: atelet.LocalCheckpointConfiguration + (*ExternalCheckpointConfiguration)(nil), // 22: atelet.ExternalCheckpointConfiguration + (*CheckpointRequest)(nil), // 23: atelet.CheckpointRequest + (*CheckpointResponse)(nil), // 24: atelet.CheckpointResponse + (*UploadPausedCheckpointRequest)(nil), // 25: atelet.UploadPausedCheckpointRequest + (*UploadPausedCheckpointResponse)(nil), // 26: atelet.UploadPausedCheckpointResponse + (*RestoreRequest)(nil), // 27: atelet.RestoreRequest + (*RestoreResponse)(nil), // 28: atelet.RestoreResponse + nil, // 29: atelet.ArchAssets.FilesEntry + nil, // 30: atelet.SandboxAssets.AssetsEntry + nil, // 31: 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 + 29, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 30, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry + 16, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container + 14, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume + 31, // 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 + 13, // 11: atelet.Volume.image:type_name -> atelet.ImageVolumeSource + 17, // 12: atelet.Container.env:type_name -> atelet.EnvEntry + 18, // 13: atelet.Container.readyz:type_name -> atelet.Readyz + 15, // 14: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount + 19, // 15: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction + 10, // 16: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 17: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType + 21, // 18: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 22, // 19: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 20: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope + 2, // 21: atelet.UploadPausedCheckpointRequest.desired_scope:type_name -> atelet.SnapshotScope + 10, // 22: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 23: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 21, // 24: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 22, // 25: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 26: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 6, // 27: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 7, // 28: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 8, // 29: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 3, // 30: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest + 5, // 31: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 23, // 32: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 27, // 33: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 25, // 34: atelet.AteomHerder.UploadPausedCheckpoint:input_type -> atelet.UploadPausedCheckpointRequest + 4, // 35: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse + 20, // 36: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 24, // 37: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 28, // 38: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 26, // 39: atelet.AteomHerder.UploadPausedCheckpoint:output_type -> atelet.UploadPausedCheckpointResponse + 35, // [35:40] is the sub-list for method output_type + 30, // [30:35] is the sub-list for method input_type + 30, // [30:30] is the sub-list for extension type_name + 30, // [30:30] is the sub-list for extension extendee + 0, // [0:30] is the sub-list for field type_name } func init() { file_atelet_proto_init() } @@ -2178,15 +2247,16 @@ 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{ (*Volume_DurableDir)(nil), (*Volume_External)(nil), + (*Volume_Image)(nil), } - file_atelet_proto_msgTypes[19].OneofWrappers = []any{ + file_atelet_proto_msgTypes[20].OneofWrappers = []any{ (*CheckpointRequest_LocalConfig)(nil), (*CheckpointRequest_ExternalConfig)(nil), } - file_atelet_proto_msgTypes[23].OneofWrappers = []any{ + file_atelet_proto_msgTypes[24].OneofWrappers = []any{ (*RestoreRequest_LocalConfig)(nil), (*RestoreRequest_ExternalConfig)(nil), } @@ -2196,7 +2266,7 @@ func file_atelet_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_atelet_proto_rawDesc), len(file_atelet_proto_rawDesc)), NumEnums: 3, - NumMessages: 28, + NumMessages: 29, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index 6b5a68c2ef..16591007b2 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -136,6 +136,7 @@ enum VolumeType { VOLUME_TYPE_UNSPECIFIED = 0; VOLUME_TYPE_DURABLE_DIR = 1; VOLUME_TYPE_EXTERNAL = 2; + VOLUME_TYPE_IMAGE = 3; } message DurableDirVolume { @@ -147,6 +148,10 @@ message ExternalVolumeSource { map volume_context = 3; } +message ImageVolumeSource { + string reference = 1; +} + message Volume { string name = 1; @@ -155,6 +160,7 @@ message Volume { oneof source { DurableDirVolume durable_dir = 3; ExternalVolumeSource external = 4; + ImageVolumeSource image = 5; } } diff --git a/internal/proto/ateompb/ateom.pb.go b/internal/proto/ateompb/ateom.pb.go index c865d0a1c6..af0aa1dd8a 100644 --- a/internal/proto/ateompb/ateom.pb.go +++ b/internal/proto/ateompb/ateom.pb.go @@ -500,8 +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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // image_volume_mounts are the image volumes this container mounts, if any. + ImageVolumeMounts []*ImageVolumeMount `protobuf:"bytes,5,rep,name=image_volume_mounts,json=imageVolumeMounts,proto3" json:"image_volume_mounts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Container) Reset() { @@ -555,6 +557,13 @@ func (x *Container) GetDurableDirVolumeMounts() []*DurableDirVolumeMount { return nil } +func (x *Container) GetImageVolumeMounts() []*ImageVolumeMount { + if x != nil { + return x.ImageVolumeMounts + } + return nil +} + // DurableDirVolumeMount is one durable-dir volume mounted into a container. type DurableDirVolumeMount struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -611,6 +620,63 @@ func (x *DurableDirVolumeMount) GetMountPath() string { return "" } +// ImageVolumeMount is one image volume mounted into a container. ateom uses +// these to construct the container's volume mounts — each names the volume +// and its destination path. +type ImageVolumeMount struct { + state protoimpl.MessageState `protogen:"open.v1"` + // volume_name is the name the ActorTemplate gave the volume. + 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 *ImageVolumeMount) Reset() { + *x = ImageVolumeMount{} + mi := &file_ateom_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ImageVolumeMount) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ImageVolumeMount) ProtoMessage() {} + +func (x *ImageVolumeMount) 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 ImageVolumeMount.ProtoReflect.Descriptor instead. +func (*ImageVolumeMount) Descriptor() ([]byte, []int) { + return file_ateom_proto_rawDescGZIP(), []int{5} +} + +func (x *ImageVolumeMount) GetVolumeName() string { + if x != nil { + return x.VolumeName + } + return "" +} + +func (x *ImageVolumeMount) 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 +691,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 +703,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 +716,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 +746,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 +758,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 +771,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 +796,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 +808,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 +821,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 +855,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 +867,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 +880,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 +965,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 +977,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 +990,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 +1035,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 +1047,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 +1060,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 +1169,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 +1181,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 +1194,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 +1210,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 +1222,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 +1235,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 +1299,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 +1311,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 +1324,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 +1420,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 +1432,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 +1445,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 +1463,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 +1475,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 +1488,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 +1510,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 +1522,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 +1535,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 +1608,21 @@ const file_ateom_proto_rawDesc = "" + "\fWorkloadSpec\x120\n" + "\n" + "containers\x18\x01 \x03(\v2\x10.ateom.ContainerR\n" + - "containers\"\xba\x01\n" + + "containers\"\x83\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\x12G\n" + + "\x13image_volume_mounts\x18\x05 \x03(\v2\x17.ateom.ImageVolumeMountR\x11imageVolumeMountsJ\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\"R\n" + + "\x10ImageVolumeMount\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 +1734,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 +1745,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 + (*ImageVolumeMount)(nil), // 9: ateom.ImageVolumeMount + (*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.image_volume_mounts:type_name -> ateom.ImageVolumeMount + 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 +1806,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 +1817,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 9ec80a232d..3c75e2312e 100644 --- a/internal/proto/ateompb/ateom.proto +++ b/internal/proto/ateompb/ateom.proto @@ -152,6 +152,9 @@ message Container { // durable_dir_volume_mounts are the durable-dir volumes this container // mounts, if any. repeated DurableDirVolumeMount durable_dir_volume_mounts = 4; + + // image_volume_mounts are the image volumes this container mounts, if any. + repeated ImageVolumeMount image_volume_mounts = 5; } // DurableDirVolumeMount is one durable-dir volume mounted into a container. @@ -163,6 +166,16 @@ message DurableDirVolumeMount { string mount_path = 2; } +// ImageVolumeMount is one image volume mounted into a container. ateom uses +// these to construct the container's volume mounts — each names the volume +// and its destination path. +message ImageVolumeMount { + // volume_name is the name the ActorTemplate gave the volume. + 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 { diff --git a/manifests/ate-install/generated/ate.dev_actortemplates.yaml b/manifests/ate-install/generated/ate.dev_actortemplates.yaml index 2cb17b29a6..12d28181f5 100644 --- a/manifests/ate-install/generated/ate.dev_actortemplates.yaml +++ b/manifests/ate-install/generated/ate.dev_actortemplates.yaml @@ -405,6 +405,21 @@ spec: - capacity - storageClassName type: object + image: + description: image represents the contents of an OCI image, + mounted read-only. + properties: + reference: + description: reference is the image to mount. + maxLength: 512 + type: string + x-kubernetes-validations: + - message: All images must be pinned (changing the image + invalidates snapshots) + rule: self.contains('@') + required: + - reference + type: object name: description: name of the volume. maxLength: 63 @@ -416,9 +431,9 @@ spec: - 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 + image] must be set + rule: '[has(self.durableDir),has(self.externalVolumeTemplate),has(self.image)].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 3244ccdc6e..bf9ad86f54 100644 --- a/pkg/api/v1alpha1/actortemplate_types.go +++ b/pkg/api/v1alpha1/actortemplate_types.go @@ -36,6 +36,16 @@ const ( type DurableDirVolumeSource struct { } +// Represents the contents of an OCI image, mounted read-only. +type ImageVolumeSource struct { + // reference is the image to mount. + // + // +required + // +kubebuilder:validation:MaxLength=512 + // +kubebuilder:validation:XValidation:rule="self.contains('@')",message="All images must be pinned (changing the image invalidates snapshots)" + Reference string `json:"reference"` +} + // Represents an external volume dynamically provisioned for each actor. type ExternalVolumeTemplate struct { // capacity specifies the size of the volume to create. @@ -51,13 +61,17 @@ type ExternalVolumeTemplate struct { // // When adding a new source type, list it in the ExactlyOneOf marker below. // -// +kubebuilder:validation:ExactlyOneOf={durableDir,externalVolumeTemplate} +// +kubebuilder:validation:ExactlyOneOf={durableDir,externalVolumeTemplate,image} type VolumeSource struct { // durableDir represents a durable directory on rootfs that persists across // resumes and participates in snapshots. // +optional DurableDir *DurableDirVolumeSource `json:"durableDir,omitempty"` + // image represents the contents of an OCI image, mounted read-only. + // +optional + Image *ImageVolumeSource `json:"image,omitempty"` + // externalVolumeTemplate represents an external volume dynamically provisioned // for each actor. The volume only lives as long as the actor and is deleted // when the actor is deleted. diff --git a/pkg/api/v1alpha1/actortemplate_validation_test.go b/pkg/api/v1alpha1/actortemplate_validation_test.go index 4b9a8e864a..ad9566294d 100644 --- a/pkg/api/v1alpha1/actortemplate_validation_test.go +++ b/pkg/api/v1alpha1/actortemplate_validation_test.go @@ -610,6 +610,93 @@ func TestActorTemplateValidation(t *testing.T) { } }, wantErr: false, + }, { + name: "Volumes: 1 Image mount is valid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + {Name: "agent", VolumeSource: VolumeSource{Image: &ImageVolumeSource{ + Reference: "example.com/agent@sha256:326e0e090a9a4057e62a1b94236e7a2df2f2f76722f67232e0e47854e4df9c53", + }}}, + } + at.Spec.Containers[0].VolumeMounts = []VolumeMount{ + {Name: "agent", MountPath: "/ate"}, + } + }, + wantErr: false, + }, { + name: "Volumes: unpinned Image reference is invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + {Name: "agent", VolumeSource: VolumeSource{Image: &ImageVolumeSource{ + Reference: "example.com/agent:latest", + }}}, + } + at.Spec.Containers[0].VolumeMounts = []VolumeMount{ + {Name: "agent", MountPath: "/ate"}, + } + }, + wantErr: true, + errMsg: "All images must be pinned", + }, { + name: "Volumes: Image reference is required", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + {Name: "agent", VolumeSource: VolumeSource{Image: &ImageVolumeSource{}}}, + } + at.Spec.Containers[0].VolumeMounts = []VolumeMount{ + {Name: "agent", MountPath: "/ate"}, + } + }, + wantErr: true, + errMsg: "All images must be pinned", + }, { + name: "Volumes: VolumeSource with both Image and DurableDir set is invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + { + Name: "agent", + VolumeSource: VolumeSource{ + DurableDir: &DurableDirVolumeSource{}, + Image: &ImageVolumeSource{ + Reference: "example.com/agent@sha256:326e0e090a9a4057e62a1b94236e7a2df2f2f76722f67232e0e47854e4df9c53", + }, + }, + }, + } + at.Spec.Containers[0].VolumeMounts = []VolumeMount{ + {Name: "agent", MountPath: "/ate"}, + } + }, + wantErr: true, + errMsg: "exactly one of the fields in [durableDir externalVolumeTemplate image] must be set", + }, { + name: "Volumes: an unmounted Image volume is invalid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + {Name: "agent", VolumeSource: VolumeSource{Image: &ImageVolumeSource{ + Reference: "example.com/agent@sha256:326e0e090a9a4057e62a1b94236e7a2df2f2f76722f67232e0e47854e4df9c53", + }}}, + } + }, + wantErr: true, + errMsg: "All volumes defined in spec.volumes must be mounted by at least one container", + }, { + name: "Volumes: 2 Image volumes in template is valid", + mutate: func(at *ActorTemplate) { + at.Spec.Volumes = []Volume{ + {Name: "agent", VolumeSource: VolumeSource{Image: &ImageVolumeSource{ + Reference: "example.com/agent@sha256:326e0e090a9a4057e62a1b94236e7a2df2f2f76722f67232e0e47854e4df9c53", + }}}, + {Name: "tools", VolumeSource: VolumeSource{Image: &ImageVolumeSource{ + Reference: "example.com/tools@sha256:326e0e090a9a4057e62a1b94236e7a2df2f2f76722f67232e0e47854e4df9c53", + }}}, + } + at.Spec.Containers[0].VolumeMounts = []VolumeMount{ + {Name: "agent", MountPath: "/ate"}, + {Name: "tools", MountPath: "/tools"}, + } + }, + wantErr: false, }, { name: "Volumes: 2 DurableDir volumes in template is valid", mutate: func(at *ActorTemplate) { @@ -761,7 +848,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 image] must be set", }, { name: "Volumes: VolumeSource with no source set is invalid", mutate: func(at *ActorTemplate) { @@ -770,7 +857,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 image] must be set", }, { name: "Volumes: VolumeSource with no source set is invalid (mixed with a valid DurableDir volume)", mutate: func(at *ActorTemplate) { @@ -784,7 +871,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 image] 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 8b54a42592..2ec18df026 100644 --- a/pkg/api/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/api/v1alpha1/zz_generated.deepcopy.go @@ -355,6 +355,21 @@ func (in *HTTPGetAction) DeepCopy() *HTTPGetAction { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageVolumeSource) DeepCopyInto(out *ImageVolumeSource) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageVolumeSource. +func (in *ImageVolumeSource) DeepCopy() *ImageVolumeSource { + if in == nil { + return nil + } + out := new(ImageVolumeSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OnResumeConfig) DeepCopyInto(out *OnResumeConfig) { *out = *in @@ -516,6 +531,11 @@ func (in *VolumeSource) DeepCopyInto(out *VolumeSource) { *out = new(DurableDirVolumeSource) **out = **in } + if in.Image != nil { + in, out := &in.Image, &out.Image + *out = new(ImageVolumeSource) + **out = **in + } if in.ExternalVolumeTemplate != nil { in, out := &in.ExternalVolumeTemplate, &out.ExternalVolumeTemplate *out = new(ExternalVolumeTemplate)