Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ workerSelector:
workload: benchmark-worker
volumes:
- name: external-storage
externalVolumeTemplate:
capacity: 1Gi
storageClassName: ${STORAGE_CLASS_NAME}
external:
template:
capacity: 1Gi
storageClassName: ${STORAGE_CLASS_NAME}
containers:
- name: glutton-storage
image: ko://go.ate.dev/substrate/cmd/benchmarking/glutton
Expand Down
7 changes: 4 additions & 3 deletions cmd/ate-setup/internal/demos/counter/counter.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ func (d *demo) externalVolumeValues(e *steps.Env) map[string]string {
"EXTERNAL_VOLUME_MOUNTS": " - name: external-data\n" +
" mountPath: /external-data",
"EXTERNAL_VOLUMES": "- name: external-data\n" +
" externalVolumeTemplate:\n" +
" capacity: 1Gi\n" +
" storageClassName: " + sc,
" external:\n" +
" template:\n" +
" capacity: 1Gi\n" +
" storageClassName: " + sc,
}
}

Expand Down
3 changes: 2 additions & 1 deletion cmd/ate-setup/internal/demos/counter/counter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ func TestExternalVolumeRenders(t *testing.T) {
for _, want := range []string{
"--validate-existing-file-path=/external-data/test.txt",
"mountPath: /external-data",
"externalVolumeTemplate:",
"external:",
"template:",
tc.wantSC,
} {
if !strings.Contains(string(manifest), want) {
Expand Down
42 changes: 21 additions & 21 deletions cmd/ateapi/internal/apivalidation/actor_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,15 +217,15 @@ func TestValidateCreateActorTemplateRequest(t *testing.T) {
func TestValidateCreateActorTemplateRequest_PreviewRestricted(t *testing.T) {
setPreviewForTest(t, "ALL")
req := &ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi", StorageClassName: "fast-ssd"}}}
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", External: &ateapipb.ExternalVolumeSource{Template: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi", StorageClassName: "fast-ssd"}}}}
})}

