diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..a4b2eec --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,118 @@ +name: Integration Test Matrix + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + inputs: + pod_image: + description: 'Image to test for Pod lifecycle' + required: false + default: 'docker.io/library/alpine' + serverless_image: + description: 'Image to test for Serverless lifecycle' + required: false + default: 'fngarvin/ci-minimal-serverless@sha256:6a33a9bac95b8bc871725db9092af2922a7f1e3b63175248b2191b38be4e93a0' + +concurrency: + group: integration-tests-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + test-matrix: + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + user_mode: [root, non-root] + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.7' + - name: Install dependencies + run: | + sudo apt-get update && sudo apt-get install -y wget curl coreutils jq bash tar grep sed + - name: Setup User for Mode + run: | + if [ "${{ matrix.user_mode }}" == "non-root" ]; then + # useradd is faster than the adduser Perl wrapper + sudo useradd -m -s /bin/bash tester + # Pre-create bin dir for the installer + sudo -u tester mkdir -p /home/tester/.local/bin + + # Optimization: git checkout-index is instant compared to cp -r . + # it exports tracked files without the bulky .git folder (fixes 50s bottleneck) + mkdir -p /tmp/runpodctl-test + git checkout-index -a -f --prefix=/tmp/runpodctl-test/ + sudo chown -R tester:tester /tmp/runpodctl-test + fi + - name: Build and Install runpodctl + run: | + # 1. Build the local PR version (the "new" code) + go build -o runpodctl main.go + chmod +x runpodctl + + # 2. Run installer as the correct user to validate PORTABILITY logic + if [ "${{ matrix.user_mode }}" == "root" ]; then + sudo bash install.sh + else + # Ensure the installer sees the tester's local bin + sudo -u tester env "PATH=$PATH:/home/tester/.local/bin" bash install.sh + fi + + # 3. Overwrite with PR code so the tests below are testing the REAL changes + if [ "${{ matrix.user_mode }}" == "root" ]; then + sudo cp -f runpodctl /usr/local/bin/runpodctl + sudo chmod +x /usr/local/bin/runpodctl + mkdir -p ~/go/bin && cp runpodctl ~/go/bin/runpodctl + else + # Update the tester's binaries and satisfy upstream hardcoded paths + sudo cp -f runpodctl /home/tester/.local/bin/runpodctl + sudo -u tester mkdir -p /home/tester/go/bin + sudo cp runpodctl /home/tester/go/bin/runpodctl + sudo chown tester:tester /home/tester/.local/bin/runpodctl /home/tester/go/bin/runpodctl + sudo chmod +x /home/tester/.local/bin/runpodctl /home/tester/go/bin/runpodctl + fi + - name: Run Go E2E Tests + env: + RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }} + RUNPOD_TEST_POD_IMAGE: ${{ github.event.inputs.pod_image || 'docker.io/library/alpine' }} + RUNPOD_TEST_SERVERLESS_IMAGE: ${{ github.event.inputs.serverless_image || 'fngarvin/ci-minimal-serverless@sha256:6a33a9bac95b8bc871725db9092af2922a7f1e3b63175248b2191b38be4e93a0' }} + run: | + # Use -run to ONLY execute our safe tests, but ./e2e/... to ensure package compilation + TEST_PATTERN="^TestE2E_CLILifecycle" + + if [ "${{ matrix.user_mode }}" == "root" ]; then + go test -tags e2e -v -run "$TEST_PATTERN" ./e2e/... + else + # Execute the tests as the tester user, preserving path and env + sudo -u tester env "PATH=$PATH" "RUNPOD_API_KEY=${{ secrets.RUNPOD_API_KEY }}" \ + "RUNPOD_TEST_POD_IMAGE=${{ github.event.inputs.pod_image || 'docker.io/library/alpine' }}" \ + "RUNPOD_TEST_SERVERLESS_IMAGE=${{ github.event.inputs.serverless_image || 'fngarvin/ci-minimal-serverless@sha256:6a33a9bac95b8bc871725db9092af2922a7f1e3b63175248b2191b38be4e93a0' }}" \ + bash -c "cd /tmp/runpodctl-test && go test -tags e2e -v -run \"$TEST_PATTERN\" ./e2e/..." + fi + - name: Post-Run Cleanup (Emergency) + if: always() + env: + RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }} + run: | + RP="./runpodctl" + if [ "${{ matrix.user_mode }}" == "non-root" ]; then + RP="/tmp/runpodctl-test/runpodctl" + fi + + if [ -n "$RUNPOD_API_KEY" ]; then + echo "Ensuring safe sweeping of CI resources explicitly prefixed with 'ci-test-'..." + # Only delete pods named exactly starting with "ci-test-" + $RP pod list --output json 2>/dev/null | jq -r '.[] | select(.name | startswith("ci-test-")) | .id' | xargs -r -I {} $RP pod delete {} || true + $RP serverless list --output json 2>/dev/null | jq -r '.[] | select(.name | startswith("ci-test-")) | .id' | xargs -r -I {} $RP serverless delete {} || true + $RP template list --output json 2>/dev/null | jq -r '.[] | select(.name | startswith("ci-test-")) | .id' | xargs -r -I {} $RP template delete {} || true + fi diff --git a/.gitignore b/.gitignore index 4acb03c..ae74431 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ vendor/ ## auto generated file during make and release version + +# User built binaries +runpodctl diff --git a/e2e/cli_lifecycle_test.go b/e2e/cli_lifecycle_test.go new file mode 100644 index 0000000..3434d19 --- /dev/null +++ b/e2e/cli_lifecycle_test.go @@ -0,0 +1,530 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// Default testing variables +const ( + defaultPodImage = "docker.io/library/alpine" + defaultPodDiskSize = "5" // GB + defaultServerlessImage = "fngarvin/ci-minimal-serverless@sha256:6a33a9bac95b8bc871725db9092af2922a7f1e3b63175248b2191b38be4e93a0" +) + +// Regex to catch standard RunPod API keys (rpa_ followed by alphanumeric) +var apiKeyRegex = regexp.MustCompile(`rpa_[a-zA-Z0-9]+`) + +func redactSensitive(input string) string { + return apiKeyRegex.ReplaceAllString(input, "[REDACTED]") +} + +// HELPER: Get value from env or return default +func getEnvOrDefault(key, fallback string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return fallback +} + +// findBinaryPath searches for the runpodctl binary in standard locations +func findBinaryPath() (string, error) { + pathsToTry := []string{ + "./runpodctl", + "../runpodctl", + os.ExpandEnv("$HOME/.local/bin/runpodctl"), + "/usr/local/bin/runpodctl", + "runpodctl", // system path + } + + for _, p := range pathsToTry { + if _, err := exec.LookPath(p); err == nil { + return p, nil + } + } + return "", fmt.Errorf("runpodctl binary not found in PATH or standard locations") +} + +// HELPER: execute the runpodctl binary +func runE2ECmd(args ...string) (string, error) { + binaryPath, err := findBinaryPath() + if err != nil { + return "", err + } + + // Sanitize the command echo to hide keys in arguments if any + // cmdStr := fmt.Sprintf("%s %s", binaryPath, strings.Join(args, " ")) + + cmd := exec.Command(binaryPath, args...) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + + err = cmd.Run() + output := redactSensitive(out.String()) + return output, err +} + +func extractIDField(jsonOutput string) (string, error) { + var result map[string]interface{} + + start := strings.Index(jsonOutput, "{") + end := strings.LastIndex(jsonOutput, "}") + + if start == -1 || end == -1 || end < start { + return "", fmt.Errorf("could not find JSON block in output: %s", jsonOutput) + } + + jsonStr := jsonOutput[start : end+1] + + err := json.Unmarshal([]byte(jsonStr), &result) + if err != nil { + return "", fmt.Errorf("could not parse json: %v, output captured: %s", err, jsonStr) + } + + id, ok := result["id"].(string) + if !ok { + return "", fmt.Errorf("id field missing or not a string in json: %s", jsonStr) + } + return id, nil +} + +func TestE2E_CLILifecycle_Pod(t *testing.T) { + if os.Getenv("RUNPOD_API_KEY") == "" { + t.Skip("RUNPOD_API_KEY is not set, skipping integration test") + } + + podImage := getEnvOrDefault("RUNPOD_TEST_POD_IMAGE", defaultPodImage) + podDisk := getEnvOrDefault("RUNPOD_TEST_POD_DISK", defaultPodDiskSize) + + // Prefix with ci-test- for safe scoping + podName := fmt.Sprintf("ci-test-pod-%d", time.Now().Unix()) + + t.Logf("Creating pod %s with image %s", podName, podImage) + + // Create Pod + out, err := runE2ECmd( + "pod", "create", + "--name", podName, + "--image", podImage, + "--container-disk-in-gb", podDisk, + "--compute-type", "CPU", + "--output", "json", + ) + + if err != nil { + t.Fatalf("Failed to create pod: %v\nOutput: %s", err, out) + } + + podID, err := extractIDField(out) + if err != nil { + t.Fatalf("Failed to extract Pod ID: %v", err) + } + t.Logf("Created Pod ID: %s", podID) + + // Register cleanup to run even if test fails + t.Cleanup(func() { + t.Logf("Cleaning up pod %s...", podID) + _, delErr := runE2ECmd("pod", "delete", podID) + if delErr != nil { + t.Logf("Warning: failed to delete pod %s in cleanup: %v", podID, delErr) + } else { + t.Logf("Successfully deleted pod %s", podID) + } + }) + + // Wait for propagation + time.Sleep(5 * time.Second) + + // List Pods and look for ours + t.Logf("Listing pods to verify presence...") + listOut, listErr := runE2ECmd("pod", "list", "--output", "json") + if listErr != nil { + t.Errorf("Failed to list pods: %v\nOutput: %s", listErr, listOut) + } else if !strings.Contains(listOut, podID) { + t.Errorf("Pod ID %s not found in list output", podID) + } + + // Get Pod + t.Logf("Getting pod details...") + getOut, getErr := runE2ECmd("pod", "get", podID, "--output", "json") + if getErr != nil { + t.Fatalf("Failed to get pod: %v\nOutput: %s", getErr, getOut) + } + + var pod map[string]interface{} + if err := json.Unmarshal([]byte(getOut), &pod); err != nil { + t.Fatalf("Failed to parse pod get output as JSON: %v\nOutput: %s", err, getOut) + } + if pod["id"] != podID { + t.Fatalf("Expected pod ID %s from get, got %v", podID, pod["id"]) + } + + // Update Pod + newName := podName + "-updated" + t.Logf("Updating pod name to %s...", newName) + updateOut, updateErr := runE2ECmd("pod", "update", podID, "--name", newName) + if updateErr != nil { + t.Fatalf("Failed to update pod: %v\nOutput: %s", updateErr, updateOut) + } + + // Verify update + getOutUpdated, getErrUpdated := runE2ECmd("pod", "get", podID, "--output", "json") + if getErrUpdated != nil { + t.Fatalf("Failed to get updated pod: %v\nOutput: %s", getErrUpdated, getOutUpdated) + } + var podUpdated map[string]interface{} + if err := json.Unmarshal([]byte(getOutUpdated), &podUpdated); err != nil { + t.Fatalf("Failed to parse updated pod get output as JSON: %v\nOutput: %s", err, getOutUpdated) + } + if podUpdated["name"] != newName { + t.Fatalf("Expected pod name %s after update, got %v", newName, podUpdated["name"]) + } + + // Stop Pod + t.Logf("Stopping pod...") + stopOut, stopErr := runE2ECmd("pod", "stop", podID) + if stopErr != nil { + t.Errorf("Failed to stop pod: %v\nOutput: %s", stopErr, stopOut) + } + + // Start Pod + t.Logf("Starting pod...") + startOut, startErr := runE2ECmd("pod", "start", podID) + if startErr != nil { + t.Errorf("Failed to start pod: %v\nOutput: %s", startErr, startOut) + } + + // Test Croc File Transfer (Send/Receive) + enableCroc := os.Getenv("RUNPOD_E2E_TEST_CROC") != "" + if !enableCroc { + t.Logf("Skipping croc file transfer test: RUNPOD_E2E_TEST_CROC not set") + } else { + t.Logf("RUNPOD_E2E_TEST_CROC set; croc file transfer test is required") + t.Logf("Testing croc file transfer...") + testFileName := "ci-test-file.txt" + testFileContent := "v1.14.15-ci-test" + if err := os.WriteFile(testFileName, []byte(testFileContent), 0644); err != nil { + t.Fatalf("Failed to create croc test file %q: %v", testFileName, err) + } + defer os.Remove(testFileName) + + // Start send in background + binaryPath, err := findBinaryPath() + if err != nil { + t.Fatalf("RUNPOD_E2E_TEST_CROC is set but binary path lookup failed: %v", err) + } + + sendCmd := exec.Command(binaryPath, "send", testFileName) + var sendOut bytes.Buffer + sendCmd.Stdout = &sendOut + sendCmd.Stderr = &sendOut + + if err := sendCmd.Start(); err != nil { + t.Fatalf("Failed to start croc send command: %v", err) + } + defer sendCmd.Process.Kill() // Ensure we don't leak the process + + // Poll for code + var crocCode string + for i := 0; i < 15; i++ { + outStr := sendOut.String() + // Robust exact-match extraction: parse the exact instruction string + if strings.Contains(outStr, " ") { + lines := strings.Split(outStr, "\n") + for _, l := range lines { + if idx := strings.Index(l, "runpodctl receive "); idx != -1 { + remainder := strings.TrimSpace(l[idx+len("runpodctl receive "):]) + tokens := strings.Fields(remainder) + if len(tokens) > 0 { + crocCode = tokens[0] + break + } + } + } + } + if crocCode != "" { + break + } + time.Sleep(1 * time.Second) + } + + if crocCode != "" { + t.Logf("Captured Croc Code: %s", crocCode) + // Test receive + pwd, _ := os.Getwd() + recvDir := filepath.Join(pwd, "recv_test") + if err := os.MkdirAll(recvDir, 0755); err != nil { + t.Fatalf("Failed to create croc receive directory %q: %v", recvDir, err) + } + defer os.RemoveAll(recvDir) + + recvCmd := exec.Command(binaryPath, "receive", crocCode) + recvCmd.Dir = recvDir + recvErr := recvCmd.Run() + if recvErr != nil { + t.Logf("Warning: croc receive failed (expected if sender hasn't fully registered with relay): %v", recvErr) + } + } else { + t.Fatalf("Could not extract croc code in time. Send output: %s", sendOut.String()) + } + } +} + +func TestE2E_CLILifecycle_Serverless(t *testing.T) { + if os.Getenv("RUNPOD_API_KEY") == "" { + t.Skip("RUNPOD_API_KEY is not set, skipping integration test") + } + + slsImage := getEnvOrDefault("RUNPOD_TEST_SERVERLESS_IMAGE", defaultServerlessImage) + epName := fmt.Sprintf("ci-test-ep-%d", time.Now().Unix()) + + // Step 1: Create a temporary template from the image + tplName := fmt.Sprintf("ci-test-tpl-%d", time.Now().Unix()) + t.Logf("Creating temporary serverless template %s with image %s", tplName, slsImage) + + tplOut, err := runE2ECmd( + "template", "create", + "--name", tplName, + "--image", slsImage, + "--serverless", + "--output", "json", + ) + if err != nil { + t.Fatalf("Failed to create temporary template: %v\nOutput: %s", err, tplOut) + } + + tplID, err := extractIDField(tplOut) + if err != nil { + t.Fatalf("Failed to extract Template ID: %v", err) + } + t.Logf("Created Template ID: %s", tplID) + + // Register template cleanup + t.Cleanup(func() { + t.Logf("Cleaning up template %s...", tplID) + _, delErr := runE2ECmd("template", "delete", tplID) + if delErr != nil { + t.Logf("Warning: failed to delete template %s in cleanup: %v", tplID, delErr) + } + }) + + // Step 2: Create endpoint using the new template + t.Logf("Creating serverless endpoint %s with template %s", epName, tplID) + out, err := runE2ECmd( + "serverless", "create", + "--name", epName, + "--template-id", tplID, + "--workers-min", "1", + "--workers-max", "1", + "--gpu-count", "0", + "--compute-type", "CPU", + "--output", "json", + ) + + if err != nil { + t.Fatalf("Failed to create endpoint: %v\nOutput: %s", err, out) + } + + epID, err := extractIDField(out) + if err != nil { + t.Fatalf("Failed to extract Endpoint ID: %v", err) + } + t.Logf("Created Endpoint ID: %s", epID) + + // Register endpoint cleanup + t.Cleanup(func() { + t.Logf("Cleaning up endpoint %s...", epID) + _, delErr := runE2ECmd("serverless", "delete", epID) + if delErr != nil { + t.Logf("Warning: failed to delete endpoint %s in cleanup: %v", epID, delErr) + } else { + t.Logf("Successfully deleted endpoint %s", epID) + } + }) + + // Wait for API propagation + ready := false + for i := 0; i < 30; i++ { + _, getErr := runE2ECmd("serverless", "get", epID) + if getErr == nil { + ready = true + break + } + time.Sleep(10 * time.Second) + } + + if !ready { + t.Fatalf("Endpoint %s did not become available in the API within 5 minutes", epID) + } + + t.Logf("Endpoint is ready and propagated.") + + // List endpoints and assert the created endpoint exists + listOutRaw, listErr := runE2ECmd("serverless", "list", "--output", "json") + if listErr != nil { + t.Fatalf("Failed to list endpoints: %v\nOutput: %s", listErr, listOutRaw) + } + + // We isolate the JSON array block robustly + listStart := strings.Index(listOutRaw, "[") + listEnd := strings.LastIndex(listOutRaw, "]") + if listStart == -1 || listEnd == -1 || listEnd < listStart { + t.Fatalf("Failed to find JSON block in serverless list output: %s", listOutRaw) + } + listOut := listOutRaw[listStart : listEnd+1] + + type serverlessEndpoint struct { + ID string `json:"id"` + Name string `json:"name"` + } + + var endpoints []serverlessEndpoint + if err := json.Unmarshal([]byte(listOut), &endpoints); err != nil { + t.Fatalf("Failed to parse serverless list output as JSON: %v\nOutput: %s", err, listOut) + } + + var listedEp *serverlessEndpoint + for i := range endpoints { + if endpoints[i].ID == epID { + listedEp = &endpoints[i] + break + } + } + if listedEp == nil { + t.Fatalf("Endpoint ID %s not found in serverless list output", epID) + } + + // Update endpoint name + newName := epName + "-updated" + t.Logf("Updating endpoint name to %s...", newName) + updateOut, updateErr := runE2ECmd("serverless", "update", epID, "--name", newName) + if updateErr != nil { + t.Fatalf("Failed to update serverless endpoint: %v\nOutput: %s", updateErr, updateOut) + } + + // Get endpoint and assert the name was updated + getOutRaw, getErr := runE2ECmd("serverless", "get", epID, "--output", "json") + if getErr != nil { + t.Fatalf("Failed to get serverless endpoint: %v\nOutput: %s", getErr, getOutRaw) + } + + getStart := strings.Index(getOutRaw, "{") + getEnd := strings.LastIndex(getOutRaw, "}") + if getStart == -1 || getEnd == -1 || getEnd < getStart { + t.Fatalf("Failed to find JSON block in serverless get output: %s", getOutRaw) + } + getOut := getOutRaw[getStart : getEnd+1] + + var updatedEp serverlessEndpoint + if err := json.Unmarshal([]byte(getOut), &updatedEp); err != nil { + t.Fatalf("Failed to parse serverless get output as JSON: %v\nOutput: %s", err, getOut) + } + + if updatedEp.ID != epID { + t.Fatalf("Expected endpoint ID %s from get, got %s", epID, updatedEp.ID) + } + if !strings.HasPrefix(updatedEp.Name, newName) { + t.Fatalf("Expected endpoint name starting with %s after update, got %s", newName, updatedEp.Name) + } + + // --- DATA PLANE TEST --- + // Demonstrate functional image capability by submitting and polling a job + t.Logf("Submitting test job to endpoint %s...", epID) + + apiKey := os.Getenv("RUNPOD_API_KEY") + submitURL := fmt.Sprintf("https://api.runpod.ai/v2/%s/run", epID) + + payload := []byte(`{"input": {"test": "data"}}`) + req, err := http.NewRequest("POST", submitURL, bytes.NewBuffer(payload)) + if err != nil { + t.Fatalf("Failed to create job request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("accept", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to submit job: %v", err) + } + defer resp.Body.Close() + + var jobResp map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&jobResp); err != nil { + t.Fatalf("Failed to decode job response: %v", err) + } + + jobIDStr, ok := jobResp["id"].(string) + if !ok || jobIDStr == "" { + t.Fatalf("Failed to get job ID from response: %v", jobResp) + } + + t.Logf("Job submitted: %s. Polling for completion...", jobIDStr) + + statusURL := fmt.Sprintf("https://api.runpod.ai/v2/%s/status/%s", epID, jobIDStr) + maxRetries := 60 // 5 minutes max (initial cold start of a brand new template can take a few minutes) + success := false + + for i := 0; i < maxRetries; i++ { + req, err := http.NewRequest("GET", statusURL, nil) + if err != nil { + t.Fatalf("Failed to create status request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + t.Logf("Warning: status request failed (retry %d): %v", i, err) + time.Sleep(5 * time.Second) + continue + } + + var statusResp map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&statusResp) + resp.Body.Close() + + if err != nil { + t.Logf("Warning: failed to decode status response (retry %d): %v", i, err) + time.Sleep(5 * time.Second) + continue + } + + status, _ := statusResp["status"].(string) + t.Logf(".. Status: %s (%ds/%ds)", status, i*5, maxRetries*5) + + if status == "COMPLETED" { + output, ok := statusResp["output"].(string) + if ok && strings.Contains(output, "FNGarvin-CI-ECHO") { + t.Logf("++ Serverless Data-Plane: SUCCESS (Expected hook marker 'FNGarvin-CI-ECHO' found in output: %v)", output) + success = true + break + } else { + t.Fatalf("!! Serverless Data-Plane: FAILED (Output did not contain expected echo: %v)", statusResp["output"]) + } + } else if status == "FAILED" { + t.Fatalf("!! Job Failed: %v", statusResp["error"]) + } + + time.Sleep(5 * time.Second) + } + + if !success { + t.Fatalf("!! Integration Suite Timed Out waiting for job completion.") + } +} + +//EOF cli_lifecycle_test.go diff --git a/install.sh b/install.sh index a66abe5..f5ad3d2 100644 --- a/install.sh +++ b/install.sh @@ -10,22 +10,81 @@ # Requirements: # - Bash shell # - Internet connection -# - Homebrew (for macOS users) -# - jq (for JSON processing, will be installed automatically) +# - tar, wget, grep, sed (standard on most systems) # # Supported Platforms: -# - Linux (amd64) -# - macOS (Intel and Apple Silicon) +# - Linux (amd64, arm64) +# - macOS (Universal binary) set -e -REQUIRED_PKGS=("jq") # Add all required packages to this list, separated by spaces. +# ---------------------------- Environment Setup ----------------------------- # +detect_install_dir() { + if [ "$EUID" -eq 0 ]; then + INSTALL_DIR="/usr/local/bin" + else + # Tiered Path Discovery: Prefer directories already in PATH + local preferred_dirs="$HOME/.local/bin $HOME/bin $HOME/.bin" + INSTALL_DIR="" + + for dir in $preferred_dirs; do + if [[ ":$PATH:" == *":$dir:"* ]] && ([ -d "$dir" ] && [ -w "$dir" ]); then + INSTALL_DIR="$dir" + break + fi + done + + # If none found in PATH, check if they exist and are writable + if [ -z "$INSTALL_DIR" ]; then + for dir in $preferred_dirs; do + if [ -d "$dir" ] && [ -w "$dir" ]; then + INSTALL_DIR="$dir" + break + fi + done + fi + + # Fallback to creating ~/.local/bin + if [ -z "$INSTALL_DIR" ]; then + INSTALL_DIR="$HOME/.local/bin" + mkdir -p "$INSTALL_DIR" + fi + + # High-visibility warning box + local width + width=$(tput cols 2>/dev/null || echo 80) + [ "$width" -gt 80 ] && width=80 + local inner_width=$((width - 4)) + + local line="" + local i=0 + while [ $i -lt "$inner_width" ]; do + line="${line}━" + i=$((i + 1)) + done + + echo "┏━${line}━┓" + printf "┃ %-${inner_width}s ┃\n" "USER-SPACE INSTALLATION DETECTED" + echo "┣━${line}━┫" + printf "┃ %-${inner_width}s ┃\n" "Target: $INSTALL_DIR" + printf "┃ %-${inner_width}s ┃\n" "" + printf "┃ %-${inner_width}s ┃\n" "To install for ALL USERS (requires root), please run:" + printf "┃ %-${inner_width}s ┃\n" "sudo bash <(wget -qO- cli.runpod.io) # or curl -sL" + echo "┗━${line}━┛" + + # Check if INSTALL_DIR is in PATH + if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + echo "Warning: $INSTALL_DIR is not in your PATH." + echo "Add it to your profile (e.g., ~/.bashrc or ~/.zshrc):" + echo " export PATH=\"$INSTALL_DIR:\$PATH\"" + fi + fi +} # -------------------------------- Check Root -------------------------------- # check_root() { if [ "$EUID" -ne 0 ]; then - echo "Please run as root with sudo." - exit 1 + echo "Note: Running as non-root. Installing to user-space." fi } @@ -33,62 +92,48 @@ check_root() { install_with_brew() { local package=$1 echo "Installing $package with Homebrew..." - local original_user=$(logname) - su - "$original_user" -c "brew install $package" + local original_user + original_user=$(logname 2>/dev/null || echo "$SUDO_USER") + + if [[ -n "$original_user" && "$original_user" != "root" ]]; then + su - "$original_user" -c "brew install \"$package\"" + else + brew install "$package" + fi } # ------------------------- Install Required Packages ------------------------ # -install_package() { - local package=$1 - echo "Installing $package..." - - case $OSTYPE in - linux-gnu*) - if [[ -f /etc/debian_version ]]; then - apt-get update && apt-get install -y "$package" - elif [[ -f /etc/redhat-release ]]; then - yum install -y "$package" - elif [[ -f /etc/fedora-release ]]; then - dnf install -y "$package" - else - echo "Unsupported Linux distribution for automatic installation of $package." - exit 1 - fi - ;; - darwin*) - install_with_brew "$package" - ;; - *) - echo "Unsupported OS for automatic installation of $package." - exit 1 - ;; - esac -} - check_system_requirements() { - local all_installed=true - - for pkg in "${REQUIRED_PKGS[@]}"; do - if ! command -v "$pkg" >/dev/null 2>&1; then - echo "$pkg is not installed." - install_package "$pkg" - all_installed=false + local missing_pkgs="" + # Essential tools for downloading and extracting + for cmd in wget tar grep sed; do + if ! command -v "$cmd" >/dev/null 2>&1; then + missing_pkgs="$missing_pkgs $cmd" fi done - if [ "$all_installed" = true ]; then - echo "All system requirements satisfied." + if [ -n "$missing_pkgs" ]; then + echo "Error: Missing required commands: $missing_pkgs" + exit 1 fi } # ----------------------------- runpodctl Version ---------------------------- # fetch_latest_version() { local version_url="https://api.github.com/repos/runpod/runpodctl/releases/latest" - VERSION=$(wget -q -O- "$version_url" | jq -r '.tag_name') - if [ -z "$VERSION" ]; then - echo "Failed to fetch the latest version of runpodctl." - exit 1 - fi + # Using grep/sed instead of jq for zero-dependency parsing + # - Robust extraction that doesn't depend on indentation or whitespace + VERSION=$(wget -q -O- "$version_url" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') + + # Ensure we got a plausible semantic version tag (e.g., v1.2.3) + case "$VERSION" in + v[0-9]*) ;; # Valid format + *) + echo "Failed to fetch a valid latest version of runpodctl (got: '${VERSION:-}')." + exit 1 + ;; + esac + echo "Latest version of runpodctl: $VERSION" } @@ -99,8 +144,7 @@ download_url_constructor() { if [[ "$os_type" == "darwin" ]]; then # macOS uses a universal binary (all architectures) - DOWNLOAD_URL="https://github.com/runpod/runpodctl/releases/download/${VERSION}/runpodctl-darwin-all.tar.gz" - return + DOWNLOAD_URLS=("https://github.com/runpod/runpodctl/releases/download/${VERSION}/runpodctl-darwin-all.tar.gz") elif [[ "$os_type" == "linux" ]]; then if [[ "$arch_type" == "x86_64" ]]; then arch_type="amd64" @@ -110,39 +154,107 @@ download_url_constructor() { echo "Unsupported Linux architecture: $arch_type" exit 1 fi + + # URL 1: Clean name (PR #235) - runpodctl-linux-amd64.tar.gz + DOWNLOAD_URLS=("https://github.com/runpod/runpodctl/releases/download/${VERSION}/runpodctl-${os_type}-${arch_type}.tar.gz") else echo "Unsupported operating system: $os_type" exit 1 fi +} + +# ----------------------------- Homebrew Support ----------------------------- # +try_brew_install() { + if [[ "$(uname -s)" != "Darwin" ]]; then + return 1 + fi + + if ! command -v brew >/dev/null 2>&1; then + echo "Homebrew not detected. Falling back to binary installation..." + return 1 + fi + + echo "macOS detected. Attempting to install runpodctl via Homebrew..." + if install_with_brew "runpod/runpodctl/runpodctl"; then + echo "runpodctl installed successfully via Homebrew." + exit 0 + fi - DOWNLOAD_URL="https://github.com/runpod/runpodctl/releases/download/${VERSION}/runpodctl-${os_type}-${arch_type}.tar.gz" + echo "Homebrew installation failed or was skipped. Falling back to binary..." + return 1 } # ---------------------------- Download & Install ---------------------------- # download_and_install_cli() { + # Define a unique name for the downloaded archive within our sandbox. local cli_archive_file_name="runpodctl.tar.gz" - if ! wget -q --progress=bar "$DOWNLOAD_URL" -O "$cli_archive_file_name"; then - echo "Failed to download $cli_archive_file_name." + local success=false + + # Create an isolated temporary directory for downloading and extracting the binary. + # Attempts to use 'mktemp' for a secure, unique path; falls back to a PID-based + # path in /tmp if 'mktemp' is unavailable. + local tmp_dir + if command -v mktemp >/dev/null 2>&1; then + # Handle variations between GNU and BSD (macOS) mktemp + tmp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t 'runpodctl-XXXXXX') + else + tmp_dir="/tmp/runpodctl-install-$$" + mkdir -p "$tmp_dir" + fi + + # Register an EXIT trap to ensure the temporary directory is nuked regardless of script outcome. + trap 'rm -rf "$tmp_dir"' EXIT + + # Determine if wget supports --show-progress (introduced in wget 1.16+) + local wget_progress_flag="" + if wget --help | grep -q 'show-progress'; then + # Use -q (quiet) + --show-progress + bar for a clean, non-spammy progress bar. + wget_progress_flag="-q --show-progress --progress=bar:force:noscroll" + fi + + for url in "${DOWNLOAD_URLS[@]}"; do + echo "Attempting to download runpodctl from $url ..." + if wget $wget_progress_flag "$url" -O "$tmp_dir/$cli_archive_file_name"; then + success=true + break + fi + done + + if [ "$success" = false ]; then + echo "Failed to download runpodctl from any provided URLs." exit 1 fi + local cli_file_name="runpodctl" - tar -xzf "$cli_archive_file_name" "$cli_file_name" - chmod +x "$cli_file_name" - if ! mv "$cli_file_name" /usr/local/bin/; then - echo "Failed to move $cli_file_name to /usr/local/bin/." + # Extract to the hermetic sandbox + tar -C "$tmp_dir" -xzf "$tmp_dir/$cli_archive_file_name" "$cli_file_name" || { echo "Failed to extract $cli_file_name."; exit 1; } + chmod +x "$tmp_dir/$cli_file_name" + + # Relocate to the final destination using -f (force) to bypass any host-level aliases + # that might cause the script to hang waiting for user input. + if ! mv -f "$tmp_dir/$cli_file_name" "$INSTALL_DIR/"; then + echo "Failed to move $cli_file_name to $INSTALL_DIR/." exit 1 fi - echo "runpodctl installed successfully." + echo "runpodctl installed successfully to $INSTALL_DIR." } - # ---------------------------------------------------------------------------- # # Main # # ---------------------------------------------------------------------------- # echo "Installing runpodctl..." +# 1. Prioritize Homebrew on macOS +if try_brew_install; then + exit 0 +fi + +# 2. Resilient Binary Installation (Universal Fallback) +detect_install_dir check_root check_system_requirements fetch_latest_version download_url_constructor download_and_install_cli + +#EOF install.sh