From fb92d3cd766de7a39138d510455892ce56fdf710 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Sat, 24 Jan 2026 01:46:07 +0000 Subject: [PATCH 01/17] Add Anyscale CLI wrapper with comprehensive tests Adds AnyscaleCLI struct with methods for: - Compute config management (Create, Get, List) - Workspace operations (Create, Start, Terminate, Push, RunCommand, GetStatus, WaitForState) - Build ID to image URI conversion - Compute config name parsing All methods are standalone and don't depend on external types. Full unit test coverage included. Co-Authored-By: Claude Opus 4.5 --- rayapp/anyscale_cli.go | 260 +++++++++++++++++ rayapp/anyscale_cli_test.go | 568 ++++++++++++++++++++++++++++++++++++ 2 files changed, 828 insertions(+) create mode 100644 rayapp/anyscale_cli.go create mode 100644 rayapp/anyscale_cli_test.go diff --git a/rayapp/anyscale_cli.go b/rayapp/anyscale_cli.go new file mode 100644 index 00000000..61e61280 --- /dev/null +++ b/rayapp/anyscale_cli.go @@ -0,0 +1,260 @@ +package rayapp + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type WorkspaceState int + +const ( + StateTerminated WorkspaceState = iota + StateStarting + StateRunning +) + +var WorkspaceStateName = map[WorkspaceState]string{ + StateTerminated: "TERMINATED", + StateStarting: "STARTING", + StateRunning: "RUNNING", +} + +func (ws WorkspaceState) String() string { + return WorkspaceStateName[ws] +} + +type AnyscaleCLI struct { + token string +} + +var errAnyscaleNotInstalled = errors.New("anyscale is not installed") + +func NewAnyscaleCLI(token string) *AnyscaleCLI { + return &AnyscaleCLI{token: token} +} + +func isAnyscaleInstalled() bool { + _, err := exec.LookPath("anyscale") + return err == nil +} + +func (ac *AnyscaleCLI) Authenticate() error { + cmd := exec.Command("anyscale", "login") + err := cmd.Run() + if err != nil { + return fmt.Errorf("anyscale auth login failed, please set ANYSCALE_CLI_TOKEN & ANYSCALE_HOST env variables: %w", err) + } + return nil +} + +// runAnyscaleCLI runs the anyscale CLI with the given arguments. +// Returns the combined output and any error that occurred. +// Output is displayed to the terminal with colors preserved. +func (ac *AnyscaleCLI) runAnyscaleCLI(args []string) (string, error) { + if !isAnyscaleInstalled() { + return "", errAnyscaleNotInstalled + } + + fmt.Println("anyscale cli args: ", args) + cmd := exec.Command("anyscale", args...) + + // Capture output while also displaying to terminal with colors + var outputBuf bytes.Buffer + cmd.Stdout = io.MultiWriter(os.Stdout, &outputBuf) + cmd.Stderr = io.MultiWriter(os.Stderr, &outputBuf) + + if err := cmd.Run(); err != nil { + return outputBuf.String(), fmt.Errorf("anyscale error: %w", err) + } + + return outputBuf.String(), nil +} + +// parseComputeConfigName parses the AWS config path and converts it to a config name. +// e.g., "configs/basic-single-node/aws.yaml" -> "basic-single-node-aws" +func parseComputeConfigName(awsConfigPath string) string { + // Get the directory and filename + dir := filepath.Dir(awsConfigPath) // "configs/basic-single-node" + base := filepath.Base(awsConfigPath) // "aws.yaml" + ext := filepath.Ext(base) // ".yaml" + filename := strings.TrimSuffix(base, ext) // "aws" + + // Get the last directory component (the config name) + configDir := filepath.Base(dir) // "basic-single-node" + + // Combine: "basic-single-node-aws" + return configDir + "-" + filename +} + +// CreateComputeConfig creates a new compute config from a YAML file if it doesn't already exist. +// name: the name for the compute config (without version tag) +// configFile: path to the YAML config file +// Returns the output from the CLI and any error. +func (ac *AnyscaleCLI) CreateComputeConfig(name, configFilePath string) (string, error) { + // Check if compute config already exists + if output, err := ac.GetComputeConfig(name); err == nil { + fmt.Printf("Compute config %q already exists, skipping creation\n", name) + return output, nil + } + + // Create the compute config since it doesn't exist + args := []string{"compute-config", "create", "-n", name, "-f", configFilePath} + output, err := ac.runAnyscaleCLI(args) + if err != nil { + return output, fmt.Errorf("create compute config failed: %w", err) + } + return output, nil +} + +// GetComputeConfig retrieves the details of a compute config by name. +// name: the name of the compute config (optionally with version tag, e.g., "name:1") +// Returns the output from the CLI and any error. +func (ac *AnyscaleCLI) GetComputeConfig(name string) (string, error) { + args := []string{"compute-config", "get", "-n", name} + output, err := ac.runAnyscaleCLI(args) + if err != nil { + return output, fmt.Errorf("get compute config failed: %w", err) + } + return output, nil +} + +// ListComputeConfigs lists compute configs with optional filters. +// name: filter by name (optional, empty string for no filter) +// includeShared: include shared compute configs +// maxItems: maximum number of items to return (0 for no limit) +// Returns the output from the CLI and any error. +func (ac *AnyscaleCLI) ListComputeConfigs(name string, includeShared bool, maxItems int) (string, error) { + args := []string{"compute-config", "list"} + if name != "" { + args = append(args, "-n", name) + } + if includeShared { + args = append(args, "--include-shared") + } + if maxItems > 0 { + args = append(args, "--max-items", fmt.Sprintf("%d", maxItems)) + } + output, err := ac.runAnyscaleCLI(args) + if err != nil { + return output, fmt.Errorf("list compute configs failed: %w", err) + } + return output, nil +} + +// CreateWorkspace creates a new workspace with the given parameters. +func (ac *AnyscaleCLI) CreateWorkspace(workspaceName, imageURI, rayVersion, computeConfig string) error { + args := []string{"workspace_v2", "create"} + args = append(args, "--name", workspaceName) + args = append(args, "--image-uri", imageURI) + args = append(args, "--ray-version", rayVersion) + + if computeConfig != "" { + args = append(args, "--compute-config", computeConfig) + } + + output, err := ac.runAnyscaleCLI(args) + if err != nil { + return fmt.Errorf("create workspace failed: %w", err) + } + fmt.Println("create workspace output:\n", output) + return nil +} + +// TerminateWorkspace terminates a workspace by name. +func (ac *AnyscaleCLI) TerminateWorkspace(workspaceName string) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "terminate", "--name", workspaceName}) + if err != nil { + return fmt.Errorf("terminate workspace failed: %w", err) + } + fmt.Println("terminate workspace output:\n", output) + return nil +} + +// PushToWorkspace pushes a local directory to a workspace. +func (ac *AnyscaleCLI) PushToWorkspace(workspaceName, localDir string) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "push", "--name", workspaceName, "--local-dir", localDir}) + if err != nil { + return fmt.Errorf("push to workspace failed: %w", err) + } + fmt.Println("push to workspace output:\n", output) + return nil +} + +// RunCommandInWorkspace runs a command in the specified workspace. +func (ac *AnyscaleCLI) RunCommandInWorkspace(workspaceName, cmd string) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "run_command", "--name", workspaceName, cmd}) + if err != nil { + return fmt.Errorf("run command in workspace failed: %w", err) + } + fmt.Println("run command in workspace output:\n", output) + return nil +} + +// StartWorkspace starts a workspace by name. +func (ac *AnyscaleCLI) StartWorkspace(workspaceName string) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "start", "--name", workspaceName}) + if err != nil { + return fmt.Errorf("start workspace failed: %w", err) + } + fmt.Println("start workspace output:\n", output) + return nil +} + +// GetWorkspaceStatus returns the status of a workspace. +func (ac *AnyscaleCLI) GetWorkspaceStatus(workspaceName string) (string, error) { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "status", "--name", workspaceName}) + if err != nil { + return "", fmt.Errorf("get workspace status failed: %w", err) + } + return output, nil +} + +// WaitForWorkspaceState waits for a workspace to reach the specified state. +func (ac *AnyscaleCLI) WaitForWorkspaceState(workspaceName string, state WorkspaceState) (string, error) { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "wait", "--name", workspaceName, "--state", state.String()}) + if err != nil { + return "", fmt.Errorf("wait for workspace state failed: %w", err) + } + return output, nil +} + +// ConvertBuildIdToImageURI converts a build ID to an image URI and ray version. +// e.g., "anyscaleray2441-py312-cu128" -> ("anyscale/ray:2.44.1-py312-cu128", "2.44.1") +func ConvertBuildIdToImageURI(buildId string) (string, string, error) { + const prefix = "anyscaleray" + if !strings.HasPrefix(buildId, prefix) { + return "", "", fmt.Errorf("build ID must start with %q: %s", prefix, buildId) + } + + // Remove the prefix to get "2441-py312-cu128" + remainder := strings.TrimPrefix(buildId, prefix) + + // Find the first hyphen to separate version from suffix + hyphenIdx := strings.Index(remainder, "-") + var versionStr, suffix string + if hyphenIdx == -1 { + versionStr = remainder + suffix = "" + } else { + versionStr = remainder[:hyphenIdx] + suffix = remainder[hyphenIdx:] // includes the hyphen + } + + // Parse version: "2441" -> "2.44.1" + // Format: first digit = major, next two = minor, rest = patch + if len(versionStr) < 4 { + return "", "", fmt.Errorf("version string too short: %s", versionStr) + } + + major := versionStr[0:1] + minor := versionStr[1:3] + patch := versionStr[3:] + + return fmt.Sprintf("anyscale/ray:%s.%s.%s%s", major, minor, patch, suffix), fmt.Sprintf("%s.%s.%s", major, minor, patch), nil +} diff --git a/rayapp/anyscale_cli_test.go b/rayapp/anyscale_cli_test.go new file mode 100644 index 00000000..6d04b9fc --- /dev/null +++ b/rayapp/anyscale_cli_test.go @@ -0,0 +1,568 @@ +package rayapp + +import ( + "errors" + "os" + "strings" + "testing" +) + +// setupMockAnyscale creates a mock anyscale script and returns a cleanup function. +func setupMockAnyscale(t *testing.T, script string) { + t.Helper() + tmp := t.TempDir() + + if script != "" { + mockScript := tmp + "/anyscale" + if err := os.WriteFile(mockScript, []byte(script), 0755); err != nil { + t.Fatalf("failed to create mock script: %v", err) + } + } + + origPath := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + os.Setenv("PATH", tmp) +} + +func TestNewAnyscaleCLI(t *testing.T) { + cli := NewAnyscaleCLI("test-token") + if cli == nil { + t.Fatal("expected non-nil AnyscaleCLI") + } + if cli.token != "test-token" { + t.Errorf("expected token 'test-token', got %q", cli.token) + } +} + +func TestWorkspaceStateString(t *testing.T) { + tests := []struct { + state WorkspaceState + want string + }{ + {StateTerminated, "TERMINATED"}, + {StateStarting, "STARTING"}, + {StateRunning, "RUNNING"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.state.String(); got != tt.want { + t.Errorf("WorkspaceState.String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestConvertBuildIdToImageURI(t *testing.T) { + tests := []struct { + name string + buildId string + wantImageURI string + wantRayVersion string + wantErr bool + errContains string + }{ + { + name: "valid build ID with suffix", + buildId: "anyscaleray2441-py312-cu128", + wantImageURI: "anyscale/ray:2.44.1-py312-cu128", + wantRayVersion: "2.44.1", + }, + { + name: "valid build ID without suffix", + buildId: "anyscaleray2440", + wantImageURI: "anyscale/ray:2.44.0", + wantRayVersion: "2.44.0", + }, + { + name: "valid build ID with only python suffix", + buildId: "anyscaleray2350-py311", + wantImageURI: "anyscale/ray:2.35.0-py311", + wantRayVersion: "2.35.0", + }, + { + name: "valid build ID version 3", + buildId: "anyscaleray3001-py312", + wantImageURI: "anyscale/ray:3.00.1-py312", + wantRayVersion: "3.00.1", + }, + { + name: "invalid prefix", + buildId: "rayimage2441-py312", + wantErr: true, + errContains: "must start with", + }, + { + name: "version too short", + buildId: "anyscaleray123", + wantErr: true, + errContains: "version string too short", + }, + { + name: "empty build ID", + buildId: "", + wantErr: true, + errContains: "must start with", + }, + { + name: "only prefix", + buildId: "anyscaleray", + wantErr: true, + errContains: "version string too short", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + imageURI, rayVersion, err := ConvertBuildIdToImageURI(tt.buildId) + + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("error %q should contain %q", err.Error(), tt.errContains) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if imageURI != tt.wantImageURI { + t.Errorf("imageURI = %q, want %q", imageURI, tt.wantImageURI) + } + if rayVersion != tt.wantRayVersion { + t.Errorf("rayVersion = %q, want %q", rayVersion, tt.wantRayVersion) + } + }) + } +} + +func TestRunAnyscaleCLI(t *testing.T) { + tests := []struct { + name string + script string + args []string + wantErr error + wantSubstr string + }{ + { + name: "anyscale not installed", + script: "", // empty PATH, no script + args: []string{"--version"}, + wantErr: errAnyscaleNotInstalled, + }, + { + name: "success", + script: "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\necho \"output: $@\"", + args: []string{"service", "deploy"}, + wantSubstr: "output: service deploy", + }, + { + name: "empty args", + script: "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\necho \"help\"", + args: []string{}, + wantSubstr: "help", + }, + { + name: "command fails with stderr", + script: "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\necho \"error msg\" >&2; exit 1", + args: []string{"deploy"}, + wantSubstr: "error msg", + wantErr: errors.New("anyscale error"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupMockAnyscale(t, tt.script) + cli := NewAnyscaleCLI("") + + output, err := cli.runAnyscaleCLI(tt.args) + + if tt.wantErr != nil { + if err == nil { + t.Fatal("expected error, got nil") + } + if errors.Is(tt.wantErr, errAnyscaleNotInstalled) { + if !errors.Is(err, errAnyscaleNotInstalled) { + t.Errorf("expected errAnyscaleNotInstalled, got: %v", err) + } + } else if !strings.Contains(err.Error(), tt.wantErr.Error()) { + t.Errorf("error %q should contain %q", err.Error(), tt.wantErr.Error()) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantSubstr != "" && !strings.Contains(output, tt.wantSubstr) { + t.Errorf("output %q should contain %q", output, tt.wantSubstr) + } + }) + } +} + +func TestIsAnyscaleInstalled(t *testing.T) { + t.Run("not installed", func(t *testing.T) { + setupMockAnyscale(t, "") + if isAnyscaleInstalled() { + t.Error("should return false when not in PATH") + } + }) + + t.Run("installed", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho mock") + if !isAnyscaleInstalled() { + t.Error("should return true when in PATH") + } + }) +} + +func TestAuthenticate(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\nexit 1") + cli := NewAnyscaleCLI("") + if err := cli.Authenticate(); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + err := cli.Authenticate() + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "anyscale auth login failed") { + t.Errorf("error %q should contain 'anyscale auth login failed'", err.Error()) + } + }) +} + +func TestParseComputeConfigName(t *testing.T) { + tests := []struct { + name string + awsConfigPath string + wantConfigName string + }{ + { + name: "basic-single-node config", + awsConfigPath: "configs/basic-single-node/aws.yaml", + wantConfigName: "basic-single-node-aws", + }, + { + name: "simple configs directory", + awsConfigPath: "configs/aws.yaml", + wantConfigName: "configs-aws", + }, + { + name: "nested directory", + awsConfigPath: "configs/compute/production/aws.yaml", + wantConfigName: "production-aws", + }, + { + name: "gcp config", + awsConfigPath: "configs/basic-single-node/gcp.yaml", + wantConfigName: "basic-single-node-gcp", + }, + { + name: "yaml extension", + awsConfigPath: "configs/my-config/aws.yaml", + wantConfigName: "my-config-aws", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseComputeConfigName(tt.awsConfigPath) + if got != tt.wantConfigName { + t.Errorf("parseComputeConfigName(%q) = %q, want %q", tt.awsConfigPath, got, tt.wantConfigName) + } + }) + } +} + +func TestCreateComputeConfig(t *testing.T) { + t.Run("creates when config does not exist", func(t *testing.T) { + script := `#!/bin/sh +if [ "$1" = "compute-config" ] && [ "$2" = "get" ]; then + echo "config not found" + exit 1 +fi +if [ "$1" = "compute-config" ] && [ "$2" = "create" ]; then + echo "created compute config: $@" + exit 0 +fi +exit 1 +` + setupMockAnyscale(t, script) + cli := NewAnyscaleCLI("") + + output, err := cli.CreateComputeConfig("my-config", "/path/to/config.yaml") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "compute-config create") { + t.Errorf("output %q should contain 'compute-config create'", output) + } + }) + + t.Run("skips creation when config exists", func(t *testing.T) { + script := `#!/bin/sh +if [ "$1" = "compute-config" ] && [ "$2" = "get" ]; then + echo "name: my-config" + exit 0 +fi +exit 1 +` + setupMockAnyscale(t, script) + cli := NewAnyscaleCLI("") + + output, err := cli.CreateComputeConfig("my-config", "/path/to/config.yaml") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "name: my-config") { + t.Errorf("output %q should contain 'name: my-config'", output) + } + }) + + t.Run("failure when create fails", func(t *testing.T) { + script := `#!/bin/sh +if [ "$1" = "compute-config" ] && [ "$2" = "get" ]; then + exit 1 +fi +if [ "$1" = "compute-config" ] && [ "$2" = "create" ]; then + exit 1 +fi +exit 1 +` + setupMockAnyscale(t, script) + cli := NewAnyscaleCLI("") + + _, err := cli.CreateComputeConfig("my-config", "/path/to/config.yaml") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "create compute config failed") { + t.Errorf("error %q should contain 'create compute config failed'", err.Error()) + } + }) +} + +func TestGetComputeConfig(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"name: my-config\"") + cli := NewAnyscaleCLI("") + + output, err := cli.GetComputeConfig("my-config") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "name: my-config") { + t.Errorf("output %q should contain 'name: my-config'", output) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + _, err := cli.GetComputeConfig("nonexistent-config") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestListComputeConfigs(t *testing.T) { + t.Run("success with filters", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"args: $@\"") + cli := NewAnyscaleCLI("") + + output, err := cli.ListComputeConfigs("my-config", true, 5) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "-n my-config") { + t.Errorf("output %q should contain '-n my-config'", output) + } + if !strings.Contains(output, "--include-shared") { + t.Errorf("output %q should contain '--include-shared'", output) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + _, err := cli.ListComputeConfigs("", false, 0) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestCreateWorkspace(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"args: $@\"") + cli := NewAnyscaleCLI("") + + err := cli.CreateWorkspace("test-ws", "anyscale/ray:2.44.1", "2.44.1", "my-config") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + err := cli.CreateWorkspace("test-ws", "anyscale/ray:2.44.1", "2.44.1", "") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestTerminateWorkspace(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"terminated\"") + cli := NewAnyscaleCLI("") + + err := cli.TerminateWorkspace("my-workspace") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + err := cli.TerminateWorkspace("my-workspace") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestPushToWorkspace(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"pushed\"") + cli := NewAnyscaleCLI("") + + err := cli.PushToWorkspace("test-ws", "/path/to/dir") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + err := cli.PushToWorkspace("test-ws", "/path/to/dir") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestRunCommandInWorkspace(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"ran command\"") + cli := NewAnyscaleCLI("") + + err := cli.RunCommandInWorkspace("test-ws", "echo hello") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + err := cli.RunCommandInWorkspace("test-ws", "echo hello") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestStartWorkspace(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"started\"") + cli := NewAnyscaleCLI("") + + err := cli.StartWorkspace("test-ws") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + err := cli.StartWorkspace("test-ws") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestGetWorkspaceStatus(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"RUNNING\"") + cli := NewAnyscaleCLI("") + + output, err := cli.GetWorkspaceStatus("test-ws") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "RUNNING") { + t.Errorf("output %q should contain 'RUNNING'", output) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + _, err := cli.GetWorkspaceStatus("test-ws") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestWaitForWorkspaceState(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"state reached\"") + cli := NewAnyscaleCLI("") + + output, err := cli.WaitForWorkspaceState("test-ws", StateRunning) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "state reached") { + t.Errorf("output %q should contain 'state reached'", output) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + _, err := cli.WaitForWorkspaceState("test-ws", StateRunning) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} From b420305790f356f31822768ca4b251156acb7199 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Sat, 24 Jan 2026 01:47:26 +0000 Subject: [PATCH 02/17] Add Anyscale CLI wrapper and workspace test runner Adds comprehensive Anyscale CLI wrapper and workspace test runner: **Anyscale CLI (anyscale_cli.go)** - Compute config management (Create, Get, List) - Workspace operations (Create, Start, Terminate, Push, RunCommand) - Build ID to image URI conversion - Full unit test coverage **Workspace Test Runner (test.go)** - WorkspaceTestConfig for running tests in Anyscale workspaces - Automated flow: create compute config, create workspace, push template, run tests, cleanup - Zips template to temp directory before pushing - Full unit test coverage for all scenarios **Utilities (util.go)** - zipDirectory function for packaging templates Co-Authored-By: Claude Opus 4.5 --- rayapp/anyscale_cli.go | 267 +++++++++++++ rayapp/anyscale_cli_test.go | 778 ++++++++++++++++++++++++++++++++++++ rayapp/test.go | 133 ++++++ rayapp/test_test.go | 428 ++++++++++++++++++++ rayapp/util.go | 51 +++ 5 files changed, 1657 insertions(+) create mode 100644 rayapp/anyscale_cli.go create mode 100644 rayapp/anyscale_cli_test.go create mode 100644 rayapp/test.go create mode 100644 rayapp/test_test.go diff --git a/rayapp/anyscale_cli.go b/rayapp/anyscale_cli.go new file mode 100644 index 00000000..c839655e --- /dev/null +++ b/rayapp/anyscale_cli.go @@ -0,0 +1,267 @@ +package rayapp + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type WorkspaceState int + +const ( + StateTerminated WorkspaceState = iota + StateStarting + StateRunning +) + +var WorkspaceStateName = map[WorkspaceState]string{ + StateTerminated: "TERMINATED", + StateStarting: "STARTING", + StateRunning: "RUNNING", +} + +func (ws WorkspaceState) String() string { + return WorkspaceStateName[ws] +} + +type AnyscaleCLI struct { + token string +} + +var errAnyscaleNotInstalled = errors.New("anyscale is not installed") + +func NewAnyscaleCLI(token string) *AnyscaleCLI { + return &AnyscaleCLI{token: token} +} + +func isAnyscaleInstalled() bool { + _, err := exec.LookPath("anyscale") + return err == nil +} + +func (ac *AnyscaleCLI) Authenticate() error { + cmd := exec.Command("anyscale", "login") + err := cmd.Run() + if err != nil { + return fmt.Errorf("anyscale auth login failed, please set ANYSCALE_CLI_TOKEN & ANYSCALE_HOST env variables: %w", err) + } + return nil +} + +// RunAnyscaleCLI runs the anyscale CLI with the given arguments. +// Returns the combined output and any error that occurred. +// Output is displayed to the terminal with colors preserved. +func (ac *AnyscaleCLI) runAnyscaleCLI(args []string) (string, error) { + if !isAnyscaleInstalled() { + return "", errAnyscaleNotInstalled + } + + fmt.Println("anyscale cli args: ", args) + cmd := exec.Command("anyscale", args...) + + // Capture output while also displaying to terminal with colors + var outputBuf bytes.Buffer + cmd.Stdout = io.MultiWriter(os.Stdout, &outputBuf) + cmd.Stderr = io.MultiWriter(os.Stderr, &outputBuf) + + if err := cmd.Run(); err != nil { + return outputBuf.String(), fmt.Errorf("anyscale error: %w", err) + } + + return outputBuf.String(), nil +} + +// parseComputeConfigName parses the AWS config path and converts it to a config name. +// e.g., "configs/basic-single-node/aws.yaml" -> "basic-single-node-aws" +func parseComputeConfigName(awsConfigPath string) string { + // Get the directory and filename + dir := filepath.Dir(awsConfigPath) // "configs/basic-single-node" + base := filepath.Base(awsConfigPath) // "aws.yaml" + ext := filepath.Ext(base) // ".yaml" + filename := strings.TrimSuffix(base, ext) // "aws" + + // Get the last directory component (the config name) + configDir := filepath.Base(dir) // "basic-single-node" + + // Combine: "basic-single-node-aws" + return configDir + "-" + filename +} + +// CreateComputeConfig creates a new compute config from a YAML file if it doesn't already exist. +// name: the name for the compute config (without version tag) +// configFile: path to the YAML config file +// Returns the output from the CLI and any error. +func (ac *AnyscaleCLI) CreateComputeConfig(name, configFilePath string) (string, error) { + // Check if compute config already exists + if output, err := ac.GetComputeConfig(name); err == nil { + fmt.Printf("Compute config %q already exists, skipping creation\n", name) + return output, nil + } + + // Create the compute config since it doesn't exist + args := []string{"compute-config", "create", "-n", name, "-f", configFilePath} + output, err := ac.runAnyscaleCLI(args) + if err != nil { + return output, fmt.Errorf("create compute config failed: %w", err) + } + return output, nil +} + +// GetComputeConfig retrieves the details of a compute config by name. +// name: the name of the compute config (optionally with version tag, e.g., "name:1") +// Returns the output from the CLI and any error. +func (ac *AnyscaleCLI) GetComputeConfig(name string) (string, error) { + args := []string{"compute-config", "get", "-n", name} + output, err := ac.runAnyscaleCLI(args) + if err != nil { + return output, fmt.Errorf("get compute config failed: %w", err) + } + return output, nil +} + +// ListComputeConfigs lists compute configs with optional filters. +// name: filter by name (optional, empty string for no filter) +// includeShared: include shared compute configs +// maxItems: maximum number of items to return (0 for no limit) +// Returns the output from the CLI and any error. +func (ac *AnyscaleCLI) ListComputeConfigs(name string, includeShared bool, maxItems int) (string, error) { + args := []string{"compute-config", "list"} + if name != "" { + args = append(args, "-n", name) + } + if includeShared { + args = append(args, "--include-shared") + } + if maxItems > 0 { + args = append(args, "--max-items", fmt.Sprintf("%d", maxItems)) + } + output, err := ac.runAnyscaleCLI(args) + if err != nil { + return output, fmt.Errorf("list compute configs failed: %w", err) + } + return output, nil +} + +func (ac *AnyscaleCLI) createEmptyWorkspace(config *WorkspaceTestConfig) error { + args := []string{"workspace_v2", "create"} + // get image URI and ray version from build ID + imageURI, rayVersion, err := convertBuildIdToImageURI(config.template.ClusterEnv.BuildID) + if err != nil { + return fmt.Errorf("convert build ID to image URI failed: %w", err) + } + args = append(args, "--name", config.workspaceName) + args = append(args, "--image-uri", imageURI) + args = append(args, "--ray-version", rayVersion) + + // Use compute config name if set + if config.computeConfig != "" { + args = append(args, "--compute-config", config.computeConfig) + } + + output, err := ac.runAnyscaleCLI(args) + if err != nil { + return fmt.Errorf("create empty workspace failed: %w", err) + } + fmt.Println("create empty workspace output:\n", output) + return nil +} + +func (ac *AnyscaleCLI) terminateWorkspace(workspaceName string) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "terminate", "--name", workspaceName}) + if err != nil { + return fmt.Errorf("delete workspace failed: %w", err) + } + fmt.Println("terminate workspace output:\n", output) + return nil +} + +func (ac *AnyscaleCLI) copyTemplateToWorkspace(config *WorkspaceTestConfig) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "push", "--name", config.workspaceName, "--local-dir", config.template.Dir}) + if err != nil { + return fmt.Errorf("copy template to workspace failed: %w", err) + } + fmt.Println("copy template to workspace output:\n", output) + return nil +} + +func (ac *AnyscaleCLI) pushTemplateToWorkspace(workspaceName, localFilePath string) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "push", "--name", workspaceName, "--local-dir", localFilePath}) + if err != nil { + return fmt.Errorf("push file to workspace failed: %w", err) + } + fmt.Println("push file to workspace output:\n", output) + return nil +} + +func (ac *AnyscaleCLI) runCmdInWorkspace(config *WorkspaceTestConfig, cmd string) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "run_command", "--name", config.workspaceName, cmd}) + if err != nil { + return fmt.Errorf("run command in workspace failed: %w", err) + } + fmt.Println("run command in workspace output:\n", output) + return nil +} + +func (ac *AnyscaleCLI) startWorkspace(config *WorkspaceTestConfig) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "start", "--name", config.workspaceName}) + if err != nil { + return fmt.Errorf("start workspace failed: %w", err) + } + fmt.Println("start workspace output:\n", output) + return nil +} + +func (ac *AnyscaleCLI) getWorkspaceStatus(workspaceName string) (string, error) { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "status", "--name", workspaceName}) + if err != nil { + return "", fmt.Errorf("get workspace state failed: %w", err) + } + return output, nil +} + +func (ac *AnyscaleCLI) waitForWorkspaceState(workspaceName string, state WorkspaceState) (string, error) { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "wait", "--name", workspaceName, "--state", state.String()}) + if err != nil { + return "", fmt.Errorf("wait for workspace state failed: %w", err) + } + return output, nil +} + +func convertBuildIdToImageURI(buildId string) (string, string, error) { + // Convert build ID like "anyscaleray2441-py312-cu128" to "anyscale/ray:2.44.1-py312-cu128" + const prefix = "anyscaleray" + if !strings.HasPrefix(buildId, prefix) { + return "", "", fmt.Errorf("build ID must start with %q: %s", prefix, buildId) + } + + // Remove the prefix to get "2441-py312-cu128" + remainder := strings.TrimPrefix(buildId, prefix) + + // Find the first hyphen to separate version from suffix + hyphenIdx := strings.Index(remainder, "-") + var versionStr, suffix string + if hyphenIdx == -1 { + versionStr = remainder + suffix = "" + } else { + versionStr = remainder[:hyphenIdx] + suffix = remainder[hyphenIdx:] // includes the hyphen + } + + // Parse version: "2441" -> "2.44.1" + // Format: first digit = major, next two = minor, rest = patch + if len(versionStr) < 4 { + return "", "", fmt.Errorf("version string too short: %s", versionStr) + } + + major := versionStr[0:1] + minor := versionStr[1:3] + patch := versionStr[3:] + + return fmt.Sprintf("anyscale/ray:%s.%s.%s%s", major, minor, patch, suffix), fmt.Sprintf("%s.%s.%s", major, minor, patch), nil +} diff --git a/rayapp/anyscale_cli_test.go b/rayapp/anyscale_cli_test.go new file mode 100644 index 00000000..528c4896 --- /dev/null +++ b/rayapp/anyscale_cli_test.go @@ -0,0 +1,778 @@ +package rayapp + +import ( + "errors" + "os" + "strings" + "testing" +) + +// setupMockAnyscale creates a mock anyscale script and returns a cleanup function. +func setupMockAnyscale(t *testing.T, script string) { + t.Helper() + tmp := t.TempDir() + + if script != "" { + mockScript := tmp + "/anyscale" + if err := os.WriteFile(mockScript, []byte(script), 0755); err != nil { + t.Fatalf("failed to create mock script: %v", err) + } + } + + origPath := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + os.Setenv("PATH", tmp) +} + +func TestNewAnyscaleCLI(t *testing.T) { + cli := NewAnyscaleCLI("test-token") + if cli == nil { + t.Fatal("expected non-nil AnyscaleCLI") + } + if cli.token != "test-token" { + t.Errorf("expected token 'test-token', got %q", cli.token) + } +} + +func TestWorkspaceStateString(t *testing.T) { + tests := []struct { + state WorkspaceState + want string + }{ + {StateTerminated, "TERMINATED"}, + {StateStarting, "STARTING"}, + {StateRunning, "RUNNING"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.state.String(); got != tt.want { + t.Errorf("WorkspaceState.String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestConvertBuildIdToImageURI(t *testing.T) { + tests := []struct { + name string + buildId string + wantImageURI string + wantRayVersion string + wantErr bool + errContains string + }{ + { + name: "valid build ID with suffix", + buildId: "anyscaleray2441-py312-cu128", + wantImageURI: "anyscale/ray:2.44.1-py312-cu128", + wantRayVersion: "2.44.1", + }, + { + name: "valid build ID without suffix", + buildId: "anyscaleray2440", + wantImageURI: "anyscale/ray:2.44.0", + wantRayVersion: "2.44.0", + }, + { + name: "valid build ID with only python suffix", + buildId: "anyscaleray2350-py311", + wantImageURI: "anyscale/ray:2.35.0-py311", + wantRayVersion: "2.35.0", + }, + { + name: "valid build ID version 3", + buildId: "anyscaleray3001-py312", + wantImageURI: "anyscale/ray:3.00.1-py312", + wantRayVersion: "3.00.1", + }, + { + name: "invalid prefix", + buildId: "rayimage2441-py312", + wantErr: true, + errContains: "must start with", + }, + { + name: "version too short", + buildId: "anyscaleray123", + wantErr: true, + errContains: "version string too short", + }, + { + name: "empty build ID", + buildId: "", + wantErr: true, + errContains: "must start with", + }, + { + name: "only prefix", + buildId: "anyscaleray", + wantErr: true, + errContains: "version string too short", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + imageURI, rayVersion, err := convertBuildIdToImageURI(tt.buildId) + + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("error %q should contain %q", err.Error(), tt.errContains) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if imageURI != tt.wantImageURI { + t.Errorf("imageURI = %q, want %q", imageURI, tt.wantImageURI) + } + if rayVersion != tt.wantRayVersion { + t.Errorf("rayVersion = %q, want %q", rayVersion, tt.wantRayVersion) + } + }) + } +} + +func TestRunAnyscaleCLI(t *testing.T) { + tests := []struct { + name string + script string + args []string + wantErr error + wantSubstr string + }{ + { + name: "anyscale not installed", + script: "", // empty PATH, no script + args: []string{"--version"}, + wantErr: errAnyscaleNotInstalled, + }, + { + name: "success", + script: "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\necho \"output: $@\"", + args: []string{"service", "deploy"}, + wantSubstr: "output: service deploy", + }, + { + name: "empty args", + script: "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\necho \"help\"", + args: []string{}, + wantSubstr: "help", + }, + { + name: "command fails with stderr", + script: "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\necho \"error msg\" >&2; exit 1", + args: []string{"deploy"}, + wantSubstr: "error msg", + wantErr: errors.New("anyscale error"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupMockAnyscale(t, tt.script) + cli := NewAnyscaleCLI("") + + output, err := cli.runAnyscaleCLI(tt.args) + + if tt.wantErr != nil { + if err == nil { + t.Fatal("expected error, got nil") + } + if errors.Is(tt.wantErr, errAnyscaleNotInstalled) { + if !errors.Is(err, errAnyscaleNotInstalled) { + t.Errorf("expected errAnyscaleNotInstalled, got: %v", err) + } + } else if !strings.Contains(err.Error(), tt.wantErr.Error()) { + t.Errorf("error %q should contain %q", err.Error(), tt.wantErr.Error()) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantSubstr != "" && !strings.Contains(output, tt.wantSubstr) { + t.Errorf("output %q should contain %q", output, tt.wantSubstr) + } + }) + } +} + +func TestIsAnyscaleInstalled(t *testing.T) { + t.Run("not installed", func(t *testing.T) { + setupMockAnyscale(t, "") + if isAnyscaleInstalled() { + t.Error("should return false when not in PATH") + } + }) + + t.Run("installed", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho mock") + if !isAnyscaleInstalled() { + t.Error("should return true when in PATH") + } + }) +} + +func TestAuthenticate(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\nexit 1") + cli := NewAnyscaleCLI("") + if err := cli.Authenticate(); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + err := cli.Authenticate() + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "anyscale auth login failed") { + t.Errorf("error %q should contain 'anyscale auth login failed'", err.Error()) + } + }) +} + +func TestParseComputeConfigName(t *testing.T) { + tests := []struct { + name string + awsConfigPath string + wantConfigName string + }{ + { + name: "basic-single-node config", + awsConfigPath: "configs/basic-single-node/aws.yaml", + wantConfigName: "basic-single-node-aws", + }, + { + name: "simple configs directory", + awsConfigPath: "configs/aws.yaml", + wantConfigName: "configs-aws", + }, + { + name: "nested directory", + awsConfigPath: "configs/compute/production/aws.yaml", + wantConfigName: "production-aws", + }, + { + name: "gcp config", + awsConfigPath: "configs/basic-single-node/gcp.yaml", + wantConfigName: "basic-single-node-gcp", + }, + { + name: "yaml extension", + awsConfigPath: "configs/my-config/aws.yaml", + wantConfigName: "my-config-aws", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseComputeConfigName(tt.awsConfigPath) + if got != tt.wantConfigName { + t.Errorf("parseComputeConfigName(%q) = %q, want %q", tt.awsConfigPath, got, tt.wantConfigName) + } + }) + } +} + +func TestCreateComputeConfig(t *testing.T) { + t.Run("creates when config does not exist", func(t *testing.T) { + // Mock: get fails (not found), create succeeds + script := `#!/bin/sh +if [ "$1" = "compute-config" ] && [ "$2" = "get" ]; then + echo "config not found" + exit 1 +fi +if [ "$1" = "compute-config" ] && [ "$2" = "create" ]; then + echo "created compute config: $@" + exit 0 +fi +exit 1 +` + setupMockAnyscale(t, script) + cli := NewAnyscaleCLI("") + + output, err := cli.CreateComputeConfig("my-config", "/path/to/config.yaml") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "compute-config create") { + t.Errorf("output %q should contain 'compute-config create'", output) + } + if !strings.Contains(output, "-n my-config") { + t.Errorf("output %q should contain '-n my-config'", output) + } + if !strings.Contains(output, "-f /path/to/config.yaml") { + t.Errorf("output %q should contain '-f /path/to/config.yaml'", output) + } + }) + + t.Run("skips creation when config exists", func(t *testing.T) { + // Mock: get succeeds (config found) + script := `#!/bin/sh +if [ "$1" = "compute-config" ] && [ "$2" = "get" ]; then + echo "name: my-config" + exit 0 +fi +exit 1 +` + setupMockAnyscale(t, script) + cli := NewAnyscaleCLI("") + + output, err := cli.CreateComputeConfig("my-config", "/path/to/config.yaml") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "name: my-config") { + t.Errorf("output %q should contain 'name: my-config'", output) + } + // Should NOT contain create since it was skipped + if strings.Contains(output, "compute-config create") { + t.Errorf("output %q should NOT contain 'compute-config create' when config exists", output) + } + }) + + t.Run("failure when create fails", func(t *testing.T) { + // Mock: get fails (not found), create also fails + script := `#!/bin/sh +if [ "$1" = "compute-config" ] && [ "$2" = "get" ]; then + exit 1 +fi +if [ "$1" = "compute-config" ] && [ "$2" = "create" ]; then + exit 1 +fi +exit 1 +` + setupMockAnyscale(t, script) + cli := NewAnyscaleCLI("") + + _, err := cli.CreateComputeConfig("my-config", "/path/to/config.yaml") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "create compute config failed") { + t.Errorf("error %q should contain 'create compute config failed'", err.Error()) + } + }) +} + +func TestGetComputeConfig(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"name: my-config\nhead_node:\n instance_type: m5.xlarge\"") + cli := NewAnyscaleCLI("") + + output, err := cli.GetComputeConfig("my-config") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "name: my-config") { + t.Errorf("output %q should contain 'name: my-config'", output) + } + }) + + t.Run("success with version", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"args: $@\"") + cli := NewAnyscaleCLI("") + + output, err := cli.GetComputeConfig("my-config:2") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "-n my-config:2") { + t.Errorf("output %q should contain '-n my-config:2'", output) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + _, err := cli.GetComputeConfig("nonexistent-config") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "get compute config failed") { + t.Errorf("error %q should contain 'get compute config failed'", err.Error()) + } + }) +} + +func TestListComputeConfigs(t *testing.T) { + t.Run("success with no filters", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"args: $@\"") + cli := NewAnyscaleCLI("") + + output, err := cli.ListComputeConfigs("", false, 0) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "compute-config list") { + t.Errorf("output %q should contain 'compute-config list'", output) + } + }) + + t.Run("success with name filter", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"args: $@\"") + cli := NewAnyscaleCLI("") + + output, err := cli.ListComputeConfigs("my-config", false, 0) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "-n my-config") { + t.Errorf("output %q should contain '-n my-config'", output) + } + }) + + t.Run("success with include shared", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"args: $@\"") + cli := NewAnyscaleCLI("") + + output, err := cli.ListComputeConfigs("", true, 0) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "--include-shared") { + t.Errorf("output %q should contain '--include-shared'", output) + } + }) + + t.Run("success with max items", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"args: $@\"") + cli := NewAnyscaleCLI("") + + output, err := cli.ListComputeConfigs("", false, 10) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "--max-items 10") { + t.Errorf("output %q should contain '--max-items 10'", output) + } + }) + + t.Run("success with all options", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"args: $@\"") + cli := NewAnyscaleCLI("") + + output, err := cli.ListComputeConfigs("my-config", true, 5) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "-n my-config") { + t.Errorf("output %q should contain '-n my-config'", output) + } + if !strings.Contains(output, "--include-shared") { + t.Errorf("output %q should contain '--include-shared'", output) + } + if !strings.Contains(output, "--max-items 5") { + t.Errorf("output %q should contain '--max-items 5'", output) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + _, err := cli.ListComputeConfigs("", false, 0) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "list compute configs failed") { + t.Errorf("error %q should contain 'list compute configs failed'", err.Error()) + } + }) +} + +func TestCreateEmptyWorkspace(t *testing.T) { + tests := []struct { + name string + script string + config *WorkspaceTestConfig + wantErr bool + errContains string + wantArgSubstr string + }{ + { + name: "success without compute config", + script: "#!/bin/sh\necho \"args: $@\"", + config: &WorkspaceTestConfig{ + workspaceName: "test-workspace", + template: &Template{ + ClusterEnv: &ClusterEnv{ + BuildID: "anyscaleray2441-py312-cu128", + }, + }, + }, + wantArgSubstr: "workspace_v2 create", + }, + { + name: "success with compute config name", + script: "#!/bin/sh\necho \"args: $@\"", + config: &WorkspaceTestConfig{ + workspaceName: "test-workspace", + computeConfig: "basic-single-node-aws", + template: &Template{ + ClusterEnv: &ClusterEnv{ + BuildID: "anyscaleray2441-py312-cu128", + }, + }, + }, + wantArgSubstr: "--compute-config", + }, + { + name: "invalid build ID", + script: "#!/bin/sh\necho \"args: $@\"", + config: &WorkspaceTestConfig{ + workspaceName: "test-workspace", + template: &Template{ + ClusterEnv: &ClusterEnv{ + BuildID: "invalid-build-id", + }, + }, + }, + wantErr: true, + errContains: "convert build ID to image URI failed", + }, + { + name: "CLI error", + script: "#!/bin/sh\nexit 1", + config: &WorkspaceTestConfig{ + workspaceName: "test-workspace", + template: &Template{ + ClusterEnv: &ClusterEnv{ + BuildID: "anyscaleray2441-py312-cu128", + }, + }, + }, + wantErr: true, + errContains: "create empty workspace failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupMockAnyscale(t, tt.script) + cli := NewAnyscaleCLI("") + + err := cli.createEmptyWorkspace(tt.config) + + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("error %q should contain %q", err.Error(), tt.errContains) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestTerminateWorkspace(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"terminating $@\"") + cli := NewAnyscaleCLI("") + + err := cli.terminateWorkspace("my-workspace") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + err := cli.terminateWorkspace("my-workspace") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "delete workspace failed") { + t.Errorf("error %q should contain 'delete workspace failed'", err.Error()) + } + }) +} + +func TestCopyTemplateToWorkspace(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"pushing $@\"") + cli := NewAnyscaleCLI("") + config := &WorkspaceTestConfig{ + workspaceName: "test-workspace", + template: &Template{ + Dir: "/path/to/template", + }, + } + + err := cli.copyTemplateToWorkspace(config) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + config := &WorkspaceTestConfig{ + workspaceName: "test-workspace", + template: &Template{ + Dir: "/path/to/template", + }, + } + + err := cli.copyTemplateToWorkspace(config) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "copy template to workspace failed") { + t.Errorf("error %q should contain 'copy template to workspace failed'", err.Error()) + } + }) +} + +func TestRunCmdInWorkspace(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"running: $@\"") + cli := NewAnyscaleCLI("") + config := &WorkspaceTestConfig{ + workspaceName: "test-workspace", + } + + err := cli.runCmdInWorkspace(config, "echo hello") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + config := &WorkspaceTestConfig{ + workspaceName: "test-workspace", + } + + err := cli.runCmdInWorkspace(config, "failing-command") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "run command in workspace failed") { + t.Errorf("error %q should contain 'run command in workspace failed'", err.Error()) + } + }) +} + +func TestStartWorkspace(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"starting $@\"") + cli := NewAnyscaleCLI("") + config := &WorkspaceTestConfig{ + workspaceName: "test-workspace", + } + + err := cli.startWorkspace(config) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + config := &WorkspaceTestConfig{ + workspaceName: "test-workspace", + } + + err := cli.startWorkspace(config) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "start workspace failed") { + t.Errorf("error %q should contain 'start workspace failed'", err.Error()) + } + }) +} + +func TestGetWorkspaceStatus(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"RUNNING\"") + cli := NewAnyscaleCLI("") + + output, err := cli.getWorkspaceStatus("test-workspace") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "RUNNING") { + t.Errorf("output %q should contain 'RUNNING'", output) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + _, err := cli.getWorkspaceStatus("test-workspace") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "get workspace state failed") { + t.Errorf("error %q should contain 'get workspace state failed'", err.Error()) + } + }) +} + +func TestWaitForWorkspaceState(t *testing.T) { + t.Run("success", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"state reached\"") + cli := NewAnyscaleCLI("") + + output, err := cli.waitForWorkspaceState("test-workspace", StateRunning) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "state reached") { + t.Errorf("output %q should contain 'state reached'", output) + } + }) + + t.Run("wait for terminated", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"terminated\"") + cli := NewAnyscaleCLI("") + + output, err := cli.waitForWorkspaceState("test-workspace", StateTerminated) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "terminated") { + t.Errorf("output %q should contain 'terminated'", output) + } + }) + + t.Run("failure", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + cli := NewAnyscaleCLI("") + + _, err := cli.waitForWorkspaceState("test-workspace", StateRunning) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "wait for workspace state failed") { + t.Errorf("error %q should contain 'wait for workspace state failed'", err.Error()) + } + }) +} diff --git a/rayapp/test.go b/rayapp/test.go new file mode 100644 index 00000000..2d4a8fa8 --- /dev/null +++ b/rayapp/test.go @@ -0,0 +1,133 @@ +package rayapp + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +func Test(tmplName, buildFile string) error { + runner := NewWorkspaceTestConfig(tmplName, buildFile) + if err := runner.Run(); err != nil { + return fmt.Errorf("test failed: %w", err) + } + return nil +} + +const testCmd = "pip install nbmake==1.5.5 pytest==7.4.0 && pytest --nbmake . -s -vv" + +const workspaceStartWaitTime = 30 * time.Second +// WorkspaceTestConfig contains all the details to test a workspace. +type WorkspaceTestConfig struct { + tmplName string + buildFile string + workspaceName string + configFile string + computeConfig string + imageURI string + rayVersion string + template *Template +} + +// NewWorkspaceTestConfig creates a new WorkspaceTestConfig for a template. +func NewWorkspaceTestConfig(tmplName, buildFile string) *WorkspaceTestConfig { + return &WorkspaceTestConfig{tmplName: tmplName, buildFile: buildFile} +} + +// Run creates an empty workspace and copies the template to it. +func (wtc *WorkspaceTestConfig) Run() error { + // init anyscale cli + anyscaleCLI := NewAnyscaleCLI(os.Getenv("ANYSCALE_CLI_TOKEN")) + + // read build file and get template details + tmpls, err := readTemplates(wtc.buildFile) + if err != nil { + return fmt.Errorf("read templates failed: %w", err) + } + + // Get the directory containing the build file to resolve relative paths + buildDir := filepath.Dir(wtc.buildFile) + + for _, tmpl := range tmpls { + if tmpl.Name == wtc.tmplName { + wtc.template = tmpl + // Resolve template directory relative to build file + wtc.template.Dir = filepath.Join(buildDir, tmpl.Dir) + break + } + } + + if wtc.template == nil { + return fmt.Errorf("template %q not found in %s", wtc.tmplName, wtc.buildFile) + } + + // Parse compute config name from template's AWS config path and create if needed + if awsConfigPath, ok := wtc.template.ComputeConfig["AWS"]; ok { + wtc.computeConfig = parseComputeConfigName(awsConfigPath) + // Create compute config if it doesn't already exist + if _, err := anyscaleCLI.CreateComputeConfig(wtc.computeConfig, awsConfigPath); err != nil { + return fmt.Errorf("create compute config failed: %w", err) + } + } + + // generate workspace name + workspaceName := wtc.tmplName + "-" + time.Now().Format("20060102150405") + wtc.workspaceName = workspaceName + + // create empty workspace + if err := anyscaleCLI.createEmptyWorkspace(wtc); err != nil { + return fmt.Errorf("create empty workspace failed: %w", err) + } + + if err := anyscaleCLI.startWorkspace(wtc); err != nil { + return fmt.Errorf("start workspace failed: %w", err) + } + + if _, err := anyscaleCLI.waitForWorkspaceState(wtc.workspaceName, StateRunning); err != nil { + return fmt.Errorf("wait for workspace running state failed: %w", err) + } + + // state, err := anyscaleCLI.getWorkspaceStatus(wtc.workspaceName) + // if err != nil { + // return fmt.Errorf("get workspace state failed: %w", err) + // } + + // for !strings.Contains(state, StateRunning.String()) { + // state, err = anyscaleCLI.getWorkspaceStatus(wtc.workspaceName) + // if err != nil { + // return fmt.Errorf("get workspace status failed: %w, retrying...", err) + // } + // time.Sleep(workspaceStartWaitTime) + // fmt.Println("workspace state: ", state) + // } + + // Create temp directory for the zip file + templateZipDir, err := os.MkdirTemp("", "template_zip") + if err != nil { + return fmt.Errorf("create temp directory failed: %w", err) + } + defer os.RemoveAll(templateZipDir) // clean up temp directory after push + + // Zip template directory to the temp directory + zipFileName := filepath.Join(templateZipDir, wtc.tmplName+".zip") + if err := zipDirectory(wtc.template.Dir, zipFileName); err != nil { + return fmt.Errorf("zip template directory failed: %w", err) + } + + if err := anyscaleCLI.pushTemplateToWorkspace(wtc.workspaceName, templateZipDir); err != nil { + return fmt.Errorf("push zip to workspace failed: %w", err) + } + + // run test in workspace + if err := anyscaleCLI.runCmdInWorkspace(wtc, testCmd); err != nil { + return fmt.Errorf("run test in workspace failed: %w", err) + } + + // terminate workspace + if err := anyscaleCLI.terminateWorkspace(wtc.workspaceName); err != nil { + return fmt.Errorf("terminate workspace failed: %w", err) + } + + return nil +} diff --git a/rayapp/test_test.go b/rayapp/test_test.go new file mode 100644 index 00000000..0ad8c465 --- /dev/null +++ b/rayapp/test_test.go @@ -0,0 +1,428 @@ +package rayapp + +import ( + "os" + "strings" + "testing" +) + +func TestNewWorkspaceTestConfig(t *testing.T) { + tests := []struct { + name string + tmplName string + buildFile string + }{ + { + name: "basic config", + tmplName: "my-template", + buildFile: "path/to/build.yaml", + }, + { + name: "empty values", + tmplName: "", + buildFile: "", + }, + { + name: "special characters", + tmplName: "template-with-dashes_and_underscores", + buildFile: "/path/with spaces/build.yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := NewWorkspaceTestConfig(tt.tmplName, tt.buildFile) + + if config == nil { + t.Fatal("expected non-nil WorkspaceTestConfig") + } + if config.tmplName != tt.tmplName { + t.Errorf("tmplName = %q, want %q", config.tmplName, tt.tmplName) + } + if config.buildFile != tt.buildFile { + t.Errorf("buildFile = %q, want %q", config.buildFile, tt.buildFile) + } + // Other fields should be zero values + if config.workspaceName != "" { + t.Errorf("workspaceName should be empty, got %q", config.workspaceName) + } + if config.template != nil { + t.Error("template should be nil") + } + }) + } +} + +func TestWorkspaceTestConfigRun_InvalidBuildFile(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho mock") + + config := NewWorkspaceTestConfig("my-template", "nonexistent/build.yaml") + err := config.Run() + + if err == nil { + t.Fatal("expected error for nonexistent build file") + } + if !strings.Contains(err.Error(), "read templates failed") { + t.Errorf("error %q should contain 'read templates failed'", err.Error()) + } +} + +func TestWorkspaceTestConfigRun_TemplateNotFound(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho mock") + + config := NewWorkspaceTestConfig("nonexistent-template", "testdata/BUILD.yaml") + err := config.Run() + + if err == nil { + t.Fatal("expected error for nonexistent template") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error %q should contain 'not found'", err.Error()) + } +} + +func TestWorkspaceTestConfigRun_CreateWorkspaceFails(t *testing.T) { + // Mock script that fails on workspace_v2 create + script := `#!/bin/sh +if [ "$1" = "workspace_v2" ] && [ "$2" = "create" ]; then + echo "create failed" >&2 + exit 1 +fi +echo "ok" +` + setupMockAnyscale(t, script) + + config := NewWorkspaceTestConfig("reefy-ray", "testdata/BUILD.yaml") + err := config.Run() + + if err == nil { + t.Fatal("expected error when create workspace fails") + } + if !strings.Contains(err.Error(), "create empty workspace failed") { + t.Errorf("error %q should contain 'create empty workspace failed'", err.Error()) + } +} + +func TestWorkspaceTestConfigRun_StartWorkspaceFails(t *testing.T) { + // Mock script that succeeds on create but fails on start + script := `#!/bin/sh +if [ "$1" = "workspace_v2" ] && [ "$2" = "create" ]; then + echo "created" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "start" ]; then + echo "start failed" >&2 + exit 1 +fi +echo "ok" +` + setupMockAnyscale(t, script) + + config := NewWorkspaceTestConfig("reefy-ray", "testdata/BUILD.yaml") + err := config.Run() + + if err == nil { + t.Fatal("expected error when start workspace fails") + } + if !strings.Contains(err.Error(), "start workspace failed") { + t.Errorf("error %q should contain 'start workspace failed'", err.Error()) + } +} + +func TestWorkspaceTestConfigRun_WaitForStateFails(t *testing.T) { + // Mock script that succeeds on create and start but fails on wait + script := `#!/bin/sh +if [ "$1" = "workspace_v2" ] && [ "$2" = "create" ]; then + echo "created" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "start" ]; then + echo "started" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "wait" ]; then + echo "wait failed" >&2 + exit 1 +fi +echo "ok" +` + setupMockAnyscale(t, script) + + config := NewWorkspaceTestConfig("reefy-ray", "testdata/BUILD.yaml") + err := config.Run() + + if err == nil { + t.Fatal("expected error when wait for state fails") + } + if !strings.Contains(err.Error(), "wait for workspace running state failed") { + t.Errorf("error %q should contain 'wait for workspace running state failed'", err.Error()) + } +} + +func TestWorkspaceTestConfigRun_CopyTemplateFails(t *testing.T) { + // Mock script that succeeds until push + script := `#!/bin/sh +if [ "$1" = "workspace_v2" ] && [ "$2" = "create" ]; then + echo "created" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "start" ]; then + echo "started" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "wait" ]; then + echo "running" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "push" ]; then + echo "push failed" >&2 + exit 1 +fi +echo "ok" +` + setupMockAnyscale(t, script) + + config := NewWorkspaceTestConfig("reefy-ray", "testdata/BUILD.yaml") + err := config.Run() + + if err == nil { + t.Fatal("expected error when copy template fails") + } + if !strings.Contains(err.Error(), "push zip to workspace failed") { + t.Errorf("error %q should contain 'push zip to workspace failed'", err.Error()) + } +} + +func TestWorkspaceTestConfigRun_RunCommandFails(t *testing.T) { + // Mock script that succeeds until run_command + script := `#!/bin/sh +if [ "$1" = "workspace_v2" ] && [ "$2" = "create" ]; then + echo "created" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "start" ]; then + echo "started" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "wait" ]; then + echo "running" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "push" ]; then + echo "pushed" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "run_command" ]; then + echo "run_command failed" >&2 + exit 1 +fi +echo "ok" +` + setupMockAnyscale(t, script) + + config := NewWorkspaceTestConfig("reefy-ray", "testdata/BUILD.yaml") + err := config.Run() + + if err == nil { + t.Fatal("expected error when run command fails") + } + if !strings.Contains(err.Error(), "run test in workspace failed") { + t.Errorf("error %q should contain 'run test in workspace failed'", err.Error()) + } +} + +func TestWorkspaceTestConfigRun_TerminateFails(t *testing.T) { + // Mock script that succeeds until terminate + script := `#!/bin/sh +if [ "$1" = "workspace_v2" ] && [ "$2" = "create" ]; then + echo "created" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "start" ]; then + echo "started" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "wait" ]; then + echo "running" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "push" ]; then + echo "pushed" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "run_command" ]; then + echo "tests passed" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "terminate" ]; then + echo "terminate failed" >&2 + exit 1 +fi +echo "ok" +` + setupMockAnyscale(t, script) + + config := NewWorkspaceTestConfig("reefy-ray", "testdata/BUILD.yaml") + err := config.Run() + + if err == nil { + t.Fatal("expected error when terminate fails") + } + if !strings.Contains(err.Error(), "terminate workspace failed") { + t.Errorf("error %q should contain 'terminate workspace failed'", err.Error()) + } +} + +func TestWorkspaceTestConfigRun_Success(t *testing.T) { + // Mock script that succeeds for all operations + script := `#!/bin/sh +if [ "$1" = "compute-config" ] && [ "$2" = "get" ]; then + echo "config not found" + exit 1 +fi +if [ "$1" = "compute-config" ] && [ "$2" = "create" ]; then + echo "created compute config" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "create" ]; then + echo "created" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "start" ]; then + echo "started" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "wait" ]; then + echo "running" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "push" ]; then + echo "pushed" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "run_command" ]; then + echo "tests passed" + exit 0 +fi +if [ "$1" = "workspace_v2" ] && [ "$2" = "terminate" ]; then + echo "terminated" + exit 0 +fi +echo "unknown command: $@" +exit 1 +` + setupMockAnyscale(t, script) + + config := NewWorkspaceTestConfig("reefy-ray", "testdata/BUILD.yaml") + err := config.Run() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify the config was populated correctly + if config.template == nil { + t.Error("template should be set after successful run") + } + if config.template != nil && config.template.Name != "reefy-ray" { + t.Errorf("template.Name = %q, want %q", config.template.Name, "reefy-ray") + } + if config.workspaceName == "" { + t.Error("workspaceName should be set after successful run") + } + if !strings.HasPrefix(config.workspaceName, "reefy-ray-") { + t.Errorf("workspaceName %q should start with 'reefy-ray-'", config.workspaceName) + } +} + +func TestTest_Success(t *testing.T) { + // Mock script that succeeds for all operations + script := `#!/bin/sh +if [ "$1" = "compute-config" ] && [ "$2" = "get" ]; then + echo "config not found" + exit 1 +fi +if [ "$1" = "compute-config" ] && [ "$2" = "create" ]; then + echo "created" + exit 0 +fi +if [ "$1" = "workspace_v2" ]; then + echo "success" + exit 0 +fi +echo "unknown" +exit 1 +` + setupMockAnyscale(t, script) + + err := Test("reefy-ray", "testdata/BUILD.yaml") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTest_Failure(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + + err := Test("reefy-ray", "testdata/BUILD.yaml") + + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "test failed") { + t.Errorf("error %q should contain 'test failed'", err.Error()) + } +} + +func TestTest_TemplateNotFound(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho ok") + + err := Test("nonexistent-template", "testdata/BUILD.yaml") + + if err == nil { + t.Fatal("expected error for nonexistent template") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error %q should contain 'not found'", err.Error()) + } +} + +func TestTestCmd_Constant(t *testing.T) { + // Verify the test command constant is set correctly + if testCmd == "" { + t.Error("testCmd should not be empty") + } + if !strings.Contains(testCmd, "pytest") { + t.Errorf("testCmd %q should contain 'pytest'", testCmd) + } + if !strings.Contains(testCmd, "nbmake") { + t.Errorf("testCmd %q should contain 'nbmake'", testCmd) + } +} + +func TestWorkspaceStartWaitTime_Constant(t *testing.T) { + // Verify the wait time constant is reasonable + if workspaceStartWaitTime <= 0 { + t.Error("workspaceStartWaitTime should be positive") + } +} + +func TestWorkspaceTestConfigRun_UsesAnyscaleToken(t *testing.T) { + // Set a test token + origToken := os.Getenv("ANYSCALE_CLI_TOKEN") + t.Cleanup(func() { + if origToken == "" { + os.Unsetenv("ANYSCALE_CLI_TOKEN") + } else { + os.Setenv("ANYSCALE_CLI_TOKEN", origToken) + } + }) + os.Setenv("ANYSCALE_CLI_TOKEN", "test-token-123") + + // Mock that fails immediately so we can test without full execution + setupMockAnyscale(t, "#!/bin/sh\nexit 1") + + config := NewWorkspaceTestConfig("reefy-ray", "testdata/BUILD.yaml") + _ = config.Run() // We don't care about the error, just that it uses the token +} diff --git a/rayapp/util.go b/rayapp/util.go index c9e19d49..2f4f13c7 100644 --- a/rayapp/util.go +++ b/rayapp/util.go @@ -98,3 +98,54 @@ func buildZip(srcDir string, files []*zipFile, out string) error { } return nil } + +// zipDirectory creates a zip file containing all files from the source directory. +// The files are stored with paths relative to the source directory. +// outPath is the path where the zip file will be created. +func zipDirectory(srcDir, outPath string) error { + outFile, err := os.Create(outPath) + if err != nil { + return fmt.Errorf("create zip file: %w", err) + } + defer outFile.Close() + + z := zip.NewWriter(outFile) + defer z.Close() + + err = filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories - they're created implicitly by file paths + if info.IsDir() { + return nil + } + + // Get the relative path for the zip entry + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return fmt.Errorf("get relative path: %w", err) + } + + if err := addFileToZip(z, path, relPath); err != nil { + return fmt.Errorf("add file %q to zip: %w", relPath, err) + } + + return nil + }) + + if err != nil { + return fmt.Errorf("walk directory: %w", err) + } + + if err := z.Close(); err != nil { + return fmt.Errorf("close zip writer: %w", err) + } + + if err := outFile.Sync(); err != nil { + return fmt.Errorf("flush zip file to storage: %w", err) + } + + return nil +} From 57947d67c0224cac4de0a6e758a25718ef575d1d Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Sun, 25 Jan 2026 23:05:42 +0000 Subject: [PATCH 03/17] Fix Anyscale CLI tests and remove ListComputeConfigs - Restore WorkspaceTestConfig struct that was commented out but still referenced by workspace functions - Update TestCreateComputeConfig to use temp config files instead of non-existent paths, since CreateComputeConfig now reads the file - Remove ListComputeConfigs function and its tests Co-Authored-By: Claude Opus 4.5 --- rayapp/anyscale_cli.go | 265 +++++++++++++++++++++++++++--------- rayapp/anyscale_cli_test.go | 257 ++++++++++++++++++++++++++-------- 2 files changed, 398 insertions(+), 124 deletions(-) diff --git a/rayapp/anyscale_cli.go b/rayapp/anyscale_cli.go index 61e61280..5c8ff16c 100644 --- a/rayapp/anyscale_cli.go +++ b/rayapp/anyscale_cli.go @@ -9,24 +9,38 @@ import ( "os/exec" "path/filepath" "strings" + + "gopkg.in/yaml.v2" ) type WorkspaceState int +// WorkspaceTestConfig contains all the details to test a workspace. +type WorkspaceTestConfig struct { + tmplName string + buildFile string + workspaceName string + configFile string + computeConfig string + imageURI string + rayVersion string + template *Template +} + const ( - StateTerminated WorkspaceState = iota - StateStarting - StateRunning + StateTerminated WorkspaceState = iota + StateStarting + StateRunning ) var WorkspaceStateName = map[WorkspaceState]string{ - StateTerminated: "TERMINATED", - StateStarting: "STARTING", - StateRunning: "RUNNING", + StateTerminated: "TERMINATED", + StateStarting: "STARTING", + StateRunning: "RUNNING", } func (ws WorkspaceState) String() string { - return WorkspaceStateName[ws] + return WorkspaceStateName[ws] } type AnyscaleCLI struct { @@ -53,7 +67,7 @@ func (ac *AnyscaleCLI) Authenticate() error { return nil } -// runAnyscaleCLI runs the anyscale CLI with the given arguments. +// RunAnyscaleCLI runs the anyscale CLI with the given arguments. // Returns the combined output and any error that occurred. // Output is displayed to the terminal with colors preserved. func (ac *AnyscaleCLI) runAnyscaleCLI(args []string) (string, error) { @@ -80,10 +94,10 @@ func (ac *AnyscaleCLI) runAnyscaleCLI(args []string) (string, error) { // e.g., "configs/basic-single-node/aws.yaml" -> "basic-single-node-aws" func parseComputeConfigName(awsConfigPath string) string { // Get the directory and filename - dir := filepath.Dir(awsConfigPath) // "configs/basic-single-node" - base := filepath.Base(awsConfigPath) // "aws.yaml" - ext := filepath.Ext(base) // ".yaml" - filename := strings.TrimSuffix(base, ext) // "aws" + dir := filepath.Dir(awsConfigPath) // "configs/basic-single-node" + base := filepath.Base(awsConfigPath) // "aws.yaml" + ext := filepath.Ext(base) // ".yaml" + filename := strings.TrimSuffix(base, ext) // "aws" // Get the last directory component (the config name) configDir := filepath.Base(dir) // "basic-single-node" @@ -92,7 +106,30 @@ func parseComputeConfigName(awsConfigPath string) string { return configDir + "-" + filename } +// isOldComputeConfigFormat checks if a YAML file uses the old compute config format +// by looking for old-style keys like "head_node_type" or "worker_node_types". +func isOldComputeConfigFormat(configFilePath string) (bool, error) { + data, err := os.ReadFile(configFilePath) + if err != nil { + return false, fmt.Errorf("failed to read config file: %w", err) + } + + // Parse into a generic map to check for old-style keys + var configMap map[string]interface{} + if err := yaml.Unmarshal(data, &configMap); err != nil { + return false, fmt.Errorf("failed to parse config file: %w", err) + } + + // Check for old format keys + _, hasHeadNodeType := configMap["head_node_type"] + _, hasWorkerNodeTypes := configMap["worker_node_types"] + + return hasHeadNodeType || hasWorkerNodeTypes, nil +} + // CreateComputeConfig creates a new compute config from a YAML file if it doesn't already exist. +// If the config file uses the old format (head_node_type, worker_node_types), it will be +// automatically converted to the new format before creation. // name: the name for the compute config (without version tag) // configFile: path to the YAML config file // Returns the output from the CLI and any error. @@ -103,8 +140,41 @@ func (ac *AnyscaleCLI) CreateComputeConfig(name, configFilePath string) (string, return output, nil } - // Create the compute config since it doesn't exist - args := []string{"compute-config", "create", "-n", name, "-f", configFilePath} + // Check if the config file uses the old format + isOldFormat, err := isOldComputeConfigFormat(configFilePath) + if err != nil { + return "", fmt.Errorf("failed to check config format: %w", err) + } + + // If old format, convert to new format and use a temp file + actualConfigPath := configFilePath + if isOldFormat { + fmt.Printf("Detected old compute config format, converting to new format...\n") + + newConfigData, err := ConvertComputeConfig(configFilePath) + if err != nil { + return "", fmt.Errorf("failed to convert old config: %w", err) + } + + // Create a temp file for the converted config + tmpFile, err := os.CreateTemp("", "compute-config-*.yaml") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(newConfigData); err != nil { + tmpFile.Close() + return "", fmt.Errorf("failed to write temp file: %w", err) + } + tmpFile.Close() + + actualConfigPath = tmpFile.Name() + fmt.Printf("Converted config saved to temp file: %s\n", actualConfigPath) + } + + // Create the compute config + args := []string{"compute-config", "create", "-n", name, "-f", actualConfigPath} output, err := ac.runAnyscaleCLI(args) if err != nil { return output, fmt.Errorf("create compute config failed: %w", err) @@ -124,71 +194,59 @@ func (ac *AnyscaleCLI) GetComputeConfig(name string) (string, error) { return output, nil } -// ListComputeConfigs lists compute configs with optional filters. -// name: filter by name (optional, empty string for no filter) -// includeShared: include shared compute configs -// maxItems: maximum number of items to return (0 for no limit) -// Returns the output from the CLI and any error. -func (ac *AnyscaleCLI) ListComputeConfigs(name string, includeShared bool, maxItems int) (string, error) { - args := []string{"compute-config", "list"} - if name != "" { - args = append(args, "-n", name) - } - if includeShared { - args = append(args, "--include-shared") - } - if maxItems > 0 { - args = append(args, "--max-items", fmt.Sprintf("%d", maxItems)) - } - output, err := ac.runAnyscaleCLI(args) +func (ac *AnyscaleCLI) createEmptyWorkspace(config *WorkspaceTestConfig) error { + args := []string{"workspace_v2", "create"} + // get image URI and ray version from build ID + imageURI, rayVersion, err := convertBuildIdToImageURI(config.template.ClusterEnv.BuildID) if err != nil { - return output, fmt.Errorf("list compute configs failed: %w", err) + return fmt.Errorf("convert build ID to image URI failed: %w", err) } - return output, nil -} - -// CreateWorkspace creates a new workspace with the given parameters. -func (ac *AnyscaleCLI) CreateWorkspace(workspaceName, imageURI, rayVersion, computeConfig string) error { - args := []string{"workspace_v2", "create"} - args = append(args, "--name", workspaceName) + args = append(args, "--name", config.workspaceName) args = append(args, "--image-uri", imageURI) args = append(args, "--ray-version", rayVersion) - if computeConfig != "" { - args = append(args, "--compute-config", computeConfig) + // Use compute config name if set + if config.computeConfig != "" { + args = append(args, "--compute-config", config.computeConfig) } output, err := ac.runAnyscaleCLI(args) if err != nil { - return fmt.Errorf("create workspace failed: %w", err) + return fmt.Errorf("create empty workspace failed: %w", err) } - fmt.Println("create workspace output:\n", output) + fmt.Println("create empty workspace output:\n", output) return nil } -// TerminateWorkspace terminates a workspace by name. -func (ac *AnyscaleCLI) TerminateWorkspace(workspaceName string) error { +func (ac *AnyscaleCLI) terminateWorkspace(workspaceName string) error { output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "terminate", "--name", workspaceName}) if err != nil { - return fmt.Errorf("terminate workspace failed: %w", err) + return fmt.Errorf("delete workspace failed: %w", err) } fmt.Println("terminate workspace output:\n", output) return nil } -// PushToWorkspace pushes a local directory to a workspace. -func (ac *AnyscaleCLI) PushToWorkspace(workspaceName, localDir string) error { - output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "push", "--name", workspaceName, "--local-dir", localDir}) +func (ac *AnyscaleCLI) copyTemplateToWorkspace(config *WorkspaceTestConfig) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "push", "--name", config.workspaceName, "--local-dir", config.template.Dir}) + if err != nil { + return fmt.Errorf("copy template to workspace failed: %w", err) + } + fmt.Println("copy template to workspace output:\n", output) + return nil +} + +func (ac *AnyscaleCLI) pushTemplateToWorkspace(workspaceName, localFilePath string) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "push", "--name", workspaceName, "--local-dir", localFilePath}) if err != nil { - return fmt.Errorf("push to workspace failed: %w", err) + return fmt.Errorf("push file to workspace failed: %w", err) } - fmt.Println("push to workspace output:\n", output) + fmt.Println("push file to workspace output:\n", output) return nil } -// RunCommandInWorkspace runs a command in the specified workspace. -func (ac *AnyscaleCLI) RunCommandInWorkspace(workspaceName, cmd string) error { - output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "run_command", "--name", workspaceName, cmd}) +func (ac *AnyscaleCLI) runCmdInWorkspace(config *WorkspaceTestConfig, cmd string) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "run_command", "--name", config.workspaceName, cmd}) if err != nil { return fmt.Errorf("run command in workspace failed: %w", err) } @@ -196,9 +254,8 @@ func (ac *AnyscaleCLI) RunCommandInWorkspace(workspaceName, cmd string) error { return nil } -// StartWorkspace starts a workspace by name. -func (ac *AnyscaleCLI) StartWorkspace(workspaceName string) error { - output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "start", "--name", workspaceName}) +func (ac *AnyscaleCLI) startWorkspace(config *WorkspaceTestConfig) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "start", "--name", config.workspaceName}) if err != nil { return fmt.Errorf("start workspace failed: %w", err) } @@ -206,17 +263,15 @@ func (ac *AnyscaleCLI) StartWorkspace(workspaceName string) error { return nil } -// GetWorkspaceStatus returns the status of a workspace. -func (ac *AnyscaleCLI) GetWorkspaceStatus(workspaceName string) (string, error) { +func (ac *AnyscaleCLI) getWorkspaceStatus(workspaceName string) (string, error) { output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "status", "--name", workspaceName}) if err != nil { - return "", fmt.Errorf("get workspace status failed: %w", err) + return "", fmt.Errorf("get workspace state failed: %w", err) } return output, nil } -// WaitForWorkspaceState waits for a workspace to reach the specified state. -func (ac *AnyscaleCLI) WaitForWorkspaceState(workspaceName string, state WorkspaceState) (string, error) { +func (ac *AnyscaleCLI) waitForWorkspaceState(workspaceName string, state WorkspaceState) (string, error) { output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "wait", "--name", workspaceName, "--state", state.String()}) if err != nil { return "", fmt.Errorf("wait for workspace state failed: %w", err) @@ -224,9 +279,89 @@ func (ac *AnyscaleCLI) WaitForWorkspaceState(workspaceName string, state Workspa return output, nil } -// ConvertBuildIdToImageURI converts a build ID to an image URI and ray version. -// e.g., "anyscaleray2441-py312-cu128" -> ("anyscale/ray:2.44.1-py312-cu128", "2.44.1") -func ConvertBuildIdToImageURI(buildId string) (string, string, error) { +// OldComputeConfig represents the old compute config format +type OldComputeConfig struct { + HeadNodeType OldHeadNodeType `yaml:"head_node_type"` + WorkerNodeTypes []OldWorkerNodeType `yaml:"worker_node_types"` +} + +// OldHeadNodeType represents the head node configuration in old format +type OldHeadNodeType struct { + Name string `yaml:"name"` + InstanceType string `yaml:"instance_type"` +} + +// OldWorkerNodeType represents a worker node configuration in old format +type OldWorkerNodeType struct { + Name string `yaml:"name"` + InstanceType string `yaml:"instance_type"` +} + +// NewComputeConfig represents the new compute config format +type NewComputeConfig struct { + HeadNode NewHeadNode `yaml:"head_node"` + AutoSelectWorkerConfig bool `yaml:"auto_select_worker_config"` +} + +// NewHeadNode represents the head node configuration in new format +type NewHeadNode struct { + InstanceType string `yaml:"instance_type"` +} + +// ConvertComputeConfig converts an old format compute config to the new format. +// It reads the old YAML file, transforms the structure, and returns the new YAML content. +func ConvertComputeConfig(oldConfigPath string) ([]byte, error) { + // Read the old config file + data, err := os.ReadFile(oldConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to read old config file: %w", err) + } + + // Parse the old format + var oldConfig OldComputeConfig + if err := yaml.Unmarshal(data, &oldConfig); err != nil { + return nil, fmt.Errorf("failed to parse old config: %w", err) + } + + // Convert to new format + newConfig := NewComputeConfig{ + HeadNode: NewHeadNode{ + InstanceType: oldConfig.HeadNodeType.InstanceType, + }, + AutoSelectWorkerConfig: true, + } + + // Marshal to YAML + newData, err := yaml.Marshal(&newConfig) + if err != nil { + return nil, fmt.Errorf("failed to marshal new config: %w", err) + } + + return newData, nil +} + +// ConvertComputeConfigFile converts an old format compute config file to a new format file. +// If outputPath is empty, the new config is written to stdout. +func ConvertComputeConfigFile(oldConfigPath, newConfigPath string) error { + newData, err := ConvertComputeConfig(oldConfigPath) + if err != nil { + return err + } + + if newConfigPath == "" { + fmt.Print(string(newData)) + return nil + } + + if err := os.WriteFile(newConfigPath, newData, 0644); err != nil { + return fmt.Errorf("failed to write new config file: %w", err) + } + + return nil +} + +func convertBuildIdToImageURI(buildId string) (string, string, error) { + // Convert build ID like "anyscaleray2441-py312-cu128" to "anyscale/ray:2.44.1-py312-cu128" const prefix = "anyscaleray" if !strings.HasPrefix(buildId, prefix) { return "", "", fmt.Errorf("build ID must start with %q: %s", prefix, buildId) diff --git a/rayapp/anyscale_cli_test.go b/rayapp/anyscale_cli_test.go index 6d04b9fc..6db5d8f1 100644 --- a/rayapp/anyscale_cli_test.go +++ b/rayapp/anyscale_cli_test.go @@ -114,7 +114,7 @@ func TestConvertBuildIdToImageURI(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - imageURI, rayVersion, err := ConvertBuildIdToImageURI(tt.buildId) + imageURI, rayVersion, err := convertBuildIdToImageURI(tt.buildId) if tt.wantErr { if err == nil { @@ -288,6 +288,7 @@ func TestParseComputeConfigName(t *testing.T) { func TestCreateComputeConfig(t *testing.T) { t.Run("creates when config does not exist", func(t *testing.T) { + // Mock: get fails (not found), create succeeds script := `#!/bin/sh if [ "$1" = "compute-config" ] && [ "$2" = "get" ]; then echo "config not found" @@ -302,16 +303,29 @@ exit 1 setupMockAnyscale(t, script) cli := NewAnyscaleCLI("") - output, err := cli.CreateComputeConfig("my-config", "/path/to/config.yaml") + // Create a temporary config file with new format (no conversion needed) + tmpFile, err := os.CreateTemp("", "test-config-*.yaml") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.WriteString("head_node:\n instance_type: m5.xlarge\n") + tmpFile.Close() + + output, err := cli.CreateComputeConfig("my-config", tmpFile.Name()) if err != nil { t.Errorf("unexpected error: %v", err) } if !strings.Contains(output, "compute-config create") { t.Errorf("output %q should contain 'compute-config create'", output) } + if !strings.Contains(output, "-n my-config") { + t.Errorf("output %q should contain '-n my-config'", output) + } }) t.Run("skips creation when config exists", func(t *testing.T) { + // Mock: get succeeds (config found) script := `#!/bin/sh if [ "$1" = "compute-config" ] && [ "$2" = "get" ]; then echo "name: my-config" @@ -329,9 +343,14 @@ exit 1 if !strings.Contains(output, "name: my-config") { t.Errorf("output %q should contain 'name: my-config'", output) } + // Should NOT contain create since it was skipped + if strings.Contains(output, "compute-config create") { + t.Errorf("output %q should NOT contain 'compute-config create' when config exists", output) + } }) t.Run("failure when create fails", func(t *testing.T) { + // Mock: get fails (not found), create also fails script := `#!/bin/sh if [ "$1" = "compute-config" ] && [ "$2" = "get" ]; then exit 1 @@ -344,7 +363,16 @@ exit 1 setupMockAnyscale(t, script) cli := NewAnyscaleCLI("") - _, err := cli.CreateComputeConfig("my-config", "/path/to/config.yaml") + // Create a temporary config file with new format + tmpFile, err := os.CreateTemp("", "test-config-*.yaml") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.WriteString("head_node:\n instance_type: m5.xlarge\n") + tmpFile.Close() + + _, err = cli.CreateComputeConfig("my-config", tmpFile.Name()) if err == nil { t.Fatal("expected error, got nil") } @@ -356,7 +384,7 @@ exit 1 func TestGetComputeConfig(t *testing.T) { t.Run("success", func(t *testing.T) { - setupMockAnyscale(t, "#!/bin/sh\necho \"name: my-config\"") + setupMockAnyscale(t, "#!/bin/sh\necho \"name: my-config\nhead_node:\n instance_type: m5.xlarge\"") cli := NewAnyscaleCLI("") output, err := cli.GetComputeConfig("my-config") @@ -368,31 +396,16 @@ func TestGetComputeConfig(t *testing.T) { } }) - t.Run("failure", func(t *testing.T) { - setupMockAnyscale(t, "#!/bin/sh\nexit 1") - cli := NewAnyscaleCLI("") - - _, err := cli.GetComputeConfig("nonexistent-config") - if err == nil { - t.Fatal("expected error, got nil") - } - }) -} - -func TestListComputeConfigs(t *testing.T) { - t.Run("success with filters", func(t *testing.T) { + t.Run("success with version", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\necho \"args: $@\"") cli := NewAnyscaleCLI("") - output, err := cli.ListComputeConfigs("my-config", true, 5) + output, err := cli.GetComputeConfig("my-config:2") if err != nil { t.Errorf("unexpected error: %v", err) } - if !strings.Contains(output, "-n my-config") { - t.Errorf("output %q should contain '-n my-config'", output) - } - if !strings.Contains(output, "--include-shared") { - t.Errorf("output %q should contain '--include-shared'", output) + if !strings.Contains(output, "-n my-config:2") { + t.Errorf("output %q should contain '-n my-config:2'", output) } }) @@ -400,41 +413,112 @@ func TestListComputeConfigs(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") cli := NewAnyscaleCLI("") - _, err := cli.ListComputeConfigs("", false, 0) + _, err := cli.GetComputeConfig("nonexistent-config") if err == nil { t.Fatal("expected error, got nil") } + if !strings.Contains(err.Error(), "get compute config failed") { + t.Errorf("error %q should contain 'get compute config failed'", err.Error()) + } }) } -func TestCreateWorkspace(t *testing.T) { - t.Run("success", func(t *testing.T) { - setupMockAnyscale(t, "#!/bin/sh\necho \"args: $@\"") - cli := NewAnyscaleCLI("") +func TestCreateEmptyWorkspace(t *testing.T) { + tests := []struct { + name string + script string + config *WorkspaceTestConfig + wantErr bool + errContains string + wantArgSubstr string + }{ + { + name: "success without compute config", + script: "#!/bin/sh\necho \"args: $@\"", + config: &WorkspaceTestConfig{ + workspaceName: "test-workspace", + template: &Template{ + ClusterEnv: &ClusterEnv{ + BuildID: "anyscaleray2441-py312-cu128", + }, + }, + }, + wantArgSubstr: "workspace_v2 create", + }, + { + name: "success with compute config name", + script: "#!/bin/sh\necho \"args: $@\"", + config: &WorkspaceTestConfig{ + workspaceName: "test-workspace", + computeConfig: "basic-single-node-aws", + template: &Template{ + ClusterEnv: &ClusterEnv{ + BuildID: "anyscaleray2441-py312-cu128", + }, + }, + }, + wantArgSubstr: "--compute-config", + }, + { + name: "invalid build ID", + script: "#!/bin/sh\necho \"args: $@\"", + config: &WorkspaceTestConfig{ + workspaceName: "test-workspace", + template: &Template{ + ClusterEnv: &ClusterEnv{ + BuildID: "invalid-build-id", + }, + }, + }, + wantErr: true, + errContains: "convert build ID to image URI failed", + }, + { + name: "CLI error", + script: "#!/bin/sh\nexit 1", + config: &WorkspaceTestConfig{ + workspaceName: "test-workspace", + template: &Template{ + ClusterEnv: &ClusterEnv{ + BuildID: "anyscaleray2441-py312-cu128", + }, + }, + }, + wantErr: true, + errContains: "create empty workspace failed", + }, + } - err := cli.CreateWorkspace("test-ws", "anyscale/ray:2.44.1", "2.44.1", "my-config") - if err != nil { - t.Errorf("unexpected error: %v", err) - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupMockAnyscale(t, tt.script) + cli := NewAnyscaleCLI("") - t.Run("failure", func(t *testing.T) { - setupMockAnyscale(t, "#!/bin/sh\nexit 1") - cli := NewAnyscaleCLI("") + err := cli.createEmptyWorkspace(tt.config) - err := cli.CreateWorkspace("test-ws", "anyscale/ray:2.44.1", "2.44.1", "") - if err == nil { - t.Fatal("expected error, got nil") - } - }) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("error %q should contain %q", err.Error(), tt.errContains) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } } func TestTerminateWorkspace(t *testing.T) { t.Run("success", func(t *testing.T) { - setupMockAnyscale(t, "#!/bin/sh\necho \"terminated\"") + setupMockAnyscale(t, "#!/bin/sh\necho \"terminating $@\"") cli := NewAnyscaleCLI("") - err := cli.TerminateWorkspace("my-workspace") + err := cli.terminateWorkspace("my-workspace") if err != nil { t.Errorf("unexpected error: %v", err) } @@ -444,19 +528,28 @@ func TestTerminateWorkspace(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") cli := NewAnyscaleCLI("") - err := cli.TerminateWorkspace("my-workspace") + err := cli.terminateWorkspace("my-workspace") if err == nil { t.Fatal("expected error, got nil") } + if !strings.Contains(err.Error(), "delete workspace failed") { + t.Errorf("error %q should contain 'delete workspace failed'", err.Error()) + } }) } -func TestPushToWorkspace(t *testing.T) { +func TestCopyTemplateToWorkspace(t *testing.T) { t.Run("success", func(t *testing.T) { - setupMockAnyscale(t, "#!/bin/sh\necho \"pushed\"") + setupMockAnyscale(t, "#!/bin/sh\necho \"pushing $@\"") cli := NewAnyscaleCLI("") + config := &WorkspaceTestConfig{ + workspaceName: "test-workspace", + template: &Template{ + Dir: "/path/to/template", + }, + } - err := cli.PushToWorkspace("test-ws", "/path/to/dir") + err := cli.copyTemplateToWorkspace(config) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -465,20 +558,32 @@ func TestPushToWorkspace(t *testing.T) { t.Run("failure", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") cli := NewAnyscaleCLI("") + config := &WorkspaceTestConfig{ + workspaceName: "test-workspace", + template: &Template{ + Dir: "/path/to/template", + }, + } - err := cli.PushToWorkspace("test-ws", "/path/to/dir") + err := cli.copyTemplateToWorkspace(config) if err == nil { t.Fatal("expected error, got nil") } + if !strings.Contains(err.Error(), "copy template to workspace failed") { + t.Errorf("error %q should contain 'copy template to workspace failed'", err.Error()) + } }) } -func TestRunCommandInWorkspace(t *testing.T) { +func TestRunCmdInWorkspace(t *testing.T) { t.Run("success", func(t *testing.T) { - setupMockAnyscale(t, "#!/bin/sh\necho \"ran command\"") + setupMockAnyscale(t, "#!/bin/sh\necho \"running: $@\"") cli := NewAnyscaleCLI("") + config := &WorkspaceTestConfig{ + workspaceName: "test-workspace", + } - err := cli.RunCommandInWorkspace("test-ws", "echo hello") + err := cli.runCmdInWorkspace(config, "echo hello") if err != nil { t.Errorf("unexpected error: %v", err) } @@ -487,20 +592,29 @@ func TestRunCommandInWorkspace(t *testing.T) { t.Run("failure", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") cli := NewAnyscaleCLI("") + config := &WorkspaceTestConfig{ + workspaceName: "test-workspace", + } - err := cli.RunCommandInWorkspace("test-ws", "echo hello") + err := cli.runCmdInWorkspace(config, "failing-command") if err == nil { t.Fatal("expected error, got nil") } + if !strings.Contains(err.Error(), "run command in workspace failed") { + t.Errorf("error %q should contain 'run command in workspace failed'", err.Error()) + } }) } func TestStartWorkspace(t *testing.T) { t.Run("success", func(t *testing.T) { - setupMockAnyscale(t, "#!/bin/sh\necho \"started\"") + setupMockAnyscale(t, "#!/bin/sh\necho \"starting $@\"") cli := NewAnyscaleCLI("") + config := &WorkspaceTestConfig{ + workspaceName: "test-workspace", + } - err := cli.StartWorkspace("test-ws") + err := cli.startWorkspace(config) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -509,11 +623,17 @@ func TestStartWorkspace(t *testing.T) { t.Run("failure", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") cli := NewAnyscaleCLI("") + config := &WorkspaceTestConfig{ + workspaceName: "test-workspace", + } - err := cli.StartWorkspace("test-ws") + err := cli.startWorkspace(config) if err == nil { t.Fatal("expected error, got nil") } + if !strings.Contains(err.Error(), "start workspace failed") { + t.Errorf("error %q should contain 'start workspace failed'", err.Error()) + } }) } @@ -522,7 +642,7 @@ func TestGetWorkspaceStatus(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\necho \"RUNNING\"") cli := NewAnyscaleCLI("") - output, err := cli.GetWorkspaceStatus("test-ws") + output, err := cli.getWorkspaceStatus("test-workspace") if err != nil { t.Errorf("unexpected error: %v", err) } @@ -535,10 +655,13 @@ func TestGetWorkspaceStatus(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") cli := NewAnyscaleCLI("") - _, err := cli.GetWorkspaceStatus("test-ws") + _, err := cli.getWorkspaceStatus("test-workspace") if err == nil { t.Fatal("expected error, got nil") } + if !strings.Contains(err.Error(), "get workspace state failed") { + t.Errorf("error %q should contain 'get workspace state failed'", err.Error()) + } }) } @@ -547,7 +670,7 @@ func TestWaitForWorkspaceState(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\necho \"state reached\"") cli := NewAnyscaleCLI("") - output, err := cli.WaitForWorkspaceState("test-ws", StateRunning) + output, err := cli.waitForWorkspaceState("test-workspace", StateRunning) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -556,13 +679,29 @@ func TestWaitForWorkspaceState(t *testing.T) { } }) + t.Run("wait for terminated", func(t *testing.T) { + setupMockAnyscale(t, "#!/bin/sh\necho \"terminated\"") + cli := NewAnyscaleCLI("") + + output, err := cli.waitForWorkspaceState("test-workspace", StateTerminated) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(output, "terminated") { + t.Errorf("output %q should contain 'terminated'", output) + } + }) + t.Run("failure", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") cli := NewAnyscaleCLI("") - _, err := cli.WaitForWorkspaceState("test-ws", StateRunning) + _, err := cli.waitForWorkspaceState("test-workspace", StateRunning) if err == nil { t.Fatal("expected error, got nil") } + if !strings.Contains(err.Error(), "wait for workspace state failed") { + t.Errorf("error %q should contain 'wait for workspace state failed'", err.Error()) + } }) } From 41dda14cfab8b56b03148f4272755a393b62c673 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Sun, 25 Jan 2026 23:31:48 +0000 Subject: [PATCH 04/17] formatting Signed-off-by: elliot-barn --- rayapp/anyscale_cli.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/rayapp/anyscale_cli.go b/rayapp/anyscale_cli.go index 5c8ff16c..412b1a9f 100644 --- a/rayapp/anyscale_cli.go +++ b/rayapp/anyscale_cli.go @@ -28,19 +28,19 @@ type WorkspaceTestConfig struct { } const ( - StateTerminated WorkspaceState = iota - StateStarting - StateRunning + StateTerminated WorkspaceState = iota + StateStarting + StateRunning ) var WorkspaceStateName = map[WorkspaceState]string{ - StateTerminated: "TERMINATED", - StateStarting: "STARTING", - StateRunning: "RUNNING", + StateTerminated: "TERMINATED", + StateStarting: "STARTING", + StateRunning: "RUNNING", } func (ws WorkspaceState) String() string { - return WorkspaceStateName[ws] + return WorkspaceStateName[ws] } type AnyscaleCLI struct { @@ -94,10 +94,10 @@ func (ac *AnyscaleCLI) runAnyscaleCLI(args []string) (string, error) { // e.g., "configs/basic-single-node/aws.yaml" -> "basic-single-node-aws" func parseComputeConfigName(awsConfigPath string) string { // Get the directory and filename - dir := filepath.Dir(awsConfigPath) // "configs/basic-single-node" - base := filepath.Base(awsConfigPath) // "aws.yaml" - ext := filepath.Ext(base) // ".yaml" - filename := strings.TrimSuffix(base, ext) // "aws" + dir := filepath.Dir(awsConfigPath) // "configs/basic-single-node" + base := filepath.Base(awsConfigPath) // "aws.yaml" + ext := filepath.Ext(base) // ".yaml" + filename := strings.TrimSuffix(base, ext) // "aws" // Get the last directory component (the config name) configDir := filepath.Base(dir) // "basic-single-node" @@ -281,7 +281,7 @@ func (ac *AnyscaleCLI) waitForWorkspaceState(workspaceName string, state Workspa // OldComputeConfig represents the old compute config format type OldComputeConfig struct { - HeadNodeType OldHeadNodeType `yaml:"head_node_type"` + HeadNodeType OldHeadNodeType `yaml:"head_node_type"` WorkerNodeTypes []OldWorkerNodeType `yaml:"worker_node_types"` } From 31145d3a8a3f8856b3e8f13d68c9eba9e632b093 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Mon, 26 Jan 2026 00:29:36 +0000 Subject: [PATCH 05/17] Remove unused token field from AnyscaleCLI struct The token field was never used - authentication relies on environment variables (ANYSCALE_CLI_TOKEN, ANYSCALE_HOST) instead. Co-Authored-By: Claude Opus 4.5 --- rayapp/anyscale_cli.go | 8 +++--- rayapp/anyscale_cli_test.go | 51 +++++++++++++++++-------------------- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/rayapp/anyscale_cli.go b/rayapp/anyscale_cli.go index 412b1a9f..1381b3da 100644 --- a/rayapp/anyscale_cli.go +++ b/rayapp/anyscale_cli.go @@ -43,14 +43,12 @@ func (ws WorkspaceState) String() string { return WorkspaceStateName[ws] } -type AnyscaleCLI struct { - token string -} +type AnyscaleCLI struct{} var errAnyscaleNotInstalled = errors.New("anyscale is not installed") -func NewAnyscaleCLI(token string) *AnyscaleCLI { - return &AnyscaleCLI{token: token} +func NewAnyscaleCLI() *AnyscaleCLI { + return &AnyscaleCLI{} } func isAnyscaleInstalled() bool { diff --git a/rayapp/anyscale_cli_test.go b/rayapp/anyscale_cli_test.go index 6db5d8f1..7673fd5b 100644 --- a/rayapp/anyscale_cli_test.go +++ b/rayapp/anyscale_cli_test.go @@ -25,13 +25,10 @@ func setupMockAnyscale(t *testing.T, script string) { } func TestNewAnyscaleCLI(t *testing.T) { - cli := NewAnyscaleCLI("test-token") + cli := NewAnyscaleCLI() if cli == nil { t.Fatal("expected non-nil AnyscaleCLI") } - if cli.token != "test-token" { - t.Errorf("expected token 'test-token', got %q", cli.token) - } } func TestWorkspaceStateString(t *testing.T) { @@ -177,7 +174,7 @@ func TestRunAnyscaleCLI(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { setupMockAnyscale(t, tt.script) - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() output, err := cli.runAnyscaleCLI(tt.args) @@ -224,7 +221,7 @@ func TestIsAnyscaleInstalled(t *testing.T) { func TestAuthenticate(t *testing.T) { t.Run("success", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\nexit 1") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() if err := cli.Authenticate(); err != nil { t.Errorf("unexpected error: %v", err) } @@ -232,7 +229,7 @@ func TestAuthenticate(t *testing.T) { t.Run("failure", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() err := cli.Authenticate() if err == nil { t.Fatal("expected error, got nil") @@ -301,7 +298,7 @@ fi exit 1 ` setupMockAnyscale(t, script) - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() // Create a temporary config file with new format (no conversion needed) tmpFile, err := os.CreateTemp("", "test-config-*.yaml") @@ -334,7 +331,7 @@ fi exit 1 ` setupMockAnyscale(t, script) - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() output, err := cli.CreateComputeConfig("my-config", "/path/to/config.yaml") if err != nil { @@ -361,7 +358,7 @@ fi exit 1 ` setupMockAnyscale(t, script) - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() // Create a temporary config file with new format tmpFile, err := os.CreateTemp("", "test-config-*.yaml") @@ -385,7 +382,7 @@ exit 1 func TestGetComputeConfig(t *testing.T) { t.Run("success", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\necho \"name: my-config\nhead_node:\n instance_type: m5.xlarge\"") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() output, err := cli.GetComputeConfig("my-config") if err != nil { @@ -398,7 +395,7 @@ func TestGetComputeConfig(t *testing.T) { t.Run("success with version", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\necho \"args: $@\"") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() output, err := cli.GetComputeConfig("my-config:2") if err != nil { @@ -411,7 +408,7 @@ func TestGetComputeConfig(t *testing.T) { t.Run("failure", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() _, err := cli.GetComputeConfig("nonexistent-config") if err == nil { @@ -492,7 +489,7 @@ func TestCreateEmptyWorkspace(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { setupMockAnyscale(t, tt.script) - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() err := cli.createEmptyWorkspace(tt.config) @@ -516,7 +513,7 @@ func TestCreateEmptyWorkspace(t *testing.T) { func TestTerminateWorkspace(t *testing.T) { t.Run("success", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\necho \"terminating $@\"") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() err := cli.terminateWorkspace("my-workspace") if err != nil { @@ -526,7 +523,7 @@ func TestTerminateWorkspace(t *testing.T) { t.Run("failure", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() err := cli.terminateWorkspace("my-workspace") if err == nil { @@ -541,7 +538,7 @@ func TestTerminateWorkspace(t *testing.T) { func TestCopyTemplateToWorkspace(t *testing.T) { t.Run("success", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\necho \"pushing $@\"") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() config := &WorkspaceTestConfig{ workspaceName: "test-workspace", template: &Template{ @@ -557,7 +554,7 @@ func TestCopyTemplateToWorkspace(t *testing.T) { t.Run("failure", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() config := &WorkspaceTestConfig{ workspaceName: "test-workspace", template: &Template{ @@ -578,7 +575,7 @@ func TestCopyTemplateToWorkspace(t *testing.T) { func TestRunCmdInWorkspace(t *testing.T) { t.Run("success", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\necho \"running: $@\"") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() config := &WorkspaceTestConfig{ workspaceName: "test-workspace", } @@ -591,7 +588,7 @@ func TestRunCmdInWorkspace(t *testing.T) { t.Run("failure", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() config := &WorkspaceTestConfig{ workspaceName: "test-workspace", } @@ -609,7 +606,7 @@ func TestRunCmdInWorkspace(t *testing.T) { func TestStartWorkspace(t *testing.T) { t.Run("success", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\necho \"starting $@\"") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() config := &WorkspaceTestConfig{ workspaceName: "test-workspace", } @@ -622,7 +619,7 @@ func TestStartWorkspace(t *testing.T) { t.Run("failure", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() config := &WorkspaceTestConfig{ workspaceName: "test-workspace", } @@ -640,7 +637,7 @@ func TestStartWorkspace(t *testing.T) { func TestGetWorkspaceStatus(t *testing.T) { t.Run("success", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\necho \"RUNNING\"") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() output, err := cli.getWorkspaceStatus("test-workspace") if err != nil { @@ -653,7 +650,7 @@ func TestGetWorkspaceStatus(t *testing.T) { t.Run("failure", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() _, err := cli.getWorkspaceStatus("test-workspace") if err == nil { @@ -668,7 +665,7 @@ func TestGetWorkspaceStatus(t *testing.T) { func TestWaitForWorkspaceState(t *testing.T) { t.Run("success", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\necho \"state reached\"") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() output, err := cli.waitForWorkspaceState("test-workspace", StateRunning) if err != nil { @@ -681,7 +678,7 @@ func TestWaitForWorkspaceState(t *testing.T) { t.Run("wait for terminated", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\necho \"terminated\"") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() output, err := cli.waitForWorkspaceState("test-workspace", StateTerminated) if err != nil { @@ -694,7 +691,7 @@ func TestWaitForWorkspaceState(t *testing.T) { t.Run("failure", func(t *testing.T) { setupMockAnyscale(t, "#!/bin/sh\nexit 1") - cli := NewAnyscaleCLI("") + cli := NewAnyscaleCLI() _, err := cli.waitForWorkspaceState("test-workspace", StateRunning) if err == nil { From 8b819b4da8ccb9f5f97d15e3d9478a91360a16d0 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Tue, 27 Jan 2026 01:15:51 +0000 Subject: [PATCH 06/17] formatting Signed-off-by: elliot-barn --- rayapp/test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/rayapp/test.go b/rayapp/test.go index 870c7b6e..83d76da2 100644 --- a/rayapp/test.go +++ b/rayapp/test.go @@ -18,6 +18,7 @@ func Test(tmplName, buildFile string) error { const testCmd = "pip install nbmake==1.5.5 pytest==7.4.0 && pytest --nbmake . -s -vv" const workspaceStartWaitTime = 30 * time.Second + // WorkspaceTestConfig contains all the details to test a workspace. type WorkspaceTestConfig struct { tmplName string From ffed720e614607689be7094e5fbc7586fda5cf2d Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Tue, 27 Jan 2026 02:30:43 +0000 Subject: [PATCH 07/17] Fix compute config path resolution in tests Co-Authored-By: Claude Opus 4.5 --- rayapp/test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rayapp/test.go b/rayapp/test.go index 83d76da2..97d59849 100644 --- a/rayapp/test.go +++ b/rayapp/test.go @@ -39,7 +39,7 @@ func NewWorkspaceTestConfig(tmplName, buildFile string) *WorkspaceTestConfig { // Run creates an empty workspace and copies the template to it. func (wtc *WorkspaceTestConfig) Run() error { // init anyscale cli - anyscaleCLI := NewAnyscaleCLI(os.Getenv("ANYSCALE_CLI_TOKEN")) + anyscaleCLI := NewAnyscaleCLI() // read build file and get template details tmpls, err := readTemplates(wtc.buildFile) @@ -66,8 +66,10 @@ func (wtc *WorkspaceTestConfig) Run() error { // Parse compute config name from template's AWS config path and create if needed if awsConfigPath, ok := wtc.template.ComputeConfig["AWS"]; ok { wtc.computeConfig = parseComputeConfigName(awsConfigPath) + // Resolve compute config path relative to build file directory + resolvedConfigPath := filepath.Join(buildDir, awsConfigPath) // Create compute config if it doesn't already exist - if _, err := anyscaleCLI.CreateComputeConfig(wtc.computeConfig, awsConfigPath); err != nil { + if _, err := anyscaleCLI.CreateComputeConfig(wtc.computeConfig, resolvedConfigPath); err != nil { return fmt.Errorf("create compute config failed: %w", err) } } From 54fb0042fe9b8ce7814f3144b78a39925eb509a7 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Tue, 27 Jan 2026 02:31:20 +0000 Subject: [PATCH 08/17] updating cli Signed-off-by: elliot-barn --- rayapp/anyscale_cli.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/rayapp/anyscale_cli.go b/rayapp/anyscale_cli.go index 1381b3da..fbfa1ede 100644 --- a/rayapp/anyscale_cli.go +++ b/rayapp/anyscale_cli.go @@ -15,18 +15,6 @@ import ( type WorkspaceState int -// WorkspaceTestConfig contains all the details to test a workspace. -type WorkspaceTestConfig struct { - tmplName string - buildFile string - workspaceName string - configFile string - computeConfig string - imageURI string - rayVersion string - template *Template -} - const ( StateTerminated WorkspaceState = iota StateStarting From c631acc61f81a9303f1a6707be7d985db0d2d5e3 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Tue, 27 Jan 2026 18:49:16 +0000 Subject: [PATCH 09/17] Remove Anyscale login functionality Authentication is handled via environment variables (ANYSCALE_CLI_TOKEN & ANYSCALE_HOST) rather than interactive login. Co-Authored-By: Claude Opus 4.5 --- rayapp/anyscale_cli.go | 9 --------- rayapp/anyscale_cli_test.go | 28 +++------------------------- 2 files changed, 3 insertions(+), 34 deletions(-) diff --git a/rayapp/anyscale_cli.go b/rayapp/anyscale_cli.go index fbfa1ede..8801c03c 100644 --- a/rayapp/anyscale_cli.go +++ b/rayapp/anyscale_cli.go @@ -44,15 +44,6 @@ func isAnyscaleInstalled() bool { return err == nil } -func (ac *AnyscaleCLI) Authenticate() error { - cmd := exec.Command("anyscale", "login") - err := cmd.Run() - if err != nil { - return fmt.Errorf("anyscale auth login failed, please set ANYSCALE_CLI_TOKEN & ANYSCALE_HOST env variables: %w", err) - } - return nil -} - // RunAnyscaleCLI runs the anyscale CLI with the given arguments. // Returns the combined output and any error that occurred. // Output is displayed to the terminal with colors preserved. diff --git a/rayapp/anyscale_cli_test.go b/rayapp/anyscale_cli_test.go index 0fac627d..1812c64c 100644 --- a/rayapp/anyscale_cli_test.go +++ b/rayapp/anyscale_cli_test.go @@ -145,19 +145,19 @@ func TestRunAnyscaleCLI(t *testing.T) { }, { name: "success", - script: "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\necho \"output: $@\"", + script: "#!/bin/sh\necho \"output: $@\"", args: []string{"service", "deploy"}, wantSubstr: "output: service deploy", }, { name: "empty args", - script: "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\necho \"help\"", + script: "#!/bin/sh\necho \"help\"", args: []string{}, wantSubstr: "help", }, { name: "command fails with stderr", - script: "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\necho \"error msg\" >&2; exit 1", + script: "#!/bin/sh\necho \"error msg\" >&2; exit 1", args: []string{"deploy"}, wantSubstr: "error msg", wantErr: errors.New("anyscale error"), @@ -211,28 +211,6 @@ func TestIsAnyscaleInstalled(t *testing.T) { }) } -func TestAuthenticate(t *testing.T) { - t.Run("success", func(t *testing.T) { - setupMockAnyscale(t, "#!/bin/sh\nif [ \"$1\" = \"login\" ]; then exit 0; fi\nexit 1") - cli := NewAnyscaleCLI() - if err := cli.Authenticate(); err != nil { - t.Errorf("unexpected error: %v", err) - } - }) - - t.Run("failure", func(t *testing.T) { - setupMockAnyscale(t, "#!/bin/sh\nexit 1") - cli := NewAnyscaleCLI() - err := cli.Authenticate() - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "anyscale auth login failed") { - t.Errorf("error %q should contain 'anyscale auth login failed'", err.Error()) - } - }) -} - func TestParseComputeConfigName(t *testing.T) { tests := []struct { name string From 0dd96590b9a74a9572fdfed52374ad9d4a5944f3 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Fri, 30 Jan 2026 00:01:02 +0000 Subject: [PATCH 10/17] updating build_rayapp script Signed-off-by: elliot-barn --- build_rayapp.sh | 2 +- rayapp/anyscale_cli.go | 80 +++++++++++++++++++++++++++++++++++-- rayapp/anyscale_cli_test.go | 10 +++-- rayapp/test.go | 5 ++- 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/build_rayapp.sh b/build_rayapp.sh index d4805b66..55cfcf29 100755 --- a/build_rayapp.sh +++ b/build_rayapp.sh @@ -8,4 +8,4 @@ go build -o ci/bin/rayapp ./rayapp/rayapp echo "--- Building template releases" rm -rf _build -exec ci/bin/rayapp "$@" +mv ci/bin/rayapp ../rayapp \ No newline at end of file diff --git a/rayapp/anyscale_cli.go b/rayapp/anyscale_cli.go index 8801c03c..790ffed6 100644 --- a/rayapp/anyscale_cli.go +++ b/rayapp/anyscale_cli.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "io" + "net/http" "os" "os/exec" "path/filepath" + "regexp" "strings" "gopkg.in/yaml.v2" @@ -31,6 +33,17 @@ func (ws WorkspaceState) String() string { return WorkspaceStateName[ws] } +// extractWorkspaceID extracts the workspace ID from the CLI output. +// Expected format: "Workspace created successfully id: expwrk_xxx" +func extractWorkspaceID(output string) (string, error) { + re := regexp.MustCompile(`id:\s*(expwrk_[a-zA-Z0-9]+)`) + matches := re.FindStringSubmatch(output) + if len(matches) < 2 { + return "", fmt.Errorf("could not extract workspace ID from output: %s", output) + } + return matches[1], nil +} + type AnyscaleCLI struct{} var errAnyscaleNotInstalled = errors.New("anyscale is not installed") @@ -171,12 +184,12 @@ func (ac *AnyscaleCLI) GetComputeConfig(name string) (string, error) { return output, nil } -func (ac *AnyscaleCLI) createEmptyWorkspace(config *WorkspaceTestConfig) error { +func (ac *AnyscaleCLI) createEmptyWorkspace(config *WorkspaceTestConfig) (string, error) { args := []string{"workspace_v2", "create"} // get image URI and ray version from build ID imageURI, rayVersion, err := convertBuildIdToImageURI(config.template.ClusterEnv.BuildID) if err != nil { - return fmt.Errorf("convert build ID to image URI failed: %w", err) + return "", fmt.Errorf("convert build ID to image URI failed: %w", err) } args = append(args, "--name", config.workspaceName) args = append(args, "--image-uri", imageURI) @@ -189,10 +202,16 @@ func (ac *AnyscaleCLI) createEmptyWorkspace(config *WorkspaceTestConfig) error { output, err := ac.runAnyscaleCLI(args) if err != nil { - return fmt.Errorf("create empty workspace failed: %w", err) + return "", fmt.Errorf("create empty workspace failed: %w", err) } fmt.Println("create empty workspace output:\n", output) - return nil + + workspaceID, err := extractWorkspaceID(output) + if err != nil { + return "", fmt.Errorf("failed to extract workspace ID: %w", err) + } + + return workspaceID, nil } func (ac *AnyscaleCLI) terminateWorkspace(workspaceName string) error { @@ -204,6 +223,59 @@ func (ac *AnyscaleCLI) terminateWorkspace(workspaceName string) error { return nil } +func (ac *AnyscaleCLI) deleteWorkspace(workspaceName string) error { + output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "delete", "--name", workspaceName}) + if err != nil { + return fmt.Errorf("delete workspace failed: %w", err) + } + fmt.Println("delete workspace output:\n", output) + return nil +} + +// deleteWorkspaceByID deletes a workspace by its ID using the Anyscale REST API. +// It uses the ANYSCALE_HOST environment variable for the API host and +// ANYSCALE_CLI_TOKEN for authentication. +func (ac *AnyscaleCLI) deleteWorkspaceByID(workspaceID string) error { + anyscaleHost := os.Getenv("ANYSCALE_HOST") + if anyscaleHost == "" { + return errors.New("ANYSCALE_HOST environment variable is not set") + } + + apiToken := os.Getenv("ANYSCALE_CLI_TOKEN") + if apiToken == "" { + return errors.New("ANYSCALE_CLI_TOKEN environment variable is not set") + } + + url := fmt.Sprintf("%s/api/v2/experimental_workspaces/%s", anyscaleHost, workspaceID) + + req, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+apiToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("delete workspace failed with status %d: %s", resp.StatusCode, string(body)) + } + + fmt.Printf("delete workspace %s succeeded: %s\n", workspaceID, string(body)) + return nil +} + func (ac *AnyscaleCLI) copyTemplateToWorkspace(config *WorkspaceTestConfig) error { output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "push", "--name", config.workspaceName, "--local-dir", config.template.Dir}) if err != nil { diff --git a/rayapp/anyscale_cli_test.go b/rayapp/anyscale_cli_test.go index 1812c64c..9c4ad8dc 100644 --- a/rayapp/anyscale_cli_test.go +++ b/rayapp/anyscale_cli_test.go @@ -402,7 +402,7 @@ func TestCreateEmptyWorkspace(t *testing.T) { }{ { name: "success without compute config", - script: "#!/bin/sh\necho \"args: $@\"", + script: "#!/bin/sh\necho \"args: $@\"\necho \"(anyscale +1.0s) Workspace created successfully id: expwrk_testid123\"", config: &WorkspaceTestConfig{ workspaceName: "test-workspace", template: &Template{ @@ -415,7 +415,7 @@ func TestCreateEmptyWorkspace(t *testing.T) { }, { name: "success with compute config name", - script: "#!/bin/sh\necho \"args: $@\"", + script: "#!/bin/sh\necho \"args: $@\"\necho \"(anyscale +1.0s) Workspace created successfully id: expwrk_testid123\"", config: &WorkspaceTestConfig{ workspaceName: "test-workspace", computeConfig: "basic-single-node-aws", @@ -462,7 +462,7 @@ func TestCreateEmptyWorkspace(t *testing.T) { setupMockAnyscale(t, tt.script) cli := NewAnyscaleCLI() - err := cli.createEmptyWorkspace(tt.config) + workspaceID, err := cli.createEmptyWorkspace(tt.config) if tt.wantErr { if err == nil { @@ -477,6 +477,10 @@ func TestCreateEmptyWorkspace(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + + if workspaceID == "" { + t.Error("expected workspace ID, got empty string") + } }) } } diff --git a/rayapp/test.go b/rayapp/test.go index 97d59849..641a5088 100644 --- a/rayapp/test.go +++ b/rayapp/test.go @@ -24,6 +24,7 @@ type WorkspaceTestConfig struct { tmplName string buildFile string workspaceName string + workspaceID string configFile string computeConfig string imageURI string @@ -79,9 +80,11 @@ func (wtc *WorkspaceTestConfig) Run() error { wtc.workspaceName = workspaceName // create empty workspace - if err := anyscaleCLI.createEmptyWorkspace(wtc); err != nil { + workspaceID, err := anyscaleCLI.createEmptyWorkspace(wtc) + if err != nil { return fmt.Errorf("create empty workspace failed: %w", err) } + wtc.workspaceID = workspaceID if err := anyscaleCLI.startWorkspace(wtc); err != nil { return fmt.Errorf("start workspace failed: %w", err) From c2ff6d76ad0a609c18813b83262d7b53901e3c49 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Fri, 30 Jan 2026 00:14:20 +0000 Subject: [PATCH 11/17] removign delete workspace Signed-off-by: elliot-barn --- rayapp/anyscale_cli.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/rayapp/anyscale_cli.go b/rayapp/anyscale_cli.go index 790ffed6..2525311e 100644 --- a/rayapp/anyscale_cli.go +++ b/rayapp/anyscale_cli.go @@ -223,15 +223,6 @@ func (ac *AnyscaleCLI) terminateWorkspace(workspaceName string) error { return nil } -func (ac *AnyscaleCLI) deleteWorkspace(workspaceName string) error { - output, err := ac.runAnyscaleCLI([]string{"workspace_v2", "delete", "--name", workspaceName}) - if err != nil { - return fmt.Errorf("delete workspace failed: %w", err) - } - fmt.Println("delete workspace output:\n", output) - return nil -} - // deleteWorkspaceByID deletes a workspace by its ID using the Anyscale REST API. // It uses the ANYSCALE_HOST environment variable for the API host and // ANYSCALE_CLI_TOKEN for authentication. From d0dd3809b378a6a1f53cac372e5e40e76f8aecd3 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Fri, 30 Jan 2026 00:16:52 +0000 Subject: [PATCH 12/17] adding test command handler Signed-off-by: elliot-barn --- rayapp/rayapp/main.go | 68 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/rayapp/rayapp/main.go b/rayapp/rayapp/main.go index bd747cdc..359f01a0 100644 --- a/rayapp/rayapp/main.go +++ b/rayapp/rayapp/main.go @@ -3,30 +3,78 @@ package main import ( "flag" "fmt" - "github.com/ray-project/rayci/rayapp" "log" + "os" + + "github.com/ray-project/rayci/rayapp" ) func main() { - base := flag.String("base", ".", "base directory") - output := flag.String("output", "_build", "output directory") - buildFile := flag.String("build", "BUILD.yaml", "build file") + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + // Build command flags (shared by build and build-all) + buildFlags := flag.NewFlagSet("build", flag.ExitOnError) + base := buildFlags.String("base", ".", "base directory") + output := buildFlags.String("output", "_build", "output directory") + buildFile := buildFlags.String("build", "BUILD.yaml", "build file") - flag.Parse() + // Test command flags + testFlags := flag.NewFlagSet("test", flag.ExitOnError) + testBuildFile := testFlags.String("build", "BUILD.yaml", "build file") + // workspaceName := testFlags.String("workspace-name", "", "workspace name (required)") + // templateDir := testFlags.String("template-dir", "", "template directory (required)") + // config := testFlags.String("config", "config.yml", "config file path (required)") - args := flag.Args() - switch args[0] { + switch os.Args[1] { case "build-all": + buildFlags.Parse(os.Args[2:]) if err := rayapp.BuildAll(*buildFile, *base, *output); err != nil { log.Fatal(err) } case "build": - if err := rayapp.Build(*buildFile, args[1], *base, *output); err != nil { + buildFlags.Parse(os.Args[2:]) + args := buildFlags.Args() + if len(args) < 1 { + log.Fatal("build requires a template name") + } + if err := rayapp.Build(*buildFile, args[0], *base, *output); err != nil { + log.Fatal(err) + } + case "test": + testFlags.Parse(os.Args[2:]) + args := testFlags.Args() + if len(args) < 1 { + log.Fatal("test requires flag") + } + if err := rayapp.Test(args[0], *testBuildFile); err != nil { log.Fatal(err) } case "help": - fmt.Println("Usage: rayapp build-all | build | help") + printUsage() default: - log.Fatal("unknown command") + log.Fatalf("unknown command: %s", os.Args[1]) } } + +func printUsage() { + fmt.Println("Usage: rayapp [flags]") + fmt.Println() + fmt.Println("Commands:") + fmt.Println(" build-all Build all templates") + fmt.Println(" build Build a specific template") + fmt.Println(" test Test templates") + fmt.Println(" help Show this help message") + fmt.Println() + fmt.Println("Build flags (build, build-all):") + fmt.Println(" --base string Base directory (default \".\")") + fmt.Println(" --output string Output directory (default \"_build\")") + fmt.Println(" --build string Build file (default \"BUILD.yaml\")") + fmt.Println() + fmt.Println("Test flags:") + fmt.Println(" --workspace string Workspace name (required)") + fmt.Println(" --template-dir string Template directory (required)") + fmt.Println(" --config string Config file path (required)") +} \ No newline at end of file From cf335bda2e1f2394902618b3000d8addcd5a0de4 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Fri, 30 Jan 2026 00:24:20 +0000 Subject: [PATCH 13/17] deleting workspace by id Signed-off-by: elliot-barn --- rayapp/test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rayapp/test.go b/rayapp/test.go index 641a5088..a15e7935 100644 --- a/rayapp/test.go +++ b/rayapp/test.go @@ -121,5 +121,9 @@ func (wtc *WorkspaceTestConfig) Run() error { return fmt.Errorf("terminate workspace failed: %w", err) } + if err := anyscaleCLI.deleteWorkspaceByID(wtc.workspaceID); err != nil { + return fmt.Errorf("delete workspace failed: %w", err) + } + return nil } From 59f67b4b6227634d0edb107306f305d00d9ebb27 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Fri, 30 Jan 2026 01:44:44 +0000 Subject: [PATCH 14/17] adding wait before deletion Signed-off-by: elliot-barn --- rayapp/test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rayapp/test.go b/rayapp/test.go index a15e7935..a7deb0f8 100644 --- a/rayapp/test.go +++ b/rayapp/test.go @@ -121,6 +121,10 @@ func (wtc *WorkspaceTestConfig) Run() error { return fmt.Errorf("terminate workspace failed: %w", err) } + if _, err := anyscaleCLI.waitForWorkspaceState(wtc.workspaceName, StateTerminated); err != nil { + return fmt.Errorf("wait for workspace terminated state failed: %w", err) + } + if err := anyscaleCLI.deleteWorkspaceByID(wtc.workspaceID); err != nil { return fmt.Errorf("delete workspace failed: %w", err) } From 78d43c2a72b1473de3a679265d4c7cecf5cb6463 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Fri, 30 Jan 2026 01:58:55 +0000 Subject: [PATCH 15/17] formatting Signed-off-by: elliot-barn --- rayapp/rayapp/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rayapp/rayapp/main.go b/rayapp/rayapp/main.go index 359f01a0..a23c74d4 100644 --- a/rayapp/rayapp/main.go +++ b/rayapp/rayapp/main.go @@ -77,4 +77,4 @@ func printUsage() { fmt.Println(" --workspace string Workspace name (required)") fmt.Println(" --template-dir string Template directory (required)") fmt.Println(" --config string Config file path (required)") -} \ No newline at end of file +} From 2c8ebec13770491048b78b0582c0dd563c4dbf5a Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Tue, 3 Feb 2026 22:22:28 +0000 Subject: [PATCH 16/17] update test command to unzip template before running tests Co-Authored-By: Claude Opus 4.5 --- rayapp/test.go | 3 ++- rayapp/test_test.go | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/rayapp/test.go b/rayapp/test.go index a7deb0f8..015528ff 100644 --- a/rayapp/test.go +++ b/rayapp/test.go @@ -15,7 +15,7 @@ func Test(tmplName, buildFile string) error { return nil } -const testCmd = "pip install nbmake==1.5.5 pytest==7.4.0 && pytest --nbmake . -s -vv" +const testCmdFmt = "unzip %s.zip && pip install nbmake==1.5.5 pytest==7.4.0 && pytest --nbmake . -s -vv" const workspaceStartWaitTime = 30 * time.Second @@ -112,6 +112,7 @@ func (wtc *WorkspaceTestConfig) Run() error { } // run test in workspace + testCmd := fmt.Sprintf(testCmdFmt, wtc.tmplName) if err := anyscaleCLI.runCmdInWorkspace(wtc, testCmd); err != nil { return fmt.Errorf("run test in workspace failed: %w", err) } diff --git a/rayapp/test_test.go b/rayapp/test_test.go index 0ad8c465..929715ab 100644 --- a/rayapp/test_test.go +++ b/rayapp/test_test.go @@ -388,16 +388,19 @@ func TestTest_TemplateNotFound(t *testing.T) { } } -func TestTestCmd_Constant(t *testing.T) { - // Verify the test command constant is set correctly - if testCmd == "" { - t.Error("testCmd should not be empty") +func TestTestCmdFmt_Constant(t *testing.T) { + // Verify the test command format constant is set correctly + if testCmdFmt == "" { + t.Error("testCmdFmt should not be empty") } - if !strings.Contains(testCmd, "pytest") { - t.Errorf("testCmd %q should contain 'pytest'", testCmd) + if !strings.Contains(testCmdFmt, "pytest") { + t.Errorf("testCmdFmt %q should contain 'pytest'", testCmdFmt) } - if !strings.Contains(testCmd, "nbmake") { - t.Errorf("testCmd %q should contain 'nbmake'", testCmd) + if !strings.Contains(testCmdFmt, "nbmake") { + t.Errorf("testCmdFmt %q should contain 'nbmake'", testCmdFmt) + } + if !strings.Contains(testCmdFmt, "%s") { + t.Errorf("testCmdFmt %q should contain '%%s' format specifier", testCmdFmt) } } From fae055429a5ea92739daf8eea57266113f0ff89f Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Wed, 4 Feb 2026 01:40:55 +0000 Subject: [PATCH 17/17] better build id to image uri conversion Signed-off-by: elliot-barn --- rayapp/anyscale_cli.go | 46 ++++++++++++++++++++++++++++++++----- rayapp/anyscale_cli_test.go | 6 +++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/rayapp/anyscale_cli.go b/rayapp/anyscale_cli.go index 2525311e..d0529940 100644 --- a/rayapp/anyscale_cli.go +++ b/rayapp/anyscale_cli.go @@ -402,14 +402,40 @@ func ConvertComputeConfigFile(oldConfigPath, newConfigPath string) error { func convertBuildIdToImageURI(buildId string) (string, string, error) { // Convert build ID like "anyscaleray2441-py312-cu128" to "anyscale/ray:2.44.1-py312-cu128" + // Also handles "anyscaleray-llm2501-py311-cu128" to "anyscale/ray-llm:25.0.1-py311-cu128" const prefix = "anyscaleray" if !strings.HasPrefix(buildId, prefix) { return "", "", fmt.Errorf("build ID must start with %q: %s", prefix, buildId) } - // Remove the prefix to get "2441-py312-cu128" + // Remove the prefix to get "2441-py312-cu128" or "-llm2501-py311-cu128" remainder := strings.TrimPrefix(buildId, prefix) + // Find where the version number starts (first digit sequence of 4+ chars) + // This handles both "ray2441-py312" and "ray-llm2501-py311" + versionStartIdx := -1 + for i := 0; i < len(remainder); i++ { + if remainder[i] >= '0' && remainder[i] <= '9' { + // Check if this starts a version (4+ digits before hyphen or end) + endIdx := i + for endIdx < len(remainder) && remainder[endIdx] >= '0' && remainder[endIdx] <= '9' { + endIdx++ + } + if endIdx-i >= 4 && (endIdx == len(remainder) || remainder[endIdx] == '-') { + versionStartIdx = i + break + } + } + } + + if versionStartIdx == -1 { + return "", "", fmt.Errorf("version string too short: %s", buildId) + } + + // Extract image name suffix (e.g., "" for "ray" or "-llm" for "ray-llm") + imageNameSuffix := remainder[:versionStartIdx] + remainder = remainder[versionStartIdx:] + // Find the first hyphen to separate version from suffix hyphenIdx := strings.Index(remainder, "-") var versionStr, suffix string @@ -422,14 +448,22 @@ func convertBuildIdToImageURI(buildId string) (string, string, error) { } // Parse version: "2441" -> "2.44.1" - // Format: first digit = major, next two = minor, rest = patch if len(versionStr) < 4 { return "", "", fmt.Errorf("version string too short: %s", versionStr) } - major := versionStr[0:1] - minor := versionStr[1:3] - patch := versionStr[3:] + var major, minor, patch string + // For ray-llm and similar images (with suffix), use format XX.Y.Z (2-digit major) + // For standard ray images, use format X.YY.Z (1-digit major) + if imageNameSuffix != "" { + major = versionStr[0:2] + minor = versionStr[2:3] + patch = versionStr[3:] + } else { + major = versionStr[0:1] + minor = versionStr[1:3] + patch = versionStr[3:] + } - return fmt.Sprintf("anyscale/ray:%s.%s.%s%s", major, minor, patch, suffix), fmt.Sprintf("%s.%s.%s", major, minor, patch), nil + return fmt.Sprintf("anyscale/ray%s:%s.%s.%s%s", imageNameSuffix, major, minor, patch, suffix), fmt.Sprintf("%s.%s.%s", major, minor, patch), nil } diff --git a/rayapp/anyscale_cli_test.go b/rayapp/anyscale_cli_test.go index 9c4ad8dc..5f8b73b3 100644 --- a/rayapp/anyscale_cli_test.go +++ b/rayapp/anyscale_cli_test.go @@ -76,6 +76,12 @@ func TestConvertBuildIdToImageURI(t *testing.T) { wantImageURI: "anyscale/ray:3.00.1-py312", wantRayVersion: "3.00.1", }, + { + name: "valid build ID with ray-llm image", + buildId: "anyscaleray-llm2501-py311-cu128", + wantImageURI: "anyscale/ray-llm:25.0.1-py311-cu128", + wantRayVersion: "25.0.1", + }, { name: "invalid prefix", buildId: "rayimage2441-py312",