From c0912766a6155c1bcc2af63a3cf9868ac784c95c Mon Sep 17 00:00:00 2001 From: cloudonly Date: Wed, 7 Oct 2026 09:57:03 +0800 Subject: [PATCH 1/2] ateapi: stamp the golden tag into the created Actor's source_tag Creating an Actor from a template with a golden snapshot borrows the golden tag's snapshot into Status.ExternalSnapshot without recording which tag it borrowed. The template's golden_tag pointer was the only record of that reference, so deleting the template left never-resumed actors with no way to name their snapshot source. Default the request's source_tag to the template's golden tag so the stored Actor carries the reference itself. The stamp is applied to a copy of the request, so the caller's message and the RPC log keep reflecting what was sent. Explicit source tags and golden-scope validation are unchanged. Recording the reference also tightens updates: a whole-object UpdateActor that omits source_tag on such an Actor now fails the existing immutability rule. Resume still resolves the template unconditionally, so consuming the recorded reference is future work. --- cmd/ateapi/internal/controlapi/actor.go | 15 +++++++++++++-- cmd/ateapi/internal/controlapi/actor_test.go | 7 +++++++ .../controlapi/functionaltest/actor_test.go | 6 ++++++ docs/api-guide.md | 2 +- pkg/proto/ateapipb/ateapi.pb.go | 7 ++++--- pkg/proto/ateapipb/ateapi.proto | 7 ++++--- 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/cmd/ateapi/internal/controlapi/actor.go b/cmd/ateapi/internal/controlapi/actor.go index a3a75247ed..11ee9b2e32 100644 --- a/cmd/ateapi/internal/controlapi/actor.go +++ b/cmd/ateapi/internal/controlapi/actor.go @@ -88,9 +88,20 @@ func (s *ServiceImpl) CreateActor(ctx context.Context, inActor *ateapipb.Actor) } // Resolve the explicit tag, or freeze the template's current golden default. + goldenDefault := inActor.GetSourceTag() == nil tagRef := inActor.GetSourceTag() - if tagRef == nil { + if goldenDefault { tagRef = template.GetStatus().GetGoldenSnapshotStatus().GetGoldenTag() + if tagRef != nil { + // Default the request's source tag to the golden tag it borrows + // from, so the stored Actor carries the reference itself. The + // template is otherwise the only record of it, and deleting the + // template strands actors that have not resumed yet. Stamp a copy + // of the request: the caller's message, which the RPC interceptor + // logs after the handler returns, keeps reflecting what was sent. + inActor = proto.CloneOf(inActor) + inActor.SourceTag = proto.CloneOf(tagRef) + } } else { for _, volume := range template.GetVolumes() { if volume.GetExternalVolumeTemplate() != nil { @@ -105,7 +116,7 @@ func (s *ServiceImpl) CreateActor(ctx context.Context, inActor *ateapipb.Actor) if err != nil { return nil, err } - if inActor.GetSourceTag() == nil { + if goldenDefault { if err := validateGoldenSnapshotFidelity(sourceTag.GetStatus().GetSnapshot()); err != nil { return nil, err } diff --git a/cmd/ateapi/internal/controlapi/actor_test.go b/cmd/ateapi/internal/controlapi/actor_test.go index 6a9eb5b627..d296f862c4 100644 --- a/cmd/ateapi/internal/controlapi/actor_test.go +++ b/cmd/ateapi/internal/controlapi/actor_test.go @@ -862,6 +862,13 @@ func TestCreateActor_GoldenTagDefault(t *testing.T) { if got := created.GetStatus(); got.GetExternalSnapshot().GetSnapshotUri() != tag.GetStatus().GetSnapshot().GetSnapshotUri() || got.GetExternalSnapshot().GetActorTemplateUid() != tmpl.GetMetadata().GetUid() { t.Fatalf("incorrect initial status: %v", got) } + wantTag := ref + if scenario == "explicit tag" { + wantTag = actor.GetSourceTag() + } + if got := created.GetSourceTag(); got.GetAtespace() != wantTag.GetAtespace() || got.GetName() != wantTag.GetName() { + t.Fatalf("source tag = %v, want %v", got, wantTag) + } if scenario == "own snapshot" { uri, err := resources.NewActorSnapshotURI(tmpl.GetSnapshotConfig().GetStorageLocation(), "team-a", created.GetMetadata().GetUid(), "snapshot") if err != nil { diff --git a/cmd/ateapi/internal/controlapi/functionaltest/actor_test.go b/cmd/ateapi/internal/controlapi/functionaltest/actor_test.go index 6eba54a915..f08b1e116c 100644 --- a/cmd/ateapi/internal/controlapi/functionaltest/actor_test.go +++ b/cmd/ateapi/internal/controlapi/functionaltest/actor_test.go @@ -77,6 +77,7 @@ func TestCreateActor_Success(t *testing.T) { want := &ateapipb.Actor{ Metadata: &ateapipb.ResourceMetadata{Name: "id1", Atespace: testAtespace, Version: 1}, ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"}, + SourceTag: &ateapipb.ObjectRef{Atespace: resources.GoldenActorAtespace, Name: tmpl.GetMetadata().GetUid()}, Status: &ateapipb.ActorStatus{ State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED, ExternalSnapshot: &ateapipb.ExternalSnapshot{SnapshotUri: goldenSnapshotURI(t), Fidelity: ateapipb.SnapshotFidelity_SNAPSHOT_FIDELITY_MEMORY, ActorTemplateUid: tmpl.GetMetadata().GetUid()}, @@ -698,6 +699,7 @@ func TestUpdateActor_Success(t *testing.T) { wantActor := &ateapipb.Actor{ Metadata: &ateapipb.ResourceMetadata{Name: "id1", Atespace: testAtespace, Version: 2}, ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"}, + SourceTag: &ateapipb.ObjectRef{Atespace: resources.GoldenActorAtespace, Name: tmpl.GetMetadata().GetUid()}, Status: &ateapipb.ActorStatus{ State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED, ExternalSnapshot: &ateapipb.ExternalSnapshot{SnapshotUri: goldenSnapshotURI(t), Fidelity: ateapipb.SnapshotFidelity_SNAPSHOT_FIDELITY_MEMORY, ActorTemplateUid: tmpl.GetMetadata().GetUid()}, @@ -844,6 +846,7 @@ func TestUpdateActor(t *testing.T) { wantActor := &ateapipb.Actor{ Metadata: &ateapipb.ResourceMetadata{Name: "id1", Atespace: testAtespace, Version: 2}, ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"}, + SourceTag: &ateapipb.ObjectRef{Atespace: resources.GoldenActorAtespace, Name: tmpl.GetMetadata().GetUid()}, Status: &ateapipb.ActorStatus{ State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED, ExternalSnapshot: &ateapipb.ExternalSnapshot{SnapshotUri: goldenSnapshotURI(t), Fidelity: ateapipb.SnapshotFidelity_SNAPSHOT_FIDELITY_MEMORY, ActorTemplateUid: tmpl.GetMetadata().GetUid()}, @@ -2907,6 +2910,7 @@ func TestResumeActor(t *testing.T) { want := &ateapipb.Actor{ Metadata: &ateapipb.ResourceMetadata{Name: name, Atespace: testAtespace}, ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"}, + SourceTag: &ateapipb.ObjectRef{Atespace: resources.GoldenActorAtespace, Name: tmpl.GetMetadata().GetUid()}, Status: &ateapipb.ActorStatus{ State: ateapipb.ActorState_ACTOR_STATE_RUNNING, AssignedNode: "node1", @@ -3746,6 +3750,7 @@ func TestSuspendActor(t *testing.T) { want := &ateapipb.Actor{ Metadata: &ateapipb.ResourceMetadata{Name: name, Atespace: testAtespace}, ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"}, + SourceTag: &ateapipb.ObjectRef{Atespace: resources.GoldenActorAtespace, Name: tmpl.GetMetadata().GetUid()}, Status: &ateapipb.ActorStatus{ State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED, ExternalSnapshot: &ateapipb.ExternalSnapshot{ @@ -4038,6 +4043,7 @@ func TestPauseActor(t *testing.T) { want := &ateapipb.Actor{ Metadata: &ateapipb.ResourceMetadata{Name: name, Atespace: testAtespace}, ActorTemplate: &ateapipb.ObjectRef{Atespace: testAtespace, Name: "tmpl1"}, + SourceTag: &ateapipb.ObjectRef{Atespace: resources.GoldenActorAtespace, Name: tmpl.GetMetadata().GetUid()}, Status: &ateapipb.ActorStatus{ State: ateapipb.ActorState_ACTOR_STATE_PAUSED, AssignedNode: "node1", diff --git a/docs/api-guide.md b/docs/api-guide.md index 3c07ad6e16..efd78fbcc6 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -465,7 +465,7 @@ When an `ActorTemplate` is created: 3. It creates a published tag named after the template UID, copying the snapshot into tag-owned storage. 4. It deletes the golden actor and records the tag reference in the template status. -`CreateActor` uses an explicit `sourceTag` when supplied; otherwise it resolves the template's golden tag and records that snapshot on the new actor. If the golden tag is not ready yet, the actor starts without a snapshot and cold-boots even if the tag becomes ready before its first resume. The default does not populate the caller-owned `sourceTag` field. Deleting the template collects its golden tag and any unfinished golden actor. +`CreateActor` uses an explicit `sourceTag` when supplied; otherwise it resolves the template's golden tag, records that snapshot on the new actor, and stamps the golden tag into the actor's `sourceTag`. If the golden tag is not ready yet, the actor starts without a snapshot and cold-boots even if the tag becomes ready before its first resume. Deleting the template collects its golden tag and any unfinished golden actor. ### Resumption Lifecycle Once a template is `Ready`, creating an actor logically (via `kubectl ate create actor`) allows it to be resumed instantly on any free worker in the referenced `WorkerPool`. Substrate bypasses the standard container boot and restores the process directly from its last saved state. diff --git a/pkg/proto/ateapipb/ateapi.pb.go b/pkg/proto/ateapipb/ateapi.pb.go index 10bd750fae..680bb17199 100644 --- a/pkg/proto/ateapipb/ateapi.pb.go +++ b/pkg/proto/ateapipb/ateapi.pb.go @@ -889,9 +889,10 @@ type Actor struct { // // +k8s:optional WorkerSelector *Selector `protobuf:"bytes,5,opt,name=worker_selector,json=workerSelector,proto3" json:"worker_selector,omitempty"` - // The tag specified by the caller at CreateActor to seed this Actor from an - // ActorSnapshot. Unset if the Actor was not created from a snapshot. Set - // once at creation and immutable afterward. + // The tag this Actor was seeded from, an ActorSnapshot's tag. Set at + // CreateActor: to the caller-specified tag when one is supplied, otherwise + // defaulted to the template's golden tag when the template has one. Unset + // if the Actor was not created from a snapshot. Immutable afterward. // // +k8s:optional // +k8s:subfield(atespace)=+k8s:required diff --git a/pkg/proto/ateapipb/ateapi.proto b/pkg/proto/ateapipb/ateapi.proto index 4f51e88cfa..b3b383fb0d 100644 --- a/pkg/proto/ateapipb/ateapi.proto +++ b/pkg/proto/ateapipb/ateapi.proto @@ -398,9 +398,10 @@ message Actor { // +k8s:optional Selector worker_selector = 5; - // The tag specified by the caller at CreateActor to seed this Actor from an - // ActorSnapshot. Unset if the Actor was not created from a snapshot. Set - // once at creation and immutable afterward. + // The tag this Actor was seeded from, an ActorSnapshot's tag. Set at + // CreateActor: to the caller-specified tag when one is supplied, otherwise + // defaulted to the template's golden tag when the template has one. Unset + // if the Actor was not created from a snapshot. Immutable afterward. // // +k8s:optional // +k8s:subfield(atespace)=+k8s:required From ad895b9446c89b476e1df0c5fa67144ecb12f31f Mon Sep 17 00:00:00 2001 From: cloudonly Date: Wed, 7 Oct 2026 10:17:00 +0800 Subject: [PATCH 2/2] ateapi: verify golden creation preserves the caller request --- cmd/ateapi/internal/controlapi/actor_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/ateapi/internal/controlapi/actor_test.go b/cmd/ateapi/internal/controlapi/actor_test.go index d296f862c4..f87e6c29ac 100644 --- a/cmd/ateapi/internal/controlapi/actor_test.go +++ b/cmd/ateapi/internal/controlapi/actor_test.go @@ -38,6 +38,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" "google.golang.org/grpc/peer" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/timestamppb" "k8s.io/apimachinery/pkg/util/validation/field" @@ -852,7 +853,11 @@ func TestCreateActor_GoldenTagDefault(t *testing.T) { actor.SourceTag = &ateapipb.ObjectRef{Atespace: ref.Atespace, Name: "explicit"} } svc := &ServiceImpl{store: persistence} + original := proto.CloneOf(actor) created, err := svc.CreateActor(ctx, actor) + if diff := cmp.Diff(original, actor, protocmp.Transform()); diff != "" { + t.Fatalf("CreateActor mutated input (-want +got):\n%s", diff) + } if apierror.Code(err) != wantCode { t.Fatalf("CreateActor = %v, want %v", err, wantCode) }