mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-10-02 03:24:42 +08:00
ateapi: store self-describing snapshots under stable paths
This commit is contained in:
@@ -2049,7 +2049,7 @@ func TestSuspendActor(t *testing.T) {
|
||||
LatestSnapshotInfo: &ateapipb.SnapshotInfo{
|
||||
Data: &ateapipb.SnapshotInfo_External{
|
||||
External: &ateapipb.ExternalSnapshotInfo{
|
||||
SnapshotUriPrefix: fmt.Sprintf("gs://fake-fake-fake/%s/", name),
|
||||
SnapshotUriPrefix: "gs://fake-fake-fake/snapshots/",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -94,7 +94,7 @@ func (s *MarkSuspendingStep) CheckPrerequisite(ctx context.Context, input *Suspe
|
||||
func (s *MarkSuspendingStep) Execute(ctx context.Context, input *SuspendInput, state *SuspendState) error {
|
||||
state.Actor.Status = ateapipb.Actor_STATUS_SUSPENDING
|
||||
snapshotID := time.Now().Format(time.RFC3339) + "-" + rand.Text()
|
||||
state.Actor.InProgressSnapshot = strings.TrimSuffix(state.ActorTemplate.Spec.SnapshotsConfig.Location, "/") + "/" + input.ActorRef.Name + "/" + snapshotID
|
||||
state.Actor.InProgressSnapshot = strings.TrimSuffix(state.ActorTemplate.Spec.SnapshotsConfig.Location, "/") + "/snapshots/" + snapshotID
|
||||
updatedActor, err := s.store.UpdateActor(ctx, state.Actor, state.Actor.GetMetadata().GetVersion())
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -16,12 +16,14 @@ package controlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
|
||||
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store/ateredis"
|
||||
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store/storetest"
|
||||
"github.com/agent-substrate/substrate/internal/resources"
|
||||
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
|
||||
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -30,6 +32,36 @@ import (
|
||||
"k8s.io/client-go/tools/cache"
|
||||
)
|
||||
|
||||
func TestMarkSuspendingStep_SnapshotLocation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
persistence := newTestPersistence(t)
|
||||
actor, err := persistence.CreateActor(ctx, &ateapipb.Actor{
|
||||
Metadata: &ateapipb.ResourceMetadata{Atespace: "team-a", Name: "actor-1"},
|
||||
Status: ateapipb.Actor_STATUS_RUNNING,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateActor: %v", err)
|
||||
}
|
||||
state := &SuspendState{
|
||||
Actor: actor,
|
||||
ActorTemplate: &atev1alpha1.ActorTemplate{Spec: atev1alpha1.ActorTemplateSpec{
|
||||
SnapshotsConfig: atev1alpha1.SnapshotsConfig{Location: "gs://bucket/root/"},
|
||||
}},
|
||||
}
|
||||
if err := (&MarkSuspendingStep{store: persistence}).Execute(ctx, &SuspendInput{ActorRef: resources.ActorRef{Atespace: "team-a", Name: "actor-1"}}, state); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
const prefix = "gs://bucket/root/snapshots/"
|
||||
snapshotID, ok := strings.CutPrefix(state.Actor.GetInProgressSnapshot(), prefix)
|
||||
if !ok {
|
||||
t.Fatalf("snapshot location = %q, want prefix %q", state.Actor.GetInProgressSnapshot(), prefix)
|
||||
}
|
||||
if snapshotID == "" {
|
||||
t.Fatal("snapshot ID is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuspendActorWorkflow_RejectedAndIdempotentPaths covers the two
|
||||
// short-circuit paths of the suspend workflow: rejection by
|
||||
// MarkSuspendingStep's CheckPrerequisite and the IsComplete idempotent
|
||||
|
||||
+7
-4
@@ -389,6 +389,11 @@ func (s *AteomHerder) Checkpoint(ctx context.Context, req *ateletpb.CheckpointRe
|
||||
if len(sandboxRec.SnapshotFiles) == 0 {
|
||||
return nil, ateerrors.NewGRPCError(ctx, codes.DataLoss, ateerrors.ReasonInvalidCheckpointResult, ateerrors.ActorCrashedMetadata(), errors.New("ateom reported no snapshot files for checkpoint"))
|
||||
}
|
||||
sandboxRec.Atespace = req.GetAtespace()
|
||||
sandboxRec.ActorName = req.GetActorName()
|
||||
sandboxRec.ActorUID = req.GetActorUid()
|
||||
sandboxRec.ActorTemplateNamespace = req.GetActorTemplateNamespace()
|
||||
sandboxRec.ActorTemplateName = req.GetActorTemplateName()
|
||||
|
||||
switch req.GetType() {
|
||||
case ateletpb.CheckpointType_CHECKPOINT_TYPE_EXTERNAL:
|
||||
@@ -442,8 +447,7 @@ func (s *AteomHerder) moveLocalCheckpoint(ctx context.Context, req *ateletpb.Che
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the sandbox binaries + snapshot file list into a manifest beside the
|
||||
// images so a later Restore is self-describing.
|
||||
// Write the self-describing snapshot manifest beside the images.
|
||||
manifest, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("while marshaling snapshot manifest: %w", err)
|
||||
@@ -475,8 +479,7 @@ func (s *AteomHerder) uploadExternalCheckpoint(ctx context.Context, req *ateletp
|
||||
return err
|
||||
}
|
||||
|
||||
// Pin the sandbox binaries + snapshot file list into a manifest beside the
|
||||
// images, written last, so a Restore on any node is self-describing.
|
||||
// Write the self-describing snapshot manifest last.
|
||||
manifest, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("while marshaling snapshot manifest: %w", err)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -37,6 +38,25 @@ import (
|
||||
"google.golang.org/protobuf/testing/protocmp"
|
||||
)
|
||||
|
||||
func TestSnapshotManifestActorMetadata(t *testing.T) {
|
||||
rec := sandboxAssetsRecord{
|
||||
Atespace: "team-a",
|
||||
ActorName: "actor-1",
|
||||
ActorUID: "actor-uid",
|
||||
ActorTemplateNamespace: "templates",
|
||||
ActorTemplateName: "agent",
|
||||
}
|
||||
got, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"atespace":"team-a"`, `"actorName":"actor-1"`, `"actorUid":"actor-uid"`, `"actorTemplateNamespace":"templates"`, `"actorTemplateName":"agent"`} {
|
||||
if !bytes.Contains(got, []byte(want)) {
|
||||
t.Errorf("manifest %s missing %s", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileAtomic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "actor-id")
|
||||
|
||||
@@ -36,9 +36,8 @@ import (
|
||||
)
|
||||
|
||||
// sandboxManifestName is the object/file name of the per-snapshot manifest that
|
||||
// records which sandbox binaries created a snapshot. It is written next to the
|
||||
// checkpoint images (in the external object store, or the local checkpoint dir)
|
||||
// so a Restore — possibly on another node — is self-describing.
|
||||
// records the actor identity, snapshot files, and sandbox binaries. It is written
|
||||
// next to the checkpoint images so a snapshot is self-describing.
|
||||
const sandboxManifestName = "manifest.json"
|
||||
|
||||
// maxAssetBytes guards disk against an unbounded download URL; a var so tests can lower it.
|
||||
@@ -59,6 +58,13 @@ type assetEntry struct {
|
||||
type sandboxAssetsRecord struct {
|
||||
SandboxClass string `json:"sandboxClass"`
|
||||
Assets map[string]assetEntry `json:"assets"`
|
||||
// Actor identity makes a flat snapshot self-identifying if control-plane
|
||||
// persistence is unavailable.
|
||||
Atespace string `json:"atespace,omitempty"`
|
||||
ActorName string `json:"actorName,omitempty"`
|
||||
ActorUID string `json:"actorUid,omitempty"`
|
||||
ActorTemplateNamespace string `json:"actorTemplateNamespace,omitempty"`
|
||||
ActorTemplateName string `json:"actorTemplateName,omitempty"`
|
||||
// SnapshotFiles are the (relative) names of the files ateom wrote into the
|
||||
// checkpoint directory, as reported by CheckpointWorkloadResponse. Recorded
|
||||
// in the snapshot manifest so Restore ships/downloads exactly this set
|
||||
|
||||
Reference in New Issue
Block a user