Files
hajiler 73e4d56216 csi: reformat external volumes objects (#2653)
Fixes #2407, #2436.

> It's a good idea to open an issue first for discussion.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR

## What changes

One rename in the volume API, split out of #2567 so the breaking part
lands before GA and the shared-volume feature can follow as a
non-breaking addition.

**`Volume.external_volume_template` becomes `Volume.external`**, typed
`ExternalVolumeSource`, with the existing `ExternalVolumeTemplate` under
its `template` member. The field name now matches its siblings
(`durable_dir`, `system_info`, `image`) and the message follows the
`*VolumeSource` convention (#2407, #2436). The `ExternalVolumes` preview
gate moves onto the new member, so gating behaviour is unchanged.
`ExternalVolumeSource` is a union with one member today; the
shared-volume reference is added as a second member in #2567.

   ```yaml
   volumes:
   - name: data
     external:
       template:
         capacity: 10Gi
         storageClassName: standard
   ```

The per-actor record in ActorStatus gets wrapped in a new
ExternalVolumes message. This will make changes for shared volume
support easier post GA.

Callers, tests, demos, benchmark manifests and docs are updated;
generated code is regenerated.

## Testing

Unit, store (Postgres), controlapi and functional suites pass;
`hack/verify/gofmt.sh`, `hack/verify/boilerplate.sh`, `apitool validate`
and its exemption test pass.

#### Breaking change

`ActorTemplate.volumes[].externalVolumeTemplate` is gone; templates must
use `volumes[].external.template`. Field number 3 is retired, not
reused, so templates stored under the old field decode with no volume
source and must be recreated (they fail validation on their next
update). Go consumers of `pkg/proto/ateapipb` see the new
`ExternalVolumeSource` type and the moved getter.

Requesting the `breaking-change` label.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-19 17:43:17 +00:00

644 lines
18 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 (
"testing"
"github.com/google/go-cmp/cmp"
"go.ate.dev/substrate/internal/proto/ateletpb"
"go.ate.dev/substrate/pkg/proto/ateapipb"
"google.golang.org/protobuf/testing/protocmp"
)
func TestWorkloadSpecFromActorTemplate(t *testing.T) {
tests := []struct {
name string
template *ateapipb.ActorTemplate
want *ateletpb.WorkloadSpec
}{
{
name: "converts DurableDir volume and mounts",
template: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: "agent-ns", Name: "tmpl1"},
Volumes: []*ateapipb.Volume{
{Name: "home", DurableDir: &ateapipb.DurableDirVolumeSource{}},
},
Containers: []*ateapipb.Container{
{
Name: "main",
Image: "main",
VolumeMounts: []*ateapipb.VolumeMount{
{Name: "home", MountPath: "/home/user"},
{Name: "home", MountPath: "/workspace"},
},
},
},
},
want: &ateletpb.WorkloadSpec{
Volumes: []*ateletpb.Volume{
{
Name: "home",
DurableDir: &ateletpb.DurableDirVolumeSource{},
},
},
Containers: []*ateletpb.Container{
{
Name: "main",
Image: "main",
VolumeMounts: []*ateletpb.VolumeMount{
{Name: "home", MountPath: "/home/user"},
{Name: "home", MountPath: "/workspace"},
},
},
},
},
},
{
name: "converts SystemInfo volume with actorMetadata items",
template: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: "agent-ns", Name: "tmpl1"},
Volumes: []*ateapipb.Volume{
{
Name: "system-info",
SystemInfo: &ateapipb.SystemInfoVolumeSource{
DataSources: []*ateapipb.SystemInfoDataSource{
{ActorMetadata: &ateapipb.ActorMetadataDataSource{
Items: []*ateapipb.ActorMetadataItem{
{Field: ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME, Path: "actor-name"},
{Field: ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_ATESPACE, Path: "atespace"},
{Field: ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_UID, Path: "identity/actor-uid"},
},
}},
},
},
},
},
Containers: []*ateapipb.Container{
{
Name: "main",
Image: "main",
VolumeMounts: []*ateapipb.VolumeMount{
{Name: "system-info", MountPath: "/run/ate"},
},
},
},
},
want: &ateletpb.WorkloadSpec{
Volumes: []*ateletpb.Volume{
{
Name: "system-info",
SystemInfo: &ateletpb.SystemInfoVolumeSource{
DataSources: []*ateletpb.SystemInfoDataSource{
{ActorMetadata: &ateletpb.ActorMetadataDataSource{
Items: []*ateletpb.ActorMetadataItem{
{Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME, Path: "actor-name"},
{Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_ATESPACE, Path: "atespace"},
{Field: ateletpb.ActorMetadataField_ACTOR_METADATA_FIELD_UID, Path: "identity/actor-uid"},
},
}},
},
},
},
},
Containers: []*ateletpb.Container{
{
Name: "main",
Image: "main",
VolumeMounts: []*ateletpb.VolumeMount{
{Name: "system-info", MountPath: "/run/ate"},
},
},
},
},
},
{
name: "converts Image volume and mounts",
template: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: "agent-ns", Name: "tmpl1"},
Volumes: []*ateapipb.Volume{
{Name: "home", DurableDir: &ateapipb.DurableDirVolumeSource{}},
{Name: "agent", Image: &ateapipb.ImageVolumeSource{Reference: "example.com/agent@sha256:abc"}},
},
Containers: []*ateapipb.Container{
{
Name: "main",
Image: "main",
VolumeMounts: []*ateapipb.VolumeMount{
{Name: "home", MountPath: "/home/user"},
{Name: "agent", MountPath: "/ate"},
},
},
},
},
want: &ateletpb.WorkloadSpec{
Volumes: []*ateletpb.Volume{
{
Name: "home",
DurableDir: &ateletpb.DurableDirVolumeSource{},
},
{
Name: "agent",
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: "converts trustBundle data sources carrying the name for node-side resolution",
template: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: "agent-ns", Name: "tmpl1"},
Volumes: []*ateapipb.Volume{
{
Name: "system-info",
SystemInfo: &ateapipb.SystemInfoVolumeSource{
DataSources: []*ateapipb.SystemInfoDataSource{
{TrustBundle: &ateapipb.TrustBundleDataSource{Names: []string{"egress-trust"}, Path: "trust/ca.pem"}},
},
},
},
},
Containers: []*ateapipb.Container{
{
Name: "main",
Image: "main",
VolumeMounts: []*ateapipb.VolumeMount{
{Name: "system-info", MountPath: "/run/substrate/certs"},
},
},
},
},
// The NAME crosses the wire; atelet resolves it against its
// allowlist and ClusterTrustBundle informer at write time.
want: &ateletpb.WorkloadSpec{
Volumes: []*ateletpb.Volume{
{
Name: "system-info",
SystemInfo: &ateletpb.SystemInfoVolumeSource{
DataSources: []*ateletpb.SystemInfoDataSource{
{TrustBundle: &ateletpb.TrustBundleDataSource{Names: []string{"egress-trust"}, Path: "trust/ca.pem"}},
},
},
},
},
Containers: []*ateletpb.Container{
{
Name: "main",
Image: "main",
VolumeMounts: []*ateletpb.VolumeMount{
{Name: "system-info", MountPath: "/run/substrate/certs"},
},
},
},
},
},
{
name: "skips non-DurableDir volumes",
template: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: "agent-ns", Name: "tmpl1"},
Volumes: []*ateapipb.Volume{
{Name: "unsupported"},
{Name: "home", DurableDir: &ateapipb.DurableDirVolumeSource{}},
},
Containers: []*ateapipb.Container{
{
Name: "main",
Image: "main",
VolumeMounts: []*ateapipb.VolumeMount{
{Name: "home", MountPath: "/workspace"},
},
},
},
},
want: &ateletpb.WorkloadSpec{
Volumes: []*ateletpb.Volume{
{
Name: "home",
DurableDir: &ateletpb.DurableDirVolumeSource{},
},
},
Containers: []*ateletpb.Container{
{
Name: "main",
Image: "main",
VolumeMounts: []*ateletpb.VolumeMount{
{Name: "home", MountPath: "/workspace"},
},
},
},
},
},
{
name: "container without volume mounts has none",
template: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: "agent-ns", Name: "tmpl1"},
Volumes: []*ateapipb.Volume{
{Name: "home", DurableDir: &ateapipb.DurableDirVolumeSource{}},
},
Containers: []*ateapipb.Container{
{Name: "main", Image: "main"},
},
},
want: &ateletpb.WorkloadSpec{
Volumes: []*ateletpb.Volume{
{
Name: "home",
DurableDir: &ateletpb.DurableDirVolumeSource{},
},
},
Containers: []*ateletpb.Container{{Name: "main", Image: "main"}},
},
},
{
name: "maps literal env",
template: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: "agent-ns", Name: "tmpl1"},
Containers: []*ateapipb.Container{
{
Name: "main",
Image: "main",
Env: []*ateapipb.EnvVar{
{Name: "LITERAL", Value: "plain"},
{Name: "EMPTY", Value: ""},
},
},
},
},
want: &ateletpb.WorkloadSpec{
Containers: []*ateletpb.Container{{
Name: "main",
Image: "main",
Env: []*ateletpb.EnvVar{
{Name: "LITERAL", Value: "plain"},
{Name: "EMPTY", Value: ""},
},
}},
},
},
{
name: "maps command and args",
template: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: "agent-ns", Name: "tmpl1"},
Containers: []*ateapipb.Container{
{
Name: "main",
Image: "main",
Command: []string{"/entrypoint"},
Args: []string{"--foo", "--bar"},
},
},
},
want: &ateletpb.WorkloadSpec{
Containers: []*ateletpb.Container{{
Name: "main",
Image: "main",
Command: []string{"/entrypoint"},
Args: []string{"--foo", "--bar"},
}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := workloadSpecFromActorTemplate(tt.template, nil, nil)
if err != nil {
t.Fatalf("workloadSpecFromActorTemplate failed: %v", err)
}
if diff := cmp.Diff(tt.want, got, protocmp.Transform()); diff != "" {
t.Errorf("WorkloadSpec mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestWorkloadSpecFromActorTemplatePropagatesWakeupProbe(t *testing.T) {
got, err := workloadSpecFromActorTemplate(&ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: "agent-ns", Name: "tmpl-wakeup-probe"},
Containers: []*ateapipb.Container{
{
Name: "with-probe",
Image: "main",
WakeupProbe: &ateapipb.WakeupProbe{
HttpGet: &ateapipb.HttpGetAction{Path: "/health", Port: 8080},
TimeoutSeconds: 45,
},
},
{
Name: "without-probe",
Image: "side",
},
},
}, nil, nil)
if err != nil {
t.Fatalf("workloadSpecFromActorTemplate failed: %v", err)
}
want := &ateletpb.WorkloadSpec{
Containers: []*ateletpb.Container{
{
Name: "with-probe",
Image: "main",
WakeupProbe: &ateletpb.WakeupProbe{
HttpGet: &ateletpb.HttpGetAction{Path: "/health", Port: 8080},
TimeoutSeconds: 45,
},
},
{
Name: "without-probe",
Image: "side",
},
},
}
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
t.Errorf("WorkloadSpec mismatch (-want +got):\n%s", diff)
}
}
func TestAppendExternalVolumes(t *testing.T) {
template := &ateapipb.ActorTemplate{
Containers: []*ateapipb.Container{
{
Name: "main",
VolumeMounts: []*ateapipb.VolumeMount{
{Name: "vol-1", MountPath: "/mnt/vol1"},
},
},
},
Volumes: []*ateapipb.Volume{
{
Name: "vol-1",
External: &ateapipb.ExternalVolumeSource{Template: &ateapipb.ExternalVolumeTemplate{
StorageClassName: "pd-standard",
}},
},
{
Name: "vol-2",
DurableDir: &ateapipb.DurableDirVolumeSource{},
},
{
Name: "unmounted-vol",
External: &ateapipb.ExternalVolumeSource{Template: &ateapipb.ExternalVolumeTemplate{
StorageClassName: "pd-standard",
}},
},
},
}
actor := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{
Atespace: "space-abc",
Name: "actor-123",
},
Status: &ateapipb.ActorStatus{
ExternalVolumes: &ateapipb.ExternalVolumes{Volumes: []*ateapipb.ExternalVolumeStatus{
{
Name: "vol-1",
StorageVolumeId: "vol-gce-pd-123",
VolumeType: "pd-standard",
VolumeContext: map[string]string{"foo": "bar"},
},
}},
},
}
for _, tt := range []struct {
name string
volumePublishContexts map[string]map[string]string
wantPublishCtx map[string]string
}{
{
name: "with attachment metadata",
volumePublishContexts: map[string]map[string]string{"vol-1": {"devicePath": "/dev/xvdba"}},
wantPublishCtx: map[string]string{"devicePath": "/dev/xvdba"},
},
{
name: "without attachment metadata",
},
} {
t.Run(tt.name, func(t *testing.T) {
workloadSpec := &ateletpb.WorkloadSpec{}
if err := appendExternalVolumes(workloadSpec, template, actor, tt.volumePublishContexts); err != nil {
t.Fatalf("appendExternalVolumes unexpected error: %v", err)
}
want := &ateletpb.WorkloadSpec{
Volumes: []*ateletpb.Volume{
{
Name: "vol-1",
External: &ateletpb.ExternalVolumeSource{
StorageVolumeId: "vol-gce-pd-123",
VolumeType: "pd-standard",
VolumeContext: map[string]string{"foo": "bar"},
PublishContext: tt.wantPublishCtx,
},
},
},
}
if diff := cmp.Diff(want, workloadSpec, protocmp.Transform()); diff != "" {
t.Errorf("appendExternalVolumes mismatch (-want +got):\n%s", diff)
}
})
}
// Test missing mounted volume returns an error
missingActor := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{
Atespace: "space-abc",
Name: "actor-123",
},
Status: &ateapipb.ActorStatus{ExternalVolumes: &ateapipb.ExternalVolumes{Volumes: []*ateapipb.ExternalVolumeStatus{}}},
}
if err := appendExternalVolumes(&ateletpb.WorkloadSpec{}, template, missingActor, nil); err == nil {
t.Errorf("appendExternalVolumes expected error for missing volume, got nil")
}
}
func TestWorkloadSpecFromActorTemplatePropagatesSecurityContext(t *testing.T) {
got, err := workloadSpecFromActorTemplate(&ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: "agent-ns", Name: "tmpl-caps"},
Containers: []*ateapipb.Container{
{
Name: "adjusted",
Image: "main",
SecurityContext: &ateapipb.SecurityContext{
Capabilities: &ateapipb.Capabilities{
Add: []string{"NET_ADMIN"},
Drop: []string{"ALL"},
},
},
},
{
Name: "unset",
Image: "side",
},
{
// An empty capabilities block asks for no adjustment, so
// nothing is put on the wire for it.
Name: "empty",
Image: "third",
SecurityContext: &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{}},
},
},
}, nil, nil)
if err != nil {
t.Fatalf("workloadSpecFromActorTemplate failed: %v", err)
}
want := &ateletpb.WorkloadSpec{
Containers: []*ateletpb.Container{
{
Name: "adjusted",
Image: "main",
SecurityContext: &ateletpb.SecurityContext{
Capabilities: &ateletpb.Capabilities{
Add: []string{"NET_ADMIN"},
Drop: []string{"ALL"},
},
},
},
{
Name: "unset",
Image: "side",
},
{
Name: "empty",
Image: "third",
},
},
}
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
t.Errorf("WorkloadSpec mismatch (-want +got):\n%s", diff)
}
}
func TestToAteletResources(t *testing.T) {
tests := []struct {
name string
in *ateapipb.Resources
want *ateletpb.ResourceLimits
wantErr bool
}{{
name: "nil resources",
in: nil,
want: nil,
}, {
name: "empty limits",
in: &ateapipb.Resources{},
want: nil,
}, {
name: "memory only",
in: &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "memory", Quantity: "256Mi"}}},
want: &ateletpb.ResourceLimits{MemoryBytes: 268435456},
}, {
name: "cpu only",
in: &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "cpu", Quantity: "200m"}}},
want: &ateletpb.ResourceLimits{CpuMillis: 200},
}, {
name: "both",
in: &ateapipb.Resources{Limits: []*ateapipb.Limits{
{Name: "cpu", Quantity: "1"},
{Name: "memory", Quantity: "1Gi"},
}},
want: &ateletpb.ResourceLimits{MemoryBytes: 1073741824, CpuMillis: 1000},
}, {
name: "invalid quantity",
in: &ateapipb.Resources{Limits: []*ateapipb.Limits{{Name: "cpu", Quantity: "banana"}}},
wantErr: true,
}}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := toAteletResources(tc.in)
if tc.wantErr {
if err == nil {
t.Fatal("toAteletResources() error = nil, want parse error")
}
return
}
if err != nil {
t.Fatalf("toAteletResources() failed: %v", err)
}
if tc.want == nil {
if got != nil {
t.Fatalf("toAteletResources() = %v, want nil", got)
}
return
}
if got == nil {
t.Fatalf("toAteletResources() = nil, want %v", tc.want)
}
if got.GetMemoryBytes() != tc.want.GetMemoryBytes() {
t.Errorf("memory_bytes = %d, want %d", got.GetMemoryBytes(), tc.want.GetMemoryBytes())
}
if got.GetCpuMillis() != tc.want.GetCpuMillis() {
t.Errorf("cpu_millis = %d, want %d", got.GetCpuMillis(), tc.want.GetCpuMillis())
}
})
}
}
// The limits a template declares must reach the workload spec atelet receives.
// Without this, removing the Resources field from the ateletpb.Container
// literal leaves every test in the repository green while limits never leave
// ate-api-server.
func TestWorkloadSpecFromActorTemplatePropagatesResources(t *testing.T) {
got, err := workloadSpecFromActorTemplate(&ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: "agent-ns", Name: "tmpl-limits"},
Containers: []*ateapipb.Container{
{
Name: "limited",
Image: "main",
Resources: &ateapipb.Resources{Limits: []*ateapipb.Limits{
{Name: "cpu", Quantity: "200m"},
{Name: "memory", Quantity: "256Mi"},
}},
},
{Name: "unlimited", Image: "main"},
},
}, nil, nil)
if err != nil {
t.Fatalf("workloadSpecFromActorTemplate failed: %v", err)
}
if len(got.GetContainers()) != 2 {
t.Fatalf("containers = %d, want 2", len(got.GetContainers()))
}
limited := got.GetContainers()[0].GetResources()
if limited == nil {
t.Fatal("limited container Resources = nil, want the declared limits")
}
if limited.GetMemoryBytes() != 268435456 {
t.Errorf("memory_bytes = %d, want 268435456", limited.GetMemoryBytes())
}
if limited.GetCpuMillis() != 200 {
t.Errorf("cpu_millis = %d, want 200", limited.GetCpuMillis())
}
if r := got.GetContainers()[1].GetResources(); r != nil {
t.Errorf("unlimited container Resources = %v, want nil", r)
}
}