-
Notifications
You must be signed in to change notification settings - Fork 4
[draft] DO NOT MERGE: rayapp test prototype #372
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 4 commits
9c9a162
1729794
7e70483
78b1840
87f5ddb
17f39f0
aca755f
eb85bac
e7c49e7
291723e
f5c9cf0
7370b08
9f5de30
5ba726f
b4bf53a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| package rayapp | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "os/exec" | ||
| "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 | ||
| } | ||
|
|
||
| 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) | ||
| if config.computeConfig != "" { | ||
| args = append(args, "--compute-config", "tmpl-test-basic-serverless-aws:1") | ||
| } | ||
| 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) 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 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,112 @@ | ||||||||||||||||||||||||||||||
| package rayapp | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||
| "errors" | ||||||||||||||||||||||||||||||
| "os" | ||||||||||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||||||||||
| "testing" | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| var anyscaleCLI *AnyscaleCLI | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // 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" | ||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For constructing file paths, it's better to use
Suggested change
|
||||||||||||||||||||||||||||||
| 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 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) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| output, err := anyscaleCLI.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()) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+188
to
+194
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The error checking logic is a bit complex and can be simplified for better readability and maintainability. Specifically,
Suggested change
|
||||||||||||||||||||||||||||||
| // For error cases, also check wantSubstr against error message | ||||||||||||||||||||||||||||||
| if tt.wantSubstr != "" && !strings.Contains(err.Error(), tt.wantSubstr) { | ||||||||||||||||||||||||||||||
| t.Errorf("error %q should contain %q", err.Error(), tt.wantSubstr) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| 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") | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.