Skip to content
Merged
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
82 changes: 67 additions & 15 deletions cmd/ateapi/internal/controlapi/actor_snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"context"
"errors"
"fmt"
"slices"

"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
"github.com/agent-substrate/substrate/internal/resources"
Expand All @@ -27,6 +28,21 @@ import (
"k8s.io/apimachinery/pkg/util/validation/field"
)

// actorSnapshotTagScopes lists the scopes a client may set on an ActorSnapshotTag.
var actorSnapshotTagScopes = []ateapipb.ActorSnapshotTagScope{
ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_ATESPACE,
ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
}

// actorSnapshotTagScopeNames names actorSnapshotTagScopes for error messages.
var actorSnapshotTagScopeNames = func() []string {
names := make([]string, len(actorSnapshotTagScopes))
for i, scope := range actorSnapshotTagScopes {
names[i] = scope.String()
}
return names
}()

func (s *Service) GetActorSnapshot(ctx context.Context, req *ateapipb.GetActorSnapshotRequest) (*ateapipb.ActorSnapshot, error) {
if err := validateActorSnapshotRef(req.GetSnapshot(), "snapshot"); err != nil {
return nil, err
Expand Down Expand Up @@ -100,31 +116,70 @@ func (s *Service) TagActorSnapshot(ctx context.Context, req *ateapipb.TagActorSn
return tag, nil
}

// actorSnapshotTagMutableFields lists the ActorSnapshotTag field paths a client
// may name in an UpdateActorSnapshotTag update_mask.
var actorSnapshotTagMutableFields = mutableFields[*ateapipb.ActorSnapshotTag]{
"scope": func(dst, src *ateapipb.ActorSnapshotTag) { dst.Scope = src.GetScope() },
}

func (s *Service) UpdateActorSnapshotTag(ctx context.Context, req *ateapipb.UpdateActorSnapshotTagRequest) (*ateapipb.ActorSnapshotTag, error) {
if errs := resources.ValidateObjectRef(req.GetTag(), field.NewPath("tag")); len(errs) > 0 {
return nil, status.Error(codes.InvalidArgument, errs.ToAggregate().Error())
}
if err := validateActorSnapshotTagScope(req.GetScope()); err != nil {
return nil, err
if errs := validateUpdateActorSnapshotTagRequest(req); len(errs) > 0 {
return nil, toGRPCStatusError(errs)
}
_, _, current, err := s.persistence.GetActorSnapshotByTag(ctx, req.GetTag().GetAtespace(), req.GetTag().GetName())
in := req.GetTag()
atespace, name := in.GetMetadata().GetAtespace(), in.GetMetadata().GetName()
_, _, current, err := s.persistence.GetActorSnapshotByTag(ctx, atespace, name)
if errors.Is(err, store.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "ActorSnapshot tag %s/%s not found", req.GetTag().GetAtespace(), req.GetTag().GetName())
return nil, status.Errorf(codes.NotFound, "ActorSnapshot tag %s/%s not found", atespace, name)
}
if err != nil {
return nil, fmt.Errorf("while getting actor snapshot tag: %w", err)
}
tag, err := s.persistence.UpdateActorSnapshotTag(ctx, req.GetTag().GetAtespace(), req.GetTag().GetName(), req.GetScope(), current.GetMetadata().GetVersion())

// UID and version preconditions.
if uid := in.GetMetadata().GetUid(); uid != "" && uid != current.GetMetadata().GetUid() {
return nil, status.Errorf(codes.Aborted, "ActorSnapshot tag %s/%s has uid %s, not %s", atespace, name, current.GetMetadata().GetUid(), uid)
}

expectedVersion := current.GetMetadata().GetVersion()
if version := in.GetMetadata().GetVersion(); version != 0 {
expectedVersion = version
}

applyUpdateMask(current, in, req.GetUpdateMask(), actorSnapshotTagMutableFields)

updatedTag, err := s.persistence.UpdateActorSnapshotTag(ctx, atespace, name, current.GetScope(), expectedVersion)
if errors.Is(err, store.ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "ActorSnapshot tag %s/%s not found", req.GetTag().GetAtespace(), req.GetTag().GetName())
return nil, status.Errorf(codes.NotFound, "ActorSnapshot tag %s/%s not found", atespace, name)
}
if errors.Is(err, store.ErrVersionConflict) {
return nil, status.Error(codes.Aborted, "concurrent update conflict, please retry")
}
if err != nil {
return nil, fmt.Errorf("while updating actor snapshot tag: %w", err)
}
return tag, nil
return updatedTag, nil
}

func validateUpdateActorSnapshotTagRequest(req *ateapipb.UpdateActorSnapshotTagRequest) field.ErrorList {
var fldPath *field.Path
var errs field.ErrorList

tag := req.GetTag()
tagPath := fldPath.Child("tag")
if tag == nil {
return field.ErrorList{field.Required(tagPath, "")}
}

errs = append(errs, resources.ValidateResourceMetadataRef(tag.GetMetadata(), tagPath.Child("metadata"))...)

errs = append(errs, validateUpdateMask(req.GetUpdateMask(), actorSnapshotTagMutableFields)...)

if scope, p := tag.GetScope(), tagPath.Child("scope"); validateActorSnapshotTagScope(scope) != nil {
errs = append(errs, field.NotSupported(p, scope.String(), actorSnapshotTagScopeNames))
}

return errs
}

func (s *Service) DeleteActorSnapshotTag(ctx context.Context, req *ateapipb.DeleteActorSnapshotTagRequest) (*ateapipb.ActorSnapshotTag, error) {
Expand Down Expand Up @@ -231,11 +286,8 @@ func validateActorSnapshotTag(tag *ateapipb.ActorSnapshotTag, name string) error
}

func validateActorSnapshotTagScope(scope ateapipb.ActorSnapshotTagScope) error {
switch scope {
case ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_ATESPACE,
ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED:
if slices.Contains(actorSnapshotTagScopes, scope) {
return nil
default:
return status.Error(codes.InvalidArgument, "invalid ActorSnapshot tag scope")
}
return status.Error(codes.InvalidArgument, "invalid ActorSnapshot tag scope")
}
226 changes: 226 additions & 0 deletions cmd/ateapi/internal/controlapi/actor_snapshot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
// 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"

"google.golang.org/protobuf/types/known/fieldmaskpb"
"k8s.io/apimachinery/pkg/util/validation/field"

"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
)

func TestValidateUpdateActorSnapshotTagRequest(t *testing.T) {
mutableFields := []string{"scope"}
scopes := []string{
ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_ATESPACE.String(),
ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED.String(),
}

tests := []struct {
name string
req *ateapipb.UpdateActorSnapshotTagRequest
wantError field.ErrorList
}{
{
name: "valid",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1"},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"scope"}},
},
wantError: nil,
},
{
name: "missing tag",
req: &ateapipb.UpdateActorSnapshotTagRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"scope"}}},
wantError: field.ErrorList{field.Required(field.NewPath("tag"), "")},
},
{
name: "missing tag.metadata.atespace",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Name: "tag1"},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"scope"}},
},
wantError: field.ErrorList{field.Required(field.NewPath("tag", "metadata", "atespace"), "")},
},
{
name: "invalid tag.metadata.atespace",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "NS1", Name: "tag1"},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"scope"}},
},
wantError: field.ErrorList{field.Invalid(field.NewPath("tag", "metadata", "atespace"), "NS1", "")},
},
{
name: "missing tag.metadata.name",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1"},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"scope"}},
},
wantError: field.ErrorList{field.Required(field.NewPath("tag", "metadata", "name"), "")},
},
{
name: "invalid tag.metadata.name",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "TAG1"},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"scope"}},
},
wantError: field.ErrorList{field.Invalid(field.NewPath("tag", "metadata", "name"), "TAG1", "")},
},
{
name: "valid tag.metadata.uid precondition",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{
Atespace: "ns1", Name: "tag1", Uid: "2a5f8c1e-9b3d-4f7a-8e6c-1d0b4a7f2e93",
},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"scope"}},
},
wantError: nil,
},
{
name: "invalid tag.metadata.uid precondition",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Uid: "not-a-uuid"},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"scope"}},
},
wantError: field.ErrorList{field.Invalid(field.NewPath("tag", "metadata", "uid"), "not-a-uuid", "")},
},
{
name: "valid tag.metadata.version precondition",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Version: 7},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"scope"}},
},
wantError: nil,
},
{
name: "negative tag.metadata.version precondition",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1", Version: -1},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"scope"}},
},
wantError: field.ErrorList{field.Invalid(field.NewPath("tag", "metadata", "version"), int64(-1), "")},
},
{
name: "missing update_mask",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1"},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
},
wantError: field.ErrorList{field.Required(field.NewPath("update_mask"), "")},
},
{
name: "empty update_mask",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1"},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{},
},
wantError: field.ErrorList{field.Required(field.NewPath("update_mask"), "")},
},
{
name: "wildcard update_mask",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1"},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"*"}},
},
wantError: field.ErrorList{field.NotSupported(field.NewPath("update_mask"), "*", mutableFields)},
},
{
name: "output-only field in update_mask",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1"},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"metadata.version"}},
},
wantError: field.ErrorList{field.NotSupported(field.NewPath("update_mask"), "metadata.version", mutableFields)},
},
{
name: "immutable field in update_mask",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1"},
Scope: ateapipb.ActorSnapshotTagScope_ACTOR_SNAPSHOT_TAG_SCOPE_PUBLISHED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"snapshot"}},
},
wantError: field.ErrorList{field.NotSupported(field.NewPath("update_mask"), "snapshot", mutableFields)},
},
{
// The zero value is ATESPACE, so leaving scope unset unpublishes the tag.
name: "unset tag.scope",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1"},
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"scope"}},
},
wantError: nil,
},
{
name: "tag.scope outside the enum",
req: &ateapipb.UpdateActorSnapshotTagRequest{
Tag: &ateapipb.ActorSnapshotTag{
Metadata: &ateapipb.ResourceMetadata{Atespace: "ns1", Name: "tag1"},
Scope: ateapipb.ActorSnapshotTagScope(7),
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"scope"}},
},
wantError: field.ErrorList{field.NotSupported(field.NewPath("tag", "scope"), "7", scopes)},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assertValidateErr(t, validateUpdateActorSnapshotTagRequest(tt.req), tt.wantError)
})
}
}
Loading
Loading