Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions rayapp/anyscale_cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package rayapp

import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
)

// 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
}

// 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
}
200 changes: 200 additions & 0 deletions rayapp/anyscale_cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
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)
}
})
}
}

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)
}
})
}
}