Skip to content

Add basic per-actor external volume flow#405

Open
Michelle Au (msau42) wants to merge 8 commits into
agent-substrate:mainfrom
msau42:mock-mount-dev
Open

Add basic per-actor external volume flow#405
Michelle Au (msau42) wants to merge 8 commits into
agent-substrate:mainfrom
msau42:mock-mount-dev

Conversation

@msau42

Copy link
Copy Markdown
Collaborator

Initial part of #232.

Extends the ActorTemplate API to add support for per-actor external volumes and adds control plane and atelet hooks following the Actor lifecycle.

Callouts to volume operations are abstracted with a Volume interface. Right now, only a mock volume plugin for testing has been implemented, but we will add CSI support next.

  • Tests pass
  • Appropriate changes to documentation are included in the PR - Not going to update documentation until we add CSI support.

Comment thread pkg/api/v1alpha1/actortemplate_types.go Outdated
Capacity resource.Quantity `json:"capacity" protobuf:"bytes,1,opt,name=capacity"`
// storageClassName refers to the StorageClass to create the volume from.
// +required
StorageClassName string `json:"storageClassName" protobuf:"bytes,2,opt,name=storageClassName"`

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

open for discussion: can we reference k8s objects or should we create our own api?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's a cleaner way. In theory we could have an ateapi level api which just acts as an indirect, but I don't see a reason at this moment.

map<string, string> match_labels = 1;
}

