Files
substrate/cmd/ateapi/internal/controlapi/workload_spec.go
T
Ron Lev 0741459b15 Mount an OCI image read-only into an actor (#923)
Fixes #783
Adds an `image` source to `ActorTemplate`'s `VolumeSource`: a container
can mount the contents of an OCI image it does not run. This is how
tooling gets into images built by third parties without rebuilding them.

```yaml
spec:
  containers:
  - name: sandbox
    image: docker.io/example/benchmark@sha256:...
    command: ["/ate/agent"]
    volumeMounts:
    - name: agent
      mountPath: /ate
  volumes:
  - name: agent
    image:
      reference: registry.example.com/agent@sha256:...
```
## How it works

atelet pulls the image through the existing layer cache and records the
volume's layers in the bundle's overlay spec, next to the rootfs layers.
ateom composes the volume inside the bundle — the cached layers with no
writable layer on top, so the mount is read-only — and the container
binds it at the declared path. The volume is composed per container:
containers of one actor may mount the same volume, and each gets its own
mount point inside its own bundle, all backed by the same shared layers.
On resume the volume is re-composed the same way.

References must be digest-pinned, the same rule as container images: a
snapshot is only valid against the exact bytes it was taken with.

On micro-VMs the volume rides the same read-only virtio-fs share as the
container rootfs: ateom stages each composed volume beside the rootfs on
the host, and the guest binds it into the container at the declared
path.
2026-08-20 10:22:56 -07:00

235 lines
7.4 KiB
Go

// 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 controlapi
import (
"fmt"
"github.com/agent-substrate/substrate/internal/proto/ateletpb"
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
)
// workloadSpecFromActorTemplate builds a WorkloadSpec from the template;
// container env is copied verbatim.
func workloadSpecFromActorTemplate(actorTemplate *atev1alpha1.ActorTemplate, actor *ateapipb.Actor) (*ateletpb.WorkloadSpec, error) {
workloadSpec := &ateletpb.WorkloadSpec{}
// Convert volumes to atelet's representation. ActorTemplate validation has
// already ensured that only one source is set.
for _, vol := range actorTemplate.Spec.Volumes {
switch {
case vol.VolumeSource.DurableDir != nil:
workloadSpec.Volumes = append(workloadSpec.Volumes, &ateletpb.Volume{
Name: vol.Name,
Source: &ateletpb.Volume_DurableDir{
DurableDir: &ateletpb.DurableDirVolume{},
},
})
case vol.VolumeSource.SystemInfo != nil:
ateletSystemInfo := &ateletpb.SystemInfoVolume{}
for _, dataSource := range vol.VolumeSource.SystemInfo.DataSources {
switch {
case dataSource.ActorMetadata != nil:
actorMetadata := &ateletpb.ActorMetadataDataSource{}
for _, item := range dataSource.ActorMetadata.Items {
actorMetadata.Items = append(actorMetadata.Items, &ateletpb.ActorMetadataItem{
Field: toAteletActorMetadataField(item.Field),
Path: item.Path,
})
}
ateletSystemInfo.DataSources = append(ateletSystemInfo.DataSources, &ateletpb.SystemInfoDataSource{
DataSource: &ateletpb.SystemInfoDataSource_ActorMetadata{
ActorMetadata: actorMetadata,
},
})
default:
continue // Drop unrecognized data sources
}
}
workloadSpec.Volumes = append(workloadSpec.Volumes, &ateletpb.Volume{
Name: vol.Name,
Source: &ateletpb.Volume_SystemInfo{
SystemInfo: ateletSystemInfo,
},
})
case vol.VolumeSource.Image != nil:
workloadSpec.Volumes = append(workloadSpec.Volumes, &ateletpb.Volume{
Name: vol.Name,
Source: &ateletpb.Volume_Image{
Image: &ateletpb.ImageVolumeSource{
Reference: vol.VolumeSource.Image.Reference,
},
},
})
default:
continue // Drop unrecognized volumes.
}
}
// TODO: order may be important for nested mounts. Also need to think about
// nested mount support in general.
if err := appendExternalVolumes(workloadSpec, actorTemplate, actor); err != nil {
return nil, err
}
for _, ctr := range actorTemplate.Spec.Containers {
ateletCtr := &ateletpb.Container{
Name: ctr.Name,
Image: ctr.Image,
Command: ctr.Command,
Args: ctr.Args,
Readyz: toAteletReadyz(ctr.Readyz),
SecurityContext: toAteletSecurityContext(ctr.SecurityContext),
}
for _, env := range ctr.Env {
ateletCtr.Env = append(ateletCtr.Env, &ateletpb.EnvEntry{
Name: env.Name,
Value: env.Value,
})
}
for _, mount := range ctr.VolumeMounts {
ateletCtr.VolumeMounts = append(ateletCtr.VolumeMounts, &ateletpb.VolumeMount{
Name: mount.Name,
MountPath: mount.MountPath,
})
}
workloadSpec.Containers = append(workloadSpec.Containers, ateletCtr)
}
return workloadSpec, nil
}
// appendExternalVolumes maps template external volumes to resolved actor volumes and appends them to workloadSpec
// if they are referenced in container volumeMounts.
func appendExternalVolumes(workloadSpec *ateletpb.WorkloadSpec, template *atev1alpha1.ActorTemplate, actor *ateapipb.Actor) error {
if template == nil {
return nil
}
for _, vol := range template.Spec.Volumes {
if vol.ExternalVolumeTemplate != nil {
if !isVolumeMounted(vol.Name, template) {
continue
}
if actor == nil {
return fmt.Errorf("actor is required when externalVolumeTemplate is present")
}
var storageVolID string
var volType string
var volCtx map[string]string
for _, dbVol := range actor.GetStatus().GetActorVolumes() {
if dbVol.GetVolumeName() == vol.Name {
storageVolID = dbVol.GetStorageVolumeId()
volType = dbVol.GetVolumeType()
volCtx = dbVol.GetVolumeContext()
break
}
}
if storageVolID == "" {
return fmt.Errorf("volume %s not found for actor %s", vol.Name, actor.GetMetadata().GetName())
}
workloadSpec.Volumes = append(workloadSpec.Volumes, &ateletpb.Volume{
Name: vol.Name,
Source: &ateletpb.Volume_External{
External: &ateletpb.ExternalVolumeSource{
StorageVolumeId: storageVolID,
VolumeType: volType,
VolumeContext: volCtx,
},
},
})
}
}
return nil
}
func isVolumeMounted(volumeName string, template *atev1alpha1.ActorTemplate) bool {
for _, ctr := range template.Spec.Containers {
for _, mount := range ctr.VolumeMounts {
if mount.Name == volumeName {
return true
}
}
}
return false
}
// toAteletReadyz projects the CRD readyz field onto the ateletpb wire type.
// Returns nil when the source is nil so containers without a probe stay
// unchanged on the wire.
// toAteletActorMetadataField projects the CRD field selector onto the atelet
// wire enum. Unknown values map to UNSPECIFIED, which atelet skips; CRD enum
// validation makes that unreachable for stored templates.
func toAteletActorMetadataField(in atev1alpha1.ActorMetadataField) ateletpb.ActorMetadataField {
switch in {
case atev1alpha1.ActorMetadataFieldName:
return ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME
case atev1alpha1.ActorMetadataFieldAtespace:
return ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_ATESPACE
case atev1alpha1.ActorMetadataFieldUID:
return ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_UID
default:
return ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_UNSPECIFIED
}
}
func toAteletReadyz(in *atev1alpha1.ContainerReadyz) *ateletpb.Readyz {
if in == nil {
return nil
}
out := &ateletpb.Readyz{TimeoutSeconds: in.TimeoutSeconds}
if in.HTTPGet != nil {
out.HttpGet = &ateletpb.HTTPGetAction{
Path: in.HTTPGet.Path,
Port: in.HTTPGet.Port,
}
}
return out
}
// toAteletSecurityContext projects the CRD securityContext onto the ateletpb
// wire type. Returns nil when the source is nil or carries nothing, so
// containers that set no security settings stay unchanged on the wire.
func toAteletSecurityContext(in *atev1alpha1.SecurityContext) *ateletpb.SecurityContext {
if in == nil || in.Capabilities == nil {
return nil
}
caps := in.Capabilities
if len(caps.Add) == 0 && len(caps.Drop) == 0 {
return nil
}
return &ateletpb.SecurityContext{
Capabilities: &ateletpb.Capabilities{
Add: capabilityNames(caps.Add),
Drop: capabilityNames(caps.Drop),
},
}
}
func capabilityNames(in []atev1alpha1.Capability) []string {
if len(in) == 0 {
return nil
}
out := make([]string, 0, len(in))
for _, c := range in {
out = append(out, string(c))
}
return out
}