From 06e3c5dff6a63b9a26490d6566341dd8dfcd7d95 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:52:00 -0600 Subject: [PATCH 1/6] feat: portable, root-free installer refactor (dependency-free) --- .github/workflows/integration-tests.yml | 69 ++++++++ install.sh | 187 ++++++++++++++------ tests/integration_suite.sh | 216 ++++++++++++++++++++++++ 3 files changed, 416 insertions(+), 56 deletions(-) create mode 100644 .github/workflows/integration-tests.yml create mode 100755 tests/integration_suite.sh diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..670fe61 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,69 @@ +name: Integration Test Matrix + +on: + push: + branches: [ main, feat-ci-tests, feat-install-noroot ] + pull_request: + branches: [ main ] + workflow_dispatch: + +concurrency: + group: integration-tests-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + test-matrix: + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + user_mode: [root, non-root] + container: + image: alpine:latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install dependencies + run: | + apk add --no-cache bash wget grep sed tar git coreutils jq curl + - name: Setup Non-Root User and Workspace + if: matrix.user_mode == 'non-root' + run: | + adduser -D -u 1000 tester + mkdir -p /tmp/runpodctl-test + cp -r . /tmp/runpodctl-test/ + chmod +x /tmp/runpodctl-test/tests/integration_suite.sh + chown -R tester:tester /tmp/runpodctl-test + - name: Run Unified Tests + env: + RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }} + run: | + chmod +x tests/integration_suite.sh + if [ "${{ matrix.user_mode }}" == "root" ]; then + ./tests/integration_suite.sh + else + su tester -c "cd /tmp/runpodctl-test && ./tests/integration_suite.sh" + fi + - name: Post-Run Cleanup (Emergency) + if: always() + env: + RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }} + run: | + # Try to find and kill any leaked pods if runpodctl was installed + RP="" + if [ -f "/usr/local/bin/runpodctl" ]; then + RP="/usr/local/bin/runpodctl" + elif [ -f "$HOME/.local/bin/runpodctl" ]; then + RP="$HOME/.local/bin/runpodctl" + elif [ -f "/home/tester/.local/bin/runpodctl" ]; then + RP="/home/tester/.local/bin/runpodctl" + fi + + if [ -n "$RP" ] && [ -n "$RUNPOD_API_KEY" ]; then + echo "Ensuring all CI resources are removed..." + $RP pod list --output json 2>/dev/null | jq -r '.[].id' | xargs -I {} $RP pod delete {} || true + $RP serverless list --output json 2>/dev/null | jq -r '.[].id' | xargs -I {} $RP serverless delete {} || true + fi diff --git a/install.sh b/install.sh index a66abe5..599f02e 100644 --- a/install.sh +++ b/install.sh @@ -10,22 +10,68 @@ # 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 + + echo "┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓" + echo "┃ USER-SPACE INSTALLATION DETECTED ┃" + echo "┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫" + echo "┃ Target: $INSTALL_DIR ┃" + echo "┃ ┃" + echo "┃ To install for ALL USERS (requires root), please run: ┃" + echo "┃ sudo bash <(wget -qO- cli.runpod.io) # or curl -sL ┃" + echo "┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" + + # 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,58 +79,37 @@ 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=() + for cmd in wget tar grep sed; do + if ! command -v "$cmd" >/dev/null 2>&1; then + missing_pkgs+=("$cmd") fi done - if [ "$all_installed" = true ]; then - echo "All system requirements satisfied." + if [ ${#missing_pkgs[@]} -ne 0 ]; 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') + # Using grep/sed instead of jq for zero-dependency parsing + VERSION=$(wget -q -O- "$version_url" | grep '"tag_name":' | sed -E 's/.*"tag_name": "([^"]+)".*/\1/') + if [ -z "$VERSION" ]; then echo "Failed to fetch the latest version of runpodctl." exit 1 @@ -99,8 +124,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,37 +134,88 @@ 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 - DOWNLOAD_URL="https://github.com/runpod/runpodctl/releases/download/${VERSION}/runpodctl-${os_type}-${arch_type}.tar.gz" + 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 + + echo "Homebrew installation failed or was skipped. Falling back to binary..." + return 1 } # ---------------------------- Download & Install ---------------------------- # download_and_install_cli() { 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 + + for url in "${DOWNLOAD_URLS[@]}"; do + echo "Attempting to download runpodctl from $url ..." + if wget --progress=bar "$url" -O "$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" + tar -xzf "$cli_archive_file_name" "$cli_file_name" || { echo "Failed to extract $cli_file_name."; exit 1; } + # Clean up the downloaded archive after successful extraction + rm -f "$cli_archive_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/." + + # Ensure the install directory is writable before attempting to move the binary + if [ ! -w "$INSTALL_DIR" ]; then + echo "Install directory '$INSTALL_DIR' is not writable. Please run with appropriate permissions or choose a different install directory." + # Clean up the extracted binary to avoid leaving stray files behind + rm -f "$cli_file_name" exit 1 fi - echo "runpodctl installed successfully." -} + if ! mv "$cli_file_name" "$INSTALL_DIR/"; then + echo "Failed to move $cli_file_name to $INSTALL_DIR/." + exit 1 + fi + 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 diff --git a/tests/integration_suite.sh b/tests/integration_suite.sh new file mode 100755 index 0000000..f1ef34f --- /dev/null +++ b/tests/integration_suite.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# integration_suite.sh: Verifies installation and exercises full-feature integration matrix. +# Reports: PASSED, FAILED, or EXPECTED_FAIL (for missing privileges/secrets). + +set -e + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${GREEN}Starting Unified Installation & Feature Matrix Tests...${NC}" + +# --- Dependencies --- +for cmd in jq curl wget tar grep sed; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "Error: Missing required command: $cmd" + exit 1 + fi +done + +# --- Helpers --- + +report_status() { + local name=$1 + local status=$2 + local msg=$3 + case "$status" in + "PASSED") echo -e " [${GREEN}PASSED${NC}] $name $msg" ;; + "FAILED") echo -e " [${RED}FAILED${NC}] $name $msg"; return 1 ;; + "EXPECTED_FAIL") echo -e " [${YELLOW}EXPECTED_FAIL${NC}] $name $msg" ;; + esac +} + +run_step() { + local name=$1 + local cmd=$2 + local expect_fail_cond=$3 + local max_attempts=${4:-1} # Default to 1 attempt unless specified + local target_output=$5 # Optional: specific file to capture output + local retry_delay=${6:-5} # Optional: delay between retries + local attempt=1 + local output_file=${target_output:-$(mktemp)} + + while [ $attempt -le $max_attempts ]; do + if eval "$cmd" > "$output_file" 2>&1; then + report_status "$name" "PASSED" + # Only remove if it's a generic temp file, not a requested capture + if [ -z "$target_output" ]; then rm "$output_file"; fi + return 0 + fi + + # Check for expected failure + if [[ -n "$expect_fail_cond" ]] && eval "$expect_fail_cond"; then + report_status "$name" "EXPECTED_FAIL" "(Environment limitation)" + if [ -z "$target_output" ]; then rm "$output_file"; fi + return 0 + fi + + if [ $attempt -lt $max_attempts ]; then + echo " [RETRY] $name failed (Attempt $attempt/$max_attempts). Retrying in ${retry_delay}s..." + sleep "$retry_delay" + attempt=$((attempt + 1)) + else + report_status "$name" "FAILED" "\nOutput:\n$(cat "$output_file")" + if [ -z "$target_output" ]; then rm "$output_file"; fi + return 1 + fi + done +} + +# --- Phase 1: Installation Validation --- +echo -e "\n${GREEN}>> Phase 1: Installation Validation${NC}" + +if [ "$(id -u)" -eq 0 ]; then + echo "Environment: Root" + run_step "Root Install (/usr/local/bin)" "bash ./install.sh" + run_step "Binary Execution (version)" "runpodctl version" +else + # Detect if the installer actually supports non-root + HAS_NONROOT_SUPPORT=$(grep "detect_install_dir" ./install.sh || true) + + mkdir -p "$HOME/.local/bin" + export PATH="$HOME/.local/bin:$PATH" + + run_step "User-space Install (~/.local/bin)" "bash ./install.sh" "[[ -z \"$HAS_NONROOT_SUPPORT\" ]]" + + if [[ -f "$HOME/.local/bin/runpodctl" ]]; then + run_step "Binary Execution (version)" "$HOME/.local/bin/runpodctl version" + fi +fi + +# --- Phase 2: Feature Integration Audit (RunPod API) --- +echo -e "\n${GREEN}>> Phase 2: Feature Integration Audit (RunPod API)${NC}" + +if [[ -z "$RUNPOD_API_KEY" ]]; then + report_status "API Integration" "EXPECTED_FAIL" "(RUNPOD_API_KEY missing)" +else + # Determine binary path + RUNPODCTL="runpodctl" + if [ "$(id -u)" -ne 0 ] && [[ -f "$HOME/.local/bin/runpodctl" ]]; then + RUNPODCTL="$HOME/.local/bin/runpodctl" + fi + + # 1. Pod Management Lifecycle + echo "Testing Pod Lifecycle (Template: bwf8egptou)..." + POD_NAME="ci-test-pod-$(date +%s)" + + create_out=$(mktemp) + if run_step "Pod Create" "$RUNPODCTL pod create --template-id bwf8egptou --compute-type CPU --name $POD_NAME" "" 3 "$create_out"; then + POD_ID=$(jq -r '.id' "$create_out" || true) + rm "$create_out" + + if [[ -n "$POD_ID" && "$POD_ID" != "null" ]]; then + echo "Waiting for Pod API propagation (5s)..." + sleep 5 + # Propagation Retry: Retry every 10s for up to 2 minutes (12 attempts) + run_step "Pod List" "$RUNPODCTL pod list --output json | jq -e \"map(select(.id == \\\"$POD_ID\\\")) | length > 0\"" "" 12 "" 10 + run_step "Pod Get" "$RUNPODCTL pod get $POD_ID" "" 3 + run_step "Pod Update" "$RUNPODCTL pod update $POD_ID --name \"${POD_NAME}-updated\"" "" 3 + run_step "Pod Stop" "$RUNPODCTL pod stop $POD_ID" "" 3 + run_step "Pod Start" "$RUNPODCTL pod start $POD_ID" "" 3 + + # Pod Data-Plane (Send/Receive) + echo "v1.14.15-ci-test" > ci-test-file.txt + CODE_OUT=$(mktemp) + echo "Starting Pod Send..." + $RUNPODCTL send ci-test-file.txt > "$CODE_OUT" 2>&1 & + SEND_PID=$! + + CROC_CODE="" + for i in {1..30}; do + CROC_CODE=$(grep -oE '[a-z0-9-]+-[0-9]+$' "$CODE_OUT" | head -n 1 || true) + if [[ -n "$CROC_CODE" ]]; then break; fi + sleep 1 + done + + if [[ -n "$CROC_CODE" ]]; then + echo "Captured Croc Code: $CROC_CODE" + run_step "Pod Receive" "$RUNPODCTL receive $CROC_CODE" "" 2 + else + report_status "Croc Code Extraction" "FAILED" "Could not capture generated code. Output:\n$(cat "$CODE_OUT")" + fi + kill $SEND_PID 2>/dev/null || true + rm -f "$CODE_OUT" ci-test-file.txt + + echo "Cleaning up Pod $POD_ID..." + $RUNPODCTL pod delete $POD_ID || true + else + report_status "Pod ID Extraction" "FAILED" "Could not extract valid ID" + fi + fi + + # 2. Serverless Lifecycle + echo -e "\nTesting Serverless Lifecycle (Template: wvrr20un0l)..." + EP_NAME="ci-test-ep-$(date +%s)" + ep_out=$(mktemp) + if run_step "Serverless Create" "$RUNPODCTL serverless create --template-id wvrr20un0l --compute-type CPU --gpu-count 0 --workers-max 1 --name '$EP_NAME'" "" 3 "$ep_out"; then + EP_ID=$(jq -r '.id' "$ep_out" 2>/dev/null || true) + if [[ -z "$EP_ID" || "$EP_ID" == "null" ]]; then + echo "Error: Failed to extract Endpoint ID from create output:" + cat "$ep_out" + rm "$ep_out" + exit 1 + fi + echo "Successfully created Endpoint: $EP_ID" + rm "$ep_out" + + if [[ -n "$EP_ID" && "$EP_ID" != "null" ]]; then + echo "Waiting for Serverless endpoint $EP_ID to become available (Polling for up to 5m)..." + polling_attempt=1 + max_polling=30 # 30 attempts * 10s = 5 minutes + ready=false + + while [ $polling_attempt -le $max_polling ]; do + if $RUNPODCTL serverless get $EP_ID > /dev/null 2>&1; then + echo " Endpoint $EP_ID found! Proceeding..." + ready=true + break + fi + echo " [Poll $polling_attempt/$max_polling] Endpoint not found yet. Sleeping 10s..." + sleep 10 + polling_attempt=$((polling_attempt + 1)) + done + + if [ "$ready" = false ]; then + report_status "Serverless Propagation" "FAILED" "Endpoint $EP_ID did not appear in the API within 5 minutes." + exit 1 + fi + + run_step "Serverless Get (Final Check)" "$RUNPODCTL serverless get $EP_ID" "" 3 + # Propagation Retry: Retry every 10s for up to 2 minutes (12 attempts) + run_step "Serverless List" "$RUNPODCTL serverless list --output json | jq -e \"map(select(.id == \\\"$EP_ID\\\")) | length > 0\"" "" 12 "" 10 + + # Propagation Retry: Retry every 10s for up to 2 minutes (12 attempts) + run_step "Serverless Update" "$RUNPODCTL serverless update $EP_ID --name \"${EP_NAME}-updated\"" "" 12 "" 10 + + echo "Testing Serverless Job Submission..." + JOB_OUT=$(mktemp) + # Submission is usually fast if the endpoint 'Get' works + if run_step "Serverless Send (Job Submit)" "curl -s -X POST \"https://api.runpod.ai/v2/$EP_ID/run\" -H \"Content-Type: application/json\" -H \"Authorization: Bearer $RUNPOD_API_KEY\" -d '{\"input\": {\"test\": \"data\"}}'" "" 3 "$JOB_OUT"; then + JOB_ID=$(jq -r '.id' "$JOB_OUT") + if [[ -n "$JOB_ID" && "$JOB_ID" != "null" ]]; then + run_step "Serverless Receive (Job Status)" "curl -s -X GET \"https://api.runpod.ai/v2/$EP_ID/status/$JOB_ID\" -H \"Authorization: Bearer $RUNPOD_API_KEY\" | jq -e '.status | test(\"COMPLETED|IN_PROGRESS|IN_QUEUE|PENDING\")'" "" 5 + fi + fi + rm -f "$JOB_OUT" + + # Propagation Retry: Retry every 10s for up to 2 minutes (12 attempts) + run_step "Serverless Delete" "$RUNPODCTL serverless delete $EP_ID" "" 12 "" 10 + fi + fi +fi + +echo -e "\n${GREEN}Unified Matrix Tests Completed.${NC}" From 6a90c8c3460a3f4d9ccc93fad8c21ed8342d0b41 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:57:59 -0600 Subject: [PATCH 2/6] Migrate integration tests to Go e2e suite with parameterization and scoped cleanup --- .github/workflows/integration-tests.yml | 70 +++-- e2e/cli_lifecycle_test.go | 343 ++++++++++++++++++++++++ install.sh | 14 +- tests/integration_suite.sh | 216 --------------- 4 files changed, 395 insertions(+), 248 deletions(-) create mode 100644 e2e/cli_lifecycle_test.go delete mode 100755 tests/integration_suite.sh diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 670fe61..8532a55 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -2,10 +2,19 @@ name: Integration Test Matrix on: push: - branches: [ main, feat-ci-tests, feat-install-noroot ] + 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 }} @@ -19,51 +28,62 @@ jobs: fail-fast: false matrix: user_mode: [root, non-root] - container: - image: alpine:latest steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' - name: Install dependencies run: | - apk add --no-cache bash wget grep sed tar git coreutils jq curl + sudo apt-get update && sudo apt-get install -y wget curl coreutils jq bash tar grep sed + - name: Build runpodctl for Testing + run: | + go build -o runpodctl main.go + chmod +x runpodctl - name: Setup Non-Root User and Workspace if: matrix.user_mode == 'non-root' run: | - adduser -D -u 1000 tester - mkdir -p /tmp/runpodctl-test - cp -r . /tmp/runpodctl-test/ - chmod +x /tmp/runpodctl-test/tests/integration_suite.sh - chown -R tester:tester /tmp/runpodctl-test - - name: Run Unified Tests + sudo adduser --disabled-password --gecos "" tester + sudo mkdir -p /tmp/runpodctl-test + sudo cp -r . /tmp/runpodctl-test/ + sudo chown -R tester:tester /tmp/runpodctl-test + - name: Run Unified 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: | - chmod +x tests/integration_suite.sh if [ "${{ matrix.user_mode }}" == "root" ]; then - ./tests/integration_suite.sh + go test -tags e2e -v ./e2e/cli_lifecycle_test.go else - su tester -c "cd /tmp/runpodctl-test && ./tests/integration_suite.sh" + # Debug info + echo "Runner user: $(whoami)" + echo "Go path: $(which go)" + go version + + # 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 ./e2e/cli_lifecycle_test.go" fi - name: Post-Run Cleanup (Emergency) if: always() env: RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }} run: | - # Try to find and kill any leaked pods if runpodctl was installed - RP="" - if [ -f "/usr/local/bin/runpodctl" ]; then - RP="/usr/local/bin/runpodctl" - elif [ -f "$HOME/.local/bin/runpodctl" ]; then - RP="$HOME/.local/bin/runpodctl" - elif [ -f "/home/tester/.local/bin/runpodctl" ]; then - RP="/home/tester/.local/bin/runpodctl" + RP="./runpodctl" + if [ "${{ matrix.user_mode }}" == "non-root" ]; then + RP="/tmp/runpodctl-test/runpodctl" fi - if [ -n "$RP" ] && [ -n "$RUNPOD_API_KEY" ]; then - echo "Ensuring all CI resources are removed..." - $RP pod list --output json 2>/dev/null | jq -r '.[].id' | xargs -I {} $RP pod delete {} || true - $RP serverless list --output json 2>/dev/null | jq -r '.[].id' | xargs -I {} $RP serverless delete {} || true + 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 fi diff --git a/e2e/cli_lifecycle_test.go b/e2e/cli_lifecycle_test.go new file mode 100644 index 0000000..397b382 --- /dev/null +++ b/e2e/cli_lifecycle_test.go @@ -0,0 +1,343 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "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 +} + +// HELPER: execute the runpodctl binary +func runCLI(args ...string) (string, error) { + // Find binary in path (assume it was installed or built locally) + // We'll prefer a local build or the installed binary + var binaryPath string + + // Fallbacks + 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 { + binaryPath = p + break + } + } + + if binaryPath == "" { + return "", fmt.Errorf("runpodctl binary not found in PATH or standard locations") + } + + // Sanitize the command echo to hide keys in arguments if any + cmdStr := fmt.Sprintf("%s %s", binaryPath, strings.Join(args, " ")) + fmt.Printf("DEBUG: Executing: %s\n", redactSensitive(cmdStr)) + + 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 := runCLI( + "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) + + // Defer cleanup to run even if test fails + defer func() { + t.Logf("Cleaning up pod %s...", podID) + _, delErr := runCLI("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 := runCLI("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 := runCLI("pod", "get", podID, "--output", "json") + if getErr != nil { + t.Errorf("Failed to get pod: %v\nOutput: %s", getErr, getOut) + } + + // Update Pod + newName := podName + "-updated" + t.Logf("Updating pod name to %s...", newName) + updateOut, updateErr := runCLI("pod", "update", podID, "--name", newName) + if updateErr != nil { + t.Errorf("Failed to update pod: %v\nOutput: %s", updateErr, updateOut) + } + + // Stop Pod + t.Logf("Stopping pod...") + stopOut, stopErr := runCLI("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 := runCLI("pod", "start", podID) + if startErr != nil { + t.Errorf("Failed to start pod: %v\nOutput: %s", startErr, startOut) + } + + // Test Croc File Transfer (Send/Receive) + t.Logf("Testing croc file transfer...") + testFileName := "ci-test-file.txt" + testFileContent := "v1.14.15-ci-test" + os.WriteFile(testFileName, []byte(testFileContent), 0644) + defer os.Remove(testFileName) + + // Start send in background + // We use the binary directly here because runCLI blocks + var binaryPath string + for _, p := range []string{"runpodctl", "../runpodctl", os.ExpandEnv("$HOME/.local/bin/runpodctl"), "/usr/local/bin/runpodctl"} { + if _, err := exec.LookPath(p); err == nil { + binaryPath = p + break + } + } + + if binaryPath != "" { + sendCmd := exec.Command(binaryPath, "send", testFileName) + var sendOut bytes.Buffer + sendCmd.Stdout = &sendOut + sendCmd.Stderr = &sendOut + + err := sendCmd.Start() + if err == nil { + 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() + // Basic extract: look for the format [word]-[word]-[word]-[number] or similar + // runpodctl prints "Code is: ..." + if strings.Contains(outStr, " ") { + lines := strings.Split(outStr, "\n") + for _, l := range lines { + if strings.HasPrefix(strings.TrimSpace(l), "Code") || len(strings.Split(l, "-")) >= 2 { + // just attempt to grab the last token + tokens := strings.Fields(l) + if len(tokens) > 0 { + possible := tokens[len(tokens)-1] + if strings.Contains(possible, "-") { + crocCode = possible + 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") + os.MkdirAll(recvDir, 0755) + 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.Logf("Warning: 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()) + + t.Logf("Creating serverless endpoint %s with image %s", epName, slsImage) + + // For Serverless, current CLI requires a template-id. + // The user mentioned bwf8egptou/wvrr20un0l as their previous templates. + // We will use wvrr20un0l as a default if none provided. + slsTemplate := getEnvOrDefault("RUNPOD_TEST_SERVERLESS_TEMPLATE_ID", "wvrr20un0l") + + out, err := runCLI( + "serverless", "create", + "--name", epName, + "--template-id", slsTemplate, + "--workers-max", "1", + "--gpu-count", "0", + "--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) + + defer func() { + t.Logf("Cleaning up endpoint %s...", epID) + _, delErr := runCLI("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 := runCLI("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 + listOut, listErr := runCLI("serverless", "list", "--output", "json") + if listErr != nil { + t.Errorf("Failed to list endpoints: %v\nOutput: %s", listErr, listOut) + } else if !strings.Contains(listOut, epID) { + t.Errorf("Endpoint ID %s not found in list output", epID) + } + + // Update + newName := epName + "-updated" + t.Logf("Updating endpoint name to %s...", newName) + updateOut, updateErr := runCLI("serverless", "update", epID, "--name", newName) + if updateErr != nil { + t.Errorf("Failed to update serverless endpoint: %v\nOutput: %s", updateErr, updateOut) + } +} diff --git a/install.sh b/install.sh index 599f02e..257639b 100644 --- a/install.sh +++ b/install.sh @@ -24,10 +24,10 @@ detect_install_dir() { INSTALL_DIR="/usr/local/bin" else # Tiered Path Discovery: Prefer directories already in PATH - local preferred_dirs=("$HOME/.local/bin" "$HOME/bin" "$HOME/.bin") + local preferred_dirs="$HOME/.local/bin $HOME/bin $HOME/.bin" INSTALL_DIR="" - for dir in "${preferred_dirs[@]}"; do + for dir in $preferred_dirs; do if [[ ":$PATH:" == *":$dir:"* ]] && ([ -d "$dir" ] && [ -w "$dir" ]); then INSTALL_DIR="$dir" break @@ -36,7 +36,7 @@ detect_install_dir() { # If none found in PATH, check if they exist and are writable if [ -z "$INSTALL_DIR" ]; then - for dir in "${preferred_dirs[@]}"; do + for dir in $preferred_dirs; do if [ -d "$dir" ] && [ -w "$dir" ]; then INSTALL_DIR="$dir" break @@ -91,15 +91,15 @@ install_with_brew() { # ------------------------- Install Required Packages ------------------------ # check_system_requirements() { - local missing_pkgs=() + local missing_pkgs="" for cmd in wget tar grep sed; do if ! command -v "$cmd" >/dev/null 2>&1; then - missing_pkgs+=("$cmd") + missing_pkgs="$missing_pkgs $cmd" fi done - if [ ${#missing_pkgs[@]} -ne 0 ]; then - echo "Error: Missing required commands: ${missing_pkgs[*]}" + if [ -n "$missing_pkgs" ]; then + echo "Error: Missing required commands: $missing_pkgs" exit 1 fi } diff --git a/tests/integration_suite.sh b/tests/integration_suite.sh deleted file mode 100755 index f1ef34f..0000000 --- a/tests/integration_suite.sh +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env bash -# integration_suite.sh: Verifies installation and exercises full-feature integration matrix. -# Reports: PASSED, FAILED, or EXPECTED_FAIL (for missing privileges/secrets). - -set -e - -# Colors for output -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -NC='\033[0m' - -echo -e "${GREEN}Starting Unified Installation & Feature Matrix Tests...${NC}" - -# --- Dependencies --- -for cmd in jq curl wget tar grep sed; do - if ! command -v "$cmd" >/dev/null 2>&1; then - echo "Error: Missing required command: $cmd" - exit 1 - fi -done - -# --- Helpers --- - -report_status() { - local name=$1 - local status=$2 - local msg=$3 - case "$status" in - "PASSED") echo -e " [${GREEN}PASSED${NC}] $name $msg" ;; - "FAILED") echo -e " [${RED}FAILED${NC}] $name $msg"; return 1 ;; - "EXPECTED_FAIL") echo -e " [${YELLOW}EXPECTED_FAIL${NC}] $name $msg" ;; - esac -} - -run_step() { - local name=$1 - local cmd=$2 - local expect_fail_cond=$3 - local max_attempts=${4:-1} # Default to 1 attempt unless specified - local target_output=$5 # Optional: specific file to capture output - local retry_delay=${6:-5} # Optional: delay between retries - local attempt=1 - local output_file=${target_output:-$(mktemp)} - - while [ $attempt -le $max_attempts ]; do - if eval "$cmd" > "$output_file" 2>&1; then - report_status "$name" "PASSED" - # Only remove if it's a generic temp file, not a requested capture - if [ -z "$target_output" ]; then rm "$output_file"; fi - return 0 - fi - - # Check for expected failure - if [[ -n "$expect_fail_cond" ]] && eval "$expect_fail_cond"; then - report_status "$name" "EXPECTED_FAIL" "(Environment limitation)" - if [ -z "$target_output" ]; then rm "$output_file"; fi - return 0 - fi - - if [ $attempt -lt $max_attempts ]; then - echo " [RETRY] $name failed (Attempt $attempt/$max_attempts). Retrying in ${retry_delay}s..." - sleep "$retry_delay" - attempt=$((attempt + 1)) - else - report_status "$name" "FAILED" "\nOutput:\n$(cat "$output_file")" - if [ -z "$target_output" ]; then rm "$output_file"; fi - return 1 - fi - done -} - -# --- Phase 1: Installation Validation --- -echo -e "\n${GREEN}>> Phase 1: Installation Validation${NC}" - -if [ "$(id -u)" -eq 0 ]; then - echo "Environment: Root" - run_step "Root Install (/usr/local/bin)" "bash ./install.sh" - run_step "Binary Execution (version)" "runpodctl version" -else - # Detect if the installer actually supports non-root - HAS_NONROOT_SUPPORT=$(grep "detect_install_dir" ./install.sh || true) - - mkdir -p "$HOME/.local/bin" - export PATH="$HOME/.local/bin:$PATH" - - run_step "User-space Install (~/.local/bin)" "bash ./install.sh" "[[ -z \"$HAS_NONROOT_SUPPORT\" ]]" - - if [[ -f "$HOME/.local/bin/runpodctl" ]]; then - run_step "Binary Execution (version)" "$HOME/.local/bin/runpodctl version" - fi -fi - -# --- Phase 2: Feature Integration Audit (RunPod API) --- -echo -e "\n${GREEN}>> Phase 2: Feature Integration Audit (RunPod API)${NC}" - -if [[ -z "$RUNPOD_API_KEY" ]]; then - report_status "API Integration" "EXPECTED_FAIL" "(RUNPOD_API_KEY missing)" -else - # Determine binary path - RUNPODCTL="runpodctl" - if [ "$(id -u)" -ne 0 ] && [[ -f "$HOME/.local/bin/runpodctl" ]]; then - RUNPODCTL="$HOME/.local/bin/runpodctl" - fi - - # 1. Pod Management Lifecycle - echo "Testing Pod Lifecycle (Template: bwf8egptou)..." - POD_NAME="ci-test-pod-$(date +%s)" - - create_out=$(mktemp) - if run_step "Pod Create" "$RUNPODCTL pod create --template-id bwf8egptou --compute-type CPU --name $POD_NAME" "" 3 "$create_out"; then - POD_ID=$(jq -r '.id' "$create_out" || true) - rm "$create_out" - - if [[ -n "$POD_ID" && "$POD_ID" != "null" ]]; then - echo "Waiting for Pod API propagation (5s)..." - sleep 5 - # Propagation Retry: Retry every 10s for up to 2 minutes (12 attempts) - run_step "Pod List" "$RUNPODCTL pod list --output json | jq -e \"map(select(.id == \\\"$POD_ID\\\")) | length > 0\"" "" 12 "" 10 - run_step "Pod Get" "$RUNPODCTL pod get $POD_ID" "" 3 - run_step "Pod Update" "$RUNPODCTL pod update $POD_ID --name \"${POD_NAME}-updated\"" "" 3 - run_step "Pod Stop" "$RUNPODCTL pod stop $POD_ID" "" 3 - run_step "Pod Start" "$RUNPODCTL pod start $POD_ID" "" 3 - - # Pod Data-Plane (Send/Receive) - echo "v1.14.15-ci-test" > ci-test-file.txt - CODE_OUT=$(mktemp) - echo "Starting Pod Send..." - $RUNPODCTL send ci-test-file.txt > "$CODE_OUT" 2>&1 & - SEND_PID=$! - - CROC_CODE="" - for i in {1..30}; do - CROC_CODE=$(grep -oE '[a-z0-9-]+-[0-9]+$' "$CODE_OUT" | head -n 1 || true) - if [[ -n "$CROC_CODE" ]]; then break; fi - sleep 1 - done - - if [[ -n "$CROC_CODE" ]]; then - echo "Captured Croc Code: $CROC_CODE" - run_step "Pod Receive" "$RUNPODCTL receive $CROC_CODE" "" 2 - else - report_status "Croc Code Extraction" "FAILED" "Could not capture generated code. Output:\n$(cat "$CODE_OUT")" - fi - kill $SEND_PID 2>/dev/null || true - rm -f "$CODE_OUT" ci-test-file.txt - - echo "Cleaning up Pod $POD_ID..." - $RUNPODCTL pod delete $POD_ID || true - else - report_status "Pod ID Extraction" "FAILED" "Could not extract valid ID" - fi - fi - - # 2. Serverless Lifecycle - echo -e "\nTesting Serverless Lifecycle (Template: wvrr20un0l)..." - EP_NAME="ci-test-ep-$(date +%s)" - ep_out=$(mktemp) - if run_step "Serverless Create" "$RUNPODCTL serverless create --template-id wvrr20un0l --compute-type CPU --gpu-count 0 --workers-max 1 --name '$EP_NAME'" "" 3 "$ep_out"; then - EP_ID=$(jq -r '.id' "$ep_out" 2>/dev/null || true) - if [[ -z "$EP_ID" || "$EP_ID" == "null" ]]; then - echo "Error: Failed to extract Endpoint ID from create output:" - cat "$ep_out" - rm "$ep_out" - exit 1 - fi - echo "Successfully created Endpoint: $EP_ID" - rm "$ep_out" - - if [[ -n "$EP_ID" && "$EP_ID" != "null" ]]; then - echo "Waiting for Serverless endpoint $EP_ID to become available (Polling for up to 5m)..." - polling_attempt=1 - max_polling=30 # 30 attempts * 10s = 5 minutes - ready=false - - while [ $polling_attempt -le $max_polling ]; do - if $RUNPODCTL serverless get $EP_ID > /dev/null 2>&1; then - echo " Endpoint $EP_ID found! Proceeding..." - ready=true - break - fi - echo " [Poll $polling_attempt/$max_polling] Endpoint not found yet. Sleeping 10s..." - sleep 10 - polling_attempt=$((polling_attempt + 1)) - done - - if [ "$ready" = false ]; then - report_status "Serverless Propagation" "FAILED" "Endpoint $EP_ID did not appear in the API within 5 minutes." - exit 1 - fi - - run_step "Serverless Get (Final Check)" "$RUNPODCTL serverless get $EP_ID" "" 3 - # Propagation Retry: Retry every 10s for up to 2 minutes (12 attempts) - run_step "Serverless List" "$RUNPODCTL serverless list --output json | jq -e \"map(select(.id == \\\"$EP_ID\\\")) | length > 0\"" "" 12 "" 10 - - # Propagation Retry: Retry every 10s for up to 2 minutes (12 attempts) - run_step "Serverless Update" "$RUNPODCTL serverless update $EP_ID --name \"${EP_NAME}-updated\"" "" 12 "" 10 - - echo "Testing Serverless Job Submission..." - JOB_OUT=$(mktemp) - # Submission is usually fast if the endpoint 'Get' works - if run_step "Serverless Send (Job Submit)" "curl -s -X POST \"https://api.runpod.ai/v2/$EP_ID/run\" -H \"Content-Type: application/json\" -H \"Authorization: Bearer $RUNPOD_API_KEY\" -d '{\"input\": {\"test\": \"data\"}}'" "" 3 "$JOB_OUT"; then - JOB_ID=$(jq -r '.id' "$JOB_OUT") - if [[ -n "$JOB_ID" && "$JOB_ID" != "null" ]]; then - run_step "Serverless Receive (Job Status)" "curl -s -X GET \"https://api.runpod.ai/v2/$EP_ID/status/$JOB_ID\" -H \"Authorization: Bearer $RUNPOD_API_KEY\" | jq -e '.status | test(\"COMPLETED|IN_PROGRESS|IN_QUEUE|PENDING\")'" "" 5 - fi - fi - rm -f "$JOB_OUT" - - # Propagation Retry: Retry every 10s for up to 2 minutes (12 attempts) - run_step "Serverless Delete" "$RUNPODCTL serverless delete $EP_ID" "" 12 "" 10 - fi - fi -fi - -echo -e "\n${GREEN}Unified Matrix Tests Completed.${NC}" From 581c6426dbd88b342f4141ab8016a01e609ee53a Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:34:09 -0600 Subject: [PATCH 3/6] implement Sourcery suggestions --- e2e/cli_lifecycle_test.go | 217 ++++++++++++++++++++++++++------------ install.sh | 17 ++- 2 files changed, 163 insertions(+), 71 deletions(-) diff --git a/e2e/cli_lifecycle_test.go b/e2e/cli_lifecycle_test.go index 397b382..9ed82d8 100644 --- a/e2e/cli_lifecycle_test.go +++ b/e2e/cli_lifecycle_test.go @@ -162,7 +162,15 @@ func TestE2E_CLILifecycle_Pod(t *testing.T) { t.Logf("Getting pod details...") getOut, getErr := runCLI("pod", "get", podID, "--output", "json") if getErr != nil { - t.Errorf("Failed to get pod: %v\nOutput: %s", getErr, getOut) + 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 @@ -170,7 +178,20 @@ func TestE2E_CLILifecycle_Pod(t *testing.T) { t.Logf("Updating pod name to %s...", newName) updateOut, updateErr := runCLI("pod", "update", podID, "--name", newName) if updateErr != nil { - t.Errorf("Failed to update pod: %v\nOutput: %s", updateErr, updateOut) + t.Fatalf("Failed to update pod: %v\nOutput: %s", updateErr, updateOut) + } + + // Verify update + getOutUpdated, getErrUpdated := runCLI("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 @@ -188,77 +209,89 @@ func TestE2E_CLILifecycle_Pod(t *testing.T) { } // Test Croc File Transfer (Send/Receive) - t.Logf("Testing croc file transfer...") - testFileName := "ci-test-file.txt" - testFileContent := "v1.14.15-ci-test" - os.WriteFile(testFileName, []byte(testFileContent), 0644) - defer os.Remove(testFileName) - - // Start send in background - // We use the binary directly here because runCLI blocks - var binaryPath string - for _, p := range []string{"runpodctl", "../runpodctl", os.ExpandEnv("$HOME/.local/bin/runpodctl"), "/usr/local/bin/runpodctl"} { - if _, err := exec.LookPath(p); err == nil { - binaryPath = p - break + 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 + // We use the binary directly here because runCLI blocks + var binaryPath string + for _, p := range []string{"runpodctl", "../runpodctl", os.ExpandEnv("$HOME/.local/bin/runpodctl"), "/usr/local/bin/runpodctl"} { + if _, err := exec.LookPath(p); err == nil { + binaryPath = p + break + } + } + + if binaryPath == "" { + t.Fatalf("RUNPOD_E2E_TEST_CROC is set but runpodctl binary was not found in any of the expected paths") } - } - if binaryPath != "" { sendCmd := exec.Command(binaryPath, "send", testFileName) var sendOut bytes.Buffer sendCmd.Stdout = &sendOut sendCmd.Stderr = &sendOut - err := sendCmd.Start() - if err == nil { - 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() - // Basic extract: look for the format [word]-[word]-[word]-[number] or similar - // runpodctl prints "Code is: ..." - if strings.Contains(outStr, " ") { - lines := strings.Split(outStr, "\n") - for _, l := range lines { - if strings.HasPrefix(strings.TrimSpace(l), "Code") || len(strings.Split(l, "-")) >= 2 { - // just attempt to grab the last token - tokens := strings.Fields(l) - if len(tokens) > 0 { - possible := tokens[len(tokens)-1] - if strings.Contains(possible, "-") { - crocCode = possible - break - } + 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() + // Basic extract: look for the format [word]-[word]-[word]-[number] or similar + // runpodctl prints "Code is: ..." + if strings.Contains(outStr, " ") { + lines := strings.Split(outStr, "\n") + for _, l := range lines { + if strings.HasPrefix(strings.TrimSpace(l), "Code") || len(strings.Split(l, "-")) >= 2 { + // just attempt to grab the last token + tokens := strings.Fields(l) + if len(tokens) > 0 { + possible := tokens[len(tokens)-1] + if strings.Contains(possible, "-") { + crocCode = possible + 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") - os.MkdirAll(recvDir, 0755) - 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.Logf("Warning: Could not extract croc code in time. Send output: %s", sendOut.String()) + 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()) } } } @@ -325,19 +358,71 @@ func TestE2E_CLILifecycle_Serverless(t *testing.T) { t.Logf("Endpoint is ready and propagated.") - // List - listOut, listErr := runCLI("serverless", "list", "--output", "json") + // List endpoints and assert the created endpoint exists + listOutRaw, listErr := runCLI("serverless", "list", "--output", "json") if listErr != nil { - t.Errorf("Failed to list endpoints: %v\nOutput: %s", listErr, listOut) - } else if !strings.Contains(listOut, epID) { - t.Errorf("Endpoint ID %s not found in list output", epID) + 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"` } - // Update + 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 := runCLI("serverless", "update", epID, "--name", newName) if updateErr != nil { - t.Errorf("Failed to update serverless endpoint: %v\nOutput: %s", updateErr, updateOut) + t.Fatalf("Failed to update serverless endpoint: %v\nOutput: %s", updateErr, updateOut) + } + + // Get endpoint and assert the name was updated + getOutRaw, getErr := runCLI("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) } } diff --git a/install.sh b/install.sh index 257639b..4e163f6 100644 --- a/install.sh +++ b/install.sh @@ -108,12 +108,19 @@ check_system_requirements() { fetch_latest_version() { local version_url="https://api.github.com/repos/runpod/runpodctl/releases/latest" # Using grep/sed instead of jq for zero-dependency parsing - VERSION=$(wget -q -O- "$version_url" | grep '"tag_name":' | sed -E 's/.*"tag_name": "([^"]+)".*/\1/') + # - Restrict to the first matching tag_name line + # - Expect the canonical JSON indentation for the field + VERSION=$(wget -q -O- "$version_url" | grep -m1 '^ "tag_name":' | sed -E 's/^[^"]*"tag_name": "([^"]+)".*/\1/') - if [ -z "$VERSION" ]; then - echo "Failed to fetch the latest version of runpodctl." - exit 1 - fi + # 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" } From 47c615a7125b9b34d1dd4a000755526bccbe97c3 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:38:11 -0600 Subject: [PATCH 4/6] refactor: address review feedback for installer and E2E tests --- .github/workflows/integration-tests.yml | 4 +- e2e/cli_lifecycle_test.go | 150 +++++++++++++----------- install.sh | 42 +++++-- 3 files changed, 117 insertions(+), 79 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 8532a55..20deafb 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -18,7 +18,7 @@ on: concurrency: group: integration-tests-${{ github.ref_name }} - cancel-in-progress: false + cancel-in-progress: true jobs: test-matrix: @@ -36,7 +36,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.22.x' - name: Install dependencies run: | sudo apt-get update && sudo apt-get install -y wget curl coreutils jq bash tar grep sed diff --git a/e2e/cli_lifecycle_test.go b/e2e/cli_lifecycle_test.go index 9ed82d8..4475ccf 100644 --- a/e2e/cli_lifecycle_test.go +++ b/e2e/cli_lifecycle_test.go @@ -1,5 +1,9 @@ //go:build e2e +// Author: FNGarvin +// License: MIT +// Year: 2026 + package e2e import ( @@ -37,13 +41,8 @@ func getEnvOrDefault(key, fallback string) string { return fallback } -// HELPER: execute the runpodctl binary -func runCLI(args ...string) (string, error) { - // Find binary in path (assume it was installed or built locally) - // We'll prefer a local build or the installed binary - var binaryPath string - - // Fallbacks +// findBinaryPath searches for the runpodctl binary in standard locations +func findBinaryPath() (string, error) { pathsToTry := []string{ "./runpodctl", "../runpodctl", @@ -54,32 +53,37 @@ func runCLI(args ...string) (string, error) { for _, p := range pathsToTry { if _, err := exec.LookPath(p); err == nil { - binaryPath = p - break + return p, nil } } + return "", fmt.Errorf("runpodctl binary not found in PATH or standard locations") +} - if binaryPath == "" { - 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, " ")) - fmt.Printf("DEBUG: Executing: %s\n", redactSensitive(cmdStr)) + // cmdStr := fmt.Sprintf("%s %s", binaryPath, strings.Join(args, " ")) + // Using fmt.Printf here for immediate visibility in CI, but t.Logf is preferred within tests. + // We'll update calls to pass *testing.T where possible. cmd := exec.Command(binaryPath, args...) var out bytes.Buffer cmd.Stdout = &out cmd.Stderr = &out - err := cmd.Run() + 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, "}") @@ -101,7 +105,6 @@ func extractIDField(jsonOutput string) (string, error) { 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") @@ -116,7 +119,7 @@ func TestE2E_CLILifecycle_Pod(t *testing.T) { t.Logf("Creating pod %s with image %s", podName, podImage) // Create Pod - out, err := runCLI( + out, err := runE2ECmd( "pod", "create", "--name", podName, "--image", podImage, @@ -124,7 +127,7 @@ func TestE2E_CLILifecycle_Pod(t *testing.T) { "--compute-type", "CPU", "--output", "json", ) - + if err != nil { t.Fatalf("Failed to create pod: %v\nOutput: %s", err, out) } @@ -135,23 +138,23 @@ func TestE2E_CLILifecycle_Pod(t *testing.T) { } t.Logf("Created Pod ID: %s", podID) - // Defer cleanup to run even if test fails - defer func() { + // Register cleanup to run even if test fails + t.Cleanup(func() { t.Logf("Cleaning up pod %s...", podID) - _, delErr := runCLI("pod", "delete", 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 := runCLI("pod", "list", "--output", "json") + 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) { @@ -160,7 +163,7 @@ func TestE2E_CLILifecycle_Pod(t *testing.T) { // Get Pod t.Logf("Getting pod details...") - getOut, getErr := runCLI("pod", "get", podID, "--output", "json") + getOut, getErr := runE2ECmd("pod", "get", podID, "--output", "json") if getErr != nil { t.Fatalf("Failed to get pod: %v\nOutput: %s", getErr, getOut) } @@ -176,13 +179,13 @@ func TestE2E_CLILifecycle_Pod(t *testing.T) { // Update Pod newName := podName + "-updated" t.Logf("Updating pod name to %s...", newName) - updateOut, updateErr := runCLI("pod", "update", podID, "--name", 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 := runCLI("pod", "get", podID, "--output", "json") + getOutUpdated, getErrUpdated := runE2ECmd("pod", "get", podID, "--output", "json") if getErrUpdated != nil { t.Fatalf("Failed to get updated pod: %v\nOutput: %s", getErrUpdated, getOutUpdated) } @@ -196,14 +199,14 @@ func TestE2E_CLILifecycle_Pod(t *testing.T) { // Stop Pod t.Logf("Stopping pod...") - stopOut, stopErr := runCLI("pod", "stop", podID) + 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 := runCLI("pod", "start", podID) + startOut, startErr := runE2ECmd("pod", "start", podID) if startErr != nil { t.Errorf("Failed to start pod: %v\nOutput: %s", startErr, startOut) } @@ -223,24 +226,16 @@ func TestE2E_CLILifecycle_Pod(t *testing.T) { defer os.Remove(testFileName) // Start send in background - // We use the binary directly here because runCLI blocks - var binaryPath string - for _, p := range []string{"runpodctl", "../runpodctl", os.ExpandEnv("$HOME/.local/bin/runpodctl"), "/usr/local/bin/runpodctl"} { - if _, err := exec.LookPath(p); err == nil { - binaryPath = p - break - } - } - - if binaryPath == "" { - t.Fatalf("RUNPOD_E2E_TEST_CROC is set but runpodctl binary was not found in any of the expected paths") + 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) } @@ -250,20 +245,16 @@ func TestE2E_CLILifecycle_Pod(t *testing.T) { var crocCode string for i := 0; i < 15; i++ { outStr := sendOut.String() - // Basic extract: look for the format [word]-[word]-[word]-[number] or similar - // runpodctl prints "Code is: ..." + // Robust exact-match extraction: parse the exact instruction string if strings.Contains(outStr, " ") { lines := strings.Split(outStr, "\n") for _, l := range lines { - if strings.HasPrefix(strings.TrimSpace(l), "Code") || len(strings.Split(l, "-")) >= 2 { - // just attempt to grab the last token - tokens := strings.Fields(l) + 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 { - possible := tokens[len(tokens)-1] - if strings.Contains(possible, "-") { - crocCode = possible - break - } + crocCode = tokens[0] + break } } } @@ -283,7 +274,7 @@ func TestE2E_CLILifecycle_Pod(t *testing.T) { 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() @@ -302,20 +293,44 @@ func TestE2E_CLILifecycle_Serverless(t *testing.T) { } slsImage := getEnvOrDefault("RUNPOD_TEST_SERVERLESS_IMAGE", defaultServerlessImage) - epName := fmt.Sprintf("ci-test-ep-%d", time.Now().Unix()) - t.Logf("Creating serverless endpoint %s with image %s", epName, slsImage) + // 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) - // For Serverless, current CLI requires a template-id. - // The user mentioned bwf8egptou/wvrr20un0l as their previous templates. - // We will use wvrr20un0l as a default if none provided. - slsTemplate := getEnvOrDefault("RUNPOD_TEST_SERVERLESS_TEMPLATE_ID", "wvrr20un0l") + 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) + } - out, err := runCLI( + 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", slsTemplate, + "--template-id", tplID, "--workers-max", "1", "--gpu-count", "0", "--output", "json", @@ -331,20 +346,21 @@ func TestE2E_CLILifecycle_Serverless(t *testing.T) { } t.Logf("Created Endpoint ID: %s", epID) - defer func() { + // Register endpoint cleanup + t.Cleanup(func() { t.Logf("Cleaning up endpoint %s...", epID) - _, delErr := runCLI("serverless", "delete", 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 := runCLI("serverless", "get", epID) + _, getErr := runE2ECmd("serverless", "get", epID) if getErr == nil { ready = true break @@ -359,7 +375,7 @@ func TestE2E_CLILifecycle_Serverless(t *testing.T) { t.Logf("Endpoint is ready and propagated.") // List endpoints and assert the created endpoint exists - listOutRaw, listErr := runCLI("serverless", "list", "--output", "json") + listOutRaw, listErr := runE2ECmd("serverless", "list", "--output", "json") if listErr != nil { t.Fatalf("Failed to list endpoints: %v\nOutput: %s", listErr, listOutRaw) } @@ -396,13 +412,13 @@ func TestE2E_CLILifecycle_Serverless(t *testing.T) { // Update endpoint name newName := epName + "-updated" t.Logf("Updating endpoint name to %s...", newName) - updateOut, updateErr := runCLI("serverless", "update", epID, "--name", 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 := runCLI("serverless", "get", epID, "--output", "json") + getOutRaw, getErr := runE2ECmd("serverless", "get", epID, "--output", "json") if getErr != nil { t.Fatalf("Failed to get serverless endpoint: %v\nOutput: %s", getErr, getOutRaw) } @@ -426,3 +442,5 @@ func TestE2E_CLILifecycle_Serverless(t *testing.T) { t.Fatalf("Expected endpoint name starting with %s after update, got %s", newName, updatedEp.Name) } } + +//EOF cli_lifecycle_test.go diff --git a/install.sh b/install.sh index 4e163f6..a791306 100644 --- a/install.sh +++ b/install.sh @@ -1,5 +1,9 @@ #!/usr/bin/env bash +# Author: FNGarvin +# License: MIT +# Year: 2026 + # Unified Installer for RunPod CLI Tool # # This script provides a unified approach to installing the RunPod CLI tool. @@ -50,14 +54,27 @@ detect_install_dir() { mkdir -p "$INSTALL_DIR" fi - echo "┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓" - echo "┃ USER-SPACE INSTALLATION DETECTED ┃" - echo "┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫" - echo "┃ Target: $INSTALL_DIR ┃" - echo "┃ ┃" - echo "┃ To install for ALL USERS (requires root), please run: ┃" - echo "┃ sudo bash <(wget -qO- cli.runpod.io) # or curl -sL ┃" - echo "┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" + # 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 @@ -108,9 +125,8 @@ check_system_requirements() { fetch_latest_version() { local version_url="https://api.github.com/repos/runpod/runpodctl/releases/latest" # Using grep/sed instead of jq for zero-dependency parsing - # - Restrict to the first matching tag_name line - # - Expect the canonical JSON indentation for the field - VERSION=$(wget -q -O- "$version_url" | grep -m1 '^ "tag_name":' | sed -E 's/^[^"]*"tag_name": "([^"]+)".*/\1/') + # - 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 @@ -206,6 +222,8 @@ download_and_install_cli() { if ! mv "$cli_file_name" "$INSTALL_DIR/"; then echo "Failed to move $cli_file_name to $INSTALL_DIR/." + echo "Removing extracted binary at '$(pwd)/$cli_file_name' to avoid leaving stray files behind." + rm -f "$cli_file_name" exit 1 fi echo "runpodctl installed successfully to $INSTALL_DIR." @@ -228,3 +246,5 @@ check_system_requirements fetch_latest_version download_url_constructor download_and_install_cli + +#EOF install.sh From 5784cc8e8adb69d21995afc97f4fcfb39f8bf3f2 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Thu, 5 Mar 2026 08:34:56 -0600 Subject: [PATCH 5/6] Refine installer for hermeticity and improve CI test coverage --- .github/workflows/integration-tests.yml | 63 +++++++++++----- .gitignore | 3 + e2e/cli_lifecycle_test.go | 96 +++++++++++++++++++++++-- install.sh | 52 ++++++++------ 4 files changed, 170 insertions(+), 44 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 20deafb..a4b2eec 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -36,40 +36,68 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.22.x' + 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: Build runpodctl for Testing + - 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 - - name: Setup Non-Root User and Workspace - if: matrix.user_mode == 'non-root' - run: | - sudo adduser --disabled-password --gecos "" tester - sudo mkdir -p /tmp/runpodctl-test - sudo cp -r . /tmp/runpodctl-test/ - sudo chown -R tester:tester /tmp/runpodctl-test - - name: Run Unified Go E2E Tests + + # 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 ./e2e/cli_lifecycle_test.go + go test -tags e2e -v -run "$TEST_PATTERN" ./e2e/... else - # Debug info - echo "Runner user: $(whoami)" - echo "Go path: $(which go)" - go version - # 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 ./e2e/cli_lifecycle_test.go" + bash -c "cd /tmp/runpodctl-test && go test -tags e2e -v -run \"$TEST_PATTERN\" ./e2e/..." fi - name: Post-Run Cleanup (Emergency) if: always() @@ -86,4 +114,5 @@ jobs: # 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 index 4475ccf..3434d19 100644 --- a/e2e/cli_lifecycle_test.go +++ b/e2e/cli_lifecycle_test.go @@ -1,15 +1,12 @@ //go:build e2e -// Author: FNGarvin -// License: MIT -// Year: 2026 - package e2e import ( "bytes" "encoding/json" "fmt" + "net/http" "os" "os/exec" "path/filepath" @@ -68,8 +65,6 @@ func runE2ECmd(args ...string) (string, error) { // Sanitize the command echo to hide keys in arguments if any // cmdStr := fmt.Sprintf("%s %s", binaryPath, strings.Join(args, " ")) - // Using fmt.Printf here for immediate visibility in CI, but t.Logf is preferred within tests. - // We'll update calls to pass *testing.T where possible. cmd := exec.Command(binaryPath, args...) var out bytes.Buffer @@ -331,8 +326,10 @@ func TestE2E_CLILifecycle_Serverless(t *testing.T) { "serverless", "create", "--name", epName, "--template-id", tplID, + "--workers-min", "1", "--workers-max", "1", "--gpu-count", "0", + "--compute-type", "CPU", "--output", "json", ) @@ -441,6 +438,93 @@ func TestE2E_CLILifecycle_Serverless(t *testing.T) { 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 a791306..f5ad3d2 100644 --- a/install.sh +++ b/install.sh @@ -1,9 +1,5 @@ #!/usr/bin/env bash -# Author: FNGarvin -# License: MIT -# Year: 2026 - # Unified Installer for RunPod CLI Tool # # This script provides a unified approach to installing the RunPod CLI tool. @@ -109,6 +105,7 @@ install_with_brew() { # ------------------------- Install Required Packages ------------------------ # check_system_requirements() { 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" @@ -189,12 +186,35 @@ try_brew_install() { # ---------------------------- 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" 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 --progress=bar "$url" -O "$cli_archive_file_name"; then + if wget $wget_progress_flag "$url" -O "$tmp_dir/$cli_archive_file_name"; then success=true break fi @@ -206,24 +226,14 @@ download_and_install_cli() { fi local cli_file_name="runpodctl" - tar -xzf "$cli_archive_file_name" "$cli_file_name" || { echo "Failed to extract $cli_file_name."; exit 1; } - # Clean up the downloaded archive after successful extraction - rm -f "$cli_archive_file_name" - - chmod +x "$cli_file_name" - - # Ensure the install directory is writable before attempting to move the binary - if [ ! -w "$INSTALL_DIR" ]; then - echo "Install directory '$INSTALL_DIR' is not writable. Please run with appropriate permissions or choose a different install directory." - # Clean up the extracted binary to avoid leaving stray files behind - rm -f "$cli_file_name" - exit 1 - fi + # 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" - if ! mv "$cli_file_name" "$INSTALL_DIR/"; then + # 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/." - echo "Removing extracted binary at '$(pwd)/$cli_file_name' to avoid leaving stray files behind." - rm -f "$cli_file_name" exit 1 fi echo "runpodctl installed successfully to $INSTALL_DIR." From 164d0dd01e8d4ef56291587c3eda1a7cf0ebdea0 Mon Sep 17 00:00:00 2001 From: "Fred N. Garvin, Esq." <184324400+FNGarvin@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:48:51 -0500 Subject: [PATCH 6/6] removing functionality --- .github/workflows/integration-tests.yml | 6 +- e2e/cli_lifecycle_test.go | 94 ++----------------------- 2 files changed, 9 insertions(+), 91 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index a4b2eec..951c948 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -14,7 +14,7 @@ on: serverless_image: description: 'Image to test for Serverless lifecycle' required: false - default: 'fngarvin/ci-minimal-serverless@sha256:6a33a9bac95b8bc871725db9092af2922a7f1e3b63175248b2191b38be4e93a0' + default: 'alpine:latest' concurrency: group: integration-tests-${{ github.ref_name }} @@ -85,7 +85,7 @@ jobs: 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' }} + RUNPOD_TEST_SERVERLESS_IMAGE: ${{ github.event.inputs.serverless_image || 'alpine:latest' }} run: | # Use -run to ONLY execute our safe tests, but ./e2e/... to ensure package compilation TEST_PATTERN="^TestE2E_CLILifecycle" @@ -96,7 +96,7 @@ jobs: # 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' }}" \ + "RUNPOD_TEST_SERVERLESS_IMAGE=${{ github.event.inputs.serverless_image || 'alpine:latest' }}" \ bash -c "cd /tmp/runpodctl-test && go test -tags e2e -v -run \"$TEST_PATTERN\" ./e2e/..." fi - name: Post-Run Cleanup (Emergency) diff --git a/e2e/cli_lifecycle_test.go b/e2e/cli_lifecycle_test.go index 3434d19..518b1b4 100644 --- a/e2e/cli_lifecycle_test.go +++ b/e2e/cli_lifecycle_test.go @@ -6,7 +6,6 @@ import ( "bytes" "encoding/json" "fmt" - "net/http" "os" "os/exec" "path/filepath" @@ -20,7 +19,7 @@ import ( const ( defaultPodImage = "docker.io/library/alpine" defaultPodDiskSize = "5" // GB - defaultServerlessImage = "fngarvin/ci-minimal-serverless@sha256:6a33a9bac95b8bc871725db9092af2922a7f1e3b63175248b2191b38be4e93a0" + defaultServerlessImage = "alpine:latest" ) // Regex to catch standard RunPod API keys (rpa_ followed by alphanumeric) @@ -288,6 +287,11 @@ func TestE2E_CLILifecycle_Serverless(t *testing.T) { } slsImage := getEnvOrDefault("RUNPOD_TEST_SERVERLESS_IMAGE", defaultServerlessImage) + if os.Getenv("RUNPOD_TEST_SERVERLESS_IMAGE") != "" { + t.Logf("Using serverless image from environment: %s", slsImage) + } else { + t.Logf("Using default serverless image: %s", slsImage) + } epName := fmt.Sprintf("ci-test-ep-%d", time.Now().Unix()) // Step 1: Create a temporary template from the image @@ -439,92 +443,6 @@ func TestE2E_CLILifecycle_Serverless(t *testing.T) { 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