Files
substrate/cmd/ateapi/internal/controlapi/functionaltest/actor_test.go
T
shrutiyam-glitch 704eeb4376 validation: resolve validation todo in ateapi.proto (#1600)
Fixes a few todos in the `ateapi.proto`

* Adds custom validation for `create_time` and `update_time` fields.
* Adds better validation method for the `Container.image` field.

- [ ] Tests pass
- [ ] Appropriate changes to documentation are included in the PR
2026-09-14 11:34:48 -04:00

3505 lines
139 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 functionaltest
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
"github.com/agent-substrate/substrate/internal/ateattr"
"github.com/agent-substrate/substrate/internal/proto/ateletpb"
"github.com/agent-substrate/substrate/internal/resources"
"github.com/agent-substrate/substrate/internal/volume"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protowire"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/testing/protocmp"
"k8s.io/apimachinery/pkg/util/wait"
)
// TestCreateActor_Success tests the happy path for creating an actor.
// Workflow:
// 1. Creates a mock ActorTemplate in the test atespace.
// 2. Calls CreateActor RPC.
// 3. Verifies that the actor is successfully created and returned in the response with a generated ID.
func TestCreateActor_Success(t *testing.T) {
ns := namespaceForTest("ns-create-success")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
createResp, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{
Atespace: testAtespace,
Name: "id1",
},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "free"}},
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_RUNNING},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
want := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Name: "id1", Atespace: testAtespace, Version: 1},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED},
WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "free"}},
}
// The diff below ignores the server-assigned uid/timestamps (non-deterministic),
// so assert they are populated separately — and that uid is server-generated,
// not the caller-supplied value.
md := createResp.GetMetadata()
if md.GetUid() == "" {
t.Errorf("CreateActor response missing server-assigned uid")
}
if md.GetUid() == "caller-supplied-uid" {
t.Errorf("CreateActor echoed caller-supplied uid instead of generating one")
}
if md.GetCreateTime() == nil {
t.Errorf("CreateActor response missing create_time")
}
if md.GetUpdateTime() == nil {
t.Errorf("CreateActor response missing update_time")
}
if diff := cmp.Diff(want, createResp, protocmp.Transform(), ignoreUID, ignoreTimestamps); diff != "" {
t.Errorf("CreateActor response mismatch (-want +got):\n%s", diff)
}
}
func TestCreateActor_WithExternalVolumes(t *testing.T) {
ns := namespaceForTest("ns-create-ext-vols")
tc := setupTest(t, ns)
defer tc.cleanup()
volumes := []*ateapipb.Volume{
{
Name: "ext-vol-1",
ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{
StorageClassName: "standard",
Capacity: "10Gi",
},
},
}
mounts := []*ateapipb.VolumeMount{
{
Name: "ext-vol-1",
MountPath: "/data",
},
}
createTemplateWithVolumes(t, tc, ns, volumes, mounts)
createResp, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "vol-actor-1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
},
})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
if len(createResp.GetStatus().GetActorVolumes()) != 1 {
t.Fatalf("expected 1 volume in CreateActor response, got %d", len(createResp.GetStatus().GetActorVolumes()))
}
vol := createResp.GetStatus().GetActorVolumes()[0]
if vol.GetVolumeName() != "ext-vol-1" {
t.Errorf("volume name = %q, want %q", vol.GetVolumeName(), "ext-vol-1")
}
if vol.GetStatus() != ateapipb.ExternalVolume_STATUS_PENDING {
t.Errorf("volume status = %v, want %v", vol.GetStatus(), ateapipb.ExternalVolume_STATUS_PENDING)
}
if vol.GetStorageVolumeId() != "" {
t.Errorf("expected empty storageVolumeId before resume, got %q", vol.GetStorageVolumeId())
}
// Verify GetActor returns the same external volume state
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "vol-actor-1"},
})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
if len(getResp.GetStatus().GetActorVolumes()) != 1 {
t.Fatalf("expected 1 volume in GetActor response, got %d", len(getResp.GetStatus().GetActorVolumes()))
}
if getResp.GetStatus().GetActorVolumes()[0].GetStatus() != ateapipb.ExternalVolume_STATUS_PENDING {
t.Errorf("GetActor status = %v, want %v", getResp.GetStatus().GetActorVolumes()[0].GetStatus(), ateapipb.ExternalVolume_STATUS_PENDING)
}
}
// TestCreateActor_TemplateNotFound tests that creating an actor with a non-existent template fails with FailedPrecondition.
func TestCreateActor_TemplateNotFound(t *testing.T) {
ns := namespaceForTest("ns-create-notfound")
tc := setupTest(t, ns)
defer tc.cleanup()
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "non-existent"},
}})
if got := status.Code(err); got != codes.FailedPrecondition {
t.Fatalf("CreateActor with a missing template = %v, want FailedPrecondition (err: %v)", got, err)
}
}
// TestCreateActor_SubstrateTemplateRef covers creation against a template
// with no golden snapshot yet: the actor names its template with an
// actor_template ObjectRef resolved from the store at create time.
func TestCreateActor_SubstrateTemplateRef(t *testing.T) {
ns := namespaceForTest("ns-create-sub-ref")
tc := setupTest(t, ns)
defer tc.cleanup()
ctx := context.Background()
ensureDefaultGvisorSandboxConfig(t, tc)
if _, err := tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{
ActorTemplate: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "sub-tmpl"},
Containers: []*ateapipb.Container{{Name: "main", Image: "example.com/app:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}},
SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"},
SandboxConfig: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: "gvisor-default"},
},
}); err != nil {
t.Fatalf("CreateActorTemplate failed: %v", err)
}
created, err := tc.client.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "ref-actor"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "sub-tmpl"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
want := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "ref-actor", Version: 1},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "sub-tmpl"},
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED},
}
if diff := cmp.Diff(want, created, protocmp.Transform(), ignoreUID, ignoreTimestamps); diff != "" {
t.Errorf("CreateActor response mismatch (-want +got):\n%s", diff)
}
// A reference to a template that does not exist fails.
_, err = tc.client.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "ref-actor-2"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "absent"},
}})
if got := status.Code(err); got != codes.FailedPrecondition {
t.Fatalf("CreateActor with an absent template ref = %v, want FailedPrecondition (err: %v)", got, err)
}
}
// TestCreateActor_Duplicate tests that creating an actor with an existing ID fails.
func TestCreateActor_Duplicate(t *testing.T) {
ns := namespaceForTest("ns-create-dup")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("first CreateActor failed: %v", err)
}
_, err = tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
assertGrpcError(t, err, codes.AlreadyExists, "Actor id1 already exists")
}
// CreateActor is the only lifecycle op with the full identity (incl. version)
// available in the request, so the whole ate.* set should land on its span.
func TestCreateActor_StampsFullSpanIdentity(t *testing.T) {
ns := namespaceForTest("ns-span-create")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
attrs := recordRootSpanAttrs(t, func(ctx context.Context) {
if _, err := tc.service.CreateActor(ctx, &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
},
}); err != nil {
t.Fatalf("CreateActor: %v", err)
}
})
assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace)
assertSpanStr(t, attrs, ateattr.ActorNameKey, testActorID)
assertSpanStr(t, attrs, ateattr.TemplateNameKey, "tmpl1")
assertSpanStr(t, attrs, ateattr.TemplateAtespaceKey, testAtespace)
// uid is server-assigned on create, so assert it is present and non-empty
// rather than a fixed value.
if v, ok := attrs[ateattr.ActorUIDKey]; !ok || v.Type() != attribute.STRING || v.AsString() == "" {
t.Errorf("%s = %v, want non-empty server-assigned uid", ateattr.ActorUIDKey, v.String())
}
if v, ok := attrs[ateattr.ActorVersionKey]; !ok || v.Type() != attribute.INT64 || v.AsInt64() != 1 {
t.Errorf("%s = %v, want int64 1", ateattr.ActorVersionKey, v.String())
}
}
func TestCreateActor_RejectsDifferentTemplateForDataSnapshot(t *testing.T) {
ns := namespaceForTest("ns-data-snapshot-template")
tc := setupTest(t, ns)
defer tc.cleanup()
tmpl := createTemplate(t, tc, ns)
createTemplateWithSelector(t, tc, "tmpl2", nil)
seedTag(t, tc, "data-source", "data-snapshot", func(tag *ateapipb.Tag) {
tag.Status.Snapshot.ContentScope = ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA
tag.Status.ActorTemplateUid = tmpl.GetMetadata().GetUid()
})
_, err := tc.service.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "clone"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl2"},
SourceTag: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "data-snapshot"},
},
})
if status.Code(err) != codes.FailedPrecondition {
t.Fatalf("CreateActor status = %v, want FailedPrecondition", status.Code(err))
}
}
func TestCreateActor_RejectsSnapshotWithExternalVolumes(t *testing.T) {
ns := namespaceForTest("ns-snapshot-external-volume")
tc := setupTest(t, ns)
defer tc.cleanup()
ensureDefaultGvisorSandboxConfig(t, tc)
template, err := tc.client.CreateActorTemplate(context.Background(), &ateapipb.CreateActorTemplateRequest{
ActorTemplate: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "tmpl1"},
SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://snapshots"},
SandboxConfig: &ateapipb.SandboxConfig{
SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR,
ConfigName: "gvisor-default",
},
Containers: []*ateapipb.Container{{
Name: "main", Image: "main@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", VolumeMounts: []*ateapipb.VolumeMount{{Name: "data", MountPath: "/data"}},
}},
Volumes: []*ateapipb.Volume{{
Name: "data",
ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{
Capacity: "1Gi", StorageClassName: "standard",
},
}},
},
})
if err != nil {
t.Fatalf("Create ActorTemplate: %v", err)
}
seedTag(t, tc, "external-volume-source", "external-volume-snapshot", func(tag *ateapipb.Tag) {
tag.Status.ActorTemplateUid = template.GetMetadata().GetUid()
})
tagRef := &ateapipb.ObjectRef{Atespace: testAtespace, Name: "external-volume-snapshot"}
_, err = tc.service.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "clone"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
SourceTag: tagRef,
},
})
if status.Code(err) != codes.FailedPrecondition {
t.Fatalf("CreateActor status = %v, want FailedPrecondition", status.Code(err))
}
}
// TestCreateActor_PendingTag verifies an Actor cannot be created from a
// tag whose create never finished: the tag names a copy that may be partial, so
// it only becomes a source once the create completes.
func TestCreateActor_PendingTag(t *testing.T) {
ns := namespaceForTest("ns-pending-tag")
tc := setupTest(t, ns)
defer tc.cleanup()
ctx := context.Background()
tmpl := createTemplate(t, tc, ns)
// Simulates a tag creation that failed in while writing the snapshot to
// external storage.
pending := seedTag(t, tc, "pending-source", "pending", func(tag *ateapipb.Tag) {
tag.Status.StorageLocation = testStorageLocation
tag.Status.Snapshot = nil
tag.Status.ActorTemplateUid = tmpl.GetMetadata().GetUid()
})
tagRef := &ateapipb.ObjectRef{Atespace: testAtespace, Name: "pending"}
// seeding an actor from the pending tag must fail.
_, err := tc.client.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "clone"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
SourceTag: tagRef,
}})
assertGrpcError(t, err, codes.FailedPrecondition, "source Tag is still being created or failed creation")
// Finishing the tag creation, so now the tag is qualified to be a tag source.
snapshotURI, err := resources.NewTagSnapshotURI(pending.GetStatus().GetStorageLocation(), pending.GetMetadata().GetAtespace(), pending.GetMetadata().GetUid())
if err != nil {
t.Fatalf("NewTagSnapshotURI: %v", err)
}
if _, err := tc.persistence.UpdateTag(ctx,
resources.TagRefFromTag(pending), store.PreconditionFrom(pending),
func(toUpdate *ateapipb.Tag) error {
toUpdate.Status.Snapshot = &ateapipb.ExternalSnapshot{
SnapshotUri: snapshotURI.String(),
ContentScope: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_FULL,
}
return nil
}); err != nil {
t.Fatalf("finalizing the tag: %v", err)
}
// The tag was finalized. Now, actor creation should succeed.
clone, err := tc.client.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "clone"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
SourceTag: tagRef,
}})
if err != nil {
t.Fatalf("CreateActor from the finished tag failed: %v", err)
}
// The clone points at the tag's snapshot, under the tag's own prefix: the
// tag still owns those objects.
if got := clone.GetStatus().GetExternalSnapshot().GetSnapshotUri(); got != snapshotURI.String() {
t.Errorf("clone external snapshot = %q, want the tag's %q", got, snapshotURI)
}
}
// TestGetActor_Found tests that an existing actor can be retrieved.
func TestGetActor_Found(t *testing.T) {
ns := namespaceForTest("ns-get-found")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
name := "id1"
createResp, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
want := createResp
if diff := cmp.Diff(want, getResp, protocmp.Transform()); diff != "" {
t.Errorf("GetActor response mismatch (-want +got):\n%s", diff)
}
}
// TestGetActor_NotFound tests that retrieving a non-existent actor fails.
// Workflow:
// 1. Calls GetActor RPC with a non-existent ID.
// 2. Verifies that it returns an error (NotFound).
func TestGetActor_NotFound(t *testing.T) {
ns := namespaceForTest("ns-get-notfound")
tc := setupTest(t, ns)
defer tc.cleanup()
_, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "non-existent"},
})
assertGrpcError(t, err, codes.NotFound, "Actor test-atespace/non-existent not found")
}
// TestListActors tests that all created actors can be listed.
// Workflow:
// 1. Creates a mock ActorTemplate.
// 2. Calls CreateActor twice to create two actors.
// 3. Calls ListActors RPC.
// 4. Verifies that both actors are returned in the list.
func TestListActors(t *testing.T) {
ns := namespaceForTest("ns-list-actors")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
resp1, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor 1 failed: %v", err)
}
resp2, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id2"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor 2 failed: %v", err)
}
listResp, err := tc.client.ListActors(context.Background(), &ateapipb.ListActorsRequest{Atespace: testAtespace})
if err != nil {
t.Fatalf("ListActors failed: %v", err)
}
if len(listResp.Actors) != 2 {
t.Fatalf("expected 2 actors, got %d", len(listResp.Actors))
}
want := []*ateapipb.Actor{
resp1,
resp2,
}
opts := []cmp.Option{
protocmp.Transform(),
cmpopts.SortSlices(func(a, b *ateapipb.Actor) bool {
return a.GetMetadata().GetName() < b.GetMetadata().GetName()
}),
}
if diff := cmp.Diff(want, listResp.Actors, opts...); diff != "" {
t.Errorf("ListActors response mismatch (-want +got):\n%s", diff)
}
}
// TestListActors_ByAtespace verifies create + list are scoped by atespace end to
// end through the RPC surface: an actor created with a given atespace is only
// returned by ListActors(atespace=X) and only fetched by GetActor(atespace=X).
func TestListActors_ByAtespace(t *testing.T) {
ns := namespaceForTest("ns-list-by-atespace")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
createAtespace(t, tc, "team-a")
createAtespace(t, tc, "team-b")
create := func(atespace, name string) *ateapipb.Actor {
resp, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: atespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor(%s, atespace=%q) failed: %v", name, atespace, err)
}
return resp
}
a1 := create("team-a", "id1")
a2 := create("team-a", "id2")
b1 := create("team-b", "id3")
sortByID := []cmp.Option{
protocmp.Transform(),
cmpopts.SortSlices(func(a, b *ateapipb.Actor) bool { return a.GetMetadata().GetName() < b.GetMetadata().GetName() }),
}
// List scoped to team-a returns only its actors.
listA, err := tc.client.ListActors(context.Background(), &ateapipb.ListActorsRequest{Atespace: "team-a"})
if err != nil {
t.Fatalf("ListActors(team-a) failed: %v", err)
}
if diff := cmp.Diff([]*ateapipb.Actor{a1, a2}, listA.GetActors(), sortByID...); diff != "" {
t.Errorf("ListActors(team-a) mismatch (-want +got):\n%s", diff)
}
// List scoped to team-b returns only its actor.
listB, err := tc.client.ListActors(context.Background(), &ateapipb.ListActorsRequest{Atespace: "team-b"})
if err != nil {
t.Fatalf("ListActors(team-b) failed: %v", err)
}
if diff := cmp.Diff([]*ateapipb.Actor{b1}, listB.GetActors(), sortByID...); diff != "" {
t.Errorf("ListActors(team-b) mismatch (-want +got):\n%s", diff)
}
// Get is scoped: the right atespace hits, the empty atespace misses (deny-across by key).
if _, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: "team-a", Name: "id1"}}); err != nil {
t.Errorf("GetActor(id1, team-a) failed: %v", err)
}
_, err = tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"}})
assertGrpcError(t, err, codes.NotFound, "Actor test-atespace/id1 not found")
}
// TestListActors_AllAtespaces verifies that an empty atespace lists actors across
// all atespaces (the `-A` / admin view), unlike the scoped single-atespace listing.
func TestListActors_AllAtespaces(t *testing.T) {
ns := namespaceForTest("ns-list-all-atespaces")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
createAtespace(t, tc, "team-a")
createAtespace(t, tc, "team-b")
create := func(atespace, name string) {
if _, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: atespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}}); err != nil {
t.Fatalf("CreateActor(%s, atespace=%q) failed: %v", name, atespace, err)
}
}
create("team-a", "id1")
create("team-b", "id2")
// Empty atespace lists across all atespaces; returned actors carry their atespace.
resp, err := tc.client.ListActors(context.Background(), &ateapipb.ListActorsRequest{})
if err != nil {
t.Fatalf("ListActors(all) failed: %v", err)
}
got := map[string]string{}
for _, a := range resp.GetActors() {
got[a.GetMetadata().GetName()] = a.GetMetadata().GetAtespace()
}
if got["id1"] != "team-a" {
t.Errorf("ListActors(all): got[id1]=%q, want team-a", got["id1"])
}
if got["id2"] != "team-b" {
t.Errorf("ListActors(all): got[id2]=%q, want team-b", got["id2"])
}
}
// TestListActors_Pagination tests that ListActors correctly paginates results.
func TestListActors_Pagination(t *testing.T) {
ns := namespaceForTest("ns-list-actors-pagination")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
var want []*ateapipb.Actor
for i := 0; i < 5; i++ {
resp, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: fmt.Sprintf("name%d", i)},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor %d failed: %v", i, err)
}
want = append(want, resp)
}
var allActors []*ateapipb.Actor
pageToken := ""
for {
listResp, err := tc.client.ListActors(context.Background(), &ateapipb.ListActorsRequest{
Atespace: testAtespace,
PageSize: 2,
PageToken: pageToken,
})
if err != nil {
t.Fatalf("ListActors failed: %v", err)
}
allActors = append(allActors, listResp.Actors...)
pageToken = listResp.GetNextPageToken()
if pageToken == "" {
break
}
}
if len(allActors) != 5 {
t.Fatalf("expected 5 actors total, got %d", len(allActors))
}
opts := []cmp.Option{
protocmp.Transform(),
cmpopts.SortSlices(func(a, b *ateapipb.Actor) bool {
return a.GetMetadata().GetName() < b.GetMetadata().GetName()
}),
}
if diff := cmp.Diff(want, allActors, opts...); diff != "" {
t.Errorf("ListActors pagination response mismatch (-want +got):\n%s", diff)
}
}
// TestUpdateActor_Success verifies UpdateActor replaces the actor's
// worker_selector and that the change is durably persisted.
func TestUpdateActor_Success(t *testing.T) {
ns := namespaceForTest("ns-update-actor")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
toUpdate, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
WorkerSelector: &ateapipb.Selector{
MatchLabels: map[string]string{"tier": "free"},
},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
toUpdate.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}}
updateResp, err := tc.client.UpdateActor(context.Background(), &ateapipb.UpdateActorRequest{
Actor: toUpdate,
})
if err != nil {
t.Fatalf("UpdateActor failed: %v", err)
}
wantActor := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Name: "id1", Atespace: testAtespace, Version: 2},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED},
WorkerSelector: &ateapipb.Selector{
MatchLabels: map[string]string{"tier": "paid"},
},
}
if diff := cmp.Diff(wantActor, updateResp, protocmp.Transform(), ignoreUID, ignoreTimestamps); diff != "" {
t.Errorf("UpdateActor response mismatch (-want +got):\n%s", diff)
}
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"}})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
wantGetResp := wantActor
if diff := cmp.Diff(wantGetResp, getResp, protocmp.Transform(), ignoreUID, ignoreTimestamps); diff != "" {
t.Errorf("GetActor response mismatch after UpdateActor (-want +got):\n%s", diff)
}
}
// TestUpdateActor_RepointTemplate verifies UpdateActor can point an actor at
// a different substrate ActorTemplate (effective on the next ResumeActor),
// and that a ref to an absent template, or to one with a different sandbox
// config, volumes, or volume mounts, is rejected.
func TestUpdateActor_RepointTemplate(t *testing.T) {
tests := []struct {
name string
template string
wantCode codes.Code
}{
{name: "absent-template", template: "absent", wantCode: codes.FailedPrecondition},
{name: "different-mounts", template: "tmpl-c", wantCode: codes.FailedPrecondition},
{name: "different-volumes", template: "tmpl-d", wantCode: codes.FailedPrecondition},
{name: "different-sandbox-config", template: "tmpl-e", wantCode: codes.FailedPrecondition},
{name: "same-volumes", template: "tmpl-b", wantCode: codes.OK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ns := namespaceForTest("ns-update-repoint-" + tt.name)
tc := setupTest(t, ns)
defer tc.cleanup()
ctx := context.Background()
ensureDefaultGvisorSandboxConfig(t, tc)
ensureGvisorSandboxConfig(t, tc, "gvisor-nightly")
// tmpl-a and tmpl-b are volume-compatible; tmpl-c mounts the data
// volume elsewhere, tmpl-d declares an extra volume, and tmpl-e
// names a different SandboxConfig.
dataVolume := &ateapipb.Volume{Name: "data", DurableDir: &ateapipb.DurableDirVolumeSource{}}
scratchVolume := &ateapipb.Volume{Name: "scratch", DurableDir: &ateapipb.DurableDirVolumeSource{}}
templates := map[string]struct {
mountPath string
volumes []*ateapipb.Volume
configName string
}{
"tmpl-a": {"/data", []*ateapipb.Volume{dataVolume}, "gvisor-default"},
"tmpl-b": {"/data", []*ateapipb.Volume{dataVolume}, "gvisor-default"},
"tmpl-c": {"/mnt/data", []*ateapipb.Volume{dataVolume}, "gvisor-default"},
"tmpl-d": {"/data", []*ateapipb.Volume{dataVolume, scratchVolume}, "gvisor-default"},
"tmpl-e": {"/data", []*ateapipb.Volume{dataVolume}, "gvisor-nightly"},
}
for name, tmpl := range templates {
if _, err := tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{
ActorTemplate: &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
Containers: []*ateapipb.Container{{
Name: "main",
Image: "example.com/app:v1@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
VolumeMounts: []*ateapipb.VolumeMount{{Name: "data", MountPath: tmpl.mountPath}},
}},
Volumes: tmpl.volumes,
SnapshotsConfig: &ateapipb.SnapshotsConfig{StorageLocation: "gs://my-bucket/snapshots"},
SandboxConfig: &ateapipb.SandboxConfig{SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR, ConfigName: tmpl.configName},
},
}); err != nil {
t.Fatalf("CreateActorTemplate %s failed: %v", name, err)
}
}
created, err := tc.client.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "repoint-actor"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl-a"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
updated, err := tc.client.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: &ateapipb.Actor{
Metadata: created.GetMetadata(),
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: tt.template},
}})
if got := status.Code(err); got != tt.wantCode {
t.Fatalf("UpdateActor repointing at %s = %v, want %v (err: %v)", tt.template, got, tt.wantCode, err)
}
if tt.wantCode != codes.OK {
return
}
want := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "repoint-actor", Version: 2},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: tt.template},
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED},
}
if diff := cmp.Diff(want, updated, protocmp.Transform(), ignoreUID, ignoreTimestamps); diff != "" {
t.Errorf("UpdateActor response mismatch (-want +got):\n%s", diff)
}
})
}
}
// TestUpdateActor verifies a typical RMW UpdateActor flow: a
// client reads an actor, modifies it and send an UpdateActor request.
// Output-only fields it sets are ignored, and the mutable actor_template ref
// can repoint the actor at another template.
func TestUpdateActor(t *testing.T) {
ns := namespaceForTest("ns-update-replace")
tc := setupTest(t, ns)
defer tc.cleanup()
tmpl := createTemplate(t, tc, ns)
created, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
WorkerSelector: &ateapipb.Selector{
MatchLabels: map[string]string{"tier": "free"},
},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
// Mutable field
created.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}}
// Output-only: server-owned, so this is ignored rather than applied.
created.Status = &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_RUNNING}
updatedActor, err := tc.client.UpdateActor(context.Background(), &ateapipb.UpdateActorRequest{Actor: created})
if err != nil {
t.Fatalf("UpdateActor failed: %v", err)
}
wantActor := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Name: "id1", Atespace: testAtespace, Version: 2},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED},
WorkerSelector: &ateapipb.Selector{
MatchLabels: map[string]string{"tier": "paid"},
},
}
if diff := cmp.Diff(wantActor, updatedActor, protocmp.Transform(), ignoreUID, ignoreTimestamps); diff != "" {
t.Errorf("UpdateActor response mismatch (-want +got):\n%s", diff)
}
// Mutable template ref: repoint the actor at a second template.
tmpl2 := proto.Clone(tmpl).(*ateapipb.ActorTemplate)
tmpl2.Metadata = &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "tmpl2"}
tmpl2.Status = nil
if _, err := tc.client.CreateActorTemplate(context.Background(), &ateapipb.CreateActorTemplateRequest{ActorTemplate: tmpl2}); err != nil {
t.Fatalf("CreateActorTemplate failed: %v", err)
}
updatedActor.ActorTemplate = &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl2"}
repointed, err := tc.client.UpdateActor(context.Background(), &ateapipb.UpdateActorRequest{Actor: updatedActor})
if err != nil {
t.Fatalf("UpdateActor failed: %v", err)
}
wantActor.Metadata.Version = 3
wantActor.ActorTemplate = &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl2"}
if diff := cmp.Diff(wantActor, repointed, protocmp.Transform(), ignoreUID, ignoreTimestamps); diff != "" {
t.Errorf("UpdateActor response mismatch after template repoint (-want +got):\n%s", diff)
}
}
// TestUpdateActor_Preconditions verifies the required version and uid guards
// carried in the embedded resource's metadata.
func TestUpdateActor_Preconditions(t *testing.T) {
ns := namespaceForTest("ns-update-preconditions")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
ctx := context.Background()
createActor := func() *ateapipb.Actor {
t.Helper()
actor, err := tc.client.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
return actor
}
update := func(observed *ateapipb.Actor, tier string) (*ateapipb.Actor, error) {
actor := proto.Clone(observed).(*ateapipb.Actor)
actor.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"tier": tier}}
return tc.client.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: actor})
}
// Delete and recreate the same atespace/name actor, so the first lifecycle's uid
// becomes stale.
staleUID := createActor().GetMetadata().GetUid()
if _, err := tc.client.DeleteActor(ctx, &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: testActorID},
}); err != nil {
t.Fatalf("DeleteActor failed: %v", err)
}
created := createActor()
staleVersion := created.GetMetadata().GetVersion()
uid := created.GetMetadata().GetUid()
if uid == staleUID {
t.Fatalf("recreated actor reused uid %s, want a fresh one", uid)
}
// No preconditions
unguarded := proto.Clone(created).(*ateapipb.Actor)
unguarded.Metadata.Uid, unguarded.Metadata.Version = "", 0
_, err := update(unguarded, "blind")
assertGrpcError(t, err, codes.InvalidArgument, "while updating actor test-atespace/id1: persistence: precondition required: uid")
// The uid from the deleted lifecycle must be rejected, even though the
// atespace/name it was observed under still resolves and the version it
// guards on matches the recreated actor's.
otherLifecycle := proto.Clone(created).(*ateapipb.Actor)
otherLifecycle.Metadata.Uid = staleUID
_, err = update(otherLifecycle, "other-lifecycle")
assertGrpcError(t, err, codes.Aborted, "concurrent update conflict, please retry")
// Both guards matching the observed state: the update goes through, and
// moves the resource past the version observed above.
first, err := update(created, "free")
if err != nil {
t.Fatalf("UpdateActor(matching guards) failed: %v", err)
}
currentVersion := first.GetMetadata().GetVersion()
if currentVersion <= staleVersion {
t.Fatalf("version = %d, want greater than %d after an update", currentVersion, staleVersion)
}
if got := first.GetWorkerSelector().GetMatchLabels()["tier"]; got != "free" {
t.Errorf("worker_selector[tier] = %q, want free", got)
}
// The version observed before that write is now stale: rejected rather than
// silently overwriting the concurrent change.
_, err = update(created, "stale")
assertGrpcError(t, err, codes.Aborted, "concurrent update conflict, please retry")
// Guarding on the version the last write produced succeeds again.
updated, err := update(first, "paid")
if err != nil {
t.Fatalf("UpdateActor(matching guards) failed: %v", err)
}
if got := updated.GetWorkerSelector().GetMatchLabels()["tier"]; got != "paid" {
t.Errorf("worker_selector[tier] = %q, want paid", got)
}
if updated.GetMetadata().GetVersion() <= currentVersion {
t.Errorf("version = %d, want greater than %d", updated.GetMetadata().GetVersion(), currentVersion)
}
// The guard the client just satisfied is now stale in turn.
_, err = update(first, "free")
assertGrpcError(t, err, codes.Aborted, "concurrent update conflict, please retry")
}
func TestUpdateActor_NotFound(t *testing.T) {
ns := namespaceForTest("ns-update-actor-notfound")
tc := setupTest(t, ns)
defer tc.cleanup()
_, err := tc.client.UpdateActor(context.Background(), &ateapipb.UpdateActorRequest{
Actor: &ateapipb.Actor{Metadata: &ateapipb.ResourceMetadata{
Atespace: testAtespace,
Name: "does-not-exist",
// Well-formed guards to pass preconditions validation
Uid: "9a2b1c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
Version: 1,
}},
})
assertGrpcError(t, err, codes.NotFound, "actor test-atespace/does-not-exist not found")
}
func TestUpdateActor_StampsFullSpanIdentity(t *testing.T) {
ns := namespaceForTest("ns-span-update")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
toUpdate, err := tc.service.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
},
})
if err != nil {
t.Fatalf("seed CreateActor: %v", err)
}
toUpdate.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"env": "prod"}}
attrs := recordRootSpanAttrs(t, func(ctx context.Context) {
if _, err := tc.service.UpdateActor(ctx, &ateapipb.UpdateActorRequest{
Actor: toUpdate,
}); err != nil {
t.Fatalf("UpdateActor: %v", err)
}
})
assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace)
assertSpanStr(t, attrs, ateattr.ActorNameKey, testActorID)
assertSpanStr(t, attrs, ateattr.TemplateNameKey, "tmpl1")
assertSpanStr(t, attrs, ateattr.TemplateAtespaceKey, testAtespace)
if v, ok := attrs[ateattr.ActorUIDKey]; !ok || v.Type() != attribute.STRING || v.AsString() == "" {
t.Errorf("%s = %v, want non-empty server-assigned uid", ateattr.ActorUIDKey, v.String())
}
if v, ok := attrs[ateattr.ActorVersionKey]; !ok || v.Type() != attribute.INT64 || v.AsInt64() != 2 {
t.Errorf("%s = %v, want int64 2 (updated version)", ateattr.ActorVersionKey, v.String())
}
}
func TestUpdateActor_FailedLookupStampsRefIdentityOnly(t *testing.T) {
ns := namespaceForTest("ns-span-update-err")
tc := setupTest(t, ns)
defer tc.cleanup()
attrs := recordRootSpanAttrs(t, func(ctx context.Context) {
if _, err := tc.service.UpdateActor(ctx, &ateapipb.UpdateActorRequest{
Actor: &ateapipb.Actor{Metadata: &ateapipb.ResourceMetadata{
Atespace: testAtespace,
Name: testActorID,
// Well-formed guards to pass preconditions validation
Uid: "9a2b1c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
Version: 1,
}},
}); status.Code(err) != codes.NotFound {
t.Fatalf("UpdateActor(missing) error = %v, want code NotFound", err)
}
})
assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace)
assertSpanStr(t, attrs, ateattr.ActorNameKey, testActorID)
for _, k := range []attribute.Key{ateattr.ActorUIDKey, ateattr.TemplateNameKey, ateattr.TemplateAtespaceKey, ateattr.ActorVersionKey} {
if _, ok := attrs[k]; ok {
t.Errorf("unexpected %s on failed-update span", k)
}
}
}
func TestDeleteActor_Success(t *testing.T) {
ns := namespaceForTest("ns-delete-success")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
deleted, err := tc.client.DeleteActor(context.Background(), &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"},
})
if err != nil {
t.Fatalf("DeleteActor failed: %v", err)
}
// DeleteActor returns the deleted resource.
if got := deleted.GetMetadata().GetName(); got != "id1" {
t.Errorf("deleted actor name = %q, want id1", got)
}
if got := deleted.GetMetadata().GetAtespace(); got != testAtespace {
t.Errorf("deleted actor atespace = %q, want %q", got, testAtespace)
}
_, err = tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"},
})
assertGrpcError(t, err, codes.NotFound, "Actor test-atespace/id1 not found")
}
func TestDeleteActor_NotSuspended(t *testing.T) {
ns := namespaceForTest("ns-delete-notsuspended")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"},
})
if err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}
_, err = tc.client.DeleteActor(context.Background(), &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"},
})
assertGrpcError(t, err, codes.FailedPrecondition, "Actor test-atespace/id1 is not in a deletable state (state: ACTOR_STATE_RUNNING)")
}
func TestDeleteActor_Crashed(t *testing.T) {
ns := namespaceForTest("ns-delete-crashed")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
created, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
actorRef := resources.ActorRef{Atespace: testAtespace, Name: "id1"}
if _, err := tc.persistence.UpdateActor(context.Background(), actorRef, store.PreconditionFrom(created), func(toUpdate *ateapipb.Actor) error {
toUpdate.Status.State = ateapipb.ActorState_ACTOR_STATE_CRASHED
return nil
}); err != nil {
t.Fatalf("UpdateActor failed: %v", err)
}
deleted, err := tc.client.DeleteActor(context.Background(), &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"},
})
if err != nil {
t.Fatalf("DeleteActor of crashed actor failed: %v", err)
}
if got := deleted.GetStatus().GetState(); got != ateapipb.ActorState_ACTOR_STATE_DELETING {
t.Errorf("deleted actor state = %v, want %v", got, ateapipb.ActorState_ACTOR_STATE_DELETING)
}
_, err = tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"},
})
assertGrpcError(t, err, codes.NotFound, "Actor test-atespace/id1 not found")
}
func TestDeleteActor_NotFound(t *testing.T) {
ns := namespaceForTest("ns-delete-notfound")
tc := setupTest(t, ns)
defer tc.cleanup()
_, err := tc.client.DeleteActor(context.Background(), &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "non-existent"},
})
assertGrpcError(t, err, codes.NotFound, "Actor test-atespace/non-existent not found")
}
// Delete addresses the actor by ref (atespace + id) and does not resolve the
// template/version, so only the ref identity is stamped.
func TestDeleteActor_StampsRefSpanIdentity(t *testing.T) {
ns := namespaceForTest("ns-span-delete")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
if _, err := tc.service.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
},
}); err != nil {
t.Fatalf("seed CreateActor: %v", err)
}
attrs := recordRootSpanAttrs(t, func(ctx context.Context) {
if _, err := tc.service.DeleteActor(ctx, &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: testActorID},
}); err != nil {
t.Fatalf("DeleteActor: %v", err)
}
})
assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace)
assertSpanStr(t, attrs, ateattr.ActorNameKey, testActorID)
}
func TestDeleteActor_StateDeleting(t *testing.T) {
ns := namespaceForTest("ns-delete-deleting")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
deletingActor := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{
Atespace: testAtespace,
Name: "deleting-actor",
},
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_DELETING},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}
if _, err := tc.persistence.CreateActor(context.Background(), deletingActor); err != nil {
t.Fatalf("CreateActor: %v", err)
}
if _, err := tc.service.DeleteActor(context.Background(), &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "deleting-actor"},
}); err != nil {
t.Fatalf("DeleteActor on ACTOR_STATE_DELETING actor failed: %v", err)
}
if _, err := tc.persistence.GetActor(context.Background(), resources.ActorRef{Atespace: testAtespace, Name: "deleting-actor"}); err == nil {
t.Errorf("expected actor to be deleted, but it still exists")
}
}
func TestDeleteActor_WrongState(t *testing.T) {
ns := namespaceForTest("ns-delete-wrong-status")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
runningActor := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{
Atespace: testAtespace,
Name: "running-actor",
},
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_RUNNING},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}
if _, err := tc.persistence.CreateActor(context.Background(), runningActor); err != nil {
t.Fatalf("CreateActor: %v", err)
}
_, err := tc.service.DeleteActor(context.Background(), &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "running-actor"},
})
if err == nil {
t.Fatalf("expected DeleteActor on ACTOR_STATE_RUNNING actor to fail, but it succeeded")
}
}
type failingVolumePlugin struct {
volume.VolumePluginControlPlane
deletedIDs []string
}
func (f *failingVolumePlugin) DeleteVolume(ctx context.Context, volumeID string) error {
f.deletedIDs = append(f.deletedIDs, volumeID)
return fmt.Errorf("simulated delete error for %s", volumeID)
}
func TestDeleteActor_MultipleVolumeDeletionFailures(t *testing.T) {
ns := namespaceForTest("ns-delete-multivol-fail")
plugin := &failingVolumePlugin{}
tc := setupTestWithVolumePlugins(t, ns, map[string]volume.VolumePluginControlPlane{
"substrate.io/mock": plugin,
})
defer tc.cleanup()
createTemplate(t, tc, ns)
actor := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{
Atespace: testAtespace,
Name: "multi-vol-actor",
},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
Status: &ateapipb.ActorStatus{
State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED,
ActorVolumes: []*ateapipb.ExternalVolume{
{VolumeName: "vol1", StorageVolumeId: "storage-vol-1", Status: ateapipb.ExternalVolume_STATUS_CREATED, VolumeType: "substrate.io/mock"},
{VolumeName: "vol2", StorageVolumeId: "storage-vol-2", Status: ateapipb.ExternalVolume_STATUS_CREATED, VolumeType: "substrate.io/mock"},
},
},
}
if _, err := tc.persistence.CreateActor(context.Background(), actor); err != nil {
t.Fatalf("CreateActor: %v", err)
}
_, err := tc.service.DeleteActor(context.Background(), &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "multi-vol-actor"},
})
if err == nil {
t.Fatalf("expected DeleteActor to fail when volume deletion fails, but it succeeded")
}
wantDeleted := []string{"storage-vol-1", "storage-vol-2"}
if diff := cmp.Diff(wantDeleted, plugin.deletedIDs); diff != "" {
t.Errorf("deletedIDs mismatch (-want +got):\n%s", diff)
}
errMsg := err.Error()
if !strings.Contains(errMsg, "storage-vol-1") || !strings.Contains(errMsg, "storage-vol-2") {
t.Errorf("expected error message to contain both volume failure details, got: %v", errMsg)
}
}
func TestCreateActor_AtespaceNotFound(t *testing.T) {
ns := namespaceForTest("ns-create-actor-no-atespace")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
// The template exists, but "missing-as" was never created. The template
// check fires first, so reaching this error proves the atespace check ran.
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: "missing-as", Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
assertGrpcError(t, err, codes.FailedPrecondition, "Atespace missing-as not found")
}
func TestValidation_Actor(t *testing.T) {
ns := namespaceForTest("ns-validation-actor")
tc := setupTest(t, ns)
defer tc.cleanup()
t.Run("CreateActor", func(t *testing.T) {
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{})
assertGrpcErrorRegex(t, err, codes.InvalidArgument, "actor: Required value")
})
t.Run("GetActor", func(t *testing.T) {
_, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{})
assertGrpcErrorRegex(t, err, codes.InvalidArgument, "actor: Required value")
})
t.Run("ResumeActor", func(t *testing.T) {
_, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{})
assertGrpcErrorRegex(t, err, codes.InvalidArgument, "actor: Required value")
})
t.Run("PauseActor", func(t *testing.T) {
_, err := tc.client.PauseActor(context.Background(), &ateapipb.PauseActorRequest{})
assertGrpcErrorRegex(t, err, codes.InvalidArgument, "actor: Required value")
})
t.Run("SuspendActor", func(t *testing.T) {
_, err := tc.client.SuspendActor(context.Background(), &ateapipb.SuspendActorRequest{})
assertGrpcErrorRegex(t, err, codes.InvalidArgument, "actor: Required value")
})
t.Run("UpdateActor", func(t *testing.T) {
_, err := tc.client.UpdateActor(context.Background(), &ateapipb.UpdateActorRequest{})
assertGrpcErrorRegex(t, err, codes.InvalidArgument, "actor: Required value")
})
t.Run("DeleteActor", func(t *testing.T) {
_, err := tc.client.DeleteActor(context.Background(), &ateapipb.DeleteActorRequest{})
assertGrpcErrorRegex(t, err, codes.InvalidArgument, "actor: Required value")
})
t.Run("ListActors", func(t *testing.T) {
_, err := tc.client.ListActors(context.Background(), &ateapipb.ListActorsRequest{PageSize: -1})
assertGrpcErrorRegex(t, err, codes.InvalidArgument, "page_size: Invalid value")
})
t.Run("ListActors invalid token", func(t *testing.T) {
_, err := tc.client.ListActors(context.Background(), &ateapipb.ListActorsRequest{PageToken: "%%%"})
assertGrpcError(t, err, codes.InvalidArgument, "invalid page_token")
})
t.Run("ListTags invalid token", func(t *testing.T) {
_, err := tc.client.ListTags(context.Background(), &ateapipb.ListTagsRequest{PageToken: "%%%"})
assertGrpcError(t, err, codes.InvalidArgument, "invalid page_token")
})
}
func TestActorLifecycle_WithExternalVolumes(t *testing.T) {
ns := namespaceForTest("ns-lifecycle-ext-vols")
tc := setupTest(t, ns)
defer tc.cleanup()
volumes := []*ateapipb.Volume{
{
Name: "data-vol",
ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{
StorageClassName: "fast",
Capacity: "20Gi",
},
},
}
mounts := []*ateapipb.VolumeMount{
{
Name: "data-vol",
MountPath: "/mnt/data",
},
}
createTemplateWithVolumes(t, tc, ns, volumes, mounts)
workerName := createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
// 1. CreateActor
createResp, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "actor-vol-lc"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
},
})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
if createResp.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDED {
t.Fatalf("expected initial state ACTOR_STATE_SUSPENDED, got %v", createResp.GetStatus().GetState())
}
if len(createResp.GetStatus().GetActorVolumes()) != 1 || createResp.GetStatus().GetActorVolumes()[0].GetStatus() != ateapipb.ExternalVolume_STATUS_PENDING {
t.Fatalf("expected 1 pending volume after CreateActor, got %v", createResp.GetStatus().GetActorVolumes())
}
// 2. ResumeActor
resumeResp, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor-vol-lc"},
})
if err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}
if resumeResp.GetActor().GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_RUNNING {
t.Fatalf("expected state ACTOR_STATE_RUNNING after resume, got %v", resumeResp.GetActor().GetStatus().GetState())
}
if len(resumeResp.GetActor().GetStatus().GetActorVolumes()) != 1 || resumeResp.GetActor().GetStatus().GetActorVolumes()[0].GetStatus() != ateapipb.ExternalVolume_STATUS_CREATED {
t.Fatalf("expected 1 created volume after ResumeActor, got %v", resumeResp.GetActor().GetStatus().GetActorVolumes())
}
if resumeResp.GetActor().GetStatus().GetActorVolumes()[0].GetStorageVolumeId() == "" {
t.Fatalf("expected non-empty storageVolumeId after ResumeActor")
}
// 3. PauseActor
pauseResp, err := tc.client.PauseActor(context.Background(), &ateapipb.PauseActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor-vol-lc"},
})
if err != nil {
t.Fatalf("PauseActor failed: %v", err)
}
if pauseResp.GetActor().GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_PAUSED {
t.Fatalf("expected state ACTOR_STATE_PAUSED after pause, got %v", pauseResp.GetActor().GetStatus().GetState())
}
waitForWorkerAvailable(t, tc, workerName)
// 4. ResumeActor from paused
waitForWorkerAvailable(t, tc, workerName)
resumeResp2, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor-vol-lc"},
})
if err != nil {
t.Fatalf("ResumeActor from paused failed: %v", err)
}
if resumeResp2.GetActor().GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_RUNNING {
t.Fatalf("expected state ACTOR_STATE_RUNNING after second resume, got %v", resumeResp2.GetActor().GetStatus().GetState())
}
// 5. SuspendActor
suspendResp, err := tc.client.SuspendActor(context.Background(), &ateapipb.SuspendActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor-vol-lc"},
})
if err != nil {
t.Fatalf("SuspendActor failed: %v", err)
}
if suspendResp.GetActor().GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDED {
t.Fatalf("expected state ACTOR_STATE_SUSPENDED after suspend, got %v", suspendResp.GetActor().GetStatus().GetState())
}
// 6. DeleteActor
deleteResp, err := tc.client.DeleteActor(context.Background(), &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor-vol-lc"},
})
if err != nil {
t.Fatalf("DeleteActor failed: %v", err)
}
if deleteResp.GetMetadata().GetName() != "actor-vol-lc" {
t.Errorf("deleted actor name = %q, want %q", deleteResp.GetMetadata().GetName(), "actor-vol-lc")
}
// Confirm GetActor returns NotFound after deletion
_, err = tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "actor-vol-lc"},
})
if status.Code(err) != codes.NotFound {
t.Errorf("GetActor after delete err = %v, want NotFound", err)
}
}
type partialFailVolumePlugin struct {
volume.VolumePluginControlPlane
deleted []string
}
func (f *partialFailVolumePlugin) CreateVolume(ctx context.Context, name, capacity, driverName string, parameters map[string]string) (string, map[string]string, error) {
if strings.HasSuffix(name, "fail-vol2") {
return "", nil, fmt.Errorf("simulated volume creation failure")
}
return "storage-" + name, parameters, nil
}
func (f *partialFailVolumePlugin) AttachVolume(ctx context.Context, volumeID, node string) error {
return nil
}
func (f *partialFailVolumePlugin) DetachVolume(ctx context.Context, volumeID, node string) error {
return nil
}
func (f *partialFailVolumePlugin) DeleteVolume(ctx context.Context, volumeID string) error {
f.deleted = append(f.deleted, volumeID)
return nil
}
// TestResumeActor_VolumeCreationFailure tests that when volume provisioning fails during ResumeActor,
// successfully created volumes are saved, the actor remains in ACTOR_STATE_SUSPENDED,
// and that calling DeleteActor on the suspended actor cleans up all partially created volumes.
func TestResumeActor_VolumeCreationFailure(t *testing.T) {
ns := namespaceForTest("ns-resume-vol-fail")
plugin := &partialFailVolumePlugin{}
tc := setupTestWithVolumePlugins(t, ns, map[string]volume.VolumePluginControlPlane{
"substrate.io/mock": plugin,
})
defer tc.cleanup()
volumes := []*ateapipb.Volume{
{
Name: "succ-vol1",
ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{
StorageClassName: "standard",
Capacity: "10Gi",
},
},
{
Name: "fail-vol2",
ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{
StorageClassName: "standard",
Capacity: "10Gi",
},
},
}
mounts := []*ateapipb.VolumeMount{
{Name: "succ-vol1", MountPath: "/mnt/vol1"},
{Name: "fail-vol2", MountPath: "/mnt/vol2"},
}
createTemplateWithVolumes(t, tc, ns, volumes, mounts)
// Call CreateActor RPC directly
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "fail-actor"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
},
})
if err != nil {
t.Fatalf("expected CreateActor to succeed, got: %v", err)
}
// Call ResumeActor RPC, which should trigger volume provisioning and fail on fail-vol2
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "fail-actor"},
})
if err == nil {
t.Fatalf("expected ResumeActor to fail due to volume creation error, but it succeeded")
}
// Verify GetActor returns the actor in ACTOR_STATE_SUSPENDED state
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "fail-actor"},
})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
if getResp.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDED {
t.Errorf("actor state = %v, want %v", getResp.GetStatus().GetState(), ateapipb.ActorState_ACTOR_STATE_SUSPENDED)
}
actorUID := getResp.GetMetadata().GetUid()
if actorUID == "" {
t.Fatalf("expected non-empty UID on actor")
}
// Verify that succ-vol1 was updated to CREATED with a storageVolumeId, and fail-vol2 is still PENDING
if len(getResp.GetStatus().GetActorVolumes()) != 2 {
t.Fatalf("expected 2 volumes on actor, got %d", len(getResp.GetStatus().GetActorVolumes()))
}
volsByName := make(map[string]*ateapipb.ExternalVolume)
for _, v := range getResp.GetStatus().GetActorVolumes() {
volsByName[v.GetVolumeName()] = v
}
if v1, ok := volsByName["succ-vol1"]; !ok || v1.GetStatus() != ateapipb.ExternalVolume_STATUS_CREATED || v1.GetStorageVolumeId() == "" {
t.Errorf("succ-vol1 unexpected state: %v", v1)
}
if v2, ok := volsByName["fail-vol2"]; !ok || v2.GetStatus() != ateapipb.ExternalVolume_STATUS_PENDING {
t.Errorf("fail-vol2 unexpected state: %v", v2)
}
// Call DeleteActor on the actor in ACTOR_STATE_SUSPENDED
_, err = tc.client.DeleteActor(context.Background(), &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "fail-actor"},
})
if err != nil {
t.Fatalf("DeleteActor failed: %v", err)
}
// Verify both volumes were deleted (succ-vol1 via storageID, fail-vol2 via fallback actorVolumeID)
wantDeleted := []string{
"storage-substrate-" + actorUID + "-succ-vol1",
"substrate-" + actorUID + "-fail-vol2",
}
if diff := cmp.Diff(wantDeleted, plugin.deleted); diff != "" {
t.Errorf("deleted volume IDs mismatch (-want +got):\n%s", diff)
}
// Confirm GetActor returns NotFound after deletion
_, err = tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "fail-actor"},
})
if status.Code(err) != codes.NotFound {
t.Errorf("GetActor after DeleteActor err = %v, want NotFound", err)
}
}
type retrySuccessVolumePlugin struct {
volume.VolumePluginControlPlane
mu sync.Mutex
attempts int
deleted []string
}
func (r *retrySuccessVolumePlugin) CreateVolume(ctx context.Context, name, capacity, driverName string, parameters map[string]string) (string, map[string]string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if strings.HasSuffix(name, "retry-vol2") {
r.attempts++
if r.attempts == 1 {
return "", nil, fmt.Errorf("simulated temporary volume creation failure")
}
}
return "storage-" + name, parameters, nil
}
func (r *retrySuccessVolumePlugin) AttachVolume(ctx context.Context, volumeID, node string) error {
return nil
}
func (r *retrySuccessVolumePlugin) DetachVolume(ctx context.Context, volumeID, node string) error {
return nil
}
func (r *retrySuccessVolumePlugin) DeleteVolume(ctx context.Context, volumeID string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.deleted = append(r.deleted, volumeID)
return nil
}
// TestResumeActor_VolumeCreationRetrySuccess tests that when volume provisioning fails on the first ResumeActor call,
// a subsequent call to ResumeActor retries provisioning only the pending volumes and succeeds.
func TestResumeActor_VolumeCreationRetrySuccess(t *testing.T) {
ns := namespaceForTest("ns-resume-vol-retry")
plugin := &retrySuccessVolumePlugin{}
tc := setupTestWithVolumePlugins(t, ns, map[string]volume.VolumePluginControlPlane{
"substrate.io/mock": plugin,
})
defer tc.cleanup()
volumes := []*ateapipb.Volume{
{
Name: "succ-vol1",
ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{
StorageClassName: "standard",
Capacity: "10Gi",
},
},
{
Name: "retry-vol2",
ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{
StorageClassName: "standard",
Capacity: "10Gi",
},
},
}
retryMounts := []*ateapipb.VolumeMount{
{Name: "succ-vol1", MountPath: "/mnt/vol1"},
{Name: "retry-vol2", MountPath: "/mnt/vol2"},
}
createTemplateWithVolumes(t, tc, ns, volumes, retryMounts)
createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
// Call CreateActor RPC directly
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "retry-actor"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
},
})
if err != nil {
t.Fatalf("expected CreateActor to succeed, got: %v", err)
}
// First call to ResumeActor RPC, which should fail on retry-vol2 (attempt 1)
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "retry-actor"},
})
if err == nil {
t.Fatalf("expected first ResumeActor to fail due to temporary volume creation error, but it succeeded")
}
// Verify GetActor returns the actor in ACTOR_STATE_SUSPENDED state with succ-vol1 created and retry-vol2 pending
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "retry-actor"},
})
if err != nil {
t.Fatalf("GetActor after first resume failed: %v", err)
}
if getResp.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDED {
t.Errorf("actor state after first resume = %v, want %v", getResp.GetStatus().GetState(), ateapipb.ActorState_ACTOR_STATE_SUSPENDED)
}
volsByName := make(map[string]*ateapipb.ExternalVolume)
for _, v := range getResp.GetStatus().GetActorVolumes() {
volsByName[v.GetVolumeName()] = v
}
if v1, ok := volsByName["succ-vol1"]; !ok || v1.GetStatus() != ateapipb.ExternalVolume_STATUS_CREATED || v1.GetStorageVolumeId() == "" {
t.Errorf("succ-vol1 unexpected state after first resume: %v", v1)
}
if v2, ok := volsByName["retry-vol2"]; !ok || v2.GetStatus() != ateapipb.ExternalVolume_STATUS_PENDING {
t.Errorf("retry-vol2 unexpected state after first resume: %v", v2)
}
// Second call to ResumeActor RPC, which should succeed on retry-vol2 (attempt 2)
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "retry-actor"},
})
if err != nil {
t.Fatalf("expected second ResumeActor to succeed, got: %v", err)
}
// Verify GetActor returns the actor in ACTOR_STATE_RUNNING state with both volumes CREATED
getResp, err = tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "retry-actor"},
})
if err != nil {
t.Fatalf("GetActor after second resume failed: %v", err)
}
if getResp.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_RUNNING {
t.Errorf("actor state after second resume = %v, want %v", getResp.GetStatus().GetState(), ateapipb.ActorState_ACTOR_STATE_RUNNING)
}
for _, v := range getResp.GetStatus().GetActorVolumes() {
if v.GetStatus() != ateapipb.ExternalVolume_STATUS_CREATED || v.GetStorageVolumeId() == "" {
t.Errorf("volume %s unexpected state after second resume: %v", v.GetVolumeName(), v)
}
}
// Clean up by suspending and deleting the actor
_, err = tc.client.SuspendActor(context.Background(), &ateapipb.SuspendActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "retry-actor"},
})
if err != nil {
t.Fatalf("SuspendActor failed: %v", err)
}
_, err = tc.client.DeleteActor(context.Background(), &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "retry-actor"},
})
if err != nil {
t.Fatalf("DeleteActor failed: %v", err)
}
}
// TestResumeActor tests the full workflow of resuming a suspended actor.
// Workflow:
// 1. Creates a mock ActorTemplate.
// 2. Creates a mock Atelet Pod in 'ate-system' namespace on 'node1'.
// 3. Creates a mock worker Pod in the test namespace on 'node1'.
// 4. Waits for the WorkerPoolSyncer to mirror the worker to the store.
// 5. Creates an actor (starts as SUSPENDED).
// 6. Calls ResumeActor RPC.
// 7. Verifies that the fake Atelet received the Restore call.
// 8. Verifies that the actor state is updated to RUNNING.
func TestResumeActor(t *testing.T) {
ns := namespaceForTest("ns-resume")
tc := setupTest(t, ns)
defer tc.cleanup()
tmpl := createTemplate(t, tc, ns)
podUID := createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
name := "id1"
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}
if !tc.fakeAtelet.RestoreCalled {
t.Errorf("expected Restore to be called")
}
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
want := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Name: name, Atespace: testAtespace},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
Status: &ateapipb.ActorStatus{
State: ateapipb.ActorState_ACTOR_STATE_RUNNING,
CurrentActorTemplateUid: tmpl.GetMetadata().GetUid(),
WorkerAssignment: &ateapipb.WorkerAssignment{
Worker: &ateapipb.ObjectRef{Name: podUID},
WorkerNamespace: ns,
WorkerPool: "pool1",
WorkerPod: "worker-1",
WorkerPodUid: podUID,
WorkerPodIp: "127.0.0.1",
},
},
}
if diff := cmp.Diff(want, getResp, protocmp.Transform(), ignoreUID, ignoreVersion, ignoreTimestamps); diff != "" {
t.Errorf("GetActor response mismatch (-want +got):\n%s", diff)
}
// Verify that the worker record also has the assigned actor details
listWorkersResp, err := tc.client.ListWorkers(context.Background(), &ateapipb.ListWorkersRequest{})
if err != nil {
t.Fatalf("ListWorkers failed: %v", err)
}
var actorWorker *ateapipb.Worker
for _, w := range listWorkersResp.GetWorkers() {
if w.GetWorkerNamespace() == ns && w.GetWorkerPod() == "worker-1" {
actorWorker = w
break
}
}
if actorWorker == nil {
t.Fatalf("expected worker-1 in namespace %s not found in ListWorkers", ns)
}
wantWorker := &ateapipb.Worker{
Metadata: &ateapipb.ResourceMetadata{Name: podUID},
WorkerNamespace: ns,
WorkerPool: "pool1",
WorkerPod: "worker-1",
WorkerPodUid: podUID,
Ip: "127.0.0.1",
NodeName: "node1",
SandboxClass: "gvisor",
Labels: map[string]string{poolLabelKey: ns},
Status: &ateapipb.WorkerStatus{
State: ateapipb.WorkerState_WORKER_STATE_ACTIVE,
// Only the ceiling the worker reported.
Capacity: &ateapipb.WorkerResources{Actors: 1},
// All a listing reports of the assignments. The actor declares
// no compute limits, so it registers as one actor and nothing
// else.
Allocated: &ateapipb.WorkerResources{Actors: 1},
},
}
if diff := cmp.Diff(wantWorker, actorWorker, protocmp.Transform(), ignoreServerMetadata,
protocmp.IgnoreFields(&ateapipb.ResourceMetadata{}, "version")); diff != "" {
t.Errorf("Worker state mismatch (-want +got):\n%s", diff)
}
}
func TestResumeActorPassesLiteralEnv(t *testing.T) {
ns := namespaceForTest("ns-resume-literal-env")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplateWithContainers(t, tc, ns, []*ateapipb.Container{
{
Name: "main",
Image: "main@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
Command: []string{"/main"},
Env: []*ateapipb.EnvVar{
{
Name: "LITERAL",
Value: "plain",
},
},
},
})
createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"},
})
if err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}
restoreReq := tc.fakeAtelet.lastRestoreRequest()
if restoreReq == nil {
t.Fatalf("expected Restore to be called")
}
if len(restoreReq.GetSpec().GetContainers()) != 1 {
t.Fatalf("expected one container in restore request, got %d", len(restoreReq.GetSpec().GetContainers()))
}
gotEnv := map[string]string{}
for _, env := range restoreReq.GetSpec().GetContainers()[0].GetEnv() {
gotEnv[env.GetName()] = env.GetValue()
}
wantEnv := map[string]string{
"LITERAL": "plain",
}
if diff := cmp.Diff(wantEnv, gotEnv); diff != "" {
t.Errorf("env mismatch (-want +got):\n%s", diff)
}
}
// TestResumeActor_NoWorkers tests that resuming an actor fails when no free workers are available.
// Workflow:
// 1. Creates a mock ActorTemplate.
// 2. Creates an actor.
// 3. Calls ResumeActor RPC without creating any workers.
// 4. Verifies that ResumeActor fails with FailedPrecondition status.
// TestResumeActor_NoWorkers tests that resuming an actor fails when no free workers are available.
// Workflow:
// 1. Creates a mock ActorTemplate.
// 2. Creates an actor.
// 3. Calls ResumeActor RPC without creating any workers.
// 4. Verifies that ResumeActor fails with FailedPrecondition status.
func TestResumeActor_NoWorkers(t *testing.T) {
ns := namespaceForTest("ns-resume-no-workers")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
createResp, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
name := createResp.GetMetadata().GetName()
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
assertGrpcError(t, err, codes.ResourceExhausted, "no free workers available")
}
// TestResumeActor_MultiPoolSelector exercises the AND-of-two-selectors path
// end to end: a template's WorkerSelector gates two pools, and the actor's
// worker_selector narrows to just one of them.
func TestResumeActor_MultiPoolSelector(t *testing.T) {
ns := namespaceForTest("ns-multi-pool")
tc := setupTest(t, ns)
defer tc.cleanup()
createWorkerPool(t, tc, ns, "pool-a", map[string]string{"group": ns, "tier": "a"})
createWorkerPool(t, tc, ns, "pool-b", map[string]string{"group": ns, "tier": "b"})
createTemplateWithSelector(t, tc, "tmpl1", &ateapipb.Selector{
MatchLabels: map[string]string{"group": ns},
})
createWorkerPod(t, tc, ns, "worker-a", "node1", "pool-a")
createWorkerPod(t, tc, ns, "worker-b", "node1", "pool-b")
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
WorkerSelector: &ateapipb.Selector{
MatchLabels: map[string]string{"tier": "b"},
},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"}})
if err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"}})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
if got := getResp.GetStatus().GetWorkerAssignment().GetWorkerPod(); got != "worker-b" {
t.Errorf("expected actor to be assigned to worker-b (pool-b, matching narrowed selector), got %q", got)
}
if got := getResp.GetStatus().GetWorkerAssignment().GetWorkerPool(); got != "pool-b" {
t.Errorf("expected actor's worker_assignment.worker_pool to be pool-b, got %q", got)
}
}
// TestResumeActor_RequiresBothSelectorsToMatch proves eligibility is the AND
// of the template's WorkerSelector and the actor's worker_selector, not
// either one alone: a pool matching only the template selector and a pool
// matching only the actor selector must both be rejected, end to end
// through CreateActor/ResumeActor (not just the eligibleWorkerPools unit
// test), while a pool matching both is the one actually used.
func TestResumeActor_RequiresBothSelectorsToMatch(t *testing.T) {
ns := namespaceForTest("ns-resume-and-selectors")
tc := setupTest(t, ns)
defer tc.cleanup()
createWorkerPool(t, tc, ns, "pool-both", map[string]string{"group": ns, "tier": "b"})
createWorkerPool(t, tc, ns, "pool-template-only", map[string]string{"group": ns, "tier": "a"})
createWorkerPool(t, tc, ns, "pool-actor-only", map[string]string{"tier": "b"})
createTemplateWithSelector(t, tc, "tmpl1", &ateapipb.Selector{
MatchLabels: map[string]string{"group": ns},
})
createWorkerPod(t, tc, ns, "worker-both", "node1", "pool-both")
createWorkerPod(t, tc, ns, "worker-template-only", "node1", "pool-template-only")
createWorkerPod(t, tc, ns, "worker-actor-only", "node1", "pool-actor-only")
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
WorkerSelector: &ateapipb.Selector{
MatchLabels: map[string]string{"tier": "b"},
},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"}}); err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"}})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
if got := getResp.GetStatus().GetWorkerAssignment().GetWorkerPool(); got != "pool-both" {
t.Errorf("expected actor to be assigned to pool-both (the only pool matching both selectors), got worker_assignment.worker_pool=%q", got)
}
}
// TestResumeActor_Reentrancy tests the failure recovery and re-entrancy of ResumeActor.
// Workflow:
// 1. Creates a mock ActorTemplate.
// 2. Creates a mock Atelet Pod and a mock Worker Pod.
// 3. Waits for the WorkerPoolSyncer to mirror the worker to store.
// 4. Creates an actor in SUSPENDED state.
// 5. Configures fake Atelet to FAIL on Restore.
// 6. Calls ResumeActor and verifies it fails, but actor state becomes RESUMING.
// 7. Configures fake Atelet to SUCCEED on Restore.
// 8. Calls ResumeActor again and verifies it succeeds and actor state becomes RUNNING.
func TestResumeActor_Reentrancy(t *testing.T) {
ns := namespaceForTest("ns-resume-reentrancy")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
// Create Worker Pod
createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
name := "id1"
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
// STEP 1: Make Atelet FAIL on Restore!
tc.fakeAtelet.FailRestore = fmt.Errorf("mock atelet failure")
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err == nil {
t.Fatalf("expected ResumeActor to fail due to atelet error")
}
// Verify actor state is RESUMING in the store.
actor, err := tc.persistence.GetActor(context.Background(), resources.ActorRef{Atespace: testAtespace, Name: name})
if err != nil {
t.Fatalf("failed to get actor from store: %v", err)
}
if actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_RESUMING {
t.Errorf("expected state RESUMING, got %v", actor.GetStatus().GetState())
}
// STEP 2: Make Atelet SUCCEED!
tc.fakeAtelet.FailRestore = nil
tc.fakeAtelet.RestoreCalled = false // reset for verification
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("ResumeActor failed on retry: %v", err)
}
if !tc.fakeAtelet.RestoreCalled {
t.Errorf("expected Restore to be called on retry")
}
// Verify actor state is RUNNING!
actor, err = tc.persistence.GetActor(context.Background(), resources.ActorRef{Atespace: testAtespace, Name: name})
if err != nil {
t.Fatalf("failed to get actor from store: %v", err)
}
if actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_RUNNING {
t.Errorf("expected state RUNNING, got %v", actor.GetStatus().GetState())
}
}
// The early ref stamp must land on the span even when the op fails, so a failed
// resume is still attributable to who/where.
func TestResumeActor_ErrorStillStampsRefSpanIdentity(t *testing.T) {
ns := namespaceForTest("ns-span-resume-err")
tc := setupTest(t, ns)
defer tc.cleanup()
attrs := recordRootSpanAttrs(t, func(ctx context.Context) {
if _, err := tc.service.ResumeActor(ctx, &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "missing"},
}); err == nil {
t.Fatal("expected error resuming missing actor")
}
})
assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace)
assertSpanStr(t, attrs, ateattr.ActorNameKey, "missing")
}
// TestSuspendActor tests the full workflow of suspending a running actor.
// Workflow:
// 1. Creates a mock ActorTemplate.
// 2. Creates a mock Atelet Pod on 'node1'.
// 3. Creates a mock worker Pod on 'node1'.
// 4. Waits for the WorkerPoolSyncer to mirror the worker to the store.
// 5. Creates an actor.
// 6. Calls ResumeActor to transition it to RUNNING.
// 7. Calls SuspendActor RPC.
// 8. Verifies that the fake Atelet received the Suspend call.
func TestSuspendActor(t *testing.T) {
ns := namespaceForTest("ns-suspend")
tc := setupTest(t, ns)
defer tc.cleanup()
tmpl := createTemplate(t, tc, ns)
workerName := createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
name := "id1"
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
// Resume first to make it running
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}
// Suspend
suspended, err := tc.client.SuspendActor(context.Background(), &ateapipb.SuspendActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("SuspendActor failed: %v", err)
}
waitForWorkerAvailable(t, tc, workerName)
if !tc.fakeAtelet.CheckpointCalled {
t.Errorf("expected atelet Checkpoint to be called")
}
sourceActor := suspended.GetActor()
snapshotURI := sourceActor.GetStatus().GetExternalSnapshot().GetSnapshotUri()
if snapshotURI == "" {
t.Fatalf("SuspendActor wrote no external snapshot: %v", suspended)
}
// The snapshot lands under the Actor's own prefix: it owns what it wrote.
assertSnapshotOwnedByActor(t, sourceActor, snapshotURI)
// Tagging is a separate call over whatever snapshot the Actor holds by then,
// which here is the one the suspend above left behind.
const tagName = "before-upgrade"
tagRef := &ateapipb.ObjectRef{Atespace: testAtespace, Name: tagName}
tagged, err := tc.client.CreateTag(context.Background(), &ateapipb.CreateTagRequest{
Tag: &ateapipb.Tag{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: tagName},
Scope: ateapipb.TagScope_TAG_SCOPE_ATESPACE,
SourceActor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
},
})
if err != nil {
t.Fatalf("CreateTag failed: %v", err)
}
// The tag owns a copy of its own, so the Actor's later suspends and its
// deletion cannot collect the tag's snapshot copy.
tagSnapshotURI := tagged.GetStatus().GetSnapshot().GetSnapshotUri()
if tagSnapshotURI == snapshotURI || tagSnapshotURI == "" {
t.Fatalf("tag snapshot uri = %q, want an external snapshot of its own", tagSnapshotURI)
}
assertSnapshotPresent(t, tc, snapshotURI)
assertSnapshotPresent(t, tc, tagSnapshotURI)
wantTag := &ateapipb.Tag{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: tagName},
Scope: ateapipb.TagScope_TAG_SCOPE_ATESPACE,
SourceActor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
Status: &ateapipb.TagStatus{
Snapshot: &ateapipb.ExternalSnapshot{SnapshotUri: tagSnapshotURI, ContentScope: sourceActor.GetStatus().GetExternalSnapshot().GetContentScope()},
ActorTemplateUid: tmpl.GetMetadata().GetUid(),
SourceActorUid: sourceActor.GetMetadata().GetUid(),
StorageLocation: tmpl.GetSnapshotsConfig().GetStorageLocation(),
},
}
stored, err := tc.client.GetTag(context.Background(), &ateapipb.GetTagRequest{Tag: tagRef})
if err != nil {
t.Fatalf("GetTag failed: %v", err)
}
if diff := cmp.Diff(wantTag, stored, protocmp.Transform(), ignoreUID, ignoreVersion, ignoreTimestamps); diff != "" {
t.Errorf("stored tag mismatch (-want +got):\n%s", diff)
}
listed, err := tc.client.ListTags(context.Background(), &ateapipb.ListTagsRequest{Atespace: testAtespace, PageSize: 1})
if err != nil || len(listed.GetTags()) != 1 {
t.Fatalf("ListTags = (%v, %v), want one", listed, err)
}
// A tag is born ATESPACE-scoped, so it cannot seed an Actor elsewhere until
// it is published.
createAtespace(t, tc, "other")
crossAtespaceClone := &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: "other", Name: "cross-atespace"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
SourceTag: tagRef,
},
}
if _, err := tc.client.CreateActor(context.Background(), crossAtespaceClone); status.Code(err) != codes.FailedPrecondition {
t.Fatalf("cross-atespace CreateActor status = %v, want FailedPrecondition", status.Code(err))
}
tagged.Scope = ateapipb.TagScope_TAG_SCOPE_PUBLISHED
updated, err := tc.client.UpdateTag(context.Background(), &ateapipb.UpdateTagRequest{
Tag: tagged,
})
if err != nil || updated.GetScope() != ateapipb.TagScope_TAG_SCOPE_PUBLISHED {
t.Fatalf("UpdateTag = (%v, %v), want published", updated, err)
}
if updated.GetStatus().GetSnapshot().GetSnapshotUri() != tagSnapshotURI {
t.Errorf("tag snapshot uri after publication = %q, want %q", updated.GetStatus().GetSnapshot().GetSnapshotUri(), tagSnapshotURI)
}
if _, err := tc.client.CreateActor(context.Background(), crossAtespaceClone); err != nil {
t.Fatalf("CreateActor from published tag failed: %v", err)
}
// A clone borrows the tag's external snapshot rather than copying it. The
// snapshot stays under the tag's prefix, which is what keeps the clone's own
// lifecycle from ever releasing it.
clone, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "clone"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
SourceTag: tagRef,
},
})
if err != nil {
t.Fatalf("CreateActor from tag failed: %v", err)
}
if got := clone.GetStatus().GetExternalSnapshot().GetSnapshotUri(); got != tagSnapshotURI {
t.Errorf("clone snapshot uri = %q, want the tag's %q", got, tagSnapshotURI)
}
if snapshotOwnedByActor(t, clone, tagSnapshotURI) {
t.Errorf("clone snapshot %s sits under the clone's own prefix, want it left under the tag's", tagSnapshotURI)
}
if !proto.Equal(clone.GetSourceTag(), tagRef) {
t.Errorf("clone source tag = %v, want %v", clone.GetSourceTag(), tagRef)
}
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "clone"}}); err != nil {
t.Fatalf("ResumeActor clone failed: %v", err)
}
if !tc.fakeAtelet.RestoreCalled {
t.Error("resuming clone did not restore its source external snapshot")
}
// The clone's first suspend writes a snapshot of its own and stops
// borrowing, which is what makes the tag's snapshot collectable later.
cloneSuspended, err := tc.client.SuspendActor(context.Background(), &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "clone"}})
if err != nil {
t.Fatalf("SuspendActor clone failed: %v", err)
}
cloneSnapshotURI := cloneSuspended.GetActor().GetStatus().GetExternalSnapshot().GetSnapshotUri()
if cloneSnapshotURI == tagSnapshotURI || cloneSnapshotURI == "" {
t.Errorf("clone snapshot uri after suspension = %q, want an external snapshot of its own", cloneSnapshotURI)
}
assertSnapshotOwnedByActor(t, cloneSuspended.GetActor(), cloneSnapshotURI)
// It stopped borrowing without releasing what it had borrowed.
assertSnapshotPresent(t, tc, tagSnapshotURI)
assertSnapshotPresent(t, tc, cloneSnapshotURI)
// The untagged suspend created no tag.
listed, err = tc.client.ListTags(context.Background(), &ateapipb.ListTagsRequest{Atespace: testAtespace})
if err != nil || len(listed.GetTags()) != 1 {
t.Fatalf("ListTags after clone suspension = (%v, %v), want one", listed, err)
}
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
want := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Name: name, Atespace: testAtespace},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
Status: &ateapipb.ActorStatus{
State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED,
ExternalSnapshot: &ateapipb.ExternalSnapshot{SnapshotUri: snapshotURI, ContentScope: sourceActor.GetStatus().GetExternalSnapshot().GetContentScope()},
CurrentActorTemplateUid: tmpl.GetMetadata().GetUid(),
},
}
if diff := cmp.Diff(want, getResp,
protocmp.Transform(),
ignoreUID,
ignoreVersion,
ignoreTimestamps,
); diff != "" {
t.Errorf("GetActor response mismatch (-want +got):\n%s", diff)
}
// The tag outlives the Actor it was taken from: it holds the snapshot on its
// own, and deleting it is what ends that snapshot's life.
if _, err := tc.client.DeleteActor(context.Background(), &ateapipb.DeleteActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}}); err != nil {
t.Fatalf("DeleteActor source failed: %v", err)
}
if _, err := tc.client.GetTag(context.Background(), &ateapipb.GetTagRequest{Tag: tagRef}); err != nil {
t.Fatalf("source tag disappeared with source Actor: %v", err)
}
// The Actor took only what it owned with it.
assertSnapshotCollected(t, tc, snapshotURI)
assertSnapshotPresent(t, tc, tagSnapshotURI)
if deleted, err := tc.client.DeleteTag(context.Background(), &ateapipb.DeleteTagRequest{Tag: tagRef}); err != nil || deleted.GetMetadata().GetName() != tagRef.GetName() {
t.Fatalf("DeleteTag = (%v, %v)", deleted, err)
}
if _, err := tc.client.GetTag(context.Background(), &ateapipb.GetTagRequest{Tag: tagRef}); status.Code(err) != codes.NotFound {
t.Fatalf("deleted tag status = %v, want NotFound", status.Code(err))
}
// Deleting the tag is what ends its snapshot's life. The clone that
// borrowed it has one of its own by now and is unaffected.
assertSnapshotCollected(t, tc, tagSnapshotURI)
assertSnapshotPresent(t, tc, cloneSnapshotURI)
}
// TestResumeActor_RepointTemplateBeforeResume checks what an Actor cloned from
// a tag asks atelet to restore on its first resume.
//
// The clone borrows a snapshot taken under the tag's template. Left on that
// template, it restores in Full: memory and filesystem come back as they were.
// Moved to another template first, it must drop to Data: the new template's
// image boots fresh and only the volume data carries over.
func TestResumeActor_RepointTemplateBeforeResume(t *testing.T) {
tests := []struct {
name string
// moveActorToAnotherTemplate moves the clone to a volume-compatible replacement template
// after it is created but before its first resume.
moveActorToAnotherTemplate bool
wantTemplate string
wantScope ateletpb.SnapshotScope
}{
{
name: "clone left on the tag's template",
moveActorToAnotherTemplate: false,
wantTemplate: "tmpl1",
wantScope: ateletpb.SnapshotScope_SNAPSHOT_SCOPE_FULL,
},
{
name: "clone repointed before its first resume",
moveActorToAnotherTemplate: true,
wantTemplate: "tmpl2",
wantScope: ateletpb.SnapshotScope_SNAPSHOT_SCOPE_DATA,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ns := namespaceForTest("ns-clone-repoint")
tc := setupTest(t, ns)
defer tc.cleanup()
ctx := context.Background()
tmpl := createTemplate(t, tc, ns)
// The tmpl2 copies tmpl1 so the repoint clears the sandbox
// class and volume compatibility checks; only the name differs.
tmpl2 := proto.Clone(tmpl).(*ateapipb.ActorTemplate)
tmpl2.Metadata = &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "tmpl2"}
tmpl2.Status = nil
if _, err := tc.client.CreateActorTemplate(ctx, &ateapipb.CreateActorTemplateRequest{ActorTemplate: tmpl2}); err != nil {
t.Fatalf("CreateActorTemplate(tmpl2) failed: %v", err)
}
worker := createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
// Run and suspend a source Actor, so we have an external snapshot we can tag
const sourceActorName = "source-actor"
if _, err := tc.client.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: sourceActorName},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}}); err != nil {
t.Fatalf("CreateActor(%s) failed: %v", sourceActorName, err)
}
actorRef := &ateapipb.ObjectRef{Atespace: testAtespace, Name: sourceActorName}
if _, err := tc.client.ResumeActor(ctx, &ateapipb.ResumeActorRequest{Actor: actorRef}); err != nil {
t.Fatalf("ResumeActor(%s) failed: %v", sourceActorName, err)
}
if _, err := tc.client.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: actorRef}); err != nil {
t.Fatalf("SuspendActor(%s) failed: %v", sourceActorName, err)
}
waitForWorkerAvailable(t, tc, worker)
const tagName = "before-upgrade"
if _, err := tc.client.CreateTag(ctx, &ateapipb.CreateTagRequest{
Tag: &ateapipb.Tag{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: tagName},
Scope: ateapipb.TagScope_TAG_SCOPE_ATESPACE,
SourceActor: actorRef,
},
}); err != nil {
t.Fatalf("CreateTag failed: %v", err)
}
const cloneActorName = "clone"
cloneActor, err := tc.client.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: cloneActorName},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
// Seeded from the tag.
SourceTag: &ateapipb.ObjectRef{Atespace: testAtespace, Name: tagName},
}})
if err != nil {
t.Fatalf("CreateActor from tag failed: %v", err)
}
if tt.moveActorToAnotherTemplate {
// Change clonetActor's template
toUpdate := proto.Clone(cloneActor).(*ateapipb.Actor)
toUpdate.ActorTemplate = &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl2"}
if _, err := tc.client.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: toUpdate}); err != nil {
t.Fatalf("UpdateActor repointing the clone at tmpl2 failed: %v", err)
}
}
cloneRef := &ateapipb.ObjectRef{Atespace: testAtespace, Name: cloneActorName}
if _, err := tc.client.ResumeActor(ctx, &ateapipb.ResumeActorRequest{Actor: cloneRef}); err != nil {
t.Fatalf("ResumeActor(%s) failed: %v", cloneActorName, err)
}
restoreReq := tc.fakeAtelet.lastRestoreRequest()
if restoreReq == nil {
t.Fatal("resuming the clone sent no Restore request to atelet")
}
if got := restoreReq.GetActorName(); got != cloneActorName {
t.Fatalf("last restore request to atelet was for actor %q, want the clone %q", got, cloneActorName)
}
if got := restoreReq.GetActorTemplateName(); got != tt.wantTemplate {
t.Errorf("restore request to atelet had actor template = %q, want %q", got, tt.wantTemplate)
}
if got := restoreReq.GetScope(); got != tt.wantScope {
t.Errorf("restore request to atelet had scope = %v, want %v", got, tt.wantScope)
}
// Either way the restore reads the snapshot the clone borrowed
// from the tag, not the template's golden image.
if got := restoreReq.GetExternalConfig().GetSnapshotUri(); got != cloneActor.GetStatus().GetExternalSnapshot().GetSnapshotUri() {
t.Errorf("restore request to atelet had snapshot uri = %q, want the clone's borrowed %q", got, cloneActor.GetStatus().GetExternalSnapshot().GetSnapshotUri())
}
if got := restoreReq.GetGoldenSnapshotUri(); got != "" {
t.Errorf("restore request to atelet had golden snapshot uri = %q, want empty", got)
}
})
}
}
// TestPauseActor tests the full workflow of pausing a running actor.
// Workflow:
// 1. Creates a mock ActorTemplate.
// 2. Creates a mock Atelet Pod on 'node1'.
// 3. Creates a mock worker Pod on 'node1'.
// 4. Waits for the WorkerPoolSyncer to mirror the worker to the store.
// 5. Creates an actor.
// 6. Calls ResumeActor to transition it to RUNNING.
// 7. Calls PauseActor RPC.
// 8. Verifies that the fake Atelet received the Pause call.
func TestPauseActor(t *testing.T) {
ns := namespaceForTest("ns-pause")
tc := setupTest(t, ns)
defer tc.cleanup()
tmpl := createTemplate(t, tc, ns)
createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
name := "id1"
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
// Resume first to make it running
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}
// Pause
_, err = tc.client.PauseActor(context.Background(), &ateapipb.PauseActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("PauseActor failed: %v", err)
}
if !tc.fakeAtelet.CheckpointCalled {
t.Errorf("expected atelet Checkpoint to be called")
}
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
want := &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Name: name, Atespace: testAtespace},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
Status: &ateapipb.ActorStatus{
State: ateapipb.ActorState_ACTOR_STATE_PAUSED,
LocalSnapshotInfo: &ateapipb.LocalSnapshotInfo{
NodeVmsWithLocalSnapshots: []string{"node1"},
ContentScope: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_FULL,
},
CurrentActorTemplateUid: tmpl.GetMetadata().GetUid(),
},
}
if diff := cmp.Diff(want, getResp,
protocmp.Transform(),
ignoreUID,
ignoreVersion,
ignoreTimestamps,
protocmp.IgnoreFields(&ateapipb.LocalSnapshotInfo{}, "snapshot_name"),
); diff != "" {
t.Errorf("GetActor response mismatch (-want +got):\n%s", diff)
}
if getResp.GetStatus().GetLocalSnapshotInfo().GetSnapshotName() == "" {
t.Error("LocalSnapshotInfo.SnapshotName is empty, want the name the pause checkpointed under")
}
}
// Pause stamps the ref identity before resolving the Actor record, so a failed
// lookup still carries who/where; it must not invent template/version, which are
// known only once the record resolves (and stamped on success).
func TestPauseActor_FailedLookupStampsRefIdentityOnly(t *testing.T) {
ns := namespaceForTest("ns-span-pause-err")
tc := setupTest(t, ns)
defer tc.cleanup()
attrs := recordRootSpanAttrs(t, func(ctx context.Context) {
if _, err := tc.service.PauseActor(ctx, &ateapipb.PauseActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: testActorID},
}); status.Code(err) != codes.NotFound {
t.Fatalf("PauseActor(missing) error = %v, want code NotFound", err)
}
})
assertSpanStr(t, attrs, ateattr.AtespaceKey, testAtespace)
assertSpanStr(t, attrs, ateattr.ActorNameKey, testActorID)
for _, k := range []attribute.Key{ateattr.ActorUIDKey, ateattr.TemplateNameKey, ateattr.TemplateAtespaceKey, ateattr.ActorVersionKey} {
if _, ok := attrs[k]; ok {
t.Errorf("unexpected %s on failed-pause span", k)
}
}
}
// TestResumeActor_ReleasesStaleWorkerWhenPoolBecomesIneligible verifies that
// a worker claimed by a failed resume attempt is released back to the free
// pool if, by the next resume attempt, the actor's worker_selector has
// changed such that the worker's pool is no longer eligible. The actor
// itself is crashed rather than transparently migrated to another pool.
// Workflow:
// 1. Creates pool-a (tier=a) and pool-b (tier=b), and an actor narrowed to
// tier=a.
// 2. Makes the fake atelet fail Run, then resumes: the actor gets assigned
// to worker-a (the only eligible pool) and the resume fails after the
// worker is claimed, leaving worker-a's actor assignment set and the actor
// stuck in RESUMING.
// 3. Updates the actor's selector to tier=b, making pool-a ineligible.
// 4. Resumes again; asserts it fails and the actor is CRASHED, that worker-a
// has been released (actor assignment cleared) rather than left dangling,
// and that worker-b remains free (the crashed actor must not claim it).
func TestResumeActor_ReleasesStaleWorkerWhenPoolBecomesIneligible(t *testing.T) {
ns := namespaceForTest("ns-resume-release-stale")
tc := setupTest(t, ns)
defer tc.cleanup()
createWorkerPool(t, tc, ns, "pool-a", map[string]string{"group": ns, "tier": "a"})
createWorkerPool(t, tc, ns, "pool-b", map[string]string{"group": ns, "tier": "b"})
createTemplateWithSelector(t, tc, "tmpl1", &ateapipb.Selector{
MatchLabels: map[string]string{"group": ns},
})
createWorkerPod(t, tc, ns, "worker-a", "node1", "pool-a")
createWorkerPod(t, tc, ns, "worker-b", "node1", "pool-b")
name := "id1"
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "a"}},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
tc.fakeAtelet.FailRun = fmt.Errorf("mock atelet failure")
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}})
if err == nil {
t.Fatalf("expected first ResumeActor (onto worker-a) to fail")
}
tc.fakeAtelet.FailRun = nil
current, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
current.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"tier": "b"}}
if _, err := tc.client.UpdateActor(context.Background(), &ateapipb.UpdateActorRequest{
Actor: current,
}); err != nil {
t.Fatalf("UpdateActor failed: %v", err)
}
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}}); err == nil {
t.Fatalf("expected second ResumeActor to fail: the assigned worker's pool is no longer eligible")
}
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
if got := getResp.GetStatus().GetState(); got != ateapipb.ActorState_ACTOR_STATE_CRASHED {
t.Errorf("expected actor state CRASHED, got %v", got)
}
listResp, err := tc.client.ListWorkers(context.Background(), &ateapipb.ListWorkersRequest{})
if err != nil {
t.Fatalf("ListWorkers failed: %v", err)
}
for _, w := range listResp.GetWorkers() {
if w.GetWorkerNamespace() != ns {
continue
}
switch w.GetWorkerPool() {
case "pool-a":
if n := w.GetStatus().GetAllocated().GetActors(); n != 0 {
t.Errorf("expected worker-a (now-ineligible pool-a) to be released, still holds %d actors", n)
}
case "pool-b":
if n := w.GetStatus().GetAllocated().GetActors(); n != 0 {
t.Errorf("expected worker-b to stay free (actor crashed, not migrated), holds %d actors", n)
}
}
}
}
// TestResumeActor_ReleasesDrainingWorkerFromPriorAttempt exercises the reuse-loop
// change in AssignWorkerStep.Execute: a worker still assigned to the actor from a
// previous (failed) attempt that has since entered DRAINING must not be reused —
// it is released and the actor is crashed.
func TestResumeActor_CrashesIfAssignedWorkerIsDraining(t *testing.T) {
ns := namespaceForTest("ns-resume-release-draining")
tc := setupTest(t, ns)
defer tc.cleanup()
// createTemplate sets up pool1 (labeled pool=<ns>) + tmpl1 (selecting it) with
// a golden snapshot, so resume drives Restore. Two workers share the pool.
createTemplate(t, tc, ns)
createWorkerPod(t, tc, ns, "worker-a", "node1", "pool1")
createWorkerPod(t, tc, ns, "worker-b", "node1", "pool1")
id := "id1"
if _, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{
Atespace: testAtespace,
Name: id,
},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
},
}); err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
// First resume fails after a worker is assigned, leaving the actor bound to
// that worker from a prior attempt.
tc.fakeAtelet.FailRestore = fmt.Errorf("mock atelet failure")
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: id}}); err == nil {
t.Fatalf("expected first ResumeActor to fail")
}
tc.fakeAtelet.FailRestore = nil
// Learn which worker got assigned (findFreeWorker shuffles), then mark it
// DRAINING as the syncer would when its pod enters Terminating.
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: id}})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
assignedPod := getResp.GetStatus().GetWorkerAssignment().GetWorkerPod()
if assignedPod == "" {
t.Fatalf("expected actor to be bound to a worker after the failed attempt")
}
assigned, err := tc.persistence.GetWorker(context.Background(), getResp.GetStatus().GetWorkerAssignment().GetWorker().GetName())
if err != nil {
t.Fatalf("GetWorker(%s) failed: %v", assignedPod, err)
}
if _, err := tc.persistence.UpdateWorker(context.Background(), assigned.GetMetadata().GetName(), store.PreconditionFrom(assigned), func(toUpdate *ateapipb.Worker) error {
toUpdate.Status.State = ateapipb.WorkerState_WORKER_STATE_DRAINING
return nil
}); err != nil {
t.Fatalf("marking worker %s draining failed: %v", assignedPod, err)
}
// Wait until the DRAINING state is observable, which also gives the store
// watch time to propagate it into the scheduler's worker cache.
if err := wait.PollUntilContextTimeout(context.Background(), 100*time.Millisecond, 5*time.Second, true, func(ctx context.Context) (bool, error) {
resp, err := tc.client.ListWorkers(ctx, &ateapipb.ListWorkersRequest{})
if err != nil {
return false, nil
}
for _, w := range resp.GetWorkers() {
if w.GetWorkerNamespace() == ns && w.GetWorkerPod() == assignedPod {
return w.GetStatus().GetState() == ateapipb.WorkerState_WORKER_STATE_DRAINING, nil
}
}
return false, nil
}); err != nil {
t.Fatalf("worker %s did not reach DRAINING: %v", assignedPod, err)
}
// Second resume must fail and crash the actor because its worker is draining.
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: id}})
if err == nil {
t.Fatalf("expected second ResumeActor to fail")
}
if status.Code(err) != codes.Aborted || !strings.Contains(err.Error(), "crashed") {
t.Errorf("expected Aborted/crashed error, got %v", err)
}
getResp, err = tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: id}})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
if got := getResp.GetStatus().GetState(); got != ateapipb.ActorState_ACTOR_STATE_CRASHED {
t.Errorf("expected actor state CRASHED, got %v", got)
}
if got := getResp.GetStatus().GetWorkerAssignment().GetWorkerPod(); got != "" {
t.Errorf("expected actor pod name to be empty, got %q", got)
}
// The draining worker must have been released.
listResp, err := tc.client.ListWorkers(context.Background(), &ateapipb.ListWorkersRequest{})
if err != nil {
t.Fatalf("ListWorkers failed: %v", err)
}
for _, w := range listResp.GetWorkers() {
if w.GetWorkerNamespace() != ns {
continue
}
if w.GetWorkerPod() == assignedPod {
if n := w.GetStatus().GetAllocated().GetActors(); n != 0 {
t.Errorf("expected draining worker %q to be released, still holds %d actors", assignedPod, n)
}
}
}
}
// TestUpdateActor_ReassignsPoolAcrossSuspendResume verifies that updating an
// actor's worker_selector moves it onto a different eligible pool not just
// on the next fresh resume, but also across a full suspend/resume cycle of
// an already-running actor.
// Workflow:
// 1. Creates two WorkerPools, pool-a (tier=a) and pool-b (tier=b), both
// under the template's gating selector.
// 2. Creates an actor narrowed to tier=a and resumes it; asserts it lands on
// pool-a/worker-a.
// 3. Updates the actor's selector to tier=b while it's still running.
// 4. Suspends then resumes the actor; asserts it now lands on
// pool-b/worker-b, proving the updated selector — not the one in effect
// when it was first scheduled — governs the new placement.
func TestUpdateActor_ReassignsPoolAcrossSuspendResume(t *testing.T) {
ns := namespaceForTest("ns-update-actor-suspend-resume")
tc := setupTest(t, ns)
defer tc.cleanup()
createWorkerPool(t, tc, ns, "pool-a", map[string]string{"group": ns, "tier": "a"})
createWorkerPool(t, tc, ns, "pool-b", map[string]string{"group": ns, "tier": "b"})
createTemplateWithSelector(t, tc, "tmpl1", &ateapipb.Selector{
MatchLabels: map[string]string{"group": ns},
})
createWorkerPod(t, tc, ns, "worker-a", "node1", "pool-a")
createWorkerPod(t, tc, ns, "worker-b", "node1", "pool-b")
name := "id1"
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
WorkerSelector: &ateapipb.Selector{
MatchLabels: map[string]string{"tier": "a"},
},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}}); err != nil {
t.Fatalf("first ResumeActor failed: %v", err)
}
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
if got := getResp.GetStatus().GetWorkerAssignment().GetWorkerPool(); got != "pool-a" {
t.Fatalf("expected actor to first resume onto pool-a, got worker_assignment.worker_pool=%q", got)
}
if got := getResp.GetStatus().GetWorkerAssignment().GetWorkerPod(); got != "worker-a" {
t.Fatalf("expected actor to first resume onto worker-a, got worker_assignment.worker_pod=%q", got)
}
getResp.WorkerSelector = &ateapipb.Selector{MatchLabels: map[string]string{"tier": "b"}}
if _, err := tc.client.UpdateActor(context.Background(), &ateapipb.UpdateActorRequest{
Actor: getResp,
}); err != nil {
t.Fatalf("UpdateActor failed: %v", err)
}
if _, err := tc.client.SuspendActor(context.Background(), &ateapipb.SuspendActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}}); err != nil {
t.Fatalf("SuspendActor failed: %v", err)
}
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}}); err != nil {
t.Fatalf("second ResumeActor failed: %v", err)
}
getResp, err = tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
if got := getResp.GetStatus().GetWorkerAssignment().GetWorkerPool(); got != "pool-b" {
t.Errorf("expected actor to resume onto pool-b after selector update, got worker_assignment.worker_pool=%q", got)
}
if got := getResp.GetStatus().GetWorkerAssignment().GetWorkerPod(); got != "worker-b" {
t.Errorf("expected actor to resume onto worker-b after selector update, got worker_assignment.worker_pod=%q", got)
}
if got := getResp.GetStatus().GetState(); got != ateapipb.ActorState_ACTOR_STATE_RUNNING {
t.Errorf("expected actor state RUNNING after second resume, got %v", got)
}
}
func TestResumeActor_LeaseConflict(t *testing.T) {
ns := namespaceForTest("ns-resume-conflict")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
name := "id1"
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
// Set a delay on the fake Atelet to hold the lease
tc.fakeAtelet.RestoreDelay = 1 * time.Second
// Launch Request A in a goroutine
errChan := make(chan error, 1)
go func() {
_, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
errChan <- err
}()
// Sleep a bit to ensure Request A acquired the lease
time.Sleep(200 * time.Millisecond)
// Launch Request B (should fail due to lease conflict)
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
assertGrpcError(t, err, codes.Aborted, "another operation is in progress for this actor")
// Wait for Request A to finish
if errA := <-errChan; errA != nil {
t.Fatalf("Request A failed: %v", errA)
}
}
func TestResumeActor_DanglingWorker(t *testing.T) {
ns := namespaceForTest("ns-resume-dangling")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
// 1. Create Worker Pod A
createWorkerPod(t, tc, ns, "worker-a", "node1", "pool1")
name := "id1"
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
// 2. Configure fake Atelet to FAIL on Restore!
tc.fakeAtelet.FailRestore = fmt.Errorf("mock atelet failure")
// 3. Call ResumeActor -> Expect failure
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err == nil {
t.Fatalf("expected ResumeActor to fail due to atelet error")
}
// Verify actor state is RESUMING with worker A assigned
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
actor := getResp
if actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_RESUMING {
t.Fatalf("expected state RESUMING, got %v", actor.GetStatus().GetState())
}
if actor.GetStatus().GetWorkerAssignment().GetWorkerPod() != "worker-a" {
t.Fatalf("expected worker-a assigned, got %v", actor.GetStatus().GetWorkerAssignment().GetWorkerPod())
}
deleteWorkerPod(t, tc, ns, "worker-a")
// 6. Create Worker Pod B
createWorkerPod(t, tc, ns, "worker-b", "node1", "pool1")
// 7. Configure fake Atelet to SUCCEED on Restore
tc.fakeAtelet.FailRestore = nil
tc.fakeAtelet.RestoreCalled = false // reset
// 8. Call ResumeActor again -> Expect it to fail because it is already CRASHED by background syncer.
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err == nil {
t.Fatalf("expected ResumeActor to fail because worker is gone")
}
if status.Code(err) != codes.FailedPrecondition || !strings.Contains(err.Error(), "ACTOR_STATE_CRASHED") {
t.Errorf("expected FailedPrecondition/ACTOR_STATE_CRASHED error, got %v", err)
}
// Verify actor state is CRASHED and worker assignment is empty
actor, err = tc.persistence.GetActor(context.Background(), resources.ActorRef{Atespace: testAtespace, Name: name})
if err != nil {
t.Fatalf("failed to get actor from store: %v", err)
}
if actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_CRASHED {
t.Errorf("expected state CRASHED, got %v", actor.GetStatus().GetState())
}
if actor.GetStatus().GetWorkerAssignment().GetWorkerPod() != "" {
t.Errorf("expected worker to be unassigned, got %v", actor.GetStatus().GetWorkerAssignment().GetWorkerPod())
}
}
func TestSuspendActor_DanglingWorker(t *testing.T) {
ns := namespaceForTest("ns-sd")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
// 1. Create Worker Pod
createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
name := "id1"
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
// Resume first to make it running
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}
deleteWorkerPod(t, tc, ns, "worker-1")
// 3. Call SuspendActor -> Expect it to fail because it is already CRASHED by background syncer
_, err = tc.client.SuspendActor(context.Background(), &ateapipb.SuspendActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err == nil {
t.Fatalf("expected SuspendActor to fail because worker is gone")
}
if status.Code(err) != codes.FailedPrecondition || !strings.Contains(err.Error(), "ACTOR_STATE_CRASHED") {
t.Errorf("expected FailedPrecondition error, got %v", err)
}
// 4. Verify it becomes CRASHED in the store.
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
if getResp.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_CRASHED {
t.Errorf("expected status CRASHED, got %v", getResp.GetStatus())
}
if getResp.GetStatus().GetWorkerAssignment() != nil {
t.Errorf("expected worker_assignment to be cleared, got %v", getResp.GetStatus().GetWorkerAssignment())
}
}
// TestSuspendActor_FromPaused suspends a PAUSED actor end-to-end: instead of
// checkpointing a running workload, ateapi asks the atelet on the node
// holding the pause snapshot to upload it, then finalizes the actor with a
// durable ActorSnapshot and no node pinning left behind.
func TestSuspendActor_FromPaused(t *testing.T) {
ns := namespaceForTest("ns-suspend-paused")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
name := "id1"
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
}); err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}
if _, err := tc.client.PauseActor(context.Background(), &ateapipb.PauseActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
}); err != nil {
t.Fatalf("PauseActor failed: %v", err)
}
paused, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
// Drop the pause's Checkpoint call so the suspend's atelet traffic is
// observable in isolation.
tc.fakeAtelet.Reset()
suspended, err := tc.client.SuspendActor(context.Background(), &ateapipb.SuspendActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("SuspendActor failed: %v", err)
}
if !tc.fakeAtelet.UploadCalled {
t.Fatal("expected atelet UploadPausedCheckpoint to be called")
}
if tc.fakeAtelet.CheckpointCalled {
t.Error("atelet Checkpoint called for a paused actor; there is no workload to checkpoint")
}
upload := tc.fakeAtelet.UploadRequest
if got, want := upload.GetLocalSnapshotName(), paused.GetStatus().GetLocalSnapshotInfo().GetSnapshotName(); got != want {
t.Errorf("upload local_snapshot_name = %q, want the pause snapshot %q", got, want)
}
if got, want := upload.GetAtespace(), testAtespace; got != want {
t.Errorf("upload atespace = %q, want %q", got, want)
}
if got := upload.GetDesiredScope(); got != ateletpb.SnapshotScope_SNAPSHOT_SCOPE_FULL {
t.Errorf("upload desired_scope = %v, want FULL (template default)", got)
}
actor := suspended.GetActor()
if actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDED {
t.Errorf("state = %v, want SUSPENDED", actor.GetStatus().GetState())
}
if actor.GetStatus().GetLocalSnapshotInfo() != nil {
t.Errorf("LocalSnapshotInfo = %v, want cleared (node pinning must not survive suspend)", actor.GetStatus().GetLocalSnapshotInfo())
}
if got, want := actor.GetStatus().GetExternalSnapshot().GetSnapshotUri(), upload.GetDestinationSnapshotUri(); got != want {
t.Errorf("snapshot URI = %q, want the upload destination %q", got, want)
}
if got := actor.GetStatus().GetExternalSnapshot().GetContentScope(); got != ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_FULL {
t.Errorf("snapshot ContentScope = %v, want FULL", got)
}
}
// TestSuspendActor_FromPaused_RetryAfterUploadFailure exercises client-driven
// forward recovery on the paused path: a failed upload leaves the actor
// SUSPENDING with its local snapshot record intact, and a retry completes the
// suspend against the same destination.
func TestSuspendActor_FromPaused_RetryAfterUploadFailure(t *testing.T) {
ns := namespaceForTest("ns-suspend-paused-retry")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
name := "id1"
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
}); err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}
if _, err := tc.client.PauseActor(context.Background(), &ateapipb.PauseActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
}); err != nil {
t.Fatalf("PauseActor failed: %v", err)
}
tc.fakeAtelet.Reset()
tc.fakeAtelet.FailUpload = status.Error(codes.Unavailable, "injected upload failure")
if _, err := tc.client.SuspendActor(context.Background(), &ateapipb.SuspendActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
}); err == nil {
t.Fatal("SuspendActor succeeded despite failing upload")
}
stuck, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("GetActor failed: %v", err)
}
if stuck.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDING {
t.Fatalf("state after failed upload = %v, want SUSPENDING (retryable)", stuck.GetStatus().GetState())
}
if stuck.GetStatus().GetLocalSnapshotInfo() == nil {
t.Fatal("LocalSnapshotInfo cleared by a failed upload; the retry could never find the snapshot")
}
firstDestination := tc.fakeAtelet.UploadRequest.GetDestinationSnapshotUri()
tc.fakeAtelet.Lock.Lock()
tc.fakeAtelet.FailUpload = nil
tc.fakeAtelet.Lock.Unlock()
retried, err := tc.client.SuspendActor(context.Background(), &ateapipb.SuspendActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name},
})
if err != nil {
t.Fatalf("SuspendActor retry failed: %v", err)
}
if got := retried.GetActor().GetStatus().GetState(); got != ateapipb.ActorState_ACTOR_STATE_SUSPENDED {
t.Errorf("state after retry = %v, want SUSPENDED", got)
}
if got := tc.fakeAtelet.UploadRequest.GetDestinationSnapshotUri(); got != firstDestination {
t.Errorf("retry destination = %q, want the original %q (idempotent upload target)", got, firstDestination)
}
}
// TestResumeActor_RelocatesAfterSuspendFromPaused covers the capacity-recovery
// flow: a PAUSED actor is pinned to the node holding its local snapshot, so it
// cannot resume while that node is full. Suspending it uploads the snapshot and
// drops the node pinning, after which it is scheduled onto a worker on a different
// node.
func TestResumeActor_RelocatesAfterSuspendFromPaused(t *testing.T) {
ns := namespaceForTest("ns-resume-relocate")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
workerName := createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
const pinned, relocated = "actor-pinned", "actor-squatter"
for _, name := range []string{pinned, relocated} {
if _, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: name},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}}); err != nil {
t.Fatalf("CreateActor(%s) failed: %v", name, err)
}
}
// The actor under test runs on node1's only worker, then pauses — which
// frees that worker but pins the actor to node1 via LocalSnapshotInfo.
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: pinned},
}); err != nil {
t.Fatalf("ResumeActor(%s) failed: %v", pinned, err)
}
if _, err := tc.client.PauseActor(context.Background(), &ateapipb.PauseActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: pinned},
}); err != nil {
t.Fatalf("PauseActor(%s) failed: %v", pinned, err)
}
paused, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: pinned},
})
if err != nil {
t.Fatalf("GetActor(%s) failed: %v", pinned, err)
}
if got := paused.GetStatus().GetLocalSnapshotInfo().GetNodeVmsWithLocalSnapshots(); len(got) != 1 || got[0] != "node1" {
t.Fatalf("paused actor pinned to %v, want [node1]", got)
}
waitForWorkerAvailable(t, tc, workerName)
// Another actor takes node1's only worker, so the pinned actor's node is full
// while free capacity exists elsewhere.
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: relocated},
}); err != nil {
t.Fatalf("ResumeActor(%s) failed: %v", relocated, err)
}
createWorkerPod(t, tc, ns, "worker-2", "node2", "pool1")
setupAteletOnNode(t, tc, "atelet-node2", "node2")
// Capacity exhaustion is ResourceExhausted.
_, err = tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: pinned},
})
assertGrpcError(t, err, codes.ResourceExhausted, "no free workers available")
suspended, err := tc.client.SuspendActor(context.Background(), &ateapipb.SuspendActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: pinned},
})
if err != nil {
t.Fatalf("SuspendActor(%s) failed: %v", pinned, err)
}
if got := suspended.GetActor().GetStatus().GetState(); got != ateapipb.ActorState_ACTOR_STATE_SUSPENDED {
t.Fatalf("state after suspend = %v, want SUSPENDED", got)
}
if got := suspended.GetActor().GetStatus().GetLocalSnapshotInfo(); got != nil {
t.Fatalf("LocalSnapshotInfo = %v, want cleared so the actor can be scheduled anywhere", got)
}
// Resume should succeed now and the actor scheduled on node2.
resumed, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: pinned},
})
if err != nil {
t.Fatalf("ResumeActor(%s) after suspend failed: %v", pinned, err)
}
if got := resumed.GetActor().GetStatus().GetWorkerAssignment().GetWorkerPod(); got != "worker-2" {
t.Errorf("resumed onto worker %q, want worker-2 (the worker on node2)", got)
}
worker, err := tc.persistence.GetWorker(context.Background(), resumed.GetActor().GetStatus().GetWorkerAssignment().GetWorker().GetName())
if err != nil {
t.Fatalf("GetWorker(worker-2) failed: %v", err)
}
if got := worker.GetNodeName(); got != "node2" {
t.Errorf("worker-2 node = %q, want node2", got)
}
}
// TestLifecycleOpPoolAttributesOnSuccess is the regression test for #957: a
// successful suspend and pause must stamp the pool they ran on. Both recorded
// the histogram from a defer that read the finalized record, whose assignment
// the finalize step had already cleared, so the pair landed only on failures.
func TestLifecycleOpPoolAttributesOnSuccess(t *testing.T) {
tests := []struct {
name string
op string
// run performs the operation on an actor that is already RUNNING.
run func(t *testing.T, tc *testContext, actor *ateapipb.ObjectRef)
}{
{
name: "suspend",
op: ateattr.OperationSuspend,
run: func(t *testing.T, tc *testContext, actor *ateapipb.ObjectRef) {
t.Helper()
if _, err := tc.client.SuspendActor(context.Background(), &ateapipb.SuspendActorRequest{Actor: actor}); err != nil {
t.Fatalf("SuspendActor failed: %v", err)
}
},
},
{
name: "pause",
op: ateattr.OperationPause,
run: func(t *testing.T, tc *testContext, actor *ateapipb.ObjectRef) {
t.Helper()
if _, err := tc.client.PauseActor(context.Background(), &ateapipb.PauseActorRequest{Actor: actor}); err != nil {
t.Fatalf("PauseActor failed: %v", err)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ns := namespaceForTest("ns-lifecycle-pool-" + tt.name)
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
createWorkerPod(t, tc, ns, "worker-1", "node1", "pool1")
actorRef := &ateapipb.ObjectRef{Atespace: testAtespace, Name: "id1"}
if _, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: actorRef.GetAtespace(), Name: actorRef.GetName()},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}}); err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{Actor: actorRef}); err != nil {
t.Fatalf("ResumeActor failed: %v", err)
}
tt.run(t, tc, actorRef)
attrs := lifecycleOpAttributes(t, tc, tt.op)
if got, ok := attrs.Value(ateattr.WorkerPoolNamespaceKey); !ok || got.AsString() != ns {
t.Errorf("%s = %q (present: %v), want %q", ateattr.WorkerPoolNamespaceKey, got.AsString(), ok, ns)
}
if got, ok := attrs.Value(ateattr.WorkerPoolNameKey); !ok || got.AsString() != "pool1" {
t.Errorf("%s = %q (present: %v), want %q", ateattr.WorkerPoolNameKey, got.AsString(), ok, "pool1")
}
// error.type's absence marks a success, so its presence would mean the
// datapoint under test is not the happy path.
if _, ok := attrs.Value(ateattr.ErrorTypeKey); ok {
t.Errorf("%s is set on the %s datapoint, want the successful operation", ateattr.ErrorTypeKey, tt.op)
}
})
}
}
// lifecycleOpAttributes returns the attribute set of the single
// ate.actor.lifecycle.operation.duration datapoint recorded for op.
func lifecycleOpAttributes(t *testing.T, tc *testContext, op string) attribute.Set {
t.Helper()
var rm metricdata.ResourceMetrics
if err := tc.metricReader.Collect(context.Background(), &rm); err != nil {
t.Fatalf("collect metrics: %v", err)
}
var got []attribute.Set
for _, sm := range rm.ScopeMetrics {
for _, m := range sm.Metrics {
if m.Name != "ate.actor.lifecycle.operation.duration" {
continue
}
hist, ok := m.Data.(metricdata.Histogram[float64])
if !ok {
t.Fatalf("%s data type = %T, want a float64 histogram", m.Name, m.Data)
}
for _, dp := range hist.DataPoints {
if v, ok := dp.Attributes.Value(ateattr.ActorOperationNameKey); ok && v.AsString() == op {
got = append(got, dp.Attributes)
}
}
}
}
if len(got) != 1 {
t.Fatalf("datapoints for %s = %d (%v), want exactly one", op, len(got), got)
}
return got[0]
}
// TestCreateActor_RejectsUnknownRequestFields checks that a request carrying a
// field this binary has no descriptor for is refused by the server-wide
// interceptor before it reaches the handler.
func TestCreateActor_RejectsUnknownRequestFields(t *testing.T) {
ns := namespaceForTest("ns-create-unknown-field")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
req := &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}}
unknown := protowire.AppendTag(nil, 9999, protowire.VarintType)
req.ProtoReflect().SetUnknown(protowire.AppendVarint(unknown, 42))
_, err := tc.client.CreateActor(context.Background(), req)
assertGrpcError(t, err, codes.InvalidArgument, "request: Invalid value: unknown field with protobuf tag 9999")
}
// The assignment commits before the Actor is updated to point at it, so a
// crash in between leaves a row no Actor references. Deleting the Actor has to
// release it anyway: nothing else ever would, and its share of the Worker's
// capacity would stay booked until the Worker itself went away.
func TestDeleteActor_ReleasesAnAssignmentTheActorDoesNotReference(t *testing.T) {
ns := namespaceForTest("ns-delete-orphan")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
createWorkerPool(t, tc, ns, "pool-1", nil)
podUID := createWorkerPod(t, tc, ns, "worker-1", "node-1", "pool-1")
ctx := context.Background()
actor, err := tc.client.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "orphaned"},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
actorUID := actor.GetMetadata().GetUid()
// Bind straight through the store, leaving the Actor's backlink unset:
// exactly the state a crash between the two writes leaves behind.
if err := tc.persistence.BindActorToWorker(ctx, podUID, &ateapipb.ActorAssignment{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "orphaned"},
ActorUid: actorUID,
ActorTemplateRef: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
}, nil); err != nil {
t.Fatalf("BindActorToWorker failed: %v", err)
}
if _, err := tc.client.DeleteActor(ctx, &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "orphaned"},
}); err != nil {
t.Fatalf("DeleteActor failed: %v", err)
}
if _, err := tc.persistence.GetWorkerAssignment(ctx, podUID, actorUID); !errors.Is(err, store.ErrNotFound) {
t.Errorf("the orphaned assignment survived DeleteActor: %v", err)
}
worker, err := tc.persistence.GetWorker(ctx, podUID)
if err != nil {
t.Fatalf("GetWorker failed: %v", err)
}
if got := worker.GetStatus().GetAllocated().GetActors(); got != 0 {
t.Errorf("worker still books %d actors after the Actor was deleted, want 0", got)
}
}
func TestMintActorJWT_Success(t *testing.T) {
ns := namespaceForTest("ns-mintactorjwt-success")
tc := setupTest(t, ns)
defer tc.cleanup()
createTemplate(t, tc, ns)
createResp, err := tc.client.CreateActor(t.Context(), &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{
Atespace: testAtespace,
Name: "id1",
},
ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"},
WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "free"}},
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_RUNNING},
},
})
if err != nil {
t.Fatalf("CreateActor failed: %v", err)
}
_, err = tc.client.MintActorJWT(t.Context(), &ateapipb.MintActorJWTRequest{
Actor: &ateapipb.ObjectRef{
Atespace: createResp.GetMetadata().GetAtespace(),
Name: createResp.GetMetadata().GetName(),
},
ActorUid: createResp.GetMetadata().GetUid(),
Audience: []string{"foo"},
})
if err != nil {
t.Fatalf("Error while calling MintActorJWT: %v", err)
}
}