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
1,359 changes: 727 additions & 632 deletions api/ateapi/v1/ateapi.pb.go

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions api/ateapi/v1/ateapi.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,40 @@ message ExternalVolumeTemplate {
string storage_class_name = 2;
}

// ExternalVolumeClass names the CSI driver that provisions external volumes
// and the parameters for its CreateVolume call. Global-scoped and immutable:
// metadata.atespace must be empty.
message ExternalVolumeClass {
option (resource) = {
scope : SCOPE_GLOBAL
plural : "ExternalVolumeClasses"
};

// Common resource metadata: name, uid, version, timestamps.
//
// +k8s:required
// +k8s:beta(since: "0.0")=+k8s:subfield(atespace)=+k8s:forbidden # TODO: get rid of beta prefix
ResourceMetadata metadata = 1;

// driver_name is the CSI driver that provisions the volumes, e.g.
// "nfs.csi.k8s.io". It is a DNS subdomain, optionally prefixed with
// "substrate.io/".
//
// +k8s:required
// +k8s:maxLength=63
// +k8s:customValidation # optional "substrate.io/" prefix + DNS-1123 subdomain
string driver_name = 2;

// parameters are passed unchanged to the driver's CreateVolume call. The
// driver defines their keys and values, so they are only bounded in size.
//
// +k8s:optional
// +k8s:maxProperties=32
// +k8s:eachKey=+k8s:maxLength=128
// +k8s:eachVal=+k8s:maxLength=256
map<string, string> parameters = 3;
}

// SystemInfoVolumeSource is a read-only volume of substrate-generated
// per-actor files (identity fields, projected trust bundles), regenerated by
// atelet on every Run/Restore.
Expand Down
38 changes: 38 additions & 0 deletions cmd/ateapi/internal/controlapi/external_volume_class.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright 2026 The Agent Substrate Authors
//
// 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 (
"context"

"go.ate.dev/substrate/api/ateapi/v1"
"go.ate.dev/substrate/cmd/ateapi/internal/store"
)

func (s *ServiceImpl) CreateExternalVolumeClass(ctx context.Context, class *ateapi.ExternalVolumeClass) (*ateapi.ExternalVolumeClass, error) {
return s.store.CreateExternalVolumeClass(ctx, class)
}

func (s *ServiceImpl) GetExternalVolumeClass(ctx context.Context, name string) (*ateapi.ExternalVolumeClass, error) {
return s.store.GetExternalVolumeClass(ctx, name)
}

func (s *ServiceImpl) ListExternalVolumeClasses(ctx context.Context, opts store.ListOptions) (store.ListResponse[*ateapi.ExternalVolumeClass], error) {
return s.store.ListExternalVolumeClasses(ctx, opts)
}

func (s *ServiceImpl) DeleteExternalVolumeClass(ctx context.Context, name string, precondition store.DeletePreconditions) (*ateapi.ExternalVolumeClass, error) {
return s.store.DeleteExternalVolumeClass(ctx, name, precondition)
}
4 changes: 4 additions & 0 deletions cmd/ateapi/internal/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ func Apply(m proto.Message) {
applyAtespaceDefaults(v)
case *ateapi.EgressPolicy:
applyEgressPolicyDefaults(v)
case *ateapi.ExternalVolumeClass:
applyExternalVolumeClassDefaults(v)
case *ateapi.Tag:
applyTagDefaults(v)
case *ateapi.Worker:
Expand Down Expand Up @@ -117,6 +119,8 @@ func applyEgressRuleDefaults(rules []*ateapi.EgressRule) {
}
}

func applyExternalVolumeClassDefaults(*ateapi.ExternalVolumeClass) {}

func applyTagDefaults(*ateapi.Tag) {}

