Files
JBD 864488c67d Replace Redis Streams queue and controller with direct execution and resource locking
Transition the AX architecture from asynchronous event queues (Redis Streams
and the `ax-controller` worker pool) to direct, synchronous reconciliation in
`ax-server` guarded by fine-grained distributed locks.

Key changes:
- Implement per-resource locking (`lock:task`, `lock:workspace`, `lock:model`)
  using Redis distributed locks (`SET NX PX` with token validation) and an
  in-memory locker for testing.
- Move Substrate reconciliation directly into `ax-server` for Task, Workspace,
  and Model lifecycle methods (`Create`, `Resume`, `Suspend`, `Delete`).
- Decommission `ax-controller`, `deploy/ax-controller.yaml`, and Redis Stream
  event publishing/consuming interfaces.
- Simplify `ax delete` and `ax watch` in the CLI to eliminate two-phase
  polling loops now that operations complete synchronously.
- Update `demo.sh`, build configurations (`Makefile`, `.ko.yaml`), and
  documentation to reflect the single-binary control plane architecture.
2026-09-27 15:23:17 -07:00

499 lines
15 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 substrate
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"github.com/google/ax/pkg/apis/v1alpha1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
// ClientOptions configures connection to the Substrate Control API server.
type ClientOptions struct {
Target string
Authority string
TokenFile string
CAFile string
InsecureTLS bool
Plaintext bool
}
type tokenAuth struct {
tokenFile string
}
func (t tokenAuth) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
if t.tokenFile == "" {
return nil, nil
}
b, err := os.ReadFile(t.tokenFile)
if err != nil {
return nil, fmt.Errorf("reading token file %q: %w", t.tokenFile, err)
}
return map[string]string{
"authorization": "Bearer " + strings.TrimSpace(string(b)),
}, nil
}
func (t tokenAuth) RequireTransportSecurity() bool {
return false
}
// Client wraps the Substrate Control API client.
type Client struct {
conn *grpc.ClientConn
control ateapipb.ControlClient
}
// NewClientWithOptions connects to Substrate with full TLS and auth configuration.
func NewClientWithOptions(opts ClientOptions, extraDialOpts ...grpc.DialOption) (*Client, error) {
if opts.Target == "" {
opts.Target = "api.ate-system.svc.cluster.local:443"
}
// Auto-detect token file if not specified
if opts.TokenFile == "" {
if envToken := os.Getenv("SUBSTRATE_TOKEN_FILE"); envToken != "" {
opts.TokenFile = envToken
} else if _, err := os.Stat("/var/run/secrets/ateapi/token"); err == nil {
opts.TokenFile = "/var/run/secrets/ateapi/token"
}
}
// Auto-detect CA file if not specified
if opts.CAFile == "" {
if envCA := os.Getenv("SUBSTRATE_CA_FILE"); envCA != "" {
opts.CAFile = envCA
} else if _, err := os.Stat("/run/servicedns-ca/trust-bundle.pem"); err == nil {
opts.CAFile = "/run/servicedns-ca/trust-bundle.pem"
} else if _, err := os.Stat("/run/servicedns-ca/ca.pem"); err == nil {
opts.CAFile = "/run/servicedns-ca/ca.pem"
}
}
dialOpts := append([]grpc.DialOption{}, extraDialOpts...)
if opts.Plaintext {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
tlsConfig := &tls.Config{}
if opts.InsecureTLS {
tlsConfig.InsecureSkipVerify = true
}
if opts.Authority != "" {
tlsConfig.ServerName = opts.Authority
} else if strings.Contains(opts.Target, "api.ate-system.svc") {
tlsConfig.ServerName = "api.ate-system.svc"
}
if opts.CAFile != "" {
caCert, err := os.ReadFile(opts.CAFile)
if err != nil {
return nil, fmt.Errorf("reading CA file %q: %w", opts.CAFile, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("failed to parse CA certificate from %q", opts.CAFile)
}
tlsConfig.RootCAs = pool
}
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
}
authority := opts.Authority
if authority == "" && strings.Contains(opts.Target, "api.ate-system.svc") {
authority = "api.ate-system.svc"
}
if authority != "" {
dialOpts = append(dialOpts, grpc.WithAuthority(authority))
}
if opts.TokenFile != "" {
dialOpts = append(dialOpts, grpc.WithPerRPCCredentials(tokenAuth{tokenFile: opts.TokenFile}))
}
conn, err := grpc.NewClient(opts.Target, dialOpts...)
if err != nil {
return nil, fmt.Errorf("failed to connect to Substrate control service at %s: %w", opts.Target, err)
}
return &Client{
conn: conn,
control: ateapipb.NewControlClient(conn),
}, nil
}
// NewClient establishes a connection to the Substrate Control API server.
func NewClient(target string, opts ...grpc.DialOption) (*Client, error) {
if len(opts) > 0 {
if target == "" {
target = "api.ate-system.svc.cluster.local:443"
}
conn, err := grpc.NewClient(target, opts...)
if err != nil {
return nil, fmt.Errorf("failed to connect to Substrate control service at %s: %w", target, err)
}
return &Client{
conn: conn,
control: ateapipb.NewControlClient(conn),
}, nil
}
// Auto-configure options when no explicit dial options given
return NewClientWithOptions(ClientOptions{Target: target})
}
// Close closes the gRPC connection.
func (c *Client) Close() error {
if c.conn != nil {
return c.conn.Close()
}
return nil
}
// EnsureAtespace creates the atespace if it does not already exist.
func (c *Client) EnsureAtespace(ctx context.Context, atespace string) error {
req := &ateapipb.CreateAtespaceRequest{
Atespace: &ateapipb.Atespace{
Metadata: &ateapipb.ResourceMetadata{
Name: atespace,
},
},
}
_, err := c.control.CreateAtespace(ctx, req)
if err != nil && status.Code(err) != codes.AlreadyExists {
return fmt.Errorf("creating atespace %q: %w", atespace, err)
}
return nil
}
// GetActorTemplate fetches an ActorTemplate by name.
func (c *Client) GetActorTemplate(ctx context.Context, atespace, templateName string) (*ateapipb.ActorTemplate, error) {
req := &ateapipb.GetActorTemplateRequest{
ActorTemplate: &ateapipb.ObjectRef{
Atespace: atespace,
Name: templateName,
},
}
return c.control.GetActorTemplate(ctx, req)
}
const (
DefaultGuestCommand = "/usr/local/bin/ax-task-runner"
DefaultSnapshotsBucket = "gs://dberkov-gke-dev3/ate-env/"
)
// BuildActorTemplate constructs a Substrate ActorTemplate based on the standard ate-env specification.
func BuildActorTemplate(atespace, name, image string, envMap map[string]string, command []string, snapshotsBucket string) *ateapipb.ActorTemplate {
if atespace == "" {
atespace = "default"
}
if image == "" {
image = v1alpha1.DefaultTaskImage
}
if len(command) == 0 {
command = []string{DefaultGuestCommand}
}
if snapshotsBucket == "" {
if envBucket := os.Getenv("AX_SNAPSHOTS_BUCKET"); envBucket != "" {
snapshotsBucket = envBucket
} else {
snapshotsBucket = DefaultSnapshotsBucket
}
}
var envList []*ateapipb.EnvVar
for k, v := range envMap {
envList = append(envList, &ateapipb.EnvVar{
Name: k,
Value: v,
})
}
return &ateapipb.ActorTemplate{
Metadata: &ateapipb.ResourceMetadata{
Name: name,
Atespace: atespace,
},
Containers: []*ateapipb.Container{{
Name: "guest",
Image: image,
Command: command,
Env: envList,
Readyz: &ateapipb.ContainerReadyz{
HttpGet: &ateapipb.HTTPGetAction{
Path: "/readyz",
Port: 80,
},
},
VolumeMounts: []*ateapipb.VolumeMount{{
Name: "workspace",
MountPath: "/workspace",
}},
}},
Volumes: []*ateapipb.Volume{{
Name: "workspace",
DurableDir: &ateapipb.DurableDirVolumeSource{},
}},
SnapshotsConfig: &ateapipb.SnapshotsConfig{
StorageLocation: snapshotsBucket,
OnPause: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA,
OnCommit: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA,
OnResume: &ateapipb.OnResumeConfig{
FromData: ateapipb.ResumeSource_RESUME_SOURCE_GOLDEN,
},
},
SandboxConfig: &ateapipb.SandboxConfig{
SandboxClass: ateapipb.SandboxClass_SANDBOX_CLASS_GVISOR,
ConfigName: "gvisor-default",
},
}
}
// EnsureActorTemplateWithImage creates an ActorTemplate using the specified container image and optional environment variables.
func (c *Client) EnsureActorTemplateWithImage(ctx context.Context, baseAtespace, baseTemplate, targetAtespace, targetTemplate, image string, extraEnv ...map[string]string) (*ateapipb.ActorTemplate, error) {
existing, err := c.GetActorTemplate(ctx, targetAtespace, targetTemplate)
if err == nil && existing != nil {
return existing, nil
}
envMap := make(map[string]string)
for _, envs := range extraEnv {
for k, v := range envs {
envMap[k] = v
}
}
tmpl := BuildActorTemplate(targetAtespace, targetTemplate, image, envMap, nil, "")
req := &ateapipb.CreateActorTemplateRequest{
ActorTemplate: tmpl,
}
created, err := c.control.CreateActorTemplate(ctx, req)
if err != nil && status.Code(err) != codes.AlreadyExists {
return nil, fmt.Errorf("creating actor template %s/%s: %w", targetAtespace, targetTemplate, err)
}
if created != nil {
return created, nil
}
return c.GetActorTemplate(ctx, targetAtespace, targetTemplate)
}
// EnsureActor creates an Actor in the specified atespace deriving from an ActorTemplate.
func (c *Client) EnsureActor(ctx context.Context, atespace, actorName, templateAtespace, templateName string) (*ateapipb.Actor, error) {
req := &ateapipb.CreateActorRequest{
Actor: &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{
Atespace: atespace,
Name: actorName,
},
ActorTemplate: &ateapipb.ObjectRef{
Atespace: templateAtespace,
Name: templateName,
},
},
}
actor, err := c.control.CreateActor(ctx, req)
if err != nil {
if status.Code(err) == codes.AlreadyExists {
existing, getErr := c.GetActor(ctx, atespace, actorName)
if getErr == nil && existing != nil {
state := existing.GetStatus().GetState()
if state == ateapipb.ActorState_ACTOR_STATE_CRASHED {
slog.Warn("existing actor is crashed, deleting and recreating", "actor", actorName)
if err := c.DeleteActor(ctx, atespace, actorName); err != nil {
return nil, err
}
actor, err = c.control.CreateActor(ctx, req)
if err == nil {
return actor, nil
}
return nil, fmt.Errorf("recreating actor %s/%s after crash: %w", atespace, actorName, err)
}
if state == ateapipb.ActorState_ACTOR_STATE_DELETING {
// Wait briefly for previous actor deletion to finalize before recreating
for i := 0; i < 20; i++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
actor, err = c.control.CreateActor(ctx, req)
if err == nil {
return actor, nil
}
if status.Code(err) != codes.AlreadyExists {
break
}
}
return nil, fmt.Errorf("actor %s/%s is still deleting; please retry", atespace, actorName)
}
}
return existing, getErr
}
return nil, fmt.Errorf("creating actor %s/%s: %w", atespace, actorName, err)
}
return actor, nil
}
// ResumeActor resumes the specified actor onto a worker and returns the worker details.
func (c *Client) ResumeActor(ctx context.Context, atespace, actorName string) (*ateapipb.Actor, string, error) {
req := &ateapipb.ResumeActorRequest{
Actor: &ateapipb.ObjectRef{
Atespace: atespace,
Name: actorName,
},
}
resp, err := c.control.ResumeActor(ctx, req)
if err != nil {
return nil, "", fmt.Errorf("resuming actor %s/%s: %w", atespace, actorName, err)
}
if resp.Actor == nil {
return nil, "", fmt.Errorf("nil actor returned when resuming %s/%s", atespace, actorName)
}
var workerIP string
if resp.Actor.Status != nil && resp.Actor.Status.WorkerAssignment != nil {
workerIP = resp.Actor.Status.WorkerAssignment.WorkerPodIp
}
return resp.Actor, workerIP, nil
}
// SuspendActor suspends the specified actor, triggering state checkpointing.
func (c *Client) SuspendActor(ctx context.Context, atespace, actorName string) error {
req := &ateapipb.SuspendActorRequest{
Actor: &ateapipb.ObjectRef{
Atespace: atespace,
Name: actorName,
},
}
_, err := c.control.SuspendActor(ctx, req)
if err != nil {
return fmt.Errorf("suspending actor %s/%s: %w", atespace, actorName, err)
}
return nil
}
// GetActor fetches an Actor by name.
func (c *Client) GetActor(ctx context.Context, atespace, actorName string) (*ateapipb.Actor, error) {
req := &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{
Atespace: atespace,
Name: actorName,
},
}
return c.control.GetActor(ctx, req)
}
// actorDeletionPollInterval is the interval between checks when waiting for an actor to be deleted.
var actorDeletionPollInterval = 200 * time.Millisecond
// DeleteActor deletes the specified actor from Substrate and blocks until the actor
// is fully deleted.
func (c *Client) DeleteActor(ctx context.Context, atespace, actorName string) error {
req := &ateapipb.DeleteActorRequest{
Actor: &ateapipb.ObjectRef{
Atespace: atespace,
Name: actorName,
},
AnyState: true,
}
_, err := c.control.DeleteActor(ctx, req)
if err != nil {
if status.Code(err) == codes.NotFound {
return nil
}
return fmt.Errorf("deleting actor %s/%s: %w", atespace, actorName, err)
}
getReq := &ateapipb.GetActorRequest{
Actor: &ateapipb.ObjectRef{
Atespace: atespace,
Name: actorName,
},
}
// Fast path: check if the actor was deleted synchronously.
_, err = c.control.GetActor(ctx, getReq)
if err != nil {
if status.Code(err) == codes.NotFound {
return nil
}
if ctx.Err() != nil {
return fmt.Errorf("waiting for actor %s/%s deletion: %w", atespace, actorName, ctx.Err())
}
}
ticker := time.NewTicker(actorDeletionPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return fmt.Errorf("waiting for actor %s/%s deletion: %w", atespace, actorName, ctx.Err())
case <-ticker.C:
_, err := c.control.GetActor(ctx, getReq)
if err != nil {
if status.Code(err) == codes.NotFound {
return nil
}
if ctx.Err() != nil {
return fmt.Errorf("waiting for actor %s/%s deletion: %w", atespace, actorName, ctx.Err())
}
}
}
}
}
// ListActorTemplates returns all ActorTemplates in the given atespace.
func (c *Client) ListActorTemplates(ctx context.Context, atespace string) ([]*ateapipb.ActorTemplate, error) {
resp, err := c.control.ListActorTemplates(ctx, &ateapipb.ListActorTemplatesRequest{Atespace: atespace})
if err != nil {
return nil, fmt.Errorf("listing actor templates in %s: %w", atespace, err)
}
if resp.GetNextPageToken() != "" {
slog.Warn("actor template listing was truncated", "atespace", atespace)
}
return resp.GetActorTemplates(), nil
}
// DeleteActorTemplate deletes the specified ActorTemplate. A missing template is not an error.
func (c *Client) DeleteActorTemplate(ctx context.Context, atespace, templateName string) error {
req := &ateapipb.DeleteActorTemplateRequest{
ActorTemplate: &ateapipb.ObjectRef{
Atespace: atespace,
Name: templateName,
},
}
_, err := c.control.DeleteActorTemplate(ctx, req)
if err != nil && status.Code(err) != codes.NotFound {
return fmt.Errorf("deleting actor template %s/%s: %w", atespace, templateName, err)
}
return nil
}