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 new file mode 100644 index 00000000..d0529940 --- /dev/null +++ b/rayapp/anyscale_cli.go @@ -0,0 +1,469 @@ +package rayapp + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v2" +) + +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] +} + +// 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") + +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 +} + +// 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 +} + +// 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. +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 + } + + // 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) + } + 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 +} + +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) + } + 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) + + 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 { + 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 +} + +// 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 { + 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 +} + +// 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" + // 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" 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 + if hyphenIdx == -1 { + versionStr = remainder + suffix = "" + } else { + versionStr = remainder[:hyphenIdx] + suffix = remainder[hyphenIdx:] // includes the hyphen + } + + // Parse version: "2441" -> "2.44.1" + if len(versionStr) < 4 { + return "", "", fmt.Errorf("version string too short: %s", versionStr) + } + + 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%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 new file mode 100644 index 00000000..5f8b73b3 --- /dev/null +++ b/rayapp/anyscale_cli_test.go @@ -0,0 +1,685 @@ +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 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: "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", + 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\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 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 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 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" + 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 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") + } + 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 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: $@\"\necho \"(anyscale +1.0s) Workspace created successfully id: expwrk_testid123\"", + 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: $@\"\necho \"(anyscale +1.0s) Workspace created successfully id: expwrk_testid123\"", + 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() + + workspaceID, 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) + } + + if workspaceID == "" { + t.Error("expected workspace ID, got empty string") + } + }) + } +} + +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/rayapp/main.go b/rayapp/rayapp/main.go index bd747cdc..a23c74d4 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)") +} diff --git a/rayapp/test.go b/rayapp/test.go new file mode 100644 index 00000000..015528ff --- /dev/null +++ b/rayapp/test.go @@ -0,0 +1,134 @@ +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 testCmdFmt = "unzip %s.zip && 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 + workspaceID 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() + + // 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) + // 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, resolvedConfigPath); 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 + 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) + } + + if _, err := anyscaleCLI.waitForWorkspaceState(wtc.workspaceName, StateRunning); err != nil { + return fmt.Errorf("wait for workspace running state failed: %w", err) + } + + // 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 + testCmd := fmt.Sprintf(testCmdFmt, wtc.tmplName) + 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) + } + + 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) + } + + return nil +} diff --git a/rayapp/test_test.go b/rayapp/test_test.go new file mode 100644 index 00000000..929715ab --- /dev/null +++ b/rayapp/test_test.go @@ -0,0 +1,431 @@ +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 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(testCmdFmt, "pytest") { + t.Errorf("testCmdFmt %q should contain 'pytest'", testCmdFmt) + } + 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) + } +} + +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 +}