crabbox/internal/cli/coordinator_test.go
Jonathan Moss 00725544c7
feat(azure): support linux and native windows leases
Add Azure as a managed provider for direct and brokered Crabbox leases.

- provision Azure Linux VMs with cloud-init, spot fallback, shared network adoption, and per-lease cleanup
- provision native Azure Windows VMs with VM Agent bootstrap and SSH/sync/run support
- add Azure broker support in the Cloudflare Worker, provider config, docs, and tests
- fix async Azure delete handling so successful 202 delete LROs do not refetch deleted resources
- keep Go core coverage above the CI threshold

Verified with CI plus live Azure Linux and native Windows leases.

Co-authored-by: Jonathan Moss <2729151+jwmoss@users.noreply.github.com>
2026-05-08 08:23:38 +01:00

646 lines
24 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestCoordinatorMachineIDAcceptsStringOrNumber(t *testing.T) {
for name, input := range map[string]string{
"string": `{"id":"i-123","labels":{}}`,
"number": `{"id":128694755,"labels":{}}`,
} {
t.Run(name, func(t *testing.T) {
var machine CoordinatorMachine
if err := json.Unmarshal([]byte(input), &machine); err != nil {
t.Fatal(err)
}
if machine.ID == "" {
t.Fatalf("machine ID was empty")
}
})
}
}
func TestSplitCurlResponseParsesTrailingStatus(t *testing.T) {
body, status, err := splitCurlResponse([]byte("{\"ok\":true}\n200"))
if err != nil {
t.Fatal(err)
}
if status != 200 {
t.Fatalf("status = %d, want 200", status)
}
if string(body) != `{"ok":true}` {
t.Fatalf("body = %q", body)
}
}
func TestDecodeCoordinatorResponseCanReadTextBody(t *testing.T) {
var buf bytes.Buffer
if err := decodeCoordinatorResponse("GET", "/v1/runs/run_1/logs", 200, strings.NewReader("hello"), &buf); err != nil {
t.Fatal(err)
}
if buf.String() != "hello" {
t.Fatalf("body=%q", buf.String())
}
}
func TestCoordinatorRunEvents(t *testing.T) {
var createBody map[string]any
var eventBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/runs":
if err := json.NewDecoder(r.Body).Decode(&createBody); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"run":{"id":"run_123","leaseID":"","owner":"peter@example.com","org":"openclaw","provider":"aws","class":"standard","serverType":"t3.small","command":["pnpm","test"],"state":"running","phase":"starting","logBytes":0,"logTruncated":false,"startedAt":"2026-05-02T00:00:00Z"}}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/runs/run_123/events":
if err := json.NewDecoder(r.Body).Decode(&eventBody); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"event":{"runID":"run_123","seq":2,"type":"sync.started","phase":"sync","createdAt":"2026-05-02T00:00:01Z"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/runs/run_123/events":
if got := r.URL.Query().Get("after"); got != "4" {
t.Fatalf("after query=%q", got)
}
if got := r.URL.Query().Get("limit"); got != "25" {
t.Fatalf("limit query=%q", got)
}
_, _ = w.Write([]byte(`{"events":[{"runID":"run_123","seq":1,"type":"run.started","phase":"starting","createdAt":"2026-05-02T00:00:00Z"}]}`))
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
run, err := client.CreateRun(context.Background(), "", Config{
Provider: "aws",
Class: "standard",
ServerType: "t3.small",
}, []string{"pnpm", "test"})
if err != nil {
t.Fatal(err)
}
if run.ID != "run_123" || run.Phase != "starting" {
t.Fatalf("run=%#v", run)
}
if got, ok := createBody["leaseID"].(string); !ok || got != "" {
t.Fatalf("leaseID body=%#v", createBody["leaseID"])
}
event, err := client.AppendRunEvent(context.Background(), run.ID, CoordinatorRunEventInput{Type: "sync.started", Phase: "sync"})
if err != nil {
t.Fatal(err)
}
if event.Type != "sync.started" || event.Seq != 2 {
t.Fatalf("event=%#v", event)
}
if got, ok := eventBody["type"].(string); !ok || got != "sync.started" {
t.Fatalf("event body=%#v", eventBody)
}
events, err := client.RunEvents(context.Background(), run.ID, 4, 25)
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Type != "run.started" {
t.Fatalf("events=%#v", events)
}
}
func TestCoordinatorFinishRunSendsLogChunks(t *testing.T) {
var finishBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/runs/run_123/finish" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&finishBody); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"run":{"id":"run_123","leaseID":"","owner":"peter@example.com","org":"openclaw","provider":"aws","class":"standard","serverType":"t3.small","command":["pnpm","test"],"state":"failed","phase":"failed","exitCode":1,"logBytes":0,"logTruncated":false,"startedAt":"2026-05-02T00:00:00Z"}}`))
}))
defer server.Close()
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
log := strings.Repeat("x", coordinatorRunLogChunkBytes) + "tail"
load := 0.42
if _, err := client.FinishRun(context.Background(), "run_123", 1, time.Second, 2*time.Second, log, false, nil, &RunTelemetrySummary{End: &LeaseTelemetry{Load1: &load}}); err != nil {
t.Fatal(err)
}
chunks, ok := finishBody["logChunks"].([]any)
if !ok {
t.Fatalf("logChunks body=%#v", finishBody["logChunks"])
}
if len(chunks) != 2 {
t.Fatalf("logChunks=%d, want 2", len(chunks))
}
if got := chunks[0].(string); len(got) != coordinatorRunLogChunkBytes {
t.Fatalf("first chunk length=%d, want %d", len(got), coordinatorRunLogChunkBytes)
}
if got := chunks[1].(string); got != "tail" {
t.Fatalf("second chunk=%q, want tail", got)
}
if got := finishBody["log"].(string); len(got) != runLogFallbackPreviewBytes || !strings.HasSuffix(got, "tail") {
t.Fatalf("fallback log length=%d suffix=%q", len(got), got[len(got)-4:])
}
if got := finishBody["telemetry"].(map[string]any)["end"].(map[string]any)["load1"]; got != 0.42 {
t.Fatalf("telemetry=%#v", finishBody["telemetry"])
}
}
func TestCurlConfigKeepsBearerTokenInConfig(t *testing.T) {
client := CoordinatorClient{
BaseURL: "https://example.test",
Token: "secret-token",
Access: AccessConfig{
ClientID: "access-client",
ClientSecret: "access-secret",
Token: "access-jwt",
},
}
config, cleanup, err := client.curlConfig("POST", "/v1/leases", []byte(`{"leaseID":"cbx"}`), true)
if err != nil {
t.Fatal(err)
}
defer cleanup()
for _, want := range []string{
`url = "https://example.test/v1/leases"`,
`request = "POST"`,
`header = "Authorization: Bearer secret-token"`,
`header = "CF-Access-Client-Id: access-client"`,
`header = "CF-Access-Client-Secret: access-secret"`,
`header = "cf-access-token: access-jwt"`,
`header = "Content-Type: application/json"`,
`data-binary = "@`,
} {
if !strings.Contains(config, want) {
t.Fatalf("config missing %q:\n%s", want, config)
}
}
bodyPath := curlConfigValueForTest(t, config, "data-binary")
bodyPath = strings.TrimPrefix(bodyPath, "@")
if _, err := os.Stat(bodyPath); err != nil {
t.Fatalf("body file missing: %v", err)
}
}
func TestCoordinatorHTTPAddsAccessHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer broker-token" {
t.Fatalf("Authorization=%q", got)
}
if got := r.Header.Get("CF-Access-Client-Id"); got != "access-client" {
t.Fatalf("CF-Access-Client-Id=%q", got)
}
if got := r.Header.Get("CF-Access-Client-Secret"); got != "access-secret" {
t.Fatalf("CF-Access-Client-Secret=%q", got)
}
if got := r.Header.Get("cf-access-token"); got != "access-jwt" {
t.Fatalf("cf-access-token=%q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
client := CoordinatorClient{
BaseURL: server.URL,
Token: "broker-token",
Access: AccessConfig{
ClientID: "access-client",
ClientSecret: "access-secret",
Token: "access-jwt",
},
Client: server.Client(),
}
if err := client.Health(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestHeartbeatRequestBodyOmitsIdleTimeoutForTouch(t *testing.T) {
if body := heartbeatRequestBody(nil, nil); len(body) != 0 {
t.Fatalf("touch heartbeat body=%v, want empty", body)
}
idleTimeout := 45 * time.Minute
body := heartbeatRequestBody(&idleTimeout, nil)
if body["idleTimeoutSeconds"] != 2700 {
t.Fatalf("heartbeat body=%v, want idle timeout seconds", body)
}
load := 0.42
body = heartbeatRequestBody(nil, &LeaseTelemetry{Load1: &load})
if body["telemetry"] == nil {
t.Fatalf("heartbeat body=%v, want telemetry", body)
}
}
func TestCoordinatorTouchAndUpdateHeartbeatBodies(t *testing.T) {
var bodies []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/leases/cbx_123/heartbeat" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
data, _ := io.ReadAll(r.Body)
bodies = append(bodies, string(data))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"lease":{"id":"cbx_123","provider":"aws","state":"active","expiresAt":"2026-05-01T00:30:00Z"}}`))
}))
defer server.Close()
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
if _, err := client.TouchLease(context.Background(), "cbx_123"); err != nil {
t.Fatal(err)
}
load := 0.42
if _, err := client.TouchLeaseWithTelemetry(context.Background(), "cbx_123", &LeaseTelemetry{Load1: &load}); err != nil {
t.Fatal(err)
}
if _, err := client.UpdateLeaseIdleTimeout(context.Background(), "cbx_123", 45*time.Minute); err != nil {
t.Fatal(err)
}
if len(bodies) != 3 || bodies[0] != "{}" || !strings.Contains(bodies[1], `"load1":0.42`) || !strings.Contains(bodies[2], `"idleTimeoutSeconds":2700`) {
t.Fatalf("heartbeat bodies=%q", bodies)
}
}
func TestCoordinatorAppendRunTelemetry(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/runs/run_123/telemetry" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
data, _ := io.ReadAll(r.Body)
body = string(data)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"run":{"id":"run_123","leaseID":"cbx_123","owner":"peter@example.com","org":"openclaw","provider":"aws","class":"standard","serverType":"t3.small","command":["sleep","60"],"state":"running","logBytes":0,"logTruncated":false,"startedAt":"2026-05-02T00:00:00Z"}}`))
}))
defer server.Close()
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
load := 0.42
if _, err := client.AppendRunTelemetry(context.Background(), "run_123", &LeaseTelemetry{Load1: &load}); err != nil {
t.Fatal(err)
}
if !strings.Contains(body, `"telemetry"`) || !strings.Contains(body, `"load1":0.42`) {
t.Fatalf("append telemetry body=%q", body)
}
}
func TestCoordinatorHeartbeatTouchesImmediately(t *testing.T) {
touches := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/leases/cbx_123/heartbeat" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
touches <- struct{}{}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"lease":{"id":"cbx_123","provider":"aws","state":"active","expiresAt":"2026-05-01T00:30:00Z"}}`))
}))
defer server.Close()
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
stop := startCoordinatorHeartbeat(context.Background(), &client, "cbx_123", 30*time.Minute, nil, nil, io.Discard)
defer stop()
select {
case <-touches:
case <-time.After(2 * time.Second):
t.Fatal("heartbeat did not touch immediately")
}
}
func TestCoordinatorHeartbeatIncludesTelemetry(t *testing.T) {
bodies := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/leases/cbx_123/heartbeat" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
data, _ := io.ReadAll(r.Body)
bodies <- string(data)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"lease":{"id":"cbx_123","provider":"aws","state":"active","expiresAt":"2026-05-01T00:30:00Z"}}`))
}))
defer server.Close()
load := 0.77
collector := func(context.Context) (*LeaseTelemetry, error) {
return &LeaseTelemetry{Load1: &load}, nil
}
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
stop := startCoordinatorHeartbeat(context.Background(), &client, "cbx_123", 30*time.Minute, nil, collector, io.Discard)
defer stop()
select {
case body := <-bodies:
if !strings.Contains(body, `"load1":0.77`) {
t.Fatalf("heartbeat body=%s, want telemetry", body)
}
case <-time.After(2 * time.Second):
t.Fatal("heartbeat did not touch immediately")
}
}
func TestCoordinatorLeaseWatchCancelsWhenLeaseReleased(t *testing.T) {
oldInterval := coordinatorLeaseWatchInterval
coordinatorLeaseWatchInterval = 10 * time.Millisecond
defer func() { coordinatorLeaseWatchInterval = oldInterval }()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/leases/cbx_123" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"lease":{"id":"cbx_123","provider":"aws","state":"released","expiresAt":"2026-05-01T00:30:00Z"}}`))
}))
defer server.Close()
ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(nil)
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
stop := startCoordinatorLeaseWatch(ctx, &client, "cbx_123", cancel, io.Discard)
defer stop()
select {
case <-ctx.Done():
if cause := context.Cause(ctx); cause == nil || !strings.Contains(cause.Error(), "became released") {
t.Fatalf("cause=%v, want released lease cause", cause)
}
case <-time.After(2 * time.Second):
t.Fatal("lease watcher did not cancel after release")
}
}
func TestCoordinatorCreateLeaseSendsAWSSSHCIDRs(t *testing.T) {
var body struct {
AWSSSHCIDRs []string `json:"awsSSHCIDRs"`
AzureLocation string `json:"azureLocation"`
AzureImage string `json:"azureImage"`
SSHFallbackPorts []string `json:"sshFallbackPorts"`
ServerTypeExplicit bool `json:"serverTypeExplicit"`
Capacity map[string]any
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/leases" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"lease":{"id":"cbx_123","provider":"aws","state":"active","host":"192.0.2.10"}}`))
}))
defer server.Close()
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
_, err := client.CreateLease(context.Background(), Config{
Provider: "aws",
ServerType: "t3.small",
ServerTypeExplicit: true,
AWSSSHCIDRs: []string{"198.51.100.7/32"},
AzureLocation: "eastus",
AzureImage: "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest",
SSHFallbackPorts: []string{"22", "2022"},
Capacity: CapacityConfig{
Market: "spot",
Strategy: "most-available",
Fallback: "on-demand-after-120s",
Hints: true,
},
}, "ssh-ed25519 test", false, "cbx_123", "blue-crab")
if err != nil {
t.Fatal(err)
}
if len(body.AWSSSHCIDRs) != 1 || body.AWSSSHCIDRs[0] != "198.51.100.7/32" {
t.Fatalf("awsSSHCIDRs=%v", body.AWSSSHCIDRs)
}
if body.AzureLocation != "eastus" {
t.Fatalf("azureLocation=%q", body.AzureLocation)
}
if body.AzureImage != "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest" {
t.Fatalf("azureImage=%q", body.AzureImage)
}
if len(body.SSHFallbackPorts) != 2 || body.SSHFallbackPorts[0] != "22" || body.SSHFallbackPorts[1] != "2022" {
t.Fatalf("sshFallbackPorts=%v", body.SSHFallbackPorts)
}
if !body.ServerTypeExplicit {
t.Fatal("serverTypeExplicit=false, want true")
}
if body.Capacity != nil {
t.Fatalf("default capacity fields should be omitted for mixed-version brokers: %#v", body.Capacity)
}
}
func TestCoordinatorCreateLeaseSendsConfiguredCapacityExtensions(t *testing.T) {
var body struct {
Capacity map[string]any
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/leases" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"lease":{"id":"cbx_123","provider":"aws","state":"active","host":"192.0.2.10"}}`))
}))
defer server.Close()
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
_, err := client.CreateLease(context.Background(), Config{
Provider: "aws",
Capacity: CapacityConfig{
Market: "spot",
Strategy: "most-available",
Fallback: "on-demand-after-120s",
Regions: []string{"eu-west-1", "eu-west-2"},
AvailabilityZones: []string{"eu-west-1a"},
Hints: false,
},
}, "ssh-ed25519 test", false, "cbx_123", "blue-crab")
if err != nil {
t.Fatal(err)
}
if got := stringSliceFromJSON(body.Capacity["regions"]); !reflect.DeepEqual(got, []string{"eu-west-1", "eu-west-2"}) {
t.Fatalf("capacity.regions=%v", got)
}
if got := stringSliceFromJSON(body.Capacity["availabilityZones"]); !reflect.DeepEqual(got, []string{"eu-west-1a"}) {
t.Fatalf("capacity.availabilityZones=%v", got)
}
if got, ok := body.Capacity["hints"].(bool); !ok || got {
t.Fatalf("capacity.hints=%#v, want false", body.Capacity["hints"])
}
}
func TestCoordinatorLeaseDecodesLegacyCapacityResponse(t *testing.T) {
var lease CoordinatorLease
if err := json.Unmarshal([]byte(`{"id":"cbx_123","provider":"aws","serverType":"c7a.8xlarge"}`), &lease); err != nil {
t.Fatal(err)
}
if lease.Market != "" || len(lease.ProvisioningAttempts) != 0 || len(lease.CapacityHints) != 0 {
t.Fatalf("new capacity fields should be optional: %#v", lease)
}
}
func TestCoordinatorLeaseDecodesProvisioningAttempts(t *testing.T) {
var lease CoordinatorLease
if err := json.Unmarshal([]byte(`{
"id":"cbx_123",
"provider":"aws",
"serverType":"c7i.24xlarge",
"requestedServerType":"c7a.48xlarge",
"market":"on-demand",
"provisioningAttempts":[{"region":"eu-west-1","serverType":"c7a.48xlarge","market":"spot","category":"policy","message":"not eligible"}],
"capacityHints":[{"code":"aws_capacity_routed","message":"AWS launch routed to eu-west-2","action":"keep regions","region":"eu-west-2","market":"on-demand","class":"beast","serverType":"c7i.24xlarge","regionsTried":["eu-west-1","eu-west-2"]}]
}`), &lease); err != nil {
t.Fatal(err)
}
if lease.RequestedServerType != "c7a.48xlarge" || lease.ServerType != "c7i.24xlarge" {
t.Fatalf("lease=%#v", lease)
}
if len(lease.ProvisioningAttempts) != 1 || lease.ProvisioningAttempts[0].Category != "policy" {
t.Fatalf("attempts=%#v", lease.ProvisioningAttempts)
}
if lease.Market != "on-demand" || len(lease.CapacityHints) != 1 || lease.CapacityHints[0].Region != "eu-west-2" {
t.Fatalf("capacity fields market=%q hints=%#v", lease.Market, lease.CapacityHints)
}
}
func stringSliceFromJSON(value any) []string {
items, _ := value.([]any)
out := make([]string, 0, len(items))
for _, item := range items {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
func TestCoordinatorFallbackSummary(t *testing.T) {
summary := coordinatorFallbackSummary(CoordinatorLease{
RequestedServerType: "c7a.48xlarge",
ServerType: "c7i.24xlarge",
ProvisioningAttempts: []ProvisioningAttempt{{
Region: "eu-west-1",
ServerType: "c7a.48xlarge",
Market: "spot",
Category: "policy",
Message: "not eligible",
}},
})
if !strings.Contains(summary, "requested_type=c7a.48xlarge") || !strings.Contains(summary, "attempts=eu-west-1/c7a.48xlarge:policy") {
t.Fatalf("summary=%q", summary)
}
}
func TestCoordinatorCapacityHintLines(t *testing.T) {
lines := coordinatorCapacityHintLines(CoordinatorLease{
CapacityHints: []CapacityHint{{
Code: "aws_capacity_routed",
Message: "AWS launch routed to eu-west-2",
Action: "keep multiple regions configured",
}},
})
if len(lines) != 1 || !strings.Contains(lines[0], "aws_capacity_routed") || !strings.Contains(lines[0], "action=keep multiple regions") {
t.Fatalf("lines=%#v", lines)
}
}
func TestCoordinatorImageCreateAndPromote(t *testing.T) {
var createBody struct {
LeaseID string `json:"leaseID"`
Name string `json:"name"`
NoReboot bool `json:"noReboot"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/v1/images":
if r.Method != http.MethodPost {
t.Fatalf("method=%s", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&createBody); err != nil {
t.Fatal(err)
}
_, _ = w.Write([]byte(`{"image":{"id":"ami-12345678","name":"openclaw-crabbox-test","state":"pending","region":"eu-west-1"}}`))
case "/v1/images/ami-12345678":
_, _ = w.Write([]byte(`{"image":{"id":"ami-12345678","name":"openclaw-crabbox-test","state":"available","region":"eu-west-1"}}`))
case "/v1/images/ami-12345678/promote":
if r.Method != http.MethodPost {
t.Fatalf("method=%s", r.Method)
}
_, _ = w.Write([]byte(`{"image":{"id":"ami-12345678","name":"openclaw-crabbox-test","state":"available","region":"eu-west-1","promotedAt":"2026-05-01T12:46:00Z"}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
created, err := client.CreateImage(context.Background(), "cbx_123", "openclaw-crabbox-test", true)
if err != nil {
t.Fatal(err)
}
if created.ID != "ami-12345678" || createBody.LeaseID != "cbx_123" || createBody.Name != "openclaw-crabbox-test" || !createBody.NoReboot {
t.Fatalf("created=%#v body=%#v", created, createBody)
}
if image, err := client.Image(context.Background(), "ami-12345678"); err != nil || image.State != "available" {
t.Fatalf("image=%#v err=%v", image, err)
}
if promoted, err := client.PromoteImage(context.Background(), "ami-12345678"); err != nil || promoted.PromotedAt == "" {
t.Fatalf("promoted=%#v err=%v", promoted, err)
}
}
func TestLeaseStatusRequiresSSHReadiness(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/leases/cbx_123" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"lease":{"id":"cbx_123","slug":"blue-crab","provider":"aws","target":"windows","windowsMode":"normal","state":"active","serverType":"m7i.4xlarge","host":"127.0.0.1","sshUser":"crabbox","sshPort":"22"}}`))
}))
defer server.Close()
state, err := (App{}).leaseStatus(context.Background(), Config{
Coordinator: server.URL,
Provider: "aws",
SSHKey: filepath.Join(t.TempDir(), "missing-key"),
}, "cbx_123")
if err != nil {
t.Fatal(err)
}
if !state.HasHost {
t.Fatalf("HasHost=false, want true")
}
if state.TargetOS != targetWindows || state.WindowsMode != windowsModeNormal {
t.Fatalf("target=%s windowsMode=%s", state.TargetOS, state.WindowsMode)
}
if state.Ready {
t.Fatalf("Ready=true, want false when ssh readiness probe fails")
}
}
func curlConfigValueForTest(t *testing.T, config, key string) string {
t.Helper()
prefix := key + " = "
for _, line := range strings.Split(config, "\n") {
if strings.HasPrefix(line, prefix) {
var value string
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, prefix)), &value); err != nil {
t.Fatal(err)
}
return value
}
}
t.Fatalf("config key %q missing:\n%s", key, config)
return ""
}