t.Run("allowed", func(t *testing.T) {
assertValidateErr(t, ValidateCreateActorTemplateRequest(context.Background(), req), nil)
})
t.Run("restricted", func(t *testing.T) {
ctx := preview.ContextWithOptOut(context.Background())
want := field.ErrorList{field.Forbidden(field.NewPath("actor_template", "volumes").Index(0).Child("external_volume_template"), "")}
want := field.ErrorList{field.Forbidden(field.NewPath("actor_template", "volumes").Index(0).Child("external"), "")}
assertValidateErr(t, ValidateCreateActorTemplateRequest(ctx, req), want)
})
}
Expand Down Expand Up @@ -832,52 +832,52 @@ func TestValidateActorTemplate(t *testing.T) {
}, {
name: "valid external volume template",
mutate: func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi", StorageClassName: "fast-ssd"}}}
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", External: &ateapipb.ExternalVolumeSource{Template: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi", StorageClassName: "fast-ssd"}}}}
},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external_volume_template"), "")},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external"), "")},
wantWithPreview: map[string]field.ErrorList{"ALL": nil},
}, {
name: "external volume template missing capacity",
mutate: func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{StorageClassName: "fast-ssd"}}}
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", External: &ateapipb.ExternalVolumeSource{Template: &ateapipb.ExternalVolumeTemplate{StorageClassName: "fast-ssd"}}}}
},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external_volume_template"), "")},
wantWithPreview: map[string]field.ErrorList{"ALL": {field.Required(field.NewPath("volumes").Index(0).Child("external_volume_template", "capacity"), "")}},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external"), "")},
wantWithPreview: map[string]field.ErrorList{"ALL": {field.Required(field.NewPath("volumes").Index(0).Child("external", "template", "capacity"), "")}},
}, {
name: "external volume template malformed capacity",
mutate: func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "ten gigs", StorageClassName: "fast-ssd"}}}
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", External: &ateapipb.ExternalVolumeSource{Template: &ateapipb.ExternalVolumeTemplate{Capacity: "ten gigs", StorageClassName: "fast-ssd"}}}}
},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external_volume_template"), "")},
wantWithPreview: map[string]field.ErrorList{"ALL": {field.Invalid(field.NewPath("volumes").Index(0).Child("external_volume_template", "capacity"), nil, "")}},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external"), "")},
wantWithPreview: map[string]field.ErrorList{"ALL": {field.Invalid(field.NewPath("volumes").Index(0).Child("external", "template", "capacity"), nil, "")}},
}, {
name: "external volume template capacity at the length bound",
mutate: func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: strings.Repeat("1", 30) + "Gi", StorageClassName: "fast-ssd"}}}
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", External: &ateapipb.ExternalVolumeSource{Template: &ateapipb.ExternalVolumeTemplate{Capacity: strings.Repeat("1", 30) + "Gi", StorageClassName: "fast-ssd"}}}}
},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external_volume_template"), "")},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external"), "")},
wantWithPreview: map[string]field.ErrorList{"ALL": nil},
}, {
name: "external volume template capacity too long",
mutate: func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: strings.Repeat("1", 31) + "Gi", StorageClassName: "fast-ssd"}}}
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", External: &ateapipb.ExternalVolumeSource{Template: &ateapipb.ExternalVolumeTemplate{Capacity: strings.Repeat("1", 31) + "Gi", StorageClassName: "fast-ssd"}}}}
},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external_volume_template"), "")},
wantWithPreview: map[string]field.ErrorList{"ALL": {field.TooLong(field.NewPath("volumes").Index(0).Child("external_volume_template", "capacity"), nil, 32).WithOrigin("maxLength")}},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external"), "")},
wantWithPreview: map[string]field.ErrorList{"ALL": {field.TooLong(field.NewPath("volumes").Index(0).Child("external", "template", "capacity"), nil, 32).WithOrigin("maxLength")}},
}, {
name: "external volume template missing storage_class_name",
mutate: func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi"}}}
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", External: &ateapipb.ExternalVolumeSource{Template: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi"}}}}
},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external_volume_template"), "")},
wantWithPreview: map[string]field.ErrorList{"ALL": {field.Required(field.NewPath("volumes").Index(0).Child("external_volume_template", "storage_class_name"), "")}},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external"), "")},
wantWithPreview: map[string]field.ErrorList{"ALL": {field.Required(field.NewPath("volumes").Index(0).Child("external", "template", "storage_class_name"), "")}},
}, {
name: "external volume template invalid storage_class_name",
mutate: func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi", StorageClassName: "Fast SSD"}}}
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", External: &ateapipb.ExternalVolumeSource{Template: &ateapipb.ExternalVolumeTemplate{Capacity: "10Gi", StorageClassName: "Fast SSD"}}}}
},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external_volume_template"), "")},
wantWithPreview: map[string]field.ErrorList{"ALL": {field.Invalid(field.NewPath("volumes").Index(0).Child("external_volume_template", "storage_class_name"), nil, "").WithOrigin("format=k8s-long-name")}},
want: field.ErrorList{field.Forbidden(field.NewPath("volumes").Index(0).Child("external"), "")},
wantWithPreview: map[string]field.ErrorList{"ALL": {field.Invalid(field.NewPath("volumes").Index(0).Child("external", "template", "storage_class_name"), nil, "").WithOrigin("format=k8s-long-name")}},
}, {
name: "valid resources",
mutate: func(tmpl *ateapipb.ActorTemplate) {
Expand Down
124 changes: 124 additions & 0 deletions cmd/ateapi/internal/apivalidation/shared_volume.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// 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 apivalidation

import (
"context"
"fmt"

"go.ate.dev/substrate/pkg/proto/ateapipb"
"k8s.io/apimachinery/pkg/api/operation"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/apimachinery/pkg/api/validate"
"k8s.io/apimachinery/pkg/util/validation/field"
)

func ValidateCreateSharedVolumeRequest(ctx context.Context, req *ateapipb.CreateSharedVolumeRequest) field.ErrorList {
op := operation.Operation{Type: operation.Create}
return Validate_CreateSharedVolumeRequest(ctx, op, nil, req, nil)
}

func ValidateGetSharedVolumeRequest(ctx context.Context, req *ateapipb.GetSharedVolumeRequest) field.ErrorList {
op := operation.Operation{Type: operation.Create}
return Validate_GetSharedVolumeRequest(ctx, op, nil, req, nil)
}

func ValidateListSharedVolumesRequest(ctx context.Context, req *ateapipb.ListSharedVolumesRequest) field.ErrorList {
op := operation.Operation{Type: operation.Create}
return Validate_ListSharedVolumesRequest(ctx, op, nil, req, nil)
}

// The request is validated as a create; the result is validated as an update
// once the stored value is known, like UpdateTagRequest.
func ValidateUpdateSharedVolumeRequest(ctx context.Context, req *ateapipb.UpdateSharedVolumeRequest) field.ErrorList {
op := operation.Operation{Type: operation.Create}
return Validate_UpdateSharedVolumeRequest(ctx, op, nil, req, nil)
}

func ValidateDeleteSharedVolumeRequest(ctx context.Context, req *ateapipb.DeleteSharedVolumeRequest) field.ErrorList {
op := operation.Operation{Type: operation.Create}
return Validate_DeleteSharedVolumeRequest(ctx, op, nil, req, nil)
}

func ValidateSharedVolumeUpdate(ctx context.Context, fldPath *field.Path, newVal, oldVal *ateapipb.SharedVolume) field.ErrorList {
op := operation.Operation{Type: operation.Update}
return Validate_SharedVolume(ctx, op, fldPath, newVal, oldVal)
}

func ValidateCustom_CreateSharedVolumeRequest(_ context.Context, _ operation.Operation, p *field.Path, req, _ *ateapipb.CreateSharedVolumeRequest) field.ErrorList {
volume := req.GetSharedVolume()
if volume == nil {
return nil
}
volumePath := p.Child("shared_volume")
storageClassName, volumeID := volume.GetStorageClassName(), volume.GetVolumeId()

var errs field.ErrorList
// TODO: accept READ_WRITE_MANY once concurrent holders are supported.
if mode := volume.GetAccessMode(); mode != ateapipb.AccessMode_ACCESS_MODE_UNSPECIFIED && mode != ateapipb.AccessMode_ACCESS_MODE_READ_WRITE_ONCE {
errs = append(errs, field.NotSupported(volumePath.Child("access_mode"), mode.String(), []string{ateapipb.AccessMode_ACCESS_MODE_READ_WRITE_ONCE.String()}))
}
switch {
case storageClassName == "" && volumeID == "":
errs = append(errs, field.Required(volumePath.Child("storage_class_name"), "one of storage_class_name or volume_id is required"))
case volumeID != "":
if volume.GetCapacity() != "" {
errs = append(errs, field.Forbidden(volumePath.Child("capacity"), "must not be set with volume_id"))
}
if volume.GetVolumeType() == "" && storageClassName == "" {
errs = append(errs, field.Required(volumePath.Child("volume_type"), "required with volume_id unless storage_class_name is set"))
}
default:
if volume.GetVolumeType() != "" {
errs = append(errs, field.Forbidden(volumePath.Child("volume_type"), "must not be set with storage_class_name; it is taken from the class's provisioner"))
}
if len(volume.GetVolumeContext()) > 0 {
errs = append(errs, field.Forbidden(volumePath.Child("volume_context"), "must not be set with storage_class_name; it is returned by the storage system"))
}
if volume.GetCapacity() == "" {
errs = append(errs, field.Required(volumePath.Child("capacity"), "required with storage_class_name"))
}
}
return errs
}

func ValidateCustom_SharedVolume_Capacity(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList {
if value == nil || *value == "" {
return nil
}
if _, err := resource.ParseQuantity(*value); err != nil {
return field.ErrorList{field.Invalid(fldPath, *value, fmt.Sprintf("must be a Kubernetes resource quantity: %v", err))}
}
return nil
}

// This exists only because nested subfield tags are not supported yet.
func ValidateCustom_UpdateSharedVolumeRequest_SharedVolume(ctx context.Context, op operation.Operation, fldPath *field.Path, volume, _ *ateapipb.SharedVolume) field.ErrorList {
if volume == nil || volume.Metadata == nil {
return nil
}
errs := Validate_ResourceMetadata(ctx, op, fldPath.Child("metadata"), volume.Metadata, nil)
errs = append(errs, validate.RequiredValue(ctx, op, fldPath.Child("metadata", "atespace"), &volume.Metadata.Atespace, nil)...)
return errs
}

// An SharedVolume names its storage the same way an ExternalVolume does.
func ValidateCustom_SharedVolume_VolumeType(ctx context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *string) field.ErrorList {
return ValidateCustom_ExternalVolume_VolumeType(ctx, op, fldPath, value, oldValue)
}

func ValidateCustom_SharedVolume_VolumeId(ctx context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *string) field.ErrorList {
return ValidateCustom_ExternalVolume_StorageVolumeId(ctx, op, fldPath, value, oldValue)
}
117 changes: 117 additions & 0 deletions cmd/ateapi/internal/apivalidation/shared_volume_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// 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 apivalidation

import (
"context"
"testing"

"github.com/google/go-cmp/cmp"
"go.ate.dev/substrate/pkg/proto/ateapipb"
"k8s.io/apimachinery/pkg/api/operation"
"k8s.io/apimachinery/pkg/util/validation/field"
)

func TestValidateCustomCreateSharedVolumeRequest(t *testing.T) {
tests := []struct {
name string
volume *ateapipb.SharedVolume
wantFields []string
}{
{
name: "provisioning from a class",
volume: &ateapipb.SharedVolume{StorageClassName: "standard", Capacity: "10Gi"},
},
{
name: "registering an existing volume",
volume: &ateapipb.SharedVolume{VolumeId: "disk-1", VolumeType: "mock"},
},
{
name: "registering an existing volume under a named class",
volume: &ateapipb.SharedVolume{VolumeId: "disk-1", StorageClassName: "standard"},
},
{
name: "neither a class nor an ID",
volume: &ateapipb.SharedVolume{},
wantFields: []string{"shared_volume.storage_class_name"},
},
{
name: "a size for a volume that already exists",
volume: &ateapipb.SharedVolume{VolumeId: "disk-1", VolumeType: "mock", Capacity: "10Gi"},
wantFields: []string{"shared_volume.capacity"},
},
{
name: "an ID with no driver to reach it through",
volume: &ateapipb.SharedVolume{VolumeId: "disk-1"},
wantFields: []string{"shared_volume.volume_type"},
},
{
name: "a driver alongside the class that supplies one",
volume: &ateapipb.SharedVolume{StorageClassName: "standard", VolumeType: "mock", Capacity: "10Gi"},
wantFields: []string{"shared_volume.volume_type"},
},
{
name: "driver metadata for a volume that does not exist yet",
volume: &ateapipb.SharedVolume{StorageClassName: "standard", Capacity: "10Gi", VolumeContext: map[string]string{"fsType": "ext4"}},
wantFields: []string{"shared_volume.volume_context"},
},
{
name: "provisioning without a size",
volume: &ateapipb.SharedVolume{StorageClassName: "standard"},
wantFields: []string{"shared_volume.capacity"},
},
{
name: "read write many is not supported yet",
volume: &ateapipb.SharedVolume{StorageClassName: "standard", Capacity: "10Gi", AccessMode: ateapipb.AccessMode_ACCESS_MODE_READ_WRITE_MANY},
wantFields: []string{"shared_volume.access_mode"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := &ateapipb.CreateSharedVolumeRequest{SharedVolume: tc.volume}
errs := ValidateCustom_CreateSharedVolumeRequest(context.Background(), operation.Operation{Type: operation.Create}, nil, req, nil)

var gotFields []string
for _, err := range errs {
gotFields = append(gotFields, err.Field)
}
if diff := cmp.Diff(tc.wantFields, gotFields); diff != "" {
t.Errorf("offending fields mismatch (-want +got):\n%s", diff)
}
})
}
}

func TestValidateCustomSharedVolumeCapacity(t *testing.T) {
tests := []struct {
value string
wantErr bool
}{
{value: ""},
{value: "10Gi"},
{value: "1500m"},
{value: "ten gigs", wantErr: true},
{value: "10GiB", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.value, func(t *testing.T) {
value := tc.value
errs := ValidateCustom_SharedVolume_Capacity(context.Background(), operation.Operation{Type: operation.Create}, field.NewPath("capacity"), &value, nil)
if gotErr := len(errs) > 0; gotErr != tc.wantErr {
t.Errorf("ValidateCustom_SharedVolume_Capacity(%q) errors = %v, wantErr %v", tc.value, errs, tc.wantErr)
}
})
}
}
Loading
Loading