Files
ax/internal/server/server_test.go
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

506 lines
17 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 server_test
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/ax/internal/server"
"github.com/google/ax/internal/store"
"github.com/google/ax/internal/store/memory"
"github.com/google/ax/pkg/apis/v1alpha1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
func TestServerHealthzHTTP(t *testing.T) {
memStore := memory.NewStore()
srv := server.NewServer(memStore)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from /healthz, got %d", rec.Code)
}
if rec.Body.String() != "ok\n" {
t.Fatalf("expected 'ok\\n' from /healthz, got %q", rec.Body.String())
}
// Verify non-healthz HTTP returns 404
req404 := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
rec404 := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec404, req404)
if rec404.Code != http.StatusNotFound {
t.Fatalf("expected 404 Not Found for /api/v1/tasks, got %d", rec404.Code)
}
}
func TestServerGRPC(t *testing.T) {
memStore := memory.NewStore()
srv := server.NewServer(memStore)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
defer ln.Close()
httpServer := &http.Server{
Handler: srv.Handler(),
}
httpServer.Protocols = new(http.Protocols)
httpServer.Protocols.SetHTTP1(true)
httpServer.Protocols.SetUnencryptedHTTP2(true)
go func() {
_ = httpServer.Serve(ln)
}()
defer httpServer.Close()
conn, err := grpc.NewClient(ln.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("failed to dial gRPC: %v", err)
}
defer conn.Close()
client := v1alpha1.NewAXClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 1. Create one resource of each kind through the typed RPCs. Metadata is left
// partially empty to exercise server-side defaulting.
if _, err := client.UpdateWorkspace(ctx, &v1alpha1.UpdateWorkspaceRequest{Workspace: &v1alpha1.Workspace{
Metadata: &v1alpha1.ObjectMeta{Name: "grpc-ws"},
Spec: &v1alpha1.WorkspaceSpec{},
}}); err != nil {
t.Fatalf("UpdateWorkspace failed: %v", err)
}
if _, err := client.UpdateModel(ctx, &v1alpha1.UpdateModelRequest{Model: &v1alpha1.Model{
Metadata: &v1alpha1.ObjectMeta{Name: "grpc-model"},
Spec: &v1alpha1.ModelSpec{Provider: "google", Model: "gemini-3.8-flash"},
}}); err != nil {
t.Fatalf("UpdateModel failed: %v", err)
}
if _, err := client.CreateTask(ctx, &v1alpha1.CreateTaskRequest{Task: &v1alpha1.Task{
Metadata: &v1alpha1.ObjectMeta{Name: "grpc-task"},
Spec: &v1alpha1.TaskSpec{Image: "alpine"},
}}); err != nil {
t.Fatalf("CreateTask failed: %v", err)
}
// 2. Defaulting applies to every kind: atespace and creation timestamp are filled in.
ws, err := client.GetWorkspace(ctx, &v1alpha1.GetWorkspaceRequest{Atespace: "default", Name: "grpc-ws"})
if err != nil {
t.Fatalf("GetWorkspace failed: %v", err)
}
if ws.GetMetadata().GetAtespace() != "default" || ws.GetMetadata().GetCreationTimestamp() == nil {
t.Errorf("expected workspace metadata to be defaulted, got %v", ws.GetMetadata())
}
// 3. GetTask & ListTasks
task, err := client.GetTask(ctx, &v1alpha1.GetTaskRequest{Atespace: "default", Name: "grpc-task"})
if err != nil {
t.Fatalf("GetTask failed: %v", err)
}
if task.Metadata.Name != "grpc-task" {
t.Errorf("expected name 'grpc-task', got %s", task.Metadata.Name)
}
if task.Metadata.CreationTimestamp == nil {
t.Errorf("expected creation timestamp to be populated on applied task")
}
listTasksResp, err := client.ListTasks(ctx, &v1alpha1.ListTasksRequest{Atespace: "default"})
if err != nil {
t.Fatalf("ListTasks failed: %v", err)
}
if len(listTasksResp.Tasks) != 1 {
t.Fatalf("expected 1 task in list, got %d", len(listTasksResp.Tasks))
}
if listTasksResp.Tasks[0].Metadata.CreationTimestamp == nil {
t.Errorf("expected creation timestamp on listed task")
}
// Test that Task is immutable
task.Spec.Image = "ghcr.io/test/updated-image"
_, err = client.CreateTask(ctx, &v1alpha1.CreateTaskRequest{Task: task})
if err == nil {
t.Fatalf("expected CreateTask to fail on existing task because tasks are immutable")
}
if status.Code(err) != codes.FailedPrecondition {
t.Errorf("expected FailedPrecondition code, got %v", status.Code(err))
}
// 4. Suspend & Resume Task
suspTask, err := client.SuspendTask(ctx, &v1alpha1.SuspendTaskRequest{Atespace: "default", Name: "grpc-task"})
if err != nil {
t.Fatalf("SuspendTask failed: %v", err)
}
if suspTask.Status.Phase != "Suspended" {
t.Errorf("expected task phase to be 'Suspended', got %q", suspTask.Status.Phase)
}
resTask, err := client.ResumeTask(ctx, &v1alpha1.ResumeTaskRequest{Atespace: "default", Name: "grpc-task"})
if err != nil {
t.Fatalf("ResumeTask failed: %v", err)
}
if resTask.Status.Phase != "Running" {
t.Errorf("expected task phase to be 'Running', got %q", resTask.Status.Phase)
}
// 5. Workspaces
ws, err = client.GetWorkspace(ctx, &v1alpha1.GetWorkspaceRequest{Atespace: "default", Name: "grpc-ws"})
if err != nil {
t.Fatalf("GetWorkspace failed: %v", err)
}
if ws.Metadata.Name != "grpc-ws" {
t.Errorf("expected workspace 'grpc-ws', got %s", ws.Metadata.Name)
}
listWorkspacesResp, err := client.ListWorkspaces(ctx, &v1alpha1.ListWorkspacesRequest{Atespace: "default"})
if err != nil {
t.Fatalf("ListWorkspaces failed: %v", err)
}
if len(listWorkspacesResp.Workspaces) != 1 {
t.Fatalf("expected 1 workspace in list, got %d", len(listWorkspacesResp.Workspaces))
}
// 7. Models
model, err := client.GetModel(ctx, &v1alpha1.GetModelRequest{Atespace: "default", Name: "grpc-model"})
if err != nil {
t.Fatalf("GetModel failed: %v", err)
}
if model.Metadata.Name != "grpc-model" {
t.Errorf("expected model 'grpc-model', got %s", model.Metadata.Name)
}
listModelsResp, err := client.ListModels(ctx, &v1alpha1.ListModelsRequest{Atespace: "default"})
if err != nil {
t.Fatalf("ListModels failed: %v", err)
}
if len(listModelsResp.Models) != 1 {
t.Fatalf("expected 1 model in list, got %d", len(listModelsResp.Models))
}
// 7b. WatchTask (should receive INITIAL state)
stream, err := client.WatchTask(ctx, &v1alpha1.WatchTaskRequest{Atespace: "default", Name: "grpc-task"})
if err != nil {
t.Fatalf("WatchTask failed: %v", err)
}
watchMsg, err := stream.Recv()
if err != nil {
t.Fatalf("WatchTask Recv failed: %v", err)
}
if watchMsg.Action != "INITIAL" {
t.Errorf("expected INITIAL action, got %s", watchMsg.Action)
}
if watchMsg.Task == nil || watchMsg.Task.Metadata == nil || watchMsg.Task.Metadata.Name != "grpc-task" {
t.Errorf("expected task 'grpc-task' in watch, got %+v", watchMsg.Task)
}
// 8. Delete operations
if _, err := client.DeleteTask(ctx, &v1alpha1.DeleteTaskRequest{Atespace: "default", Name: "grpc-task"}); err != nil {
t.Fatalf("DeleteTask failed: %v", err)
}
if _, err := client.DeleteTask(ctx, &v1alpha1.DeleteTaskRequest{Atespace: "default", Name: "no-such-task"}); status.Code(err) != codes.NotFound {
t.Errorf("expected NotFound deleting a missing task, got %v", err)
}
if _, err := client.DeleteWorkspace(ctx, &v1alpha1.DeleteWorkspaceRequest{Atespace: "default", Name: "grpc-ws"}); err != nil {
t.Fatalf("DeleteWorkspace failed: %v", err)
}
if _, err := client.DeleteModel(ctx, &v1alpha1.DeleteModelRequest{Atespace: "default", Name: "grpc-model"}); err != nil {
t.Fatalf("DeleteModel failed: %v", err)
}
// Check 404 after delete
_, err = client.GetTask(ctx, &v1alpha1.GetTaskRequest{Atespace: "default", Name: "grpc-task"})
if status.Code(err) != codes.NotFound {
t.Fatalf("expected NotFound code, got %v", err)
}
}
// Names and atespaces become Substrate resource names, which must be RFC 1123
// labels. The server rejects them up front instead of failing during Substrate
// actor creation.
func TestCreate_RejectsInvalidNames(t *testing.T) {
srv := server.NewServer(memory.NewStore())
ctx := context.Background()
for _, meta := range []*v1alpha1.ObjectMeta{
{Name: "Task-With-Caps"},
{Name: "under_score"},
{Name: ""},
{Name: "ok", Atespace: "Not-Lowercase"},
} {
if _, err := srv.CreateTask(ctx, &v1alpha1.CreateTaskRequest{Task: &v1alpha1.Task{Metadata: meta}}); status.Code(err) != codes.InvalidArgument {
t.Errorf("CreateTask(%v): got %v, want InvalidArgument", meta, err)
}
if _, err := srv.UpdateWorkspace(ctx, &v1alpha1.UpdateWorkspaceRequest{Workspace: &v1alpha1.Workspace{Metadata: meta}}); status.Code(err) != codes.InvalidArgument {
t.Errorf("UpdateWorkspace(%v): got %v, want InvalidArgument", meta, err)
}
if _, err := srv.UpdateModel(ctx, &v1alpha1.UpdateModelRequest{Model: &v1alpha1.Model{Metadata: meta}}); status.Code(err) != codes.InvalidArgument {
t.Errorf("UpdateModel(%v): got %v, want InvalidArgument", meta, err)
}
}
// Nothing invalid was persisted.
if resp, err := srv.ListTasks(ctx, &v1alpha1.ListTasksRequest{}); err != nil || len(resp.GetTasks()) != 0 {
t.Errorf("ListTasks after rejected applies = %v, %v; want empty", resp.GetTasks(), err)
}
// Valid names still go through, with and without an explicit atespace.
good := &v1alpha1.ObjectMeta{Name: "task-with-caps", Atespace: "team-a"}
if _, err := srv.CreateTask(ctx, &v1alpha1.CreateTaskRequest{Task: &v1alpha1.Task{Metadata: good}}); err != nil {
t.Errorf("CreateTask(%v): %v", good, err)
}
if _, err := srv.UpdateWorkspace(ctx, &v1alpha1.UpdateWorkspaceRequest{Workspace: &v1alpha1.Workspace{Metadata: &v1alpha1.ObjectMeta{Name: "ws-1"}}}); err != nil {
t.Errorf("UpdateWorkspace: %v", err)
}
if _, err := srv.UpdateModel(ctx, &v1alpha1.UpdateModelRequest{Model: &v1alpha1.Model{Metadata: &v1alpha1.ObjectMeta{Name: "gemini"}}}); err != nil {
t.Errorf("UpdateModel: %v", err)
}
}
func TestCreateTask_ValidatesWorkspaceBindings(t *testing.T) {
srv := server.NewServer(memory.NewStore())
ctx := context.Background()
_, err := srv.CreateTask(ctx, &v1alpha1.CreateTaskRequest{Task: &v1alpha1.Task{
Metadata: &v1alpha1.ObjectMeta{Name: "bad"},
Spec: &v1alpha1.TaskSpec{
Workspaces: []*v1alpha1.WorkspaceRef{{Name: "a", Path: "/same"}, {Name: "b", Path: "/same"}},
},
}})
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument for colliding workspace paths, got %v", err)
}
_, err = srv.CreateTask(ctx, &v1alpha1.CreateTaskRequest{Task: &v1alpha1.Task{
Metadata: &v1alpha1.ObjectMeta{Name: "good"},
Spec: &v1alpha1.TaskSpec{
Workspaces: []*v1alpha1.WorkspaceRef{{Name: "a"}, {Name: "b"}},
},
}})
if err != nil {
t.Fatalf("expected a valid multi-workspace task to be accepted, got %v", err)
}
}
type fakeReconciler struct {
reconcileCount int
deleteCount int
deleteErr error
onDelete func(ctx context.Context, atespace, taskName string) error
}
func (f *fakeReconciler) Reconcile(ctx context.Context, task *v1alpha1.Task, workspaces ...*v1alpha1.Workspace) (*v1alpha1.Task, error) {
f.reconcileCount++
task.Status = &v1alpha1.TaskStatus{
Phase: task.GetStatus().GetPhase(),
}
return task, nil
}
func (f *fakeReconciler) ReconcileDelete(ctx context.Context, atespace, taskName string) error {
f.deleteCount++
if f.onDelete != nil {
return f.onDelete(ctx, atespace, taskName)
}
return f.deleteErr
}
func TestServer_DirectReconcilerLifecycle(t *testing.T) {
rec := &fakeReconciler{}
srv := server.NewServer(memory.NewStore(), server.Options{Reconciler: rec})
ctx := context.Background()
// 1. CreateTask directly calls Reconciler
task, err := srv.CreateTask(ctx, &v1alpha1.CreateTaskRequest{
Task: &v1alpha1.Task{
Metadata: &v1alpha1.ObjectMeta{Name: "task-rec"},
Spec: &v1alpha1.TaskSpec{Image: "alpine"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
if rec.reconcileCount != 1 {
t.Errorf("expected 1 reconcile call on CreateTask, got %d", rec.reconcileCount)
}
if task.GetStatus().GetPhase() != "Suspended" {
t.Errorf("expected phase Suspended, got %s", task.GetStatus().GetPhase())
}
// 2. ResumeTask directly calls Reconciler
task, err = srv.ResumeTask(ctx, &v1alpha1.ResumeTaskRequest{Name: "task-rec"})
if err != nil {
t.Fatalf("ResumeTask: %v", err)
}
if rec.reconcileCount != 2 {
t.Errorf("expected 2 reconcile calls after ResumeTask, got %d", rec.reconcileCount)
}
if task.GetStatus().GetPhase() != "Running" {
t.Errorf("expected phase Running, got %s", task.GetStatus().GetPhase())
}
// 3. SuspendTask directly calls Reconciler
task, err = srv.SuspendTask(ctx, &v1alpha1.SuspendTaskRequest{Name: "task-rec"})
if err != nil {
t.Fatalf("SuspendTask: %v", err)
}
if rec.reconcileCount != 3 {
t.Errorf("expected 3 reconcile calls after SuspendTask, got %d", rec.reconcileCount)
}
if task.GetStatus().GetPhase() != "Suspended" {
t.Errorf("expected phase Suspended, got %s", task.GetStatus().GetPhase())
}
// 4. DeleteTask directly calls ReconcileDelete
_, err = srv.DeleteTask(ctx, &v1alpha1.DeleteTaskRequest{Name: "task-rec"})
if err != nil {
t.Fatalf("DeleteTask: %v", err)
}
if rec.deleteCount != 1 {
t.Errorf("expected 1 delete call, got %d", rec.deleteCount)
}
// 5. Verify task is gone
_, err = srv.GetTask(ctx, &v1alpha1.GetTaskRequest{Name: "task-rec"})
if status.Code(err) != codes.NotFound {
t.Errorf("expected NotFound after DeleteTask, got %v", err)
}
}
func TestServer_DeleteTask_BlocksAndSetsPhaseTerminating(t *testing.T) {
st := memory.NewStore()
var observedPhaseDuringDelete string
rec := &fakeReconciler{
onDelete: func(ctx context.Context, atespace, taskName string) error {
t, err := st.GetTask(ctx, atespace, taskName)
if err == nil && t.Status != nil {
observedPhaseDuringDelete = t.Status.Phase
}
return nil
},
}
srv := server.NewServer(st, server.Options{Reconciler: rec})
ctx := context.Background()
_, err := srv.CreateTask(ctx, &v1alpha1.CreateTaskRequest{
Task: &v1alpha1.Task{
Metadata: &v1alpha1.ObjectMeta{Name: "task-term"},
Spec: &v1alpha1.TaskSpec{Image: "alpine"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
_, err = srv.DeleteTask(ctx, &v1alpha1.DeleteTaskRequest{Name: "task-term"})
if err != nil {
t.Fatalf("DeleteTask: %v", err)
}
if observedPhaseDuringDelete != v1alpha1.PhaseTerminating {
t.Errorf("expected phase %q during delete, got %q", v1alpha1.PhaseTerminating, observedPhaseDuringDelete)
}
_, err = srv.GetTask(ctx, &v1alpha1.GetTaskRequest{Name: "task-term"})
if status.Code(err) != codes.NotFound {
t.Errorf("expected NotFound after successful DeleteTask, got %v", err)
}
}
func TestServer_DeleteTask_ReconcileErrorRetainsTask(t *testing.T) {
st := memory.NewStore()
rec := &fakeReconciler{
deleteErr: errors.New("substrate timeout deleting actor"),
}
srv := server.NewServer(st, server.Options{Reconciler: rec})
ctx := context.Background()
_, err := srv.CreateTask(ctx, &v1alpha1.CreateTaskRequest{
Task: &v1alpha1.Task{
Metadata: &v1alpha1.ObjectMeta{Name: "task-err"},
Spec: &v1alpha1.TaskSpec{Image: "alpine"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
_, err = srv.DeleteTask(ctx, &v1alpha1.DeleteTaskRequest{Name: "task-err"})
if err == nil {
t.Fatal("expected DeleteTask to fail when ReconcileDelete fails")
}
task, err := srv.GetTask(ctx, &v1alpha1.GetTaskRequest{Name: "task-err"})
if err != nil {
t.Fatalf("expected task to remain in store, got %v", err)
}
if task.GetStatus().GetPhase() != v1alpha1.PhaseTerminating {
t.Errorf("expected phase %q, got %q", v1alpha1.PhaseTerminating, task.GetStatus().GetPhase())
}
}
type failUpdateStatusStore struct {
store.Store
failUpdateStatus bool
}
func (f *failUpdateStatusStore) UpdateTaskStatus(ctx context.Context, atespace, name string, status *v1alpha1.TaskStatus) error {
if f.failUpdateStatus {
return errors.New("simulated store failure")
}
return f.Store.UpdateTaskStatus(ctx, atespace, name, status)
}
func TestServer_DeleteTask_UpdateStatusError(t *testing.T) {
base := memory.NewStore()
st := &failUpdateStatusStore{Store: base}
rec := &fakeReconciler{}
srv := server.NewServer(st, server.Options{Reconciler: rec})
ctx := context.Background()
_, err := srv.CreateTask(ctx, &v1alpha1.CreateTaskRequest{
Task: &v1alpha1.Task{
Metadata: &v1alpha1.ObjectMeta{Name: "task-status-fail"},
Spec: &v1alpha1.TaskSpec{Image: "alpine"},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
st.failUpdateStatus = true
_, err = srv.DeleteTask(ctx, &v1alpha1.DeleteTaskRequest{Name: "task-status-fail"})
if err == nil {
t.Fatal("expected DeleteTask to fail when UpdateTaskStatus fails")
}
if rec.deleteCount != 0 {
t.Errorf("expected ReconcileDelete not to be called if UpdateTaskStatus fails, got %d calls", rec.deleteCount)
}
}