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 <noreply@openrouter.ai>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-14 00:19:25 -08:00 committed by GitHub
parent 9b1361de14
commit 514bc7abc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 888 additions and 2 deletions

44
gcore/README.md Normal file
View file

@ -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) |

51
gcore/aider.sh Normal file
View file

@ -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}"

61
gcore/claude.sh Normal file
View file

@ -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"

55
gcore/goose.sh Normal file
View file

@ -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"

457
gcore/lib/common.sh Normal file
View file

@ -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"
}

View file

@ -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"
}
}

View file

@ -0,0 +1,2 @@
assert_api_called "GET" "/cloud/v1/ssh_keys/" "fetches SSH keys"
assert_api_called "POST" "/cloud/v2/instances/" "creates instance"

4
test/fixtures/gcore/_env.sh vendored Normal file
View file

@ -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"

11
test/fixtures/gcore/_metadata.json vendored Normal file
View file

@ -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"}
}
}

View file

@ -0,0 +1,4 @@
{
"tasks": ["task-uuid-1234"],
"instances": ["instance-uuid-5678"]
}

35
test/fixtures/gcore/flavors.json vendored Normal file
View file

@ -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
}
]
}

27
test/fixtures/gcore/images.json vendored Normal file
View file

@ -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"
}
]
}

4
test/fixtures/gcore/instances.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"count": 0,
"results": []
}

11
test/fixtures/gcore/projects.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"count": 1,
"results": [
{
"id": 12345,
"name": "Default project",
"state": "active",
"created_at": "2026-01-01T00:00:00Z"
}
]
}

14
test/fixtures/gcore/ssh_keys.json vendored Normal file
View file

@ -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"
}
]
}

View file

@ -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
}

View file

@ -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() {