func applyWorkerDefaults(*ateapi.Worker) {}
4 changes: 4 additions & 0 deletions cmd/ateapi/internal/defaults/defaults_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ func TestApply(t *testing.T) {
{Https: &ateapi.HttpsRule{Hostnames: []string{"custom.example.com"}, Ports: &ateapi.Ports{Numbers: []int32{8443}}}},
{TlsPassthrough: &ateapi.TlsPassthroughRule{Hostnames: []string{"tcp.example.com"}}},
}}},
}, {
name: "external volume class has no defaults",
in: &ateapi.ExternalVolumeClass{Metadata: &ateapi.ResourceMetadata{Name: "csi-nfs"}, DriverName: "nfs.csi.k8s.io"},
want: &ateapi.ExternalVolumeClass{Metadata: &ateapi.ResourceMetadata{Name: "csi-nfs"}, DriverName: "nfs.csi.k8s.io"},
}, {
name: "tag has no defaults",
in: &ateapi.Tag{Scope: ateapi.TagScope_TAG_SCOPE_PUBLISHED},
Expand Down
2 changes: 1 addition & 1 deletion cmd/ateapi/internal/store/atepg/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import (
// state, so the statement lives here rather than on Persistence.
func clearAll(t *testing.T, p *Persistence) {
t.Helper()
if _, err := p.pool.Exec(context.Background(), `TRUNCATE atespaces, global_access_policy, atespace_access_policies, actors, actor_egress_policies, actor_templates, tags, workers, worker_assignments, leases, worker_outbox, worker_outbox_trim, tuple, changelog`); err != nil {
if _, err := p.pool.Exec(context.Background(), `TRUNCATE atespaces, global_access_policy, atespace_access_policies, actors, actor_egress_policies, actor_templates, tags, workers, worker_assignments, leases, worker_outbox, worker_outbox_trim, external_volume_classes, tuple, changelog`); err != nil {
t.Fatalf("truncating tables: %v", err)
}
}
Expand Down
144 changes: 144 additions & 0 deletions cmd/ateapi/internal/store/atepg/external_volume_class.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright 2026 The Agent Substrate Authors
//
// 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 atepg

import (
"context"
"errors"
"fmt"

"github.com/jackc/pgx/v5"
"go.ate.dev/substrate/api/ateapi/v1"
"go.ate.dev/substrate/cmd/ateapi/internal/store"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)

func (p *Persistence) CreateExternalVolumeClass(ctx context.Context, class *ateapi.ExternalVolumeClass) (*ateapi.ExternalVolumeClass, error) {
name := class.GetMetadata().GetName()

dbClass := proto.Clone(class).(*ateapi.ExternalVolumeClass)
setCreateMetadata(dbClass.Metadata)

protoBytes, err := proto.Marshal(dbClass)
if err != nil {
return nil, fmt.Errorf("marshaling external volume class: %w", err)
}

_, err = p.pool.Exec(ctx, `
INSERT INTO external_volume_classes (name, uid, version, proto)
VALUES ($1, $2, $3, $4)`,
name, dbClass.GetMetadata().GetUid(), dbClass.GetMetadata().GetVersion(), protoBytes)
if err != nil {
if isUniqueViolation(err) {
return nil, store.ErrAlreadyExists
}
return nil, fmt.Errorf("inserting external volume class %q: %w", name, err)
}
return dbClass, nil
}

func (p *Persistence) GetExternalVolumeClass(ctx context.Context, name string) (*ateapi.ExternalVolumeClass, error) {
var protoBytes []byte
err := p.pool.QueryRow(ctx, `SELECT proto FROM external_volume_classes WHERE name = $1`, name).Scan(&protoBytes)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, fmt.Errorf("getting external volume class %q: %w", name, err)
}
out := &ateapi.ExternalVolumeClass{}
if err := unmarshalStored(protoBytes, out); err != nil {
return nil, fmt.Errorf("unmarshaling external volume class: %w", err)
}
return out, nil
}

func (p *Persistence) ListExternalVolumeClasses(ctx context.Context, opts store.ListOptions) (store.ListResponse[*ateapi.ExternalVolumeClass], error) {
opts, err := store.NormalizeListOptions(opts)
if err != nil {
return store.ListResponse[*ateapi.ExternalVolumeClass]{}, err
}
pageSize, pageTokenStr := opts.PageSize, opts.PageToken
token, err := decodePageToken(pageTokenStr, kindExternalVolumeClass, "", 1)
if err != nil {
return store.ListResponse[*ateapi.ExternalVolumeClass]{}, err
}
var last *string
if len(token.Last) > 0 {
last = &token.Last[0]
}

rows, err := p.pool.Query(ctx, `
SELECT name, proto FROM external_volume_classes
WHERE $1::text IS NULL OR name > $1
ORDER BY name
LIMIT $2`, last, int64(pageSize)+1)
if err != nil {
return store.ListResponse[*ateapi.ExternalVolumeClass]{}, fmt.Errorf("listing external volume classes: %w", err)
}
defer rows.Close()

var names []string
var result []*ateapi.ExternalVolumeClass
for rows.Next() {
var name string
var protoBytes []byte
if err := rows.Scan(&name, &protoBytes); err != nil {
return store.ListResponse[*ateapi.ExternalVolumeClass]{}, fmt.Errorf("scanning external volume class row: %w", err)
}
c := &ateapi.ExternalVolumeClass{}
if err := unmarshalRow(protoBytes, c, "external volume class", name); err != nil {
return store.ListResponse[*ateapi.ExternalVolumeClass]{}, err
}
result = append(result, c)
names = append(names, name)
}
if err := rows.Err(); err != nil {
return store.ListResponse[*ateapi.ExternalVolumeClass]{}, fmt.Errorf("listing external volume classes: %w", err)
}

var nextToken string
if len(result) > int(pageSize) {
result = result[:pageSize]
nextToken = encodePageToken(kindExternalVolumeClass, "", []string{names[pageSize-1]})
}
return store.ListResponse[*ateapi.ExternalVolumeClass]{Items: result, NextPageToken: nextToken}, nil
}

func (p *Persistence) DeleteExternalVolumeClass(ctx context.Context, name string, precondition store.DeletePreconditions) (*ateapi.ExternalVolumeClass, error) {
var protoBytes []byte
err := p.pool.QueryRow(ctx, `
DELETE FROM external_volume_classes
WHERE name = $1
AND ($2::text = '' OR uid = $2::text)
AND ($3::bigint = 0 OR version = $3::bigint)
RETURNING proto`, name, precondition.UID, precondition.Version).Scan(&protoBytes)
if errors.Is(err, pgx.ErrNoRows) {
var uid string
var version int64
err := p.pool.QueryRow(ctx, `SELECT uid, version FROM external_volume_classes WHERE name = $1`, name).Scan(&uid, &version)
return nil, mapDeleteError(err, uid, version, precondition)
}
if err != nil {
return nil, fmt.Errorf("deleting external volume class %q: %w", name, err)
}
out := &ateapi.ExternalVolumeClass{}
if err := unmarshalStored(protoBytes, out); err != nil {
return nil, fmt.Errorf("unmarshaling deleted external volume class: %w", err)
}
out.Metadata.DeleteTime = timestamppb.Now()
return out, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- Copyright 2026 The Agent Substrate Authors
--
-- 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.

-- +goose Up

-- ExternalVolumeClasses are global-scoped, so name alone is the primary key.
CREATE TABLE external_volume_classes (
name text PRIMARY KEY,
uid text NOT NULL,
version bigint NOT NULL,
proto bytea NOT NULL
);
2 changes: 2 additions & 0 deletions cmd/ateapi/internal/store/atepg/pagetoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const (
kindTag resourceKind = "tag"
kindWorker resourceKind = "worker"
kindWorkerAssign resourceKind = "worker-assignment"

kindExternalVolumeClass resourceKind = "external-volume-class"
)

// pageToken is PostgreSQL's opaque keyset page token. It carries no database
Expand Down
16 changes: 16 additions & 0 deletions cmd/ateapi/internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,22 @@ type Interface interface {
// precondition does not describe the policy the caller observed.
DeleteAtespaceAccessPolicy(ctx context.Context, name string, precondition DeletePreconditions) (*ateapi.AccessPolicy, error)

// Stores a new ExternalVolumeClass and returns the stored resource with
// server-assigned metadata (uid, version, timestamps). The input is not
// mutated. Returns ErrAlreadyExists if the name is taken.
CreateExternalVolumeClass(ctx context.Context, class *ateapi.ExternalVolumeClass) (*ateapi.ExternalVolumeClass, error)

// Fetches an ExternalVolumeClass by name. Returns ErrNotFound if missing.
GetExternalVolumeClass(ctx context.Context, name string) (*ateapi.ExternalVolumeClass, error)

// Lists ExternalVolumeClasses.
ListExternalVolumeClasses(ctx context.Context, opts ListOptions) (ListResponse[*ateapi.ExternalVolumeClass], error)

// Removes an ExternalVolumeClass and returns the deleted resource. Returns
// ErrNotFound if missing, or ErrUIDConflict/ErrVersionConflict if
// precondition does not describe the class the caller observed.
DeleteExternalVolumeClass(ctx context.Context, name string, precondition DeletePreconditions) (*ateapi.ExternalVolumeClass, error)

// Stores a new ActorTemplate and returns the stored resource with
// server-assigned metadata (uid, version, timestamps). The input is not
// mutated. Returns ErrAlreadyExists if the (atespace, name) is taken.
Expand Down
Loading
Loading