message ExternalVolume {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discuss: should ExternalVolume be its own standalone object rather than embedded fields into the Actor?

Right now ExternalVolume is tied to an Actor but we are also thinking about the scenario where you want to share a volume across multiple actors.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I vote 👍

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We all know how tricky volumes can be in k8s, with RWO, RWX, RO, mounts on the same node being virtually RWX but not ACTUALLY RWX, etc. If we make this a first-class resource, do we need to track the same sorts of things (e.g. NFS can be RWX, blockdev can't)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we will need to add the notion of access modes and enforce that shared volumes can only be RWX or ROX.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should ExternalVolume be its own standalone object rather than embedded fields into the Actor?

It can be standalone message, as we have other standalone messages.
About standalone object? Do you mean customer will do "kubectl ate create external_volume"? We dont have requirements to have it today. So lets open it for discussion when it will be required.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I mean standalone object. It's primarily for the shared volume use case when you want to import an existing volume that already exists.

}
err = s.persistence.CreateActor(ctx, actor)
if err != nil {
// Cleanup created volumes if DB write fails

@msau42 Michelle Au (msau42) Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discuss: In general, how should we approach errors when an operation only partially succeeds? Should we rollback all the partially completed operations? What happens if the rollback fails?

Or we leave the system in the partial state and require users to cleanup by calling Delete? Even then, if deletion fails and Create is called again, we could still have partially leftover resources from a previous invocation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My current thoughts:

If there is a pair of "Create" and "Delete" operations, then we can leave the partial state and clean it up on the Delete. This does imply we will have to add some "Creating" and possibly "Deleting" state to Actor before we start creating resources for it. I already have marked that as a todo.

For operations like "Resume", there isn't a corresponding delete operation if the resume failed. It's possible resume could be called on a different node. That means we could have partial state left on the first node. In those cases we can try to rollback the state as much as possible but that would still be a best effort operation. This has a few implications:

  • We probably need to have some process on the node that monitors oprhaned resources and tries to clean them up and/or alert
  • We need to make sure these orphaned resources don't get reused if a new actor with the same name gets created. This potentially implies that we should have some sort of actor uuid and be using that in our actor directories.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation does not handle error in deleteActorVolume.

Might be we need introduce "FAILED" state. So the delete volume will be cleaned in this state.

We dont have this problem today, since during create we just create record in suspended state.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I am thinking of 2 main changes to CreateActor:

  1. Add a new "CREATING" state and persist that to DB before starting to create volumes.
  2. Then if CreateVolume fails, we can introduce a FAILED state.
  3. User has to call DeleteActor in order to trigger deletion of volumes and Actor DB entry

@msau42 Michelle Au (msau42) changed the title Add basic external volume flow Add basic per-actor external volume flow Jul 8, 2026
@msau42
Michelle Au (msau42) force-pushed the mock-mount-dev branch 2 times, most recently from c2286ea to 96fa815 Compare July 8, 2026 22:50
Comment thread cmd/ateapi/internal/controlapi/volumes.go Outdated

@thockin Tim Hockin (thockin) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't get too far into the reading of this yet, but wanted to send a couple questions before EOD

Comment thread pkg/api/v1alpha1/actortemplate_types.go Outdated
Capacity resource.Quantity `json:"capacity" protobuf:"bytes,1,opt,name=capacity"`
// storageClassName refers to the StorageClass to create the volume from.
// +required
StorageClassName string `json:"storageClassName" protobuf:"bytes,2,opt,name=storageClassName"`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's a cleaner way. In theory we could have an ateapi level api which just acts as an indirect, but I don't see a reason at this moment.

Comment thread internal/volume/plugin.go

// VolumePlugin abstracts storage operations.
type VolumePlugin interface {
CreateVolume(ctx context.Context, name string, capacity string, storageClass string) (volumeID string, err error)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you see this as "kubernetes PVC is the plugin", or "CSI is the plugin", or "NFS is the plugin" ?

What I really want to understand is what credentials are granted to the API server to create and destroy volumes, and what additional layers are in between. E.g. if I need to create a million k8s PVCs and PVs, that's trouble, but if I need to give ateapi binaries the ability to delete EBS volumes in the same context as k8s, that's also trouble.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thought is that substrate will directly call CSI grpc APIs (so ateapi needs to be authorized to communicate with CSI drivers). We will not be creating PVC objects, although we will need to have a substrate API reference to the volume IDs (which I have added in this PR).

Comment thread cmd/atelet/oci.go
Type: "bind",
Source: ateompath.DurableDirVolumeMountPoint(atespace, actorID, vm.GetName()),
Source: srcPath,
Options: []string{"bind", "rw"},

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

double check this. durabledir didn't set any options.

Comment thread pkg/api/v1alpha1/actortemplate_types.go Outdated

@thockin Tim Hockin (thockin) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read this. It seems broadly correct. I am still worried about permissions, but not enough to block this.

I swear I had another comment, and it was important, but I forgot what it was. Oh well, Friday. :)

Comment thread pkg/api/v1alpha1/actortemplate_types.go Outdated
// for each actor. The volume only lives as long as the actor and is deleted
// when the actor is deleted.
// +optional
ExternalVolumeTemplate *ExternalVolumeTemplate `json:"externalVolumeTemplate,omitempty" protobuf:"bytes,3,opt,name=externalVolumeTemplate"`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the word "Template" in here? All of these are templates

@msau42 Michelle Au (msau42) Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open to naming, I am trying to distinguish between volumes that will trigger dynamic provisioning, and volumes that won't. So for shared volumes, for instance, I am thinking we could have something like:

volumes:
  externalVolumeTemplate:
  - name: my-own-volume
    storageClassName: class1
  externalVolume:
  - name: shared-volume
    volumeName: someExistingVolume (in the same atespace) # This volume is already created and populated with data through other means

Comment thread cmd/ateapi/internal/controlapi/workflow_pause.go
Comment thread cmd/ateapi/internal/controlapi/workflow_suspend.go
Comment thread pkg/api/v1alpha1/actortemplate_types.go Outdated
// for each actor. The volume only lives as long as the actor and is deleted
// when the actor is deleted.
// +optional
ExternalVolumeTemplate *ExternalVolumeTemplate `json:"externalVolumeTemplate,omitempty" protobuf:"bytes,3,opt,name=externalVolumeTemplate"`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What exactly does External mean here, External to substrate? If we are just wrapping a StorageClass anyway should we name it PersistentVolumeSource to be closer to the source material?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not REALLY a PV in k8s, it's just reusing k8s storage classes

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open to naming :)

But agree with Tim that this doesn't have quite the same semantics as PVs in k8s (and even the "persistent" part in k8s is misleading sometimes). At least with the per-actor volumes, the volume is definitely not persistent in the traditional sense (it only lives as long as the actor).

By "External", I'm trying to distinguish with the sandbox-backed filesystems, like rootfs and durabledir. The volumes in this case are backed by storage systems external to substrate (ie nfs, remote block, pfs, distributed fs, etc).

map<string, string> match_labels = 1;
}

