Skip to content

Commit aadc3a5

Browse files
committed
atelet handling of external volumes.
Volume operations are integrated into the actor workflows: - Run/Restore: MountVolume - Checkpoint: UnmountVolume Volumes are mounted under the actor directory and passed as bind mounts to OCI.
1 parent 479b718 commit aadc3a5

7 files changed

Lines changed: 354 additions & 116 deletions

File tree

cmd/atelet/main.go

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ func NewService(
212212
return wms
213213
}
214214

215-
func (s *AteomHerder) Run(ctx context.Context, req *ateletpb.RunRequest) (*ateletpb.RunResponse, error) {
215+
func (s *AteomHerder) Run(ctx context.Context, req *ateletpb.RunRequest) (resp *ateletpb.RunResponse, err error) {
216216
if err := validateRunRequest(req); err != nil {
217217
// status.Error so the interceptor surfaces InvalidArgument and the
218218
// message instead of masking both as Internal.
@@ -234,6 +234,16 @@ func (s *AteomHerder) Run(ctx context.Context, req *ateletpb.RunRequest) (*atele
234234
return nil, fmt.Errorf("while resetting actor dirs: %w", err)
235235
}
236236

237+
if err := s.mountExternalVolumes(ctx, atespace, actorID, req.GetSpec().GetVolumes()); err != nil {
238+
return nil, err
239+
}
240+
defer func() {
241+
if err != nil {
242+
// TODO cleanup orphaned volumes
243+
_ = s.unmountExternalVolumes(ctx, atespace, actorID, req.GetSpec().GetVolumes())
244+
}
245+
}()
246+
237247
// Record the sandbox binaries this actor is running so a later Checkpoint
238248
// (whose request no longer carries the sandbox config) can re-fetch the same
239249
// version and pin it into the snapshot manifest.
@@ -350,6 +360,7 @@ func (s *AteomHerder) Checkpoint(ctx context.Context, req *ateletpb.CheckpointRe
350360
// in the metadata if the error is not retriable.
351361
return nil, fmt.Errorf("while calling ateom.CheckpointWorkload: %w", err)
352362
}
363+
353364
sandboxRec.SnapshotFiles = resp.GetSnapshotFiles()
354365
if len(sandboxRec.SnapshotFiles) == 0 {
355366
return nil, ateerrors.NewGRPCError(ctx, codes.DataLoss, ateerrors.ReasonInvalidCheckpointResult, ateerrors.ActorCrashedMetadata(), errors.New("ateom reported no snapshot files for checkpoint"))
@@ -369,6 +380,9 @@ func (s *AteomHerder) Checkpoint(ctx context.Context, req *ateletpb.CheckpointRe
369380
return nil, fmt.Errorf("unexpected checkpoint type: %v", req.GetType())
370381
}
371382

383+
// TODO cleanup orphaned volumes
384+
_ = s.unmountExternalVolumes(ctx, atespace, actorID, req.GetSpec().GetVolumes())
385+
372386
// Note: we do not crash the actor if resetting the directory fails.
373387
if err := resetActorDirs(atespace, actorID); err != nil {
374388
return nil, fmt.Errorf("while resetting actor dirs: %w", err)
@@ -449,7 +463,7 @@ func (s *AteomHerder) uploadExternalCheckpoint(ctx context.Context, req *ateletp
449463
return nil
450464
}
451465

452-
func (s *AteomHerder) Restore(ctx context.Context, req *ateletpb.RestoreRequest) (*ateletpb.RestoreResponse, error) {
466+
func (s *AteomHerder) Restore(ctx context.Context, req *ateletpb.RestoreRequest) (resp *ateletpb.RestoreResponse, err error) {
453467
if err := validateRestoreRequest(req); err != nil {
454468
return nil, status.Error(codes.InvalidArgument, err.Error())
455469
}
@@ -462,6 +476,16 @@ func (s *AteomHerder) Restore(ctx context.Context, req *ateletpb.RestoreRequest)
462476
return nil, fmt.Errorf("while resetting actor dirs: %w", err)
463477
}
464478

479+
if err := s.mountExternalVolumes(ctx, atespace, actorID, req.GetSpec().GetVolumes()); err != nil {
480+
return nil, err
481+
}
482+
defer func() {
483+
if err != nil {
484+
// TODO cleanup orphaned mounts
485+
_ = s.unmountExternalVolumes(ctx, atespace, actorID, req.GetSpec().GetVolumes())
486+
}
487+
}()
488+
465489
checkpointDir := ateompath.RestoreStateDir(atespace, actorID)
466490

467491
// Per-step timing so we can attribute resume latency between the rustfs
@@ -657,12 +681,9 @@ func (s *AteomHerder) prepareOCIBundles(
657681
if err := writeFileAtomic(filepath.Join(identityDir, ActorIDFileName), []byte(actorID), 0o644); err != nil {
658682
return fmt.Errorf("while writing actor identity file: %w", err)
659683
}
660-
661-
ddVolumes := make(map[string]bool)
662684
// make directories for all durable-dir volumes
663685
for _, vol := range spec.GetVolumes() {
664686
if vol.GetType() == ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR {
665-
ddVolumes[vol.GetName()] = true
666687
volPath := ateompath.DurableDirVolumeMountPoint(atespace, actorID, vol.GetName())
667688
if err := os.MkdirAll(volPath, 0o700); err != nil {
668689
return fmt.Errorf("while creating %q: %w", volPath, err)
@@ -700,6 +721,7 @@ func (s *AteomHerder) prepareOCIBundles(
700721
ateompath.AteomNetNSPath(targetAteomUid),
701722
"", // pause is sandbox infra; it gets no actor identity mount.
702723
nil,
724+
nil,
703725
); err != nil {
704726
return wrapFileSystemErr("while creating pause OCI bundle", err)
705727
}
@@ -713,12 +735,6 @@ func (s *AteomHerder) prepareOCIBundles(
713735
for _, env := range ctr.GetEnv() {
714736
envs = append(envs, fmt.Sprintf("%s=%s", env.GetName(), env.GetValue()))
715737
}
716-
var ddMounts []*ateletpb.VolumeMount
717-
for _, vm := range ctr.GetVolumeMounts() {
718-
if ddVolumes[vm.GetName()] {
719-
ddMounts = append(ddMounts, vm)
720-
}
721-
}
722738
g.Go(func() error {
723739
if err := prepareOCIDirectory(
724740
gCtx,
@@ -735,7 +751,8 @@ func (s *AteomHerder) prepareOCIBundles(
735751
},
736752
ateompath.AteomNetNSPath(targetAteomUid),
737753
identityDir,
738-
ddMounts,
754+
spec.GetVolumes(),
755+
ctr.GetVolumeMounts(),
739756
); err != nil {
740757
return wrapFileSystemErr(fmt.Sprintf("while creating %q OCI bundle", ctr.GetName()), err)
741758
}
@@ -1058,5 +1075,22 @@ func resetActorDirs(atespace, actorID string) error {
10581075
return wrapFileSystemErr("while creating durable-dir volumes mount dir: %w", err)
10591076
}
10601077

1078+
// Do not call RemoveAll on volume directories in case the unmount failed.
1079+
// We do not want to delete mount content.
1080+
volumesDir := ateompath.VolumesDir(atespace, actorID)
1081+
entries, err := os.ReadDir(volumesDir)
1082+
if err != nil && !os.IsNotExist(err) {
1083+
return wrapFileSystemErr("while reading volumes dir: %w", err)
1084+
}
1085+
for _, entry := range entries {
1086+
volPath := filepath.Join(volumesDir, entry.Name())
1087+
if err := os.Remove(volPath); err != nil {
1088+
return wrapFileSystemErr("while removing volume dir: %w", err)
1089+
}
1090+
}
1091+
if err := os.MkdirAll(volumesDir, 0o755); err != nil {
1092+
return wrapFileSystemErr("while creating volumes dir: %w", err)
1093+
}
1094+
10611095
return nil
10621096
}

cmd/atelet/oci.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929

3030
"github.com/agent-substrate/substrate/cmd/atelet/internal/memorypullcache"
3131
"github.com/agent-substrate/substrate/internal/ateompath"
32+
3233
"github.com/opencontainers/runtime-spec/specs-go"
3334
"go.opentelemetry.io/otel"
3435
"go.opentelemetry.io/otel/attribute"
@@ -53,7 +54,7 @@ const (
5354
ActorIDFileName = "actor-id"
5455
)
5556

56-
func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryPullCache, atespace, actorID, containerName, ref string, args []string, env []string, annotations map[string]string, netns string, identityDir string, durableDirVolumeMounts []*ateletpb.VolumeMount) error {
57+
func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryPullCache, atespace, actorID, containerName, ref string, args []string, env []string, annotations map[string]string, netns string, identityDir string, volumes []*ateletpb.Volume, volumeMounts []*ateletpb.VolumeMount) error {
5758
tracer := otel.Tracer("prepareOCIDirectory")
5859

5960
ctx, span := tracer.Start(ctx, "prepareOCIDirectory")
@@ -90,7 +91,14 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP
9091
}
9192
}
9293

93-
ociSpec := buildActorOCISpec(atespace, actorID, args, env, annotations, netns, identityDir, durableDirVolumeMounts)
94+
// Create directories in rootfs to mount all volumes.
95+
for _, vm := range volumeMounts {
96+
if err := createMountPoint(rootPath, vm.GetMountPath()); err != nil {
97+
return fmt.Errorf("while creating volume mount point %q: %w", vm.GetMountPath(), err)
98+
}
99+
}
100+
101+
ociSpec := buildActorOCISpec(atespace, actorID, args, env, annotations, netns, identityDir, volumes, volumeMounts)
94102
ociSpecBytes, err := json.MarshalIndent(ociSpec, "", " ")
95103
if err != nil {
96104
return fmt.Errorf("while marshaling OCI spec: %w", err)
@@ -107,7 +115,7 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP
107115
// When identityDir is non-empty it adds a read-only bind mount of that host
108116
// directory at IdentityMountPath so the actor can read its own ID (see
109117
// IdentityMountPath for why this is a bind mount rather than env vars).
110-
func buildActorOCISpec(atespace string, actorID string, args []string, env []string, annotations map[string]string, netns string, identityDir string, durableDirVolumeMounts []*ateletpb.VolumeMount) *specs.Spec {
118+
func buildActorOCISpec(atespace string, actorID string, args []string, env []string, annotations map[string]string, netns string, identityDir string, volumes []*ateletpb.Volume, volumeMounts []*ateletpb.VolumeMount) *specs.Spec {
111119
envVars := []string{
112120
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
113121
}
@@ -220,12 +228,27 @@ func buildActorOCISpec(atespace string, actorID string, args []string, env []str
220228
Annotations: annotations,
221229
}
222230

223-
// Prepare and mount durable-dir volumes.
224-
for _, vm := range durableDirVolumeMounts {
231+
// Prepare and mount all volumes.
232+
volumeTypes := make(map[string]ateletpb.VolumeType)
233+
for _, vol := range volumes {
234+
volumeTypes[vol.GetName()] = vol.GetType()
235+
}
236+
237+
for _, vm := range volumeMounts {
238+
var srcPath string
239+
switch volumeTypes[vm.GetName()] {
240+
case ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR:
241+
srcPath = ateompath.DurableDirVolumeMountPoint(atespace, actorID, vm.GetName())
242+
case ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL:
243+
srcPath = ateompath.VolumeHostPath(atespace, actorID, vm.GetName())
244+
default:
245+
continue
246+
}
225247
spec.Mounts = append(spec.Mounts, specs.Mount{
226248
Destination: vm.GetMountPath(),
227249
Type: "bind",
228-
Source: ateompath.DurableDirVolumeMountPoint(atespace, actorID, vm.GetName()),
250+
Source: srcPath,
251+
Options: []string{"bind", "rw"},
229252
})
230253
}
231254

cmd/atelet/oci_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ func TestBuildActorOCISpec_IdentityMount(t *testing.T) {
9494
"/run/netns/x",
9595
"/host/actors/atespace:id/identity",
9696
nil,
97+
nil,
9798
)
9899
found := false
99100
for _, m := range spec.Mounts {
@@ -118,7 +119,7 @@ func TestBuildActorOCISpec_IdentityMount(t *testing.T) {
118119

119120
// Without an identity dir (the pause container), no identity mount appears.
120121
func TestBuildActorOCISpec_NoIdentityMountForPause(t *testing.T) {
121-
bare := buildActorOCISpec("atespace", "id", []string{"/pause"}, nil, nil, "/run/netns/x", "", nil)
122+
bare := buildActorOCISpec("atespace", "id", []string{"/pause"}, nil, nil, "/run/netns/x", "", nil, nil)
122123
for _, m := range bare.Mounts {
123124
if m.Destination == IdentityMountPath {
124125
t.Errorf("identity mount must be absent when identityDir is empty")
@@ -134,11 +135,16 @@ func TestBuildActorOCISpec_DurableDirVolumeMounts(t *testing.T) {
134135
{Name: "data", MountPath: "/var/data"},
135136
{Name: "cache", MountPath: "/var/cache"},
136137
}
138+
volumes := []*ateletpb.Volume{
139+
{Name: "data", Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR},
140+
{Name: "cache", Type: ateletpb.VolumeType_VOLUME_TYPE_DURABLE_DIR},
141+
}
137142
spec := buildActorOCISpec(
138143
atespace, id,
139144
[]string{"/app"}, nil, nil,
140145
"/run/netns/x",
141146
"",
147+
volumes,
142148
durableDirs,
143149
)
144150

cmd/atelet/volumes.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"log/slog"
21+
"os"
22+
23+
"github.com/agent-substrate/substrate/internal/ateompath"
24+
"github.com/agent-substrate/substrate/internal/proto/ateletpb"
25+
"github.com/agent-substrate/substrate/internal/volume"
26+
)
27+
28+
var (
29+
globalVolumePlugin = volume.NewMockVolumePlugin()
30+
)
31+
32+
// TODO: Replace with actual volume plugin search
33+
func getVolumePlugin() volume.VolumePlugin {
34+
return globalVolumePlugin
35+
}
36+
37+
func (s *AteomHerder) mountExternalVolumes(ctx context.Context, atespace, actorID string, volumes []*ateletpb.Volume) error {
38+
for _, vol := range volumes {
39+
if vol.GetType() != ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL {
40+
continue
41+
}
42+
ext := vol.GetExternal()
43+
if ext == nil {
44+
continue
45+
}
46+
hostPath := ateompath.VolumeHostPath(atespace, actorID, vol.GetName())
47+
if err := os.MkdirAll(hostPath, 0o750); err != nil {
48+
return fmt.Errorf("failed to create mount point %q: %w", hostPath, err)
49+
}
50+
slog.InfoContext(ctx, "Mounting volume", slog.String("volume_id", ext.GetStorageVolumeId()), slog.String("host_path", hostPath))
51+
if err := getVolumePlugin().MountVolume(ctx, ext.GetStorageVolumeId(), hostPath); err != nil {
52+
return fmt.Errorf("failed to mount volume %q to %q: %w", ext.GetStorageVolumeId(), hostPath, err)
53+
}
54+
}
55+
return nil
56+
}
57+
58+
func (s *AteomHerder) unmountExternalVolumes(ctx context.Context, atespace, actorID string, volumes []*ateletpb.Volume) error {
59+
for _, vol := range volumes {
60+
if vol.GetType() != ateletpb.VolumeType_VOLUME_TYPE_EXTERNAL {
61+
continue
62+
}
63+
ext := vol.GetExternal()
64+
if ext == nil {
65+
continue
66+
}
67+
hostPath := ateompath.VolumeHostPath(atespace, actorID, vol.GetName())
68+
slog.InfoContext(ctx, "Unmounting volume", slog.String("volume_id", ext.GetStorageVolumeId()), slog.String("host_path", hostPath))
69+
if err := getVolumePlugin().UnmountVolume(ctx, ext.GetStorageVolumeId(), hostPath); err != nil {
70+
slog.ErrorContext(ctx, "failed to unmount volume", slog.String("volume_id", ext.GetStorageVolumeId()), slog.String("host_path", hostPath), slog.Any("error", err))
71+
}
72+
}
73+
return nil
74+
}

internal/ateompath/ateompath.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,17 @@ func PIDFilePath(atespace, actorID, containerName string) string {
184184
containerName+".pid",
185185
)
186186
}
187+
188+
func VolumesDir(atespace, actorID string) string {
189+
return filepath.Join(
190+
ActorPath(atespace, actorID),
191+
"volumes",
192+
)
193+
}
194+
195+
func VolumeHostPath(atespace, actorID, volumeName string) string {
196+
return filepath.Join(
197+
VolumesDir(atespace, actorID),
198+
volumeName,
199+
)
200+
}

0 commit comments

Comments
 (0)