mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-10-23 07:00:17 +08:00
## What is this PR and Why we need it ? A deleted worker pod is deregistered by `workersync`, which calls `DeleteWorker` to mark its assigned actors `CRASHED` and clear their `WorkerAssignment`. Those actors are never terminated on the node, so `/var/lib/ate/actors/<uid>` and `/var/lib/ate/snapshots/<uid>` stay on disk until the actor is later deleted or reverted, if it ever is. (Note: this covers worker-pod removal via `DeleteWorker`; tearing down a crashed sandbox on a still-live worker is tracked separately in #2355.) ### Changes - **`atelet` workerless / gone-worker `Terminate`**: - Skips `TerminateWorkload` when `worker_pod_uid` is empty, the worker socket is gone, `sandbox-assets.json` is absent (`Run` and `Restore` write it before `RunWorkload`/`RestoreWorkload`, and an earlier `Terminate` removes it with the actor directory), or `TerminateWorkload` returns `Unavailable`/`NotFound`. Any other error from a live worker fails `Terminate` before touching disk. - Volume unmount failures in `Terminate` return `Internal` without `%w`-wrapping downstream gRPC errors, so a transient CSI failure does not surface to `ateapi` as `Unavailable` (and reaches `DeleteActor`/`RevertActor` callers as `Internal` instead of `Unavailable`). - **`ateapi` (`DeleteWorker`)**: - `DeleteWorker` takes each actor's lease (`ABORTED` if held, requeued by `workersync`), skips actors that already reached `SUSPENDED`, and calls best-effort `atelet.Terminate` with a 30s timeout to reclaim the actor's node state and local snapshots. Once a call times out (`DeadlineExceeded`) or returns `Unavailable`, remaining actors on that worker skip the node and leave cleanup to revert/delete. --- ### Testing - **Unit & functional tests**: `go test ./cmd/atelet/... ./cmd/ateapi/internal/controlapi/...` - **Live-cluster verification** (3 × `c3-standard-4` `gvisor` nodes, 3 × `c3-highcpu-4` `microvm` nodes): | Suite | `gvisor` (3 × `c3-standard-4`) | `microvm` (3 × `c3-highcpu-4`) | |---|---|---| | **`durdir_data_baseline` 1m smoke** (8 MiB `glutton-durdir-data`) | `20` reqs, `0` fails (`0.00%`) | `15` reqs, `0` fails (`0.00%`) | | **`DeleteWorker` scale-down reclaim** (3 actors, 256 MiB `durable-dir`/actor) | `/var/lib/ate/actors`: `0.00 GB -> 1.41 GB (3 dirs) -> 0.00 GB (0 dirs)`; `-0.25 GB`/node reclaimed (`+0.00 GB` vs baseline) | `/var/lib/ate/actors`: `0.00 GB -> 1.64 GB (3 dirs) -> 0.00 GB (0 dirs)`; `-0.25 GB`/node reclaimed (`+0.00 GB` vs baseline) | | **`DeleteActor` on paused actor with no worker** (`worker_pod_uid=""`) | `local-checkpoint` pruned & `/var/lib/ate/actors/<uid>` removed (`1.75 ms`) | `local-checkpoint` pruned & `/var/lib/ate/actors/<uid>` removed (`9.45 ms`) | Fixes #1688 - [x] Tests pass - [x] Appropriate changes to documentation are included in the PR
208 lines
9.7 KiB
Go
208 lines
9.7 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 controlapi
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
|
|
"go.ate.dev/substrate/cmd/ateapi/internal/store"
|
|
"go.ate.dev/substrate/cmd/ateapi/internal/workercache"
|
|
"go.ate.dev/substrate/internal/localca"
|
|
"go.ate.dev/substrate/internal/localjwtauthority"
|
|
"go.ate.dev/substrate/internal/resources"
|
|
"go.ate.dev/substrate/internal/volume"
|
|
"go.ate.dev/substrate/internal/volume/csi"
|
|
listersv1alpha1 "go.ate.dev/substrate/pkg/client/listers/api/v1alpha1"
|
|
"go.ate.dev/substrate/pkg/proto/ateapipb"
|
|
objectstorev1 "go.ate.dev/substrate/pkg/proto/objectstorepb/v1"
|
|
storagev1listers "k8s.io/client-go/listers/storage/v1"
|
|
)
|
|
|
|
// RPCService implements ateapipb.ControlServer and provides the implementation of
|
|
// the RPC service.
|
|
//
|
|
// Methods on this service should be as light as possible, delegating to the
|
|
// ServiceImpl for business logic and invariants.
|
|
type RPCService struct {
|
|
ateapipb.UnimplementedControlServer
|
|
impl serviceStore
|
|
persistence serviceStore
|
|
workerCache *workercache.Cache
|
|
dialer *AteletDialer
|
|
csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister
|
|
actorWorkflow *ActorWorkflow
|
|
workerWorkflow *WorkerWorkflow
|
|
instruments *Instruments
|
|
mu sync.RWMutex
|
|
volumePlugins map[string]volume.VolumePluginControlPlane
|
|
objectStorePlugin objectstorev1.ControlProviderClient
|
|
|
|
actorJWTIssuer string
|
|
actorIDJWTPool localjwtauthority.Pool
|
|
actorIDCAPool localca.Pool
|
|
}
|
|
|
|
var _ ateapipb.ControlServer = (*RPCService)(nil)
|
|
|
|
// VolumePluginRegistry defines the interface for dynamic CSI plugin resolution.
|
|
type VolumePluginRegistry interface {
|
|
GetPlugin(ctx context.Context, name string) (volume.VolumePluginControlPlane, error)
|
|
}
|
|
|
|
// NewRPCService creates an instance of the ControlServer service. This is what
|
|
// implements the outward-facing RPC interface.
|
|
//
|
|
// instruments may be nil; the record helpers no-op.
|
|
//
|
|
// objectStorePlugin may be nil, which leaves external snapshots in place instead
|
|
// of copying and releasing them. Only tests that never reach those steps pass
|
|
// nil; ate-api always builds one.
|
|
//
|
|
// actorJWTIssuer is copied verbatim into the iss claim of every actor JWT.
|
|
//
|
|
// snapshotLocation is ate-api-server's cluster-wide --snapshot-location; see
|
|
// resolveSnapshotLocation.
|
|
func NewRPCService(
|
|
persistence store.Interface,
|
|
workerCache *workercache.Cache,
|
|
csiDriverConfigLister listersv1alpha1.CSIDriverConfigLister,
|
|
storageClassLister storagev1listers.StorageClassLister,
|
|
dialer *AteletDialer,
|
|
instruments *Instruments,
|
|
egressGatewayAddress string,
|
|
volumePlugins map[string]volume.VolumePluginControlPlane,
|
|
objectStorePlugin objectstorev1.ControlProviderClient,
|
|
actorJWTIssuer string,
|
|
actorIDJWTPool localjwtauthority.Pool,
|
|
actorIDCAPool localca.Pool,
|
|
snapshotLocation string,
|
|
) *RPCService {
|
|
impl := newServiceImpl(persistence, storageClassLister)
|
|
impl.clusterSnapshotLocation = snapshotLocation
|
|
s := &RPCService{
|
|
impl: impl,
|
|
persistence: persistence,
|
|
workerCache: workerCache,
|
|
csiDriverConfigLister: csiDriverConfigLister,
|
|
dialer: dialer,
|
|
instruments: instruments,
|
|
volumePlugins: volumePlugins,
|
|
objectStorePlugin: objectStorePlugin,
|
|
actorJWTIssuer: actorJWTIssuer,
|
|
actorIDJWTPool: actorIDJWTPool,
|
|
actorIDCAPool: actorIDCAPool,
|
|
}
|
|
s.actorWorkflow = NewActorWorkflow(impl, workerCache, dialer, storageClassLister, instruments, egressGatewayAddress, s, objectStorePlugin)
|
|
s.actorWorkflow.clusterSnapshotLocation = snapshotLocation
|
|
s.workerWorkflow = NewWorkerWorkflow(impl, dialer)
|
|
return s
|
|
}
|
|
|
|
// serviceStore enumerates the exact storage methods needed by
|
|
// the control API and nothing more.
|
|
type serviceStore interface {
|
|
CreateActor(ctx context.Context, actor *ateapipb.Actor) (*ateapipb.Actor, error)
|
|
GetActor(ctx context.Context, actorRef resources.ActorRef) (*ateapipb.Actor, error)
|
|
UpdateActor(ctx context.Context, actorRef resources.ActorRef, precondition store.Precondition, mutate func(toUpdate *ateapipb.Actor) error) (*ateapipb.Actor, error)
|
|
ListActors(ctx context.Context, atespace string, opts store.ListOptions) (store.ListResponse[*ateapipb.Actor], error)
|
|
CreateEgressPolicy(ctx context.Context, actorRef resources.ActorRef, policy *ateapipb.EgressPolicy) (*ateapipb.EgressPolicy, error)
|
|
GetEgressPolicy(ctx context.Context, actorRef resources.ActorRef) (*ateapipb.EgressPolicy, error)
|
|
UpdateEgressPolicy(ctx context.Context, actorRef resources.ActorRef, precondition store.Precondition, mutate func(*ateapipb.EgressPolicy) error) (*ateapipb.EgressPolicy, error)
|
|
DeleteEgressPolicy(ctx context.Context, actorRef resources.ActorRef, precondition store.DeletePreconditions) (*ateapipb.EgressPolicy, error)
|
|
GetTag(ctx context.Context, tagRef resources.TagRef) (*ateapipb.Tag, error)
|
|
ListTags(ctx context.Context, atespace string, opts store.ListOptions) (store.ListResponse[*ateapipb.Tag], error)
|
|
UpdateTag(ctx context.Context, tagRef resources.TagRef, precondition store.Precondition, mutate func(toUpdate *ateapipb.Tag) error) (*ateapipb.Tag, error)
|
|
DeleteTag(ctx context.Context, tagRef resources.TagRef, precondition store.DeletePreconditions) (*ateapipb.Tag, error)
|
|
CreateAtespace(ctx context.Context, atespace *ateapipb.Atespace) (*ateapipb.Atespace, error)
|
|
GetAtespace(ctx context.Context, name string) (*ateapipb.Atespace, error)
|
|
ListAtespaces(ctx context.Context, opts store.ListOptions) (store.ListResponse[*ateapipb.Atespace], error)
|
|
DeleteAtespace(ctx context.Context, name string, precondition store.DeletePreconditions) (*ateapipb.Atespace, error)
|
|
CreateGlobalAccessPolicy(ctx context.Context, policy *ateapipb.AccessPolicy) (*ateapipb.AccessPolicy, error)
|
|
GetGlobalAccessPolicy(ctx context.Context) (*ateapipb.AccessPolicy, error)
|
|
UpdateGlobalAccessPolicy(ctx context.Context, precondition store.Precondition, mutate func(*ateapipb.AccessPolicy) error) (*ateapipb.AccessPolicy, error)
|
|
CreateAtespaceAccessPolicy(ctx context.Context, name string, policy *ateapipb.AccessPolicy) (*ateapipb.AccessPolicy, error)
|
|
GetAtespaceAccessPolicy(ctx context.Context, name string) (*ateapipb.AccessPolicy, error)
|
|
UpdateAtespaceAccessPolicy(ctx context.Context, name string, precondition store.Precondition, mutate func(*ateapipb.AccessPolicy) error) (*ateapipb.AccessPolicy, error)
|
|
DeleteAtespaceAccessPolicy(ctx context.Context, name string, precondition store.DeletePreconditions) (*ateapipb.AccessPolicy, error)
|
|
CreateActorTemplate(ctx context.Context, template *ateapipb.ActorTemplate) (*ateapipb.ActorTemplate, error)
|
|
GetActorTemplate(ctx context.Context, templateRef resources.ActorTemplateRef) (*ateapipb.ActorTemplate, error)
|
|
ListActorTemplates(ctx context.Context, atespace string, opts store.ListOptions) (store.ListResponse[*ateapipb.ActorTemplate], error)
|
|
DeleteActorTemplate(ctx context.Context, templateRef resources.ActorTemplateRef, precondition store.DeletePreconditions) (*ateapipb.ActorTemplate, error)
|
|
ListWorkers(ctx context.Context, opts store.ListOptions) (store.ListResponse[*ateapipb.Worker], error)
|
|
GetWorker(ctx context.Context, name string) (*ateapipb.Worker, error)
|
|
ListWorkerAssignments(ctx context.Context, workerName string, opts store.ListOptions) (store.ListResponse[*ateapipb.ActorAssignment], error)
|
|
CreateWorker(ctx context.Context, worker *ateapipb.Worker) (*ateapipb.Worker, error)
|
|
UpdateWorker(ctx context.Context, name string, precondition store.Precondition, mutate func(toUpdate *ateapipb.Worker) error) (*ateapipb.Worker, error)
|
|
AcquireLease(ctx context.Context, key string) (*store.Lease, error)
|
|
}
|
|
|
|
// GetPlugin retrieves a CSI volume plugin by driver name, dynamically discovering it if not present.
|
|
func (s *RPCService) GetPlugin(ctx context.Context, driverName string) (volume.VolumePluginControlPlane, error) {
|
|
s.mu.RLock()
|
|
plugin, ok := s.volumePlugins[driverName]
|
|
s.mu.RUnlock()
|
|
if ok {
|
|
return plugin, nil
|
|
}
|
|
|
|
csiPlugin, err := csi.NewCSIPlugin(ctx, s.csiDriverConfigLister, driverName, true /*isController*/)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.volumePlugins[driverName] = csiPlugin
|
|
s.mu.Unlock()
|
|
return csiPlugin, nil
|
|
}
|
|
|
|
// ServiceImpl implements store.Interface and provides the "middleware" layer
|
|
// between the RPC and storage layers. It enforces invariants and validation
|
|
// rules, and may implement additional logic beyond the storage layer.
|
|
//
|
|
// Methods on this service should hold most of the logic.
|
|
type ServiceImpl struct {
|
|
// This field is explicitly named to prevent accidentally satisfying
|
|
// methods we need to trap.
|
|
store store.Interface
|
|
|
|
storageClassLister storagev1listers.StorageClassLister
|
|
|
|
// clusterSnapshotLocation is ate-api-server's --snapshot-location; see
|
|
// resolveSnapshotLocation.
|
|
clusterSnapshotLocation string
|
|
}
|
|
|
|
var _ store.Interface = (*ServiceImpl)(nil)
|
|
|
|
// newServiceImpl creates an instance of the service's middleware
|
|
// implementation layer.
|
|
func newServiceImpl(
|
|
persistence store.Interface,
|
|
storageClassLister storagev1listers.StorageClassLister,
|
|
) *ServiceImpl {
|
|
s := &ServiceImpl{
|
|
store: persistence,
|
|
storageClassLister: storageClassLister,
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Pass-through.
|
|
func (s *ServiceImpl) AcquireLease(ctx context.Context, key string) (*store.Lease, error) {
|
|
return s.store.AcquireLease(ctx, key)
|
|
}
|