message ExternalVolume {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I vote 👍

Comment thread internal/volume/plugin.go Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[AI First Pass Experiment]

return mounted
}

func actorVolumeID(ref *ateapipb.ObjectRef, volumeName string) string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 🤖 The hyphen join is ambiguous: atespace, actor name, and volume name may all contain hyphens, so distinct actors can produce the same ID (actor a in atespace x-y vs actor y-a in atespace x). This string is the idempotency name passed to CreateVolume, and CSI dedupes by name — colliding actors would silently share one storage volume. The existing TODO about actor UUID would fix it; an unambiguous encoding of the three parts would too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I plan to address this as a folowup (I already have a todo) as it will require adding a "CREATING" status to Actor and persisting that before calling CreateVolume so that the Actor will have a UID by the time we create the volumes.

}

// Delete associated volumes (TODO: best effort?)
s.deleteActorVolumes(ctx, req.GetActor(), actor.GetActorVolumes())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 🤖 Two issues with volume deletion here. Volumes are never detached first — for an actor deleted while running or paused, CSI DeleteVolume on an attached volume fails. And since failures are only logged before the record is deleted below, the volume leaks with no record pointing at it. Consider detaching first (reuse detachActorVolumes) and deleting the record only after volume deletion succeeds, or persisting a deletion intent for background retry.

@msau42 Michelle Au (msau42) Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting question. While the state machine here (#119) does allow delete from SUSPENDED, PAUSED and CRASHED states, I believe for pause/crash, there's probably still some cleanup that has to happen from atelet, and that workflow is currently missing today. Dmitry Berkovich (@dberkov) do we plan to add this workflow?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with Benjamin Elder (@BenTheElder) , something is broken here. From moment we started deletion -> we cannot just ignore the error or keep it in suspended state.
I think we need split DELETE in 2 phases:

  • move to DELETING first
  • do actual delete all sub-resources before deleting the record from redis.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am making a change to make volume deletion a hard failure for Actor Deletion so that we do not delete the Actor record until all volumes are successfully deleted.

However my question is about transitioning from PAUSE or CRASH directly to DELETE. Right now this just directly tries to Delete without telling the workers. I think we are missing some workflow to atelet to cleanup the nodes.

Comment thread pkg/api/v1alpha1/actortemplate_types.go
Comment thread pkg/api/v1alpha1/actortemplate_types.go Outdated
Comment thread pkg/api/v1alpha1/actortemplate_types.go Outdated
ExternalVolumeTemplate *ExternalVolumeTemplate `json:"externalVolumeTemplate,omitempty"`
}

type ExternalVolumeTemplate struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:have you considered to call it "ExternalVolumeSource" rather then template?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in #405 (comment), I am trying to think about how to add the "import existing volume" scenario for the shared volume use case in the future. So in the API I want to distinguish between "this will be a new volume created per actor" vs "we're accessing an existing volume that will be shared".

}
err = s.persistence.CreateActor(ctx, actor)
if err != nil {
// Cleanup created volumes if DB write fails

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation does not handle error in deleteActorVolume.

Might be we need introduce "FAILED" state. So the delete volume will be cleaned in this state.

We dont have this problem today, since during create we just create record in suspended state.

}

// Delete associated volumes (TODO: best effort?)
s.deleteActorVolumes(ctx, req.GetActor(), actor.GetActorVolumes())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with Benjamin Elder (@BenTheElder) , something is broken here. From moment we started deletion -> we cannot just ignore the error or keep it in suspended state.
I think we need split DELETE in 2 phases:

  • move to DELETING first
  • do actual delete all sub-resources before deleting the record from redis.


