Skip to content
Open
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
124 changes: 124 additions & 0 deletions cmd/ateapi/internal/apivalidation/external_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"

"github.com/agent-substrate/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 ValidateCreateExternalVolumeRequest(ctx context.Context, req *ateapipb.CreateExternalVolumeRequest) field.ErrorList {
op := operation.Operation{Type: operation.Create}
return Validate_CreateExternalVolumeRequest(ctx, op, nil, req, nil)
}

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

func ValidateListExternalVolumesRequest(ctx context.Context, req *ateapipb.ListExternalVolumesRequest) field.ErrorList {
op := operation.Operation{Type: operation.Create}
return Validate_ListExternalVolumesRequest(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 ValidateUpdateExternalVolumeRequest(ctx context.Context, req *ateapipb.UpdateExternalVolumeRequest) field.ErrorList {
op := operation.Operation{Type: operation.Create}
return Validate_UpdateExternalVolumeRequest(ctx, op, nil, req, nil)
}

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

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

func ValidateCustom_CreateExternalVolumeRequest(_ context.Context, _ operation.Operation, p *field.Path, req, _ *ateapipb.CreateExternalVolumeRequest) field.ErrorList {
volume := req.GetExternalVolume()
if volume == nil {
return nil
}
volumePath := p.Child("external_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_ExternalVolume_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_UpdateExternalVolumeRequest_ExternalVolume(ctx context.Context, op operation.Operation, fldPath *field.Path, volume, _ *ateapipb.ExternalVolume) 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 ExternalVolume names its storage the same way an ActorVolumeStatus does.
func ValidateCustom_ExternalVolume_VolumeType(ctx context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *string) field.ErrorList {
return ValidateCustom_ActorVolumeStatus_VolumeType(ctx, op, fldPath, value, oldValue)
}

func ValidateCustom_ExternalVolume_VolumeId(ctx context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *string) field.ErrorList {
return ValidateCustom_ActorVolumeStatus_StorageVolumeId(ctx, op, fldPath, value, oldValue)
}
117 changes: 117 additions & 0 deletions cmd/ateapi/internal/apivalidation/external_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/agent-substrate/substrate/pkg/proto/ateapipb"
"github.com/google/go-cmp/cmp"
"k8s.io/apimachinery/pkg/api/operation"
"k8s.io/apimachinery/pkg/util/validation/field"
)

func TestValidateCustomCreateExternalVolumeRequest(t *testing.T) {
tests := []struct {
name string
volume *ateapipb.ExternalVolume
wantFields []string
}{
{
name: "provisioning from a class",
volume: &ateapipb.ExternalVolume{StorageClassName: "standard", Capacity: "10Gi"},
},
{
name: "registering an existing volume",
volume: &ateapipb.ExternalVolume{VolumeId: "disk-1", VolumeType: "mock"},
},
{
name: "registering an existing volume under a named class",
volume: &ateapipb.ExternalVolume{VolumeId: "disk-1", StorageClassName: "standard"},
},
{
name: "neither a class nor an ID",
volume: &ateapipb.ExternalVolume{},
wantFields: []string{"external_volume.storage_class_name"},
},
{
name: "a size for a volume that already exists",
volume: &ateapipb.ExternalVolume{VolumeId: "disk-1", VolumeType: "mock", Capacity: "10Gi"},
wantFields: []string{"external_volume.capacity"},
},
{
name: "an ID with no driver to reach it through",
volume: &ateapipb.ExternalVolume{VolumeId: "disk-1"},
wantFields: []string{"external_volume.volume_type"},
},
{
name: "a driver alongside the class that supplies one",
volume: &ateapipb.ExternalVolume{StorageClassName: "standard", VolumeType: "mock", Capacity: "10Gi"},
wantFields: []string{"external_volume.volume_type"},
},
{
name: "driver metadata for a volume that does not exist yet",
volume: &ateapipb.ExternalVolume{StorageClassName: "standard", Capacity: "10Gi", VolumeContext: map[string]string{"fsType": "ext4"}},
wantFields: []string{"external_volume.volume_context"},
},
{
name: "provisioning without a size",
volume: &ateapipb.ExternalVolume{StorageClassName: "standard"},
wantFields: []string{"external_volume.capacity"},
},
{
name: "read write many is not supported yet",
volume: &ateapipb.ExternalVolume{StorageClassName: "standard", Capacity: "10Gi", AccessMode: ateapipb.AccessMode_ACCESS_MODE_READ_WRITE_MANY},
wantFields: []string{"external_volume.access_mode"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := &ateapipb.CreateExternalVolumeRequest{ExternalVolume: tc.volume}
errs := ValidateCustom_CreateExternalVolumeRequest(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 TestValidateCustomExternalVolumeCapacity(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_ExternalVolume_Capacity(context.Background(), operation.Operation{Type: operation.Create}, field.NewPath("capacity"), &value, nil)
if gotErr := len(errs) > 0; gotErr != tc.wantErr {
t.Errorf("ValidateCustom_ExternalVolume_Capacity(%q) errors = %v, wantErr %v", tc.value, errs, tc.wantErr)
}
})
}
}
Loading
Loading