From e6c684713c27abc954516eb89489f59a9270587a Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Tue, 27 Jan 2026 19:05:52 +0000 Subject: [PATCH 1/3] feat(rayapp): add Anyscale CLI core infrastructure Add foundational components for Anyscale CLI wrapper: - AnyscaleCLI struct and NewAnyscaleCLI() constructor - errAnyscaleNotInstalled error sentinel for installation checks - isAnyscaleInstalled() helper to verify CLI availability - runAnyscaleCLI() base method for executing anyscale commands Includes comprehensive test coverage with setupMockAnyscale test helper. Co-Authored-By: Claude Opus 4.5 --- rayapp/anyscale_cli.go | 48 +++++++++++++++ rayapp/anyscale_cli_test.go | 114 ++++++++++++++++++++++++++++++++++++ 2 files changed, 162 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..76742e3a --- /dev/null +++ b/rayapp/anyscale_cli.go @@ -0,0 +1,48 @@ +package rayapp + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" +) + +// AnyscaleCLI provides methods for interacting with the Anyscale CLI. +type AnyscaleCLI struct{} + +var errAnyscaleNotInstalled = errors.New("anyscale is not installed") + +// NewAnyscaleCLI creates a new AnyscaleCLI instance. +func NewAnyscaleCLI() *AnyscaleCLI { + return &AnyscaleCLI{} +} + +func isAnyscaleInstalled() bool { + _, err := exec.LookPath("anyscale") + return err == 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 +} diff --git a/rayapp/anyscale_cli_test.go b/rayapp/anyscale_cli_test.go new file mode 100644 index 00000000..4f66f66c --- /dev/null +++ b/rayapp/anyscale_cli_test.go @@ -0,0 +1,114 @@ +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() + if cli == nil { + t.Fatal("expected non-nil AnyscaleCLI") + } +} + +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 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\necho \"output: $@\"", + args: []string{"service", "deploy"}, + wantSubstr: "output: service deploy", + }, + { + name: "empty args", + script: "#!/bin/sh\necho \"help\"", + args: []string{}, + wantSubstr: "help", + }, + { + name: "command fails with stderr", + script: "#!/bin/sh\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) + } + }) + } +} From 484e97649cbfd467e8577e913a650de625efb565 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Tue, 27 Jan 2026 19:15:50 +0000 Subject: [PATCH 2/3] feat(rayapp): add build ID to image URI conversion Add convertBuildIdToImageURI function that parses build IDs like "anyscaleray2441-py312-cu128" into image URI "anyscale/ray:2.44.1-py312-cu128" and Ray version "2.44.1". Co-Authored-By: Claude Opus 4.5 --- rayapp/anyscale_cli.go | 40 +++++++++++++++++ rayapp/anyscale_cli_test.go | 86 +++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/rayapp/anyscale_cli.go b/rayapp/anyscale_cli.go index 76742e3a..4f0b0eff 100644 --- a/rayapp/anyscale_cli.go +++ b/rayapp/anyscale_cli.go @@ -7,6 +7,7 @@ import ( "io" "os" "os/exec" + "strings" ) // AnyscaleCLI provides methods for interacting with the Anyscale CLI. @@ -46,3 +47,42 @@ func (ac *AnyscaleCLI) runAnyscaleCLI(args []string) (string, error) { return outputBuf.String(), nil } + +// convertBuildIdToImageURI converts a build ID to an image URI and Ray version. +// Build IDs have the format "anyscaleray{version}-{suffix}" where: +// - version is a 4+ digit string like "2441" representing major.minor.patch (2.44.1) +// - suffix is optional and contains Python version and CUDA version (e.g., "py312-cu128") +// Returns the image URI (e.g., "anyscale/ray:2.44.1-py312-cu128") and Ray version (e.g., "2.44.1"). +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 index 4f66f66c..4e2a0c5b 100644 --- a/rayapp/anyscale_cli_test.go +++ b/rayapp/anyscale_cli_test.go @@ -112,3 +112,89 @@ func TestRunAnyscaleCLI(t *testing.T) { }) } } + +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) + } + }) + } +} From f1b625c9903c77917283902476dae894ec553124 Mon Sep 17 00:00:00 2001 From: elliot-barn Date: Tue, 27 Jan 2026 19:19:09 +0000 Subject: [PATCH 3/3] feat(rayapp): add compute config operations Add WorkspaceState type with constants (StateTerminated, StateStarting, StateRunning) and String() method for representing workspace states. Add compute config operations: - parseComputeConfigName: parse AWS config path to config name - CreateComputeConfig: create compute config if it doesn't exist - GetComputeConfig: retrieve compute config details by name Co-Authored-By: Claude Opus 4.5 --- rayapp/anyscale_cli.go | 70 +++++++++++++ rayapp/anyscale_cli_test.go | 199 ++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) diff --git a/rayapp/anyscale_cli.go b/rayapp/anyscale_cli.go index 4f0b0eff..aa5d7bc1 100644 --- a/rayapp/anyscale_cli.go +++ b/rayapp/anyscale_cli.go @@ -7,9 +7,31 @@ import ( "io" "os" "os/exec" + "path/filepath" "strings" ) +// WorkspaceState represents the state of a workspace. +type WorkspaceState int + +const ( + StateTerminated WorkspaceState = iota + StateStarting + StateRunning +) + +// WorkspaceStateName maps WorkspaceState values to their string representations. +var WorkspaceStateName = map[WorkspaceState]string{ + StateTerminated: "TERMINATED", + StateStarting: "STARTING", + StateRunning: "RUNNING", +} + +// String returns the string representation of a WorkspaceState. +func (ws WorkspaceState) String() string { + return WorkspaceStateName[ws] +} + // AnyscaleCLI provides methods for interacting with the Anyscale CLI. type AnyscaleCLI struct{} @@ -86,3 +108,51 @@ func convertBuildIdToImageURI(buildId string) (string, string, error) { return fmt.Sprintf("anyscale/ray:%s.%s.%s%s", major, minor, patch, suffix), fmt.Sprintf("%s.%s.%s", major, minor, patch), 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) +// configFilePath: 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 + 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 +} diff --git a/rayapp/anyscale_cli_test.go b/rayapp/anyscale_cli_test.go index 4e2a0c5b..606ab7f4 100644 --- a/rayapp/anyscale_cli_test.go +++ b/rayapp/anyscale_cli_test.go @@ -113,6 +113,25 @@ func TestRunAnyscaleCLI(t *testing.T) { } } +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 @@ -198,3 +217,183 @@ func TestConvertBuildIdToImageURI(t *testing.T) { }) } } + +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() + + // Create a temporary config file + 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" + 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() + + // Create a temporary config file + 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") + } + 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()) + } + }) +}