// TODO: Replace with actual volume plugin search
func getVolumePlugin() volume.VolumePluginControlPlane {
return globalVolumePlugin

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is what actually was I was missing when I reviewed pkg/api/v1alpha1/actortemplate_types.go.

How the storage plugins will be registered in the substrate instance?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSI integration will include plugin discovery. We're still POC-ing CSI support right now but the current thought is to add a new CRD to register the CSI endpoints to use for substrate.

ref := &ateapipb.ObjectRef{Atespace: state.Actor.GetMetadata().GetAtespace(), Name: state.Actor.GetMetadata().GetName()}
for _, vol := range getMountedActorVolumes(ctx, ref, state.Actor.GetActorVolumes(), state.ActorTemplate) {
slog.InfoContext(ctx, "Attaching volume to node", slog.String("volume_id", vol.GetStorageVolumeId()), slog.String("node", node))
err := getVolumePlugin().AttachVolume(ctx, vol.GetStorageVolumeId(), node)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does attachment supposed to do? Attach volume to node VM? Is this code supposed to be executed on the atelet?

@msau42 Michelle Au (msau42) Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This depends on the actual volume driver implementation but it's essentially "anything you need to do to make the volume accessible on that node". For block storage, that would be attaching the device to the node. For file storage, that could be configuring network policies to allow the node access to the volume. This is run from the CP (ateapi)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The disadvantage to run this code in control plane, it makes resume operation slower. By running this code in atelet, this code can be executed in parallel with code that downloads images from GCS or download OCI image from artifcat registry.

@msau42 Michelle Au (msau42) Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main issue is security. In the case of block storage, we don't want to give nodes permissions to attach any volume, and in the case of filers, we don't want to give the node permissions to set ACLs for itself.

In the future, we can explore optimizations to make this a non-blocking operation.

Comment thread internal/volume/plugin.go
type VolumePluginControlPlane interface {
CreateVolume(ctx context.Context, name string, capacity string, storageClass string) (volumeID string, err error)
DeleteVolume(ctx context.Context, volumeID string) error
AttachVolume(ctx context.Context, volumeID string, node string) error

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what information about node do you need for attach? Is it just node name? Do you need more information, like IP, zone, etc?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSI spec call only takes node id as input. The driver is responsible for interpreting that and getting any additional metadata about that node that they need.

}

func (s *AttachVolumesStep) Execute(ctx context.Context, input *ResumeInput, state *ResumeState) error {
if state.Actor.GetAteomPodNamespace() == "" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you please take a look to

func crashActor(ctx context.Context, st store.Interface, atespace, actorName string) error {
. Zoe recently added CRASH state.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a look, right now it just updates DB Actor state. I don't see anything to add here wrt volumes at the moment. Like I mentioned in #405 (comment), I think we are missing some workflow to cleanup the worker/node when a PAUSED or CRASHED actor gets deleted.

Comment thread demos/counter/counter.yaml.tmpl Outdated
- name: external-data
externalVolumeTemplate:
capacity: 1Gi
storageClassName: standard

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity what exactly it will do today, from what storage class data will be exeucted?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not used right now, but once we add CSI driver support, we will extract the driverName and the opaque driver-specific properties to pass to volume creation (things like tier, performance parameters, etc)

Comment thread hack/run-demo-counter-kind.sh Outdated
Comment thread demos/counter/counter.yaml.tmpl Outdated
@msau42
Michelle Au (msau42) force-pushed the mock-mount-dev branch 3 times, most recently from e627884 to 18a4ca4 Compare July 23, 2026 05:07
This adds a new externalVolumeTemplate where users specify
the capacity, storage driver  and storage properties they would
like via StorageClass.

When an actor is created, we will also provision a volume for the
actor according to the template. The volume is deleted when the
actor is deleted.
Adds external volumes to the Actor object and integrates
control plane Actor workflows with the corresponding
volume operations:

CreateActor -> CreateVolume
DeleteActor -> DeleteVolume
ResumeActor -> AttachVolume
Pause/Suspend Actor -> DetachVolume
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.
This allows for different storage backends to be supported.
Right now it only contains the mock volume plugin, which is
only intended for testing purposes right now.
It makes the file counter path configurable so we can test it
against different volume types. And adds an optional file verification step to
test the presence of a file on an existing volume.
@msau42

Copy link
Copy Markdown
Collaborator Author

Here's the items I would like to followup in separate PRs to limit the size of this one, and to help unblock the CSI integration work;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants