From 514bc7abc94f71d7bc749f9e91016bbff0fbf29b Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:19:25 -0800 Subject: [PATCH] feat: add Gcore cloud provider with 3 agent scripts (#1079) Add Gcore (gcore.com) as a new cloud provider supporting global edge cloud instances via REST API with hourly billing. Implements full test infrastructure including mock fixtures, URL stripping, body validation, and live recording support. - gcore/lib/common.sh: Cloud library with apikey auth, project auto-detection - gcore/claude.sh, aider.sh, goose.sh: Agent deployment scripts - manifest.json: Cloud definition + 15 matrix entries (3 implemented, 12 missing) - test/mock.sh: URL stripping for Gcore path-parameter API, body validation, synthetic responses - test/record.sh: Endpoints, auth, API caller, error detection, live cycle - test/fixtures/gcore/: 8 fixture files for mock testing Co-authored-by: OpenRouter Bot Co-authored-by: Claude Sonnet 4.5 --- gcore/README.md | 44 +++ gcore/aider.sh | 51 +++ gcore/claude.sh | 61 ++++ gcore/goose.sh | 55 +++ gcore/lib/common.sh | 457 +++++++++++++++++++++++++ manifest.json | 33 +- test/fixtures/gcore/_api_assertions.sh | 2 + test/fixtures/gcore/_env.sh | 4 + test/fixtures/gcore/_metadata.json | 11 + test/fixtures/gcore/create_server.json | 4 + test/fixtures/gcore/flavors.json | 35 ++ test/fixtures/gcore/images.json | 27 ++ test/fixtures/gcore/instances.json | 4 + test/fixtures/gcore/projects.json | 11 + test/fixtures/gcore/ssh_keys.json | 14 + test/mock.sh | 9 + test/record.sh | 68 +++- 17 files changed, 888 insertions(+), 2 deletions(-) create mode 100644 gcore/README.md create mode 100644 gcore/aider.sh create mode 100644 gcore/claude.sh create mode 100644 gcore/goose.sh create mode 100644 gcore/lib/common.sh create mode 100644 test/fixtures/gcore/_api_assertions.sh create mode 100644 test/fixtures/gcore/_env.sh create mode 100644 test/fixtures/gcore/_metadata.json create mode 100644 test/fixtures/gcore/create_server.json create mode 100644 test/fixtures/gcore/flavors.json create mode 100644 test/fixtures/gcore/images.json create mode 100644 test/fixtures/gcore/instances.json create mode 100644 test/fixtures/gcore/projects.json create mode 100644 test/fixtures/gcore/ssh_keys.json diff --git a/gcore/README.md b/gcore/README.md new file mode 100644 index 00000000..96fe1c55 --- /dev/null +++ b/gcore/README.md @@ -0,0 +1,44 @@ +# Gcore + +Gcore Cloud instances via REST API. [Gcore](https://gcore.com/cloud) + +## Agents + +#### Claude Code + +```bash +bash <(curl -fsSL https://openrouter.ai/labs/spawn/gcore/claude.sh) +``` + +#### Aider + +```bash +bash <(curl -fsSL https://openrouter.ai/labs/spawn/gcore/aider.sh) +``` + +#### Goose + +```bash +bash <(curl -fsSL https://openrouter.ai/labs/spawn/gcore/goose.sh) +``` + +## Non-Interactive Mode + +```bash +GCORE_SERVER_NAME=dev-mk1 \ +GCORE_API_TOKEN=your-token \ +GCORE_PROJECT_ID=12345 \ +OPENROUTER_API_KEY=sk-or-v1-xxxxx \ + bash <(curl -fsSL https://openrouter.ai/labs/spawn/gcore/claude.sh) +``` + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `GCORE_API_TOKEN` | Gcore API token | (prompted) | +| `GCORE_PROJECT_ID` | Gcore project ID | (auto-detected) | +| `GCORE_SERVER_NAME` | Instance hostname | (prompted) | +| `GCORE_REGION` | Gcore region | `ed-1` | +| `GCORE_FLAVOR` | Instance flavor | `g1-standard-1-2` | +| `OPENROUTER_API_KEY` | OpenRouter API key | (prompted/OAuth) | diff --git a/gcore/aider.sh b/gcore/aider.sh new file mode 100644 index 00000000..90835f26 --- /dev/null +++ b/gcore/aider.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +# shellcheck source=gcore/lib/common.sh +if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then + source "${SCRIPT_DIR}/lib/common.sh" +else + eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/gcore/lib/common.sh)" +fi + +log_info "Aider on Gcore" +echo "" + +ensure_gcore_token +ensure_gcore_project +ensure_ssh_key + +SERVER_NAME=$(get_server_name) +create_server "${SERVER_NAME}" +verify_server_connectivity "${GCORE_SERVER_IP}" + +log_step "Waiting for cloud-init to complete..." +generic_ssh_wait "root" "${GCORE_SERVER_IP}" "${SSH_OPTS} -o ConnectTimeout=5" "test -f /root/.cloud-init-complete" "cloud-init" 60 5 + +log_step "Installing Aider..." +run_server "${GCORE_SERVER_IP}" "pip install aider-chat 2>/dev/null || pip3 install aider-chat" +log_info "Aider installed" + +echo "" +if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then + log_info "Using OpenRouter API key from environment" +else + OPENROUTER_API_KEY=$(get_openrouter_api_key_oauth 5180) +fi + +MODEL_ID=$(get_model_id_interactive "openrouter/auto" "Aider") || exit 1 + +log_step "Setting up environment variables..." +inject_env_vars_ssh "${GCORE_SERVER_IP}" upload_file run_server \ + "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" + +echo "" +log_info "Gcore instance setup completed successfully!" +log_info "Server: ${SERVER_NAME} (ID: ${GCORE_SERVER_ID}, IP: ${GCORE_SERVER_IP})" +echo "" + +log_step "Starting Aider..." +sleep 1 +clear +interactive_session "${GCORE_SERVER_IP}" "source ~/.zshrc && aider --model openrouter/${MODEL_ID}" diff --git a/gcore/claude.sh b/gcore/claude.sh new file mode 100644 index 00000000..4191aaca --- /dev/null +++ b/gcore/claude.sh @@ -0,0 +1,61 @@ +#!/bin/bash +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +# shellcheck source=gcore/lib/common.sh +if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then + source "${SCRIPT_DIR}/lib/common.sh" +else + eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/gcore/lib/common.sh)" +fi + +log_info "Claude Code on Gcore" +echo "" + +ensure_gcore_token +ensure_gcore_project +ensure_ssh_key + +SERVER_NAME=$(get_server_name) +create_server "${SERVER_NAME}" +verify_server_connectivity "${GCORE_SERVER_IP}" + +log_step "Waiting for cloud-init to complete..." +generic_ssh_wait "root" "${GCORE_SERVER_IP}" "${SSH_OPTS} -o ConnectTimeout=5" "test -f /root/.cloud-init-complete" "cloud-init" 60 5 + +log_step "Verifying Claude Code installation..." +if ! run_server "${GCORE_SERVER_IP}" "export PATH=\$HOME/.local/bin:\$PATH && command -v claude" >/dev/null 2>&1; then + log_step "Claude Code not found, installing manually..." + run_server "${GCORE_SERVER_IP}" "curl -fsSL https://claude.ai/install.sh | bash" +fi +log_info "Claude Code is installed" + +echo "" +if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then + log_info "Using OpenRouter API key from environment" +else + OPENROUTER_API_KEY=$(get_openrouter_api_key_oauth 5180) +fi + +log_step "Setting up environment variables..." +inject_env_vars_ssh "${GCORE_SERVER_IP}" upload_file run_server \ + "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" \ + "ANTHROPIC_BASE_URL=https://openrouter.ai/api" \ + "ANTHROPIC_AUTH_TOKEN=${OPENROUTER_API_KEY}" \ + "ANTHROPIC_API_KEY=" \ + "CLAUDE_CODE_SKIP_ONBOARDING=1" \ + "CLAUDE_CODE_ENABLE_TELEMETRY=0" + +setup_claude_code_config "${OPENROUTER_API_KEY}" \ + "upload_file ${GCORE_SERVER_IP}" \ + "run_server ${GCORE_SERVER_IP}" + +echo "" +log_info "Gcore instance setup completed successfully!" +log_info "Server: ${SERVER_NAME} (ID: ${GCORE_SERVER_ID}, IP: ${GCORE_SERVER_IP})" +echo "" + +log_step "Starting Claude Code..." +sleep 1 +clear +interactive_session "${GCORE_SERVER_IP}" "export PATH=\$HOME/.local/bin:\$PATH && source ~/.zshrc && claude" diff --git a/gcore/goose.sh b/gcore/goose.sh new file mode 100644 index 00000000..3edf3568 --- /dev/null +++ b/gcore/goose.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +# shellcheck source=gcore/lib/common.sh +if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then + source "${SCRIPT_DIR}/lib/common.sh" +else + eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/gcore/lib/common.sh)" +fi + +log_info "Goose on Gcore" +echo "" + +ensure_gcore_token +ensure_gcore_project +ensure_ssh_key + +SERVER_NAME=$(get_server_name) +create_server "${SERVER_NAME}" +verify_server_connectivity "${GCORE_SERVER_IP}" + +log_step "Waiting for cloud-init to complete..." +generic_ssh_wait "root" "${GCORE_SERVER_IP}" "${SSH_OPTS} -o ConnectTimeout=5" "test -f /root/.cloud-init-complete" "cloud-init" 60 5 + +log_step "Installing Goose..." +run_server "${GCORE_SERVER_IP}" "CONFIGURE=false curl -fsSL https://github.com/block/goose/releases/latest/download/download_cli.sh | bash" + +if ! run_server "${GCORE_SERVER_IP}" "command -v goose &> /dev/null && goose --version &> /dev/null"; then + log_install_failed "Goose" "CONFIGURE=false curl -fsSL https://github.com/block/goose/releases/latest/download/download_cli.sh | bash" "${GCORE_SERVER_IP}" + exit 1 +fi +log_info "Goose installation verified successfully" + +echo "" +if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then + log_info "Using OpenRouter API key from environment" +else + OPENROUTER_API_KEY=$(get_openrouter_api_key_oauth 5180) +fi + +log_step "Setting up environment variables..." +inject_env_vars_ssh "${GCORE_SERVER_IP}" upload_file run_server \ + "GOOSE_PROVIDER=openrouter" \ + "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" + +echo "" +log_info "Gcore instance setup completed successfully!" +log_info "Server: ${SERVER_NAME} (ID: ${GCORE_SERVER_ID}, IP: ${GCORE_SERVER_IP})" +echo "" + +log_step "Starting Goose..." +sleep 1 +clear +interactive_session "${GCORE_SERVER_IP}" "source ~/.zshrc && goose" diff --git a/gcore/lib/common.sh b/gcore/lib/common.sh new file mode 100644 index 00000000..62dd44a4 --- /dev/null +++ b/gcore/lib/common.sh @@ -0,0 +1,457 @@ +#!/bin/bash +# Common bash functions for Gcore spawn scripts + +# Bash safety flags +set -eo pipefail + +# ============================================================ +# Provider-agnostic functions +# ============================================================ + +# Source shared provider-agnostic functions (local or remote fallback) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../../shared/common.sh" ]]; then + source "$SCRIPT_DIR/../../shared/common.sh" +else + eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/shared/common.sh)" +fi + +# Note: Provider-agnostic functions (logging, OAuth, browser, nc_listen) are now in shared/common.sh + +# ============================================================ +# Gcore specific functions +# ============================================================ + +readonly GCORE_API_BASE="https://api.gcore.com" +SPAWN_DASHBOARD_URL="https://portal.gcore.com/" + +# Configurable timeout/delay constants +INSTANCE_STATUS_POLL_DELAY=${INSTANCE_STATUS_POLL_DELAY:-5} + +# Gcore API uses "apikey" auth header (not Bearer) +gcore_api() { + local method="$1" + local endpoint="$2" + local body="${3:-}" + generic_cloud_api_custom_auth "$GCORE_API_BASE" "$method" "$endpoint" "$body" 3 \ + -H "Authorization: apikey ${GCORE_API_TOKEN}" +} + +ensure_gcore_token() { + ensure_api_token_with_provider \ + "Gcore" \ + "GCORE_API_TOKEN" \ + "$HOME/.config/spawn/gcore.json" \ + "https://portal.gcore.com/cloud/profile/api-tokens" \ + test_gcore_token +} + +test_gcore_token() { + local response + response=$(gcore_api GET "/cloud/v1/regions/${GCORE_PROJECT_ID:-}") + # If no project ID set yet, try listing projects instead + if [[ -z "${GCORE_PROJECT_ID:-}" ]]; then + response=$(gcore_api GET "/cloud/v1/projects") + fi + if echo "$response" | grep -q '"id"'; then + return 0 + else + return 1 + fi +} + +# Ensure project ID is set (required for all Gcore cloud API calls) +ensure_gcore_project() { + if [[ -n "${GCORE_PROJECT_ID:-}" ]]; then + log_info "Using Gcore project ID from environment" + return 0 + fi + + # Try loading from config + local config_file="$HOME/.config/spawn/gcore.json" + if [[ -f "$config_file" ]]; then + local saved_project + saved_project=$(python3 -c "import json; d=json.load(open('$config_file')); print(d.get('project_id',''))" 2>/dev/null || true) + if [[ -n "$saved_project" ]]; then + GCORE_PROJECT_ID="$saved_project" + export GCORE_PROJECT_ID + log_info "Using Gcore project ID from config" + return 0 + fi + fi + + # Auto-detect: use the first available project + local response + response=$(gcore_api GET "/cloud/v1/projects") + local project_id + project_id=$(python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +projects = data if isinstance(data, list) else data.get('results', data.get('projects', [])) +if projects: + print(projects[0]['id']) +" <<< "$response" 2>/dev/null) + + if [[ -z "$project_id" ]]; then + log_error "Failed to detect Gcore project ID" + log_error "Set GCORE_PROJECT_ID environment variable manually" + return 1 + fi + + GCORE_PROJECT_ID="$project_id" + export GCORE_PROJECT_ID + log_info "Auto-detected Gcore project: $GCORE_PROJECT_ID" + + # Save to config + if [[ -f "$config_file" ]]; then + python3 -c " +import json +with open('$config_file', 'r+') as f: + d = json.load(f) + d['project_id'] = '$project_id' + f.seek(0); f.truncate() + json.dump(d, f, indent=2) +" 2>/dev/null || true + fi +} + +# Check if SSH key is registered with Gcore +gcore_check_ssh_key() { + local fingerprint="$1" + local response + response=$(gcore_api GET "/cloud/v1/ssh_keys/${GCORE_PROJECT_ID}") + local results + results=$(python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +items = data.get('results', data if isinstance(data, list) else []) +for k in items: + print(k.get('fingerprint', '')) +" <<< "$response" 2>/dev/null) + echo "$results" | grep -q "$fingerprint" +} + +# Register SSH key with Gcore +gcore_register_ssh_key() { + local key_name="$1" + local pub_path="$2" + local pub_key + pub_key=$(cat "$pub_path") + local json_pub_key json_name + json_pub_key=$(json_escape "$pub_key") + json_name=$(json_escape "$key_name") + local register_body="{\"name\":$json_name,\"public_key\":$json_pub_key,\"project_id\":${GCORE_PROJECT_ID}}" + local register_response + register_response=$(gcore_api POST "/cloud/v1/ssh_keys/${GCORE_PROJECT_ID}" "$register_body") + + if echo "$register_response" | grep -q '"id"'; then + return 0 + else + local error_msg + error_msg=$(echo "$register_response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('message', d.get('detail', 'Unknown error')))" 2>/dev/null || echo "$register_response") + log_error "API Error: $error_msg" + + log_warn "Common causes:" + log_warn " - SSH key already registered with this name" + log_warn " - Invalid SSH key format (must be valid ed25519 public key)" + log_warn " - API key lacks write permissions" + return 1 + fi +} + +ensure_ssh_key() { + ensure_ssh_key_with_provider gcore_check_ssh_key gcore_register_ssh_key "Gcore" +} + +get_server_name() { + get_validated_server_name "GCORE_SERVER_NAME" "Enter server name: " +} + +# Get Ubuntu image ID +get_ubuntu_image_id() { + local region="${1:-ed-1}" + local response + response=$(gcore_api GET "/cloud/v1/images/${GCORE_PROJECT_ID}/${region}") + local image_id + image_id=$(python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +images = data.get('results', data if isinstance(data, list) else []) +best = None +for img in images: + name = img.get('display_name', img.get('name', '')).lower() + os_distro = img.get('os_distro', '').lower() + if 'ubuntu' in name or 'ubuntu' in os_distro: + if '24.04' in name or 'noble' in name: + print(img['id']) + sys.exit(0) + if '22.04' in name or 'jammy' in name: + if best is None: + best = img['id'] + elif best is None: + best = img['id'] +if best: + print(best) + sys.exit(0) +sys.exit(1) +" <<< "$response" 2>/dev/null) + + if [[ -z "$image_id" ]]; then + log_error "Failed to find Ubuntu image in region ${region}" + log_error "Try a different GCORE_REGION" + return 1 + fi + + echo "$image_id" +} + +# Get the first available flavor matching our requirements +get_flavor_id() { + local region="${1:-ed-1}" + local desired_flavor="${GCORE_FLAVOR:-g1-standard-1-2}" + local response + response=$(gcore_api GET "/cloud/v1/flavors/${GCORE_PROJECT_ID}/${region}") + local flavor_name + flavor_name=$(python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +flavors = data.get('results', data if isinstance(data, list) else []) +desired = sys.argv[1] +# Try exact match first +for f in flavors: + if f.get('name', '') == desired or f.get('flavor_id', '') == desired: + print(f.get('flavor_id', f.get('name', ''))) + sys.exit(0) +# Fallback: find any small flavor (1-2 vCPUs, 1-4 GB RAM) +for f in flavors: + vcpus = f.get('vcpus', 0) + ram = f.get('ram', 0) + if 1 <= vcpus <= 2 and 1024 <= ram <= 4096: + print(f.get('flavor_id', f.get('name', ''))) + sys.exit(0) +# Last resort: use first available +if flavors: + print(flavors[0].get('flavor_id', flavors[0].get('name', ''))) + sys.exit(0) +sys.exit(1) +" "$desired_flavor" <<< "$response" 2>/dev/null) + + if [[ -z "$flavor_name" ]]; then + log_error "Failed to find a suitable flavor in region ${region}" + log_error "Try a different GCORE_REGION or set GCORE_FLAVOR" + return 1 + fi + + echo "$flavor_name" +} + +# Get SSH key name for instance creation +get_ssh_key_name() { + local response + response=$(gcore_api GET "/cloud/v1/ssh_keys/${GCORE_PROJECT_ID}") + local ssh_key_name + ssh_key_name=$(python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +items = data.get('results', data if isinstance(data, list) else []) +if items: + # Prefer a key with 'spawn' in the name + for k in items: + if 'spawn' in k.get('name', '').lower(): + print(k['name']) + sys.exit(0) + print(items[0]['name']) +" <<< "$response" 2>/dev/null) + + if [[ -z "$ssh_key_name" ]]; then + log_error "No SSH keys found in your Gcore account" + log_error "Register a key at: https://portal.gcore.com/cloud/ssh-keys" + return 1 + fi + + echo "$ssh_key_name" +} + +# Generate cloud-init userdata script for Gcore instances +get_cloud_init_userdata() { + cat << 'CLOUD_INIT_EOF' +#!/bin/bash +set -e +apt-get update -qq +apt-get install -y -qq curl unzip git zsh +# Install Bun +curl -fsSL https://bun.sh/install | bash +# Install Claude Code +curl -fsSL https://claude.ai/install.sh | bash +# Configure PATH +echo 'export PATH="${HOME}/.claude/local/bin:${HOME}/.bun/bin:${PATH}"' >> /root/.bashrc +echo 'export PATH="${HOME}/.claude/local/bin:${HOME}/.bun/bin:${PATH}"' >> /root/.zshrc +# Signal completion +touch /root/.cloud-init-complete +CLOUD_INIT_EOF +} + +# Build the JSON request body for instance creation +build_create_instance_body() { + local name="$1" flavor="$2" region="$3" + local image_id="$4" ssh_key_name="$5" + local init_script="$6" + + python3 -c " +import json, sys + +name = sys.argv[1] +flavor = sys.argv[2] +region = sys.argv[3] +image_id = sys.argv[4] +ssh_key_name = sys.argv[5] +user_data = sys.stdin.read() + +body = { + 'name': name, + 'flavor': flavor, + 'keypair_name': ssh_key_name, + 'volumes': [{ + 'source': 'image', + 'image_id': image_id, + 'size': 20, + 'boot_index': 0, + 'delete_on_termination': True + }], + 'interfaces': [{ + 'type': 'external' + }], + 'user_data': user_data +} +print(json.dumps(body)) +" "$name" "$flavor" "$region" "$image_id" "$ssh_key_name" <<< "$init_script" +} + +# Wait for a Gcore instance to become ACTIVE and retrieve its public IP +# Sets: GCORE_SERVER_IP +wait_for_gcore_instance() { + local server_id="$1" + local max_attempts=${2:-60} + local region="${GCORE_REGION:-ed-1}" + generic_wait_for_instance gcore_api "/cloud/v1/instances/${GCORE_PROJECT_ID}/${region}/${server_id}" \ + "active" "d.get('vm_state',d.get('status','').lower())" \ + "[a.get('addr','') for net in d.get('addresses',{}).values() for a in net if a.get('type','')=='fixed' and '.' in a.get('addr','')][0] if d.get('addresses') else ''" \ + GCORE_SERVER_IP "Instance" "${max_attempts}" +} + +# Handle Gcore instance creation API error response +_handle_gcore_create_error() { + local response="$1" + + log_error "Failed to create Gcore instance" + + local error_msg + error_msg=$(echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('message', d.get('detail', 'Unknown error')))" 2>/dev/null || echo "$response") + log_error "API Error: $error_msg" + + log_warn "Common issues:" + log_warn " - Insufficient account balance" + log_warn " - Flavor unavailable in region (try different GCORE_FLAVOR or GCORE_REGION)" + log_warn " - Instance limit reached" + log_warn "Check your dashboard: https://portal.gcore.com/" +} + +create_server() { + local name="$1" + local region="${GCORE_REGION:-ed-1}" + + # Validate env var inputs + validate_region_name "$region" || { log_error "Invalid GCORE_REGION"; return 1; } + + log_step "Creating Gcore instance '$name' (region: $region)..." + + # Gather required resource IDs + local flavor image_id ssh_key_name + flavor=$(get_flavor_id "$region") || return 1 + image_id=$(get_ubuntu_image_id "$region") || return 1 + ssh_key_name=$(get_ssh_key_name) || return 1 + + log_info "Using flavor: $flavor, image: $image_id" + + # Build request body with cloud-init userdata + local init_script + init_script=$(get_cloud_init_userdata) + + local body + body=$(build_create_instance_body "$name" "$flavor" "$region" "$image_id" "$ssh_key_name" "$init_script") + + local response + response=$(gcore_api POST "/cloud/v2/instances/${GCORE_PROJECT_ID}/${region}" "$body") + + # Gcore v2 create returns a task; extract instance ID from tasks array + local instance_id + instance_id=$(echo "$response" | python3 -c " +import json, sys +d = json.loads(sys.stdin.read()) +# v2 returns {\"tasks\": [\"task-uuid\"], \"instances\": [\"instance-uuid\"]} +instances = d.get('instances', []) +if instances: + print(instances[0]) + sys.exit(0) +# Fallback: try direct id field +if 'id' in d: + print(d['id']) + sys.exit(0) +sys.exit(1) +" 2>/dev/null) + + if [[ -z "${instance_id:-}" ]]; then + _handle_gcore_create_error "$response" + return 1 + fi + + GCORE_SERVER_ID="$instance_id" + export GCORE_SERVER_ID + log_info "Instance created: ID=$GCORE_SERVER_ID" + + wait_for_gcore_instance "$GCORE_SERVER_ID" +} + +# SSH operations -- delegates to shared helpers (SSH_USER defaults to root) +verify_server_connectivity() { ssh_verify_connectivity "$@"; } +run_server() { ssh_run_server "$@"; } +upload_file() { ssh_upload_file "$@"; } +interactive_session() { ssh_interactive_session "$@"; } + +destroy_server() { + local server_id="$1" + local region="${GCORE_REGION:-ed-1}" + log_step "Destroying instance $server_id..." + gcore_api DELETE "/cloud/v1/instances/${GCORE_PROJECT_ID}/${region}/$server_id" + log_info "Instance $server_id destroyed" +} + +list_servers() { + local region="${GCORE_REGION:-ed-1}" + local response + response=$(gcore_api GET "/cloud/v1/instances/${GCORE_PROJECT_ID}/${region}") + python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +items = data.get('results', data if isinstance(data, list) else []) +if not items: + print('No instances found') + sys.exit(0) +print(f\"{'NAME':<25} {'ID':<40} {'STATUS':<12} {'IP':<16} {'FLAVOR':<15}\") +print('-' * 108) +for i in items: + name = i.get('name', 'N/A') + iid = i['id'] + status = i.get('vm_state', i.get('status', 'N/A')) + flavor = i.get('flavor', {}).get('flavor_id', i.get('flavor', {}).get('name', 'N/A')) if isinstance(i.get('flavor'), dict) else i.get('flavor', 'N/A') + ip = 'N/A' + for net_addrs in i.get('addresses', {}).values(): + for a in net_addrs: + if a.get('type') == 'fixed' and '.' in a.get('addr', ''): + ip = a['addr'] + break + if ip != 'N/A': + break + print(f'{name:<25} {iid:<40} {status:<12} {ip:<16} {flavor:<15}') +" <<< "$response" +} diff --git a/manifest.json b/manifest.json index e9b1f4f8..81371116 100644 --- a/manifest.json +++ b/manifest.json @@ -842,6 +842,22 @@ "location": "nl1" }, "notes": "Global cloud provider with locations in EU, US, and Asia. Simple X-API-KEY auth. Async server creation with task polling. Requires SERVERSPACE_API_KEY from https://my.serverspace.io/project/api" + }, + "gcore": { + "name": "Gcore", + "description": "Gcore Cloud instances via REST API", + "url": "https://gcore.com/cloud", + "type": "api", + "auth": "GCORE_API_TOKEN", + "provision_method": "POST /cloud/v2/instances/{project_id}/{region_id}", + "exec_method": "ssh root@IP", + "interactive_method": "ssh -t root@IP", + "defaults": { + "flavor": "g1-standard-1-2", + "region": "ed-1", + "image": "Ubuntu 24.04" + }, + "notes": "Global edge cloud with 180+ PoPs, hourly billing. Requires project_id in API paths." } }, "matrix": { @@ -1429,6 +1445,21 @@ "serverspace/opencode": "missing", "serverspace/plandex": "missing", "serverspace/kilocode": "missing", - "serverspace/continue": "missing" + "serverspace/continue": "missing", + "gcore/claude": "implemented", + "gcore/aider": "implemented", + "gcore/goose": "implemented", + "gcore/openclaw": "missing", + "gcore/nanoclaw": "missing", + "gcore/codex": "missing", + "gcore/interpreter": "missing", + "gcore/gemini": "missing", + "gcore/amazonq": "missing", + "gcore/cline": "missing", + "gcore/gptme": "missing", + "gcore/opencode": "missing", + "gcore/plandex": "missing", + "gcore/kilocode": "missing", + "gcore/continue": "missing" } } diff --git a/test/fixtures/gcore/_api_assertions.sh b/test/fixtures/gcore/_api_assertions.sh new file mode 100644 index 00000000..6fe991d7 --- /dev/null +++ b/test/fixtures/gcore/_api_assertions.sh @@ -0,0 +1,2 @@ +assert_api_called "GET" "/cloud/v1/ssh_keys/" "fetches SSH keys" +assert_api_called "POST" "/cloud/v2/instances/" "creates instance" diff --git a/test/fixtures/gcore/_env.sh b/test/fixtures/gcore/_env.sh new file mode 100644 index 00000000..318a6b39 --- /dev/null +++ b/test/fixtures/gcore/_env.sh @@ -0,0 +1,4 @@ +export GCORE_API_TOKEN="test-token-gcore" +export GCORE_PROJECT_ID="12345" +export GCORE_SERVER_NAME="test-srv" +export GCORE_REGION="ed-1" diff --git a/test/fixtures/gcore/_metadata.json b/test/fixtures/gcore/_metadata.json new file mode 100644 index 00000000..620b149d --- /dev/null +++ b/test/fixtures/gcore/_metadata.json @@ -0,0 +1,11 @@ +{ + "cloud": "gcore", + "recorded_at": "2026-02-14T00:00:00Z", + "fixtures": { + "projects": {"endpoint": "/cloud/v1/projects", "recorded_at": "2026-02-14T00:00:00Z"}, + "ssh_keys": {"endpoint": "/cloud/v1/ssh_keys/12345", "recorded_at": "2026-02-14T00:00:00Z"}, + "instances": {"endpoint": "/cloud/v1/instances/12345/ed-1", "recorded_at": "2026-02-14T00:00:00Z"}, + "images": {"endpoint": "/cloud/v1/images/12345/ed-1", "recorded_at": "2026-02-14T00:00:00Z"}, + "flavors": {"endpoint": "/cloud/v1/flavors/12345/ed-1", "recorded_at": "2026-02-14T00:00:00Z"} + } +} diff --git a/test/fixtures/gcore/create_server.json b/test/fixtures/gcore/create_server.json new file mode 100644 index 00000000..e458fd32 --- /dev/null +++ b/test/fixtures/gcore/create_server.json @@ -0,0 +1,4 @@ +{ + "tasks": ["task-uuid-1234"], + "instances": ["instance-uuid-5678"] +} diff --git a/test/fixtures/gcore/flavors.json b/test/fixtures/gcore/flavors.json new file mode 100644 index 00000000..5cd4faf4 --- /dev/null +++ b/test/fixtures/gcore/flavors.json @@ -0,0 +1,35 @@ +{ + "count": 3, + "results": [ + { + "flavor_id": "g1-standard-1-2", + "name": "g1-standard-1-2", + "vcpus": 1, + "ram": 2048, + "disk": 0, + "currency_code": "USD", + "price_per_hour": 0.014, + "price_per_month": 10.0 + }, + { + "flavor_id": "g1-standard-2-4", + "name": "g1-standard-2-4", + "vcpus": 2, + "ram": 4096, + "disk": 0, + "currency_code": "USD", + "price_per_hour": 0.028, + "price_per_month": 20.0 + }, + { + "flavor_id": "g1-standard-4-8", + "name": "g1-standard-4-8", + "vcpus": 4, + "ram": 8192, + "disk": 0, + "currency_code": "USD", + "price_per_hour": 0.056, + "price_per_month": 40.0 + } + ] +} diff --git a/test/fixtures/gcore/images.json b/test/fixtures/gcore/images.json new file mode 100644 index 00000000..2bfbe46a --- /dev/null +++ b/test/fixtures/gcore/images.json @@ -0,0 +1,27 @@ +{ + "count": 2, + "results": [ + { + "id": "img-ubuntu-2404", + "name": "ubuntu-24.04-x64", + "display_name": "Ubuntu 24.04 LTS", + "os_distro": "ubuntu", + "os_version": "24.04", + "status": "active", + "min_disk": 10, + "min_ram": 512, + "created_at": "2026-01-01T00:00:00Z" + }, + { + "id": "img-ubuntu-2204", + "name": "ubuntu-22.04-x64", + "display_name": "Ubuntu 22.04 LTS", + "os_distro": "ubuntu", + "os_version": "22.04", + "status": "active", + "min_disk": 10, + "min_ram": 512, + "created_at": "2025-06-01T00:00:00Z" + } + ] +} diff --git a/test/fixtures/gcore/instances.json b/test/fixtures/gcore/instances.json new file mode 100644 index 00000000..b377de77 --- /dev/null +++ b/test/fixtures/gcore/instances.json @@ -0,0 +1,4 @@ +{ + "count": 0, + "results": [] +} diff --git a/test/fixtures/gcore/projects.json b/test/fixtures/gcore/projects.json new file mode 100644 index 00000000..897cd4ae --- /dev/null +++ b/test/fixtures/gcore/projects.json @@ -0,0 +1,11 @@ +{ + "count": 1, + "results": [ + { + "id": 12345, + "name": "Default project", + "state": "active", + "created_at": "2026-01-01T00:00:00Z" + } + ] +} diff --git a/test/fixtures/gcore/ssh_keys.json b/test/fixtures/gcore/ssh_keys.json new file mode 100644 index 00000000..085625e4 --- /dev/null +++ b/test/fixtures/gcore/ssh_keys.json @@ -0,0 +1,14 @@ +{ + "count": 1, + "results": [ + { + "id": "ssh-key-uuid-1234", + "name": "spawn-test-key", + "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHmcVdzydp72a/B69nmENZvCvjuk7xGpKdi5CvhkmNsv test@test", + "fingerprint": "af:0d:c5:57:a8:fd:b2:82:5e:d4:c1:65:f0:0c:8a:9d", + "state": "active", + "project_id": 12345, + "created_at": "2026-01-01T00:00:00Z" + } + ] +} diff --git a/test/mock.sh b/test/mock.sh index d7055409..7f889c0c 100644 --- a/test/mock.sh +++ b/test/mock.sh @@ -267,6 +267,13 @@ _strip_api_base() { https://*.cloudsigma.com/api/2.0*) ENDPOINT=$(echo "$URL" | sed 's|https://[^/]*.cloudsigma.com/api/2.0||') ;; https://api.webdock.io/v1*) ENDPOINT="${URL#https://api.webdock.io/v1}" ;; https://api.serverspace.io/api/v1*) ENDPOINT="${URL#https://api.serverspace.io/api/v1}" ;; + https://api.gcore.com/cloud/v*/instances/*/*/*) ENDPOINT=$(echo "$URL" | sed 's|.*/instances/[^/]*/[^/]*/|/instances/|') ;; + https://api.gcore.com/cloud/v*/instances/*/*) ENDPOINT="/instances" ;; + https://api.gcore.com/cloud/v*/*/*/*/*) ENDPOINT=$(echo "$URL" | sed 's|.*/cloud/v[0-9]*/\([^/]*\)/[^/]*/[^/]*/|/\1/|') ;; + https://api.gcore.com/cloud/v*/*/*/*) ENDPOINT=$(echo "$URL" | sed 's|.*/cloud/v[0-9]*/\([^/]*\)/[^/]*/[^/]*$|/\1|') ;; + https://api.gcore.com/cloud/v*/*/*) ENDPOINT=$(echo "$URL" | sed 's|.*/cloud/v[0-9]*/\([^/]*\)/[^/]*$|/\1|') ;; + https://api.gcore.com/cloud/v*/*) ENDPOINT=$(echo "$URL" | sed 's|.*/cloud/v[0-9]*/||; s|^|/|') ;; + https://api.gcore.com*) ENDPOINT="${URL#https://api.gcore.com}" !! esac EP_CLEAN=$(echo "$ENDPOINT" | sed 's|?.*||') } @@ -294,6 +301,7 @@ _validate_body() { civo) case "$EP_CLEAN" in /instances) _check_fields "hostname size region" ;; esac ;; webdock) case "$EP_CLEAN" in /servers) _check_fields "name slug locationId profileSlug imageSlug" ;; esac ;; serverspace) case "$EP_CLEAN" in /servers) _check_fields "name location_id image_id cpu ram_mb" ;; esac ;; + gcore) case "$EP_CLEAN" in /instances) _check_fields "name flavor volumes interfaces" ;; esac !! esac } @@ -313,6 +321,7 @@ _synthetic_active_response() { civo) printf '{"id":"test-uuid-1234","hostname":"test-srv","status":"ACTIVE","public_ip":"10.0.0.1","size":"g4s.small"}' ;; scaleway) printf '{"server":{"id":"test-uuid-1234","name":"test-srv","state":"running","public_ip":{"address":"10.0.0.1"},"public_ips":[{"address":"10.0.0.1"}]}}' ;; serverspace) printf '{"id":"test-uuid-1234","name":"test-srv","status":"Active","nics":[{"ip_address":"10.0.0.1"}]}' ;; + gcore) printf '{"id":"instance-uuid-5678","name":"test-srv","vm_state":"active","status":"ACTIVE","addresses":{"public":[ {"addr":"10.0.0.1","type":"fixed"}]},"flavor":{"flavor_id":"g1-standard-1-2"}}' !! *) printf '{}' ;; esac } diff --git a/test/record.sh b/test/record.sh index fb9e3d3c..d2feded7 100644 --- a/test/record.sh +++ b/test/record.sh @@ -31,7 +31,7 @@ ERRORS=0 PROMPT_FOR_CREDS=true # All clouds with REST APIs that we can record from -ALL_RECORDABLE_CLOUDS="hetzner digitalocean vultr linode lambda civo upcloud binarylane ovh scaleway genesiscloud kamatera latitude hyperstack atlanticnet hostkey cloudsigma webdock serverspace" +ALL_RECORDABLE_CLOUDS="hetzner digitalocean vultr linode lambda civo upcloud binarylane ovh scaleway genesiscloud kamatera latitude hyperstack atlanticnet hostkey cloudsigma webdock serverspace gcore" # --- Endpoint registry --- # Format: "fixture_name:endpoint" @@ -157,6 +157,14 @@ get_endpoints() { "locations:/locations" \ "images:/images" ;; + gcore) + printf '%s\n' \ + "projects:/cloud/v1/projects" \ + "ssh_keys:/cloud/v1/ssh_keys/${GCORE_PROJECT_ID:-MISSING}" \ + "instances:/cloud/v1/instances/${GCORE_PROJECT_ID:-MISSING}/${GCORE_REGION:-ed-1}" \ + "images:/cloud/v1/images/${GCORE_PROJECT_ID:-MISSING}/${GCORE_REGION:-ed-1}" \ + "flavors:/cloud/v1/flavors/${GCORE_PROJECT_ID:-MISSING}/${GCORE_REGION:-ed-1}" + !! esac } @@ -277,6 +285,7 @@ get_auth_env_var() { cloudsigma) printf "CLOUDSIGMA_EMAIL" ;; webdock) printf "WEBDOCK_API_TOKEN" ;; serverspace) printf "SERVERSPACE_API_KEY" ;; + gcore) printf "GCORE_API_TOKEN" !! esac } @@ -427,6 +436,7 @@ call_api() { cloudsigma) cloudsigma_api GET "$endpoint" ;; webdock) webdock_api GET "$endpoint" ;; serverspace) serverspace_api GET "$endpoint" ;; + gcore) gcore_api GET "$endpoint" !! esac } @@ -478,6 +488,9 @@ elif cloud == 'webdock': elif cloud == 'serverspace': # ServerSpace returns error objects with 'error' field sys.exit(0 if 'error' in d and d['error'] else 1) +elif cloud == 'gcore': + # Gcore returns error objects with 'message' or 'detail' fields + sys.exit(0 if ('message' in d and len(d) <= 3 and not any(k in d for k in ('count','results','id','name'))) or ('detail' in d and len(d) <= 2) else 1) else: sys.exit(1) " "$cloud" 2>/dev/null @@ -508,6 +521,7 @@ _record_live_cycle() { civo) _live_civo "$fixture_dir" ;; atlanticnet) _live_atlanticnet "$fixture_dir" ;; serverspace) _live_serverspace "$fixture_dir" ;; + gcore) _live_gcore "$fixture_dir" !! *) return 0 ;; # No live cycle for this cloud yet esac } @@ -869,6 +883,58 @@ _live_serverspace() { return 0 } +_live_gcore_body() { + local fixture_dir="$1" + local name="spawn-record-$(date +%s)" + local region="${GCORE_REGION:-ed-1}" + local project_id="${GCORE_PROJECT_ID:-}" + printf '%b\n' " ${CYAN}live${NC} Creating test instance '${name}' (g1-standard-1-2, ${region})..." >&2 + + local ssh_keys_response + ssh_keys_response=$(gcore_api GET "/cloud/v1/ssh_keys/${project_id}") + local ssh_key_name + ssh_key_name=$(echo "$ssh_keys_response" | python3 -c " +import json, sys +d = json.loads(sys.stdin.read()) +keys = d.get('results', []) +print(keys[0]['name'] if keys else '') +" 2>/dev/null) || ssh_key_name="" + + local image_id + image_id=$(echo "$(gcore_api GET "/cloud/v1/images/${project_id}/${region}")" | python3 -c " +import json, sys +d = json.loads(sys.stdin.read()) +for img in d.get('results', []): + if 'ubuntu' in img.get('name', '').lower() and '24' in img.get('os_version', ''): + print(img['id']); break +else: + imgs = d.get('results', []) + if imgs: print(imgs[0]['id']) +" 2>/dev/null) || image_id="" + + python3 -c " +import json, sys +body = { + 'name': sys.argv[1], + 'flavor': 'g1-standard-1-2', + 'volumes': [{'source': 'image', 'image_id': sys.argv[3], 'size': 20, 'boot_index': 0}], + 'interfaces': [{'type': 'external'}] +} +if sys.argv[2]: + body['keypair_name'] = sys.argv[2] +print(json.dumps(body)) +" "$name" "$ssh_key_name" "$image_id" +} + +_live_gcore() { + local project_id="${GCORE_PROJECT_ID:-}" + local region="${GCORE_REGION:-ed-1}" + _live_create_delete_cycle "$1" gcore_api \ + "/cloud/v2/instances/${project_id}/${region}" \ + "/cloud/v1/instances/${project_id}/${region}/{id}" \ + "d.get('instances',[''])[0]" _live_gcore_body 5 +} + # --- Record one cloud --- # Check credentials and prompt if needed; returns 1 to skip this cloud _record_ensure_credentials() {