mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-12 10:35:06 +08:00
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.
235 lines
7.4 KiB
Go
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
|
|
}
|