mirror of
https://github.com/google/ax.git
synced 2026-10-17 05:09:15 +08:00
Make Task manifests fully immutable once created, removing `UpdateTask` in favor of `CreateTask` and driving lifecycle transitions (suspend, resume, delete) through dedicated RPCs and event stream processing. - Proto: Replace `UpdateTask` with `CreateTask` in AX service. - Proto: Remove mutable `suspend` field from `TaskSpec` and reserve field 2. - API / Server: Implement `CreateTask` with duplicate name rejection (`codes.AlreadyExists`). Update `SuspendTask`, `ResumeTask`, and `DeleteTask` to transition status phase and publish lifecycle events. - Store: Remove redundant `SetTaskSuspended` and `MarkTaskDeleting` methods; standardize on `CreateTask`, `UpdateTaskStatus`, `DeleteTask`, and `PublishEvent`. - Controller: Decouple spec reconciliation from task suspension; introduce `ReconcileSuspend` to handle checkpointing and pausing actors on Substrate. - CLI: Update `ax apply` to submit tasks via `CreateTask`. - Docs: Update design docs and specifications to reflect task immutability.
309 lines
11 KiB
Go
309 lines
11 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"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/ax/internal/server"
|
|
"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.UpdateGateway(ctx, &v1alpha1.UpdateGatewayRequest{Gateway: &v1alpha1.Gateway{
|
|
Metadata: &v1alpha1.ObjectMeta{Name: "grpc-gw"},
|
|
Spec: &v1alpha1.GatewaySpec{
|
|
Listeners: []*v1alpha1.Listener{{Name: "http", Port: 80, Protocol: "HTTP"}},
|
|
},
|
|
}}); err != nil {
|
|
t.Fatalf("UpdateGateway failed: %v", err)
|
|
}
|
|
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.
|
|
gw, err := client.GetGateway(ctx, &v1alpha1.GetGatewayRequest{Atespace: "default", Name: "grpc-gw"})
|
|
if err != nil {
|
|
t.Fatalf("GetGateway failed: %v", err)
|
|
}
|
|
if gw.GetMetadata().GetAtespace() != "default" || gw.GetMetadata().GetCreationTimestamp() == nil {
|
|
t.Errorf("expected gateway metadata to be defaulted, got %v", gw.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")
|
|
}
|
|
|
|
// Tasks are immutable once created: attempting to create a task with the same name fails.
|
|
task.Spec.Image = "ghcr.io/test/updated-image"
|
|
if _, err := client.CreateTask(ctx, &v1alpha1.CreateTaskRequest{Task: task}); status.Code(err) != codes.AlreadyExists {
|
|
t.Fatalf("expected AlreadyExists when recreating task, got %v", err)
|
|
}
|
|
unchangedTask, err := client.GetTask(ctx, &v1alpha1.GetTaskRequest{Atespace: "default", Name: "grpc-task"})
|
|
if err != nil {
|
|
t.Fatalf("GetTask failed: %v", err)
|
|
}
|
|
if unchangedTask.Spec.Image != "alpine" {
|
|
t.Errorf("expected task image to remain 'alpine', got %s", unchangedTask.Spec.Image)
|
|
}
|
|
|
|
// 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 != v1alpha1.PhaseSuspended {
|
|
t.Errorf("expected phase %q, got %q", v1alpha1.PhaseSuspended, 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 == v1alpha1.PhaseSuspended {
|
|
t.Errorf("expected task to be resumed, still suspended")
|
|
}
|
|
|
|
// 5. Gateways
|
|
gw, err = client.GetGateway(ctx, &v1alpha1.GetGatewayRequest{Atespace: "default", Name: "grpc-gw"})
|
|
if err != nil {
|
|
t.Fatalf("GetGateway failed: %v", err)
|
|
}
|
|
if gw.Metadata.Name != "grpc-gw" {
|
|
t.Errorf("expected gateway 'grpc-gw', got %s", gw.Metadata.Name)
|
|
}
|
|
|
|
listGatewaysResp, err := client.ListGateways(ctx, &v1alpha1.ListGatewaysRequest{Atespace: "default"})
|
|
if err != nil {
|
|
t.Fatalf("ListGateways failed: %v", err)
|
|
}
|
|
if len(listGatewaysResp.Gateways) != 1 {
|
|
t.Fatalf("expected 1 gateway in list, got %d", len(listGatewaysResp.Gateways))
|
|
}
|
|
|
|
// 6. 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
|
|
// Task deletion is two-phase: the RPC marks the task Terminating and the
|
|
// controller removes the record after tearing down the actor.
|
|
if _, err := client.DeleteTask(ctx, &v1alpha1.DeleteTaskRequest{Atespace: "default", Name: "grpc-task"}); err != nil {
|
|
t.Fatalf("DeleteTask failed: %v", err)
|
|
}
|
|
terminating, err := client.GetTask(ctx, &v1alpha1.GetTaskRequest{Atespace: "default", Name: "grpc-task"})
|
|
if err != nil {
|
|
t.Fatalf("GetTask after DeleteTask failed: %v", err)
|
|
}
|
|
if terminating.GetStatus().GetPhase() != v1alpha1.PhaseTerminating {
|
|
t.Errorf("expected phase %q after DeleteTask, got %q", v1alpha1.PhaseTerminating, terminating.GetStatus().GetPhase())
|
|
}
|
|
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)
|
|
}
|
|
// Stand in for the controller finishing cleanup.
|
|
if err := memStore.DeleteTask(ctx, "default", "grpc-task"); err != nil {
|
|
t.Fatalf("removing task record failed: %v", err)
|
|
}
|
|
if _, err := client.DeleteGateway(ctx, &v1alpha1.DeleteGatewayRequest{Atespace: "default", Name: "grpc-gw"}); err != nil {
|
|
t.Fatalf("DeleteGateway failed: %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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|