Files
substrate/cmd/ateapi/internal/controlapi/functionaltest/main_test.go
T
shrutiyam-glitch 944abe3278 feat(api): introduce RevertActor lifecycle RPC (#1675)
#### Summary
This PR introduces the `RevertActor` RPC for actor lifecycle management.
It includes the API definition, the corresponding workflow execution
logic, observability metrics, and updates to the authorization model to
support reverting actors.

Fixes #1556
Docs updated in #1711 

#### Commit-wise Changes

**1. Add RevertActor RPC (`1145d99`)**
* Introduces the new `RevertActor` RPC to the API definitions.
* Updates the corresponding protobuf bindings (affecting files like
`ateapi.pb.go` and `ateapi_pb2.py`).

**2. Add the revertActor workflow and observability metrics
(`dcb1fae`)**
* Implements the core `revertActor` workflow logic, designed to be
idempotent and re-enterable. It progresses through the following steps:
* **Mark Reverting:** Validates that the actor is in a revertable state
(`RUNNING`, `PAUSED`, or `CRASHED`) and transitions its state to
`REVERTING`.
* **Discard Worker:** Safely tears down the execution environment by
terminating the workload, detaching volumes, and releasing the assigned
worker.
* **Collect In-Progress Snapshot:** Cleans up external object storage by
deleting any objects a previous suspend operation was partway through
writing.
* **Finalize:** Commits the actor to `SUSPENDED` and strips all
node-local and in-progress state pointers (clearing `WorkerAssignment`,
`LocalSnapshotInfo`, etc.), returning the actor to its untouched
external snapshot.
* Instruments the workflow with lifecycle operation metrics (e.g.,
updating `ate.actor.lifecycle.operation.duration` to track `revert`
operations).
* *Note/TODO:* Currently, when reverting a paused actor, the workflow
drops the pointer to the node-local state but does *not* actually prune
the local checkpoint bytes from the node (this is tracked in #641).

**3. Add `can_revert` to the authorization model (`2fc53ae`)**
* Adds the `can_revert` permission to the auth model, mirroring the
shape of `can_suspend` (editor tier of the parent atespace, plus a
direct grant so a machine identity can revert the actor it drives
without holding an atespace role).

**4. Serve RevertActor and add the CLI verb (`f04fab4`)**
* Wires the `Control.RevertActor` service method to the workflow
(replacing the generated stub that previously answered `Unimplemented`).
* Adds the `"ate revert actor"` CLI command, making the feature usable
end-to-end.
* Implements `Terminate` for the fake atelet. This was necessary because
reverting an actor from the `RUNNING` state is the first path to reach
this call in functional tests (previously, delete tests skipped this
step as they ran against actors with no worker assignment).

**5. Add a manual verify script for RevertActor (`8022db3`)**
* Adds a script to manually exercise `RevertActor` against a real
control plane, since unit and functional tests only run against a fake
atelet.
* Tests reverting from `CRASHED`, `RUNNING`, and `PAUSED` states, and
verifies that attempting to revert a `SUSPENDED` actor is properly
rejected.
* Simulates a crash by deleting the worker pod the actor runs on to
verify the workflow can handle the absence of a worker to terminate.


- [ ] Tests pass
- [ ] Appropriate changes to documentation are included in the PR
2026-09-18 20:18:17 +00:00

265 lines
7.5 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"
"fmt"
"log"
"net"
"os"
"sync"
"testing"
"time"
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store/storetest"
"github.com/agent-substrate/substrate/internal/objectstore/objectstoretest"
"github.com/agent-substrate/substrate/internal/proto/ateletpb"
"github.com/agent-substrate/substrate/internal/testenv"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
corev1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
var (
cfg *rest.Config
fakeAtelet = &FakeAteletServer{}
)
func TestMain(m *testing.M) {
var stopEnv func()
cfg, stopEnv = testenv.Start()
// Create ate-system namespace
k8sClient, err := kubernetes.NewForConfig(cfg)
if err != nil {
log.Fatalf("kubernetes.NewForConfig: %v", err)
}
_, err = k8sClient.CoreV1().Namespaces().Create(context.Background(), &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "ate-system"},
}, metav1.CreateOptions{})
if err != nil && !apierrors.IsAlreadyExists(err) {
log.Fatalf("create ate-system namespace: %v", err)
}
// Create StorageClasses for volume tests
_, err = k8sClient.StorageV1().StorageClasses().Create(context.Background(), &storagev1.StorageClass{
ObjectMeta: metav1.ObjectMeta{Name: "standard"},
Provisioner: "substrate.io/mock",
}, metav1.CreateOptions{})
if err != nil && !apierrors.IsAlreadyExists(err) {
log.Fatalf("create standard storage class: %v", err)
}
_, err = k8sClient.StorageV1().StorageClasses().Create(context.Background(), &storagev1.StorageClass{
ObjectMeta: metav1.ObjectMeta{Name: "fast"},
Provisioner: "substrate.io/mock",
}, metav1.CreateOptions{})
if err != nil && !apierrors.IsAlreadyExists(err) {
log.Fatalf("create fast storage class: %v", err)
}
// Create shared Atelet Pod. Tests that place workers on another node add
// their own atelet there via setupAteletOnNode.
if err := createAteletPod(k8sClient, "atelet-shared", "node1"); err != nil {
log.Fatalf("%v", err)
}
// Start Fake Atelet Server on port 8085
ateletGrpcServer := grpc.NewServer()
ateletpb.RegisterAteomHerderServer(ateletGrpcServer, fakeAtelet)
ateletLis, err := net.Listen("tcp", "127.0.0.1:8085")
if err != nil {
log.Fatalf("listen on 127.0.0.1:8085: %v", err)
}
go func() {
if err := ateletGrpcServer.Serve(ateletLis); err != nil {
fmt.Printf("atelet grpc server exited: %v\n", err)
}
}()
code := m.Run()
ateletGrpcServer.Stop()
stopEnv()
storetest.Shutdown()
os.Exit(code)
}
// FakeAteletServer implements ateletpb.WorkersServer
type FakeAteletServer struct {
ateletpb.UnimplementedAteomHerderServer
Lock sync.Mutex
RunCalled bool
RunRequest *ateletpb.RunRequest
FailRun error
CheckpointCalled bool
CheckpointRequest *ateletpb.CheckpointRequest
RestoreCalled bool
RestoreRequest *ateletpb.RestoreRequest
FailRestore error
RestoreDelay time.Duration
UploadCalled bool
UploadRequest *ateletpb.UploadPausedCheckpointRequest
FailUpload error
TerminateCalled bool
TerminateRequest *ateletpb.TerminateRequest
FailTerminate error
// objectStore, when set, receives the objects a checkpoint or an upload
// writes, so the control plane's copy and release steps have real external
// snapshots to act on. setupTest points it at the test's own store.
objectStore *objectstoretest.Fake
}
// snapshotObjects names the objects a checkpoint leaves behind. The names are
// arbitrary: nothing in the control plane reads them, it only copies and
// deletes whatever shares the snapshot's prefix.
var snapshotObjects = []string{"manifest.json", "memory.zst"}
// SetObjectStore points the fake at the store a checkpoint should write to.
func (f *FakeAteletServer) SetObjectStore(store *objectstoretest.Fake) {
f.Lock.Lock()
defer f.Lock.Unlock()
f.objectStore = store
}
// writeSnapshot records the external snapshot a checkpoint or an upload would
// have written at snapshotURI. Called with Lock held.
func (f *FakeAteletServer) writeSnapshot(snapshotURI string) error {
if f.objectStore == nil || snapshotURI == "" {
return nil
}
return f.objectStore.WriteSnapshot(snapshotURI, snapshotObjects...)
}
func (f *FakeAteletServer) Reset() {
f.Lock.Lock()
defer f.Lock.Unlock()
f.RunCalled = false
f.RunRequest = nil
f.FailRun = nil
f.CheckpointCalled = false
f.CheckpointRequest = nil
f.RestoreCalled = false
f.RestoreRequest = nil
f.FailRestore = nil
f.RestoreDelay = 0
f.UploadCalled = false
f.UploadRequest = nil
f.FailUpload = nil
f.TerminateCalled = false
f.TerminateRequest = nil
f.FailTerminate = nil
f.objectStore = nil
}
func (f *FakeAteletServer) UploadPausedCheckpoint(ctx context.Context, req *ateletpb.UploadPausedCheckpointRequest) (*ateletpb.UploadPausedCheckpointResponse, error) {
f.Lock.Lock()
defer f.Lock.Unlock()
f.UploadCalled = true
f.UploadRequest = proto.Clone(req).(*ateletpb.UploadPausedCheckpointRequest)
if f.FailUpload != nil {
return nil, f.FailUpload
}
if err := f.writeSnapshot(req.GetDestinationSnapshotUri()); err != nil {
return nil, err
}
return &ateletpb.UploadPausedCheckpointResponse{}, nil
}
func (f *FakeAteletServer) Run(ctx context.Context, req *ateletpb.RunRequest) (*ateletpb.RunResponse, error) {
f.Lock.Lock()
defer f.Lock.Unlock()
f.RunCalled = true
f.RunRequest = proto.Clone(req).(*ateletpb.RunRequest)
if f.FailRun != nil {
return nil, f.FailRun
}
return &ateletpb.RunResponse{}, nil
}
func (f *FakeAteletServer) Checkpoint(ctx context.Context, req *ateletpb.CheckpointRequest) (*ateletpb.CheckpointResponse, error) {
f.Lock.Lock()
defer f.Lock.Unlock()
f.CheckpointCalled = true
f.CheckpointRequest = proto.Clone(req).(*ateletpb.CheckpointRequest)
if err := f.writeSnapshot(req.GetExternalConfig().GetSnapshotUri()); err != nil {
return nil, err
}
return &ateletpb.CheckpointResponse{}, nil
}
func (f *FakeAteletServer) Restore(ctx context.Context, req *ateletpb.RestoreRequest) (*ateletpb.RestoreResponse, error) {
f.Lock.Lock()
defer f.Lock.Unlock()
f.RestoreCalled = true
f.RestoreRequest = proto.Clone(req).(*ateletpb.RestoreRequest)
if f.RestoreDelay > 0 {
time.Sleep(f.RestoreDelay)
}
if f.FailRestore != nil {
return nil, f.FailRestore
}
return &ateletpb.RestoreResponse{}, nil
}
func (f *FakeAteletServer) Terminate(ctx context.Context, req *ateletpb.TerminateRequest) (*ateletpb.TerminateResponse, error) {
f.Lock.Lock()
defer f.Lock.Unlock()
f.TerminateCalled = true
f.TerminateRequest = proto.Clone(req).(*ateletpb.TerminateRequest)
if f.FailTerminate != nil {
return nil, f.FailTerminate
}
return &ateletpb.TerminateResponse{}, nil
}
func (f *FakeAteletServer) lastRestoreRequest() *ateletpb.RestoreRequest {
f.Lock.Lock()
defer f.Lock.Unlock()
if f.RestoreRequest == nil {
return nil
}
return proto.Clone(f.RestoreRequest).(*ateletpb.RestoreRequest)
}