diff --git a/contabo/README.md b/contabo/README.md new file mode 100644 index 00000000..8c9b0a26 --- /dev/null +++ b/contabo/README.md @@ -0,0 +1,185 @@ +# Contabo Cloud + +Deploy AI agents on [Contabo](https://contabo.com/) - a budget-friendly European VPS provider with affordable CPU instances starting at $4.95/month. + +## Why Contabo? + +- **Budget-friendly**: VPS plans starting at $4.95/month +- **European provider**: Multiple European data centers (Germany, UK, etc.) +- **Full root access**: Complete control over your instances +- **REST API**: Full API support for automation +- **Cloud-init support**: Easy provisioning with user_data +- **Fast provisioning**: Instances typically ready in 2-5 minutes + +## Prerequisites + +### API Credentials + +Get your API credentials from the [Contabo Customer Control Panel](https://my.contabo.com/api/details): + +1. Log in to https://my.contabo.com +2. Navigate to API → API Details +3. Note down all 4 required values: + - **Client ID**: OAuth2 client identifier + - **Client Secret**: OAuth2 client secret + - **API User**: Your API username (email) + - **API Password**: Your API password + +Set them as environment variables: + +```bash +export CONTABO_CLIENT_ID="your_client_id" +export CONTABO_CLIENT_SECRET="your_client_secret" +export CONTABO_API_USER="your_api_user@example.com" +export CONTABO_API_PASSWORD="your_api_password" +``` + +Or the script will prompt you interactively and save them to `~/.config/spawn/contabo.json`. + +### SSH Key + +The scripts will automatically: +- Generate an SSH key at `~/.ssh/spawn_ed25519` (if it doesn't exist) +- Register it with Contabo as a secret +- Use it to access your instances + +## Usage + +### Deploy Claude Code + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/contabo/claude.sh) +``` + +### Deploy Aider + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/contabo/aider.sh) +``` + +### Deploy OpenClaw + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/contabo/openclaw.sh) +``` + +## Environment Variables + +### Required (if not set, you'll be prompted): + +- `CONTABO_CLIENT_ID` - OAuth2 client ID from Contabo API details +- `CONTABO_CLIENT_SECRET` - OAuth2 client secret +- `CONTABO_API_USER` - API username (email address) +- `CONTABO_API_PASSWORD` - API password + +### Optional: + +- `CONTABO_SERVER_NAME` - Instance display name (default: prompt) +- `CONTABO_PRODUCT_ID` - VPS product ID (default: `V45` = 2 vCPU, 8GB RAM) +- `CONTABO_REGION` - Region code (default: `EU`) +- `CONTABO_IMAGE_ID` - OS image (default: `ubuntu-24.04`) +- `CONTABO_PERIOD` - Billing period in months (default: `1`) +- `OPENROUTER_API_KEY` - Your OpenRouter API key (or use OAuth) + +### Common Product IDs + +| Product ID | vCPUs | RAM | Disk | Price/month | +|------------|-------|-------|---------|-------------| +| V8 | 4 | 6 GB | 50 GB | ~$6.99 | +| V45 | 2 | 8 GB | 100 GB | ~$8.99 | +| V16 | 6 | 16 GB | 400 GB | ~$14.99 | +| V32 | 8 | 30 GB | 800 GB | ~$26.99 | + +Check [Contabo VPS pricing](https://contabo.com/en/vps/) for current rates. + +### Available Regions + +- `EU` - European data centers (Germany, UK) +- `US-east` - US East Coast +- `US-central` - US Central +- `US-west` - US West Coast +- `SIN` - Singapore + +## How It Works + +1. **Authentication**: Uses OAuth2 password grant flow + - Exchanges client credentials + user credentials for an access token + - Token is cached for the session + - API calls include `Authorization: Bearer ` header + +2. **SSH Key Management**: + - Registers your SSH public key as a Contabo secret + - Includes the secret ID in instance creation request + - Root access is enabled by default + +3. **Instance Provisioning**: + - Creates instance via `POST /v1/compute/instances` + - Includes cloud-init userData for agent installation + - Polls instance status until "running" + - Extracts public IPv4 address + +4. **Agent Setup**: + - Waits for SSH connectivity + - Waits for cloud-init to complete + - Verifies agent installation (or installs manually) + - Injects OpenRouter API key + - Starts interactive session + +## Pricing + +Contabo offers **monthly billing** (not hourly): +- Minimum commitment: 1 month +- Billed monthly in advance +- No prorated refunds for early termination +- Unlimited traffic included + +**Example costs:** +- VPS S (V45): 2 vCPU, 8GB RAM → ~$8.99/month +- VPS M (V16): 6 vCPU, 16GB RAM → ~$14.99/month + +Check [current pricing](https://contabo.com/en/vps/) on their website. + +## Troubleshooting + +### Authentication Errors + +If you see "Failed to obtain Contabo OAuth token": +1. Verify all 4 credentials are correct +2. Check API user has API access enabled +3. Ensure API password is set (not your login password) +4. Visit https://my.contabo.com/api/details to verify + +### Instance Creation Fails + +Common issues: +- **Insufficient balance**: Add funds to your account +- **Account limits**: Contact support to increase limits +- **Product unavailable**: Try different `CONTABO_PRODUCT_ID` or `CONTABO_REGION` + +### SSH Connection Timeout + +- Contabo instances can take 2-5 minutes to provision +- Check instance status in Customer Control Panel +- Verify SSH key was registered correctly +- Check firewall settings allow SSH (port 22) + +### Cloud-init Not Completing + +- Check `/var/log/cloud-init-output.log` on the instance +- Verify image supports cloud-init (default Ubuntu images do) +- May need to wait longer (use higher timeout) + +## API Documentation + +- Official API docs: https://api.contabo.com/ +- Customer Control Panel: https://my.contabo.com/ +- API Details: https://my.contabo.com/api/details + +## Notes + +- Contabo uses **monthly billing**, not hourly/per-second like many other clouds +- **Budget-friendly** but less flexible than pay-per-hour providers +- Good for **long-running development environments** or **stable workloads** +- **European data centers** make it GDPR-friendly +- **Full root access** on all VPS instances +- **Unlimited traffic** included in all plans diff --git a/contabo/aider.sh b/contabo/aider.sh new file mode 100644 index 00000000..74783e13 --- /dev/null +++ b/contabo/aider.sh @@ -0,0 +1,61 @@ +#!/bin/bash +set -eo pipefail + +# Source common functions - try local file first, fall back to remote +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +# shellcheck source=contabo/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/contabo/lib/common.sh)" +fi + +log_info "Aider on Contabo" +echo "" + +# 1. Resolve Contabo credentials +ensure_contabo_credentials + +# 2. Generate + register SSH key +ensure_ssh_key + +# 3. Get server name and create server +SERVER_NAME=$(get_server_name) +create_server "${SERVER_NAME}" + +# 4. Wait for SSH and cloud-init +verify_server_connectivity "${CONTABO_SERVER_IP}" +wait_for_cloud_init "${CONTABO_SERVER_IP}" 60 + +# 5. Install Aider +log_warn "Installing Aider..." +run_server "${CONTABO_SERVER_IP}" "pip install aider-chat" + +# 6. Get OpenRouter API key +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 + +# 7. Get model ID +log_info "Aider natively supports OpenRouter" +printf "Enter model ID [openrouter/auto]: " +MODEL_ID=$(safe_read) || MODEL_ID="" +MODEL_ID="${MODEL_ID:-openrouter/auto}" + +log_warn "Setting up environment variables..." +inject_env_vars_ssh "${CONTABO_SERVER_IP}" upload_file run_server \ + "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" + +echo "" +log_info "Contabo instance setup completed successfully!" +log_info "Instance: ${SERVER_NAME} (ID: ${CONTABO_INSTANCE_ID}, IP: ${CONTABO_SERVER_IP})" +echo "" + +# 8. Start Aider interactively +log_warn "Starting Aider with model: ${MODEL_ID}..." +sleep 1 +clear +interactive_session "${CONTABO_SERVER_IP}" "source ~/.zshrc && aider --model ${MODEL_ID}" diff --git a/contabo/claude.sh b/contabo/claude.sh new file mode 100644 index 00000000..e483e3de --- /dev/null +++ b/contabo/claude.sh @@ -0,0 +1,76 @@ +#!/bin/bash +set -eo pipefail + +# Source common functions - try local file first, fall back to remote +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +# shellcheck source=contabo/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/contabo/lib/common.sh)" +fi + +log_info "Claude Code on Contabo" +echo "" + +# 1. Resolve Contabo credentials +ensure_contabo_credentials + +# 2. Generate + register SSH key +ensure_ssh_key + +# 3. Get server name and create server +SERVER_NAME=$(get_server_name) +create_server "${SERVER_NAME}" + +# 4. Wait for SSH and cloud-init +verify_server_connectivity "${CONTABO_SERVER_IP}" +wait_for_cloud_init "${CONTABO_SERVER_IP}" 60 + +# 5. Verify Claude Code is installed (fallback to manual install) +log_warn "Verifying Claude Code installation..." +if ! run_server "${CONTABO_SERVER_IP}" "export PATH=\$HOME/.local/bin:\$PATH && command -v claude" >/dev/null 2>&1; then + log_warn "Claude Code not found, installing manually..." + run_server "${CONTABO_SERVER_IP}" "curl -fsSL https://claude.ai/install.sh | bash" +fi + +# Verify installation succeeded +if ! run_server "${CONTABO_SERVER_IP}" "export PATH=\$HOME/.local/bin:\$PATH && command -v claude &> /dev/null && claude --version &> /dev/null"; then + log_error "Claude Code installation verification failed" + log_error "The 'claude' command is not available or not working properly on server ${CONTABO_SERVER_IP}" + exit 1 +fi +log_info "Claude Code installation verified successfully" + +# 6. Get OpenRouter API key +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_warn "Setting up environment variables..." +inject_env_vars_ssh "${CONTABO_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" + +# 8. Configure Claude Code settings +setup_claude_code_config "${OPENROUTER_API_KEY}" \ + "upload_file ${CONTABO_SERVER_IP}" \ + "run_server ${CONTABO_SERVER_IP}" + +echo "" +log_info "Contabo instance setup completed successfully!" +log_info "Instance: ${SERVER_NAME} (ID: ${CONTABO_INSTANCE_ID}, IP: ${CONTABO_SERVER_IP})" +echo "" + +# 9. Start Claude Code interactively +log_warn "Starting Claude Code..." +sleep 1 +clear +interactive_session "${CONTABO_SERVER_IP}" "export PATH=\$HOME/.local/bin:\$PATH && source ~/.zshrc && claude" diff --git a/contabo/lib/common.sh b/contabo/lib/common.sh new file mode 100644 index 00000000..7e586d91 --- /dev/null +++ b/contabo/lib/common.sh @@ -0,0 +1,411 @@ +#!/bin/bash +set -eo pipefail +# Common bash functions for Contabo spawn scripts + +# ============================================================ +# 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 + +# ============================================================ +# Contabo specific functions +# ============================================================ + +readonly CONTABO_API_BASE="https://api.contabo.com/v1" +readonly CONTABO_AUTH_URL="https://auth.contabo.com/auth/realms/contabo/protocol/openid-connect/token" + +# Get OAuth access token from Contabo +# Requires: CONTABO_CLIENT_ID, CONTABO_CLIENT_SECRET, CONTABO_API_USER, CONTABO_API_PASSWORD +get_contabo_access_token() { + local response + response=$(curl -fsSL \ + -d "client_id=${CONTABO_CLIENT_ID}" \ + -d "client_secret=${CONTABO_CLIENT_SECRET}" \ + --data-urlencode "username=${CONTABO_API_USER}" \ + --data-urlencode "password=${CONTABO_API_PASSWORD}" \ + -d "grant_type=password" \ + "${CONTABO_AUTH_URL}" 2>&1) || { + log_error "Failed to obtain Contabo OAuth token" + log_error "Response: $response" + return 1 + } + + if echo "$response" | grep -q '"error"'; then + log_error "OAuth authentication failed: $response" + return 1 + fi + + local token + token=$(echo "$response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('access_token',''))" 2>/dev/null) + + if [[ -z "$token" ]]; then + log_error "Failed to extract access token from response" + return 1 + fi + + echo "$token" +} + +# Centralized curl wrapper for Contabo API +contabo_api() { + local method="$1" + local endpoint="$2" + local body="${3:-}" + + # Get or refresh access token + if [[ -z "${CONTABO_ACCESS_TOKEN:-}" ]]; then + CONTABO_ACCESS_TOKEN=$(get_contabo_access_token) || return 1 + export CONTABO_ACCESS_TOKEN + fi + + local url="${CONTABO_API_BASE}${endpoint}" + local response + + if [[ "$method" == "GET" ]]; then + response=$(curl -fsSL -X GET \ + -H "Authorization: Bearer ${CONTABO_ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + "$url" 2>&1) || { + log_error "GET request failed: $response" + return 1 + } + elif [[ "$method" == "POST" ]]; then + response=$(curl -fsSL -X POST \ + -H "Authorization: Bearer ${CONTABO_ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + -H "x-request-id: spawn-$(date +%s)" \ + -d "$body" \ + "$url" 2>&1) || { + log_error "POST request failed: $response" + return 1 + } + elif [[ "$method" == "DELETE" ]]; then + response=$(curl -fsSL -X DELETE \ + -H "Authorization: Bearer ${CONTABO_ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + "$url" 2>&1) || { + log_error "DELETE request failed: $response" + return 1 + } + else + log_error "Unsupported HTTP method: $method" + return 1 + fi + + echo "$response" +} + +# Test Contabo credentials +test_contabo_credentials() { + local response + response=$(contabo_api GET "/compute/instances?page=1&size=1") + if echo "$response" | grep -q '"error"'; then + local error_msg + error_msg=$(echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('message','No details available'))" 2>/dev/null || echo "Unable to parse error") + log_error "API Error: $error_msg" + log_error "" + log_error "How to fix:" + log_error " 1. Get credentials from: https://my.contabo.com/api/details" + log_error " 2. Ensure you have all 4 required values:" + log_error " - Client ID" + log_error " - Client Secret" + log_error " - API User (username/email)" + log_error " - API Password" + return 1 + fi + return 0 +} + +# Ensure Contabo credentials are available +ensure_contabo_credentials() { + local config_file="$HOME/.config/spawn/contabo.json" + + # Try to load from config file first + if [[ -f "$config_file" ]]; then + CONTABO_CLIENT_ID=$(python3 -c "import json; print(json.load(open('$config_file')).get('client_id',''))" 2>/dev/null) + CONTABO_CLIENT_SECRET=$(python3 -c "import json; print(json.load(open('$config_file')).get('client_secret',''))" 2>/dev/null) + CONTABO_API_USER=$(python3 -c "import json; print(json.load(open('$config_file')).get('api_user',''))" 2>/dev/null) + CONTABO_API_PASSWORD=$(python3 -c "import json; print(json.load(open('$config_file')).get('api_password',''))" 2>/dev/null) + export CONTABO_CLIENT_ID CONTABO_CLIENT_SECRET CONTABO_API_USER CONTABO_API_PASSWORD + fi + + # Prompt for missing credentials + if [[ -z "${CONTABO_CLIENT_ID:-}" ]]; then + log_info "Get your Contabo API credentials from: https://my.contabo.com/api/details" + printf "Enter Contabo Client ID: " + CONTABO_CLIENT_ID=$(safe_read) || return 1 + export CONTABO_CLIENT_ID + fi + + if [[ -z "${CONTABO_CLIENT_SECRET:-}" ]]; then + printf "Enter Contabo Client Secret: " + CONTABO_CLIENT_SECRET=$(safe_read) || return 1 + export CONTABO_CLIENT_SECRET + fi + + if [[ -z "${CONTABO_API_USER:-}" ]]; then + printf "Enter Contabo API User (username/email): " + CONTABO_API_USER=$(safe_read) || return 1 + export CONTABO_API_USER + fi + + if [[ -z "${CONTABO_API_PASSWORD:-}" ]]; then + printf "Enter Contabo API Password: " + CONTABO_API_PASSWORD=$(safe_read) || return 1 + export CONTABO_API_PASSWORD + fi + + # Test credentials + log_info "Testing Contabo credentials..." + if ! test_contabo_credentials; then + return 1 + fi + + # Save to config file + mkdir -p "$(dirname "$config_file")" + python3 -c " +import json +config = { + 'client_id': '$CONTABO_CLIENT_ID', + 'client_secret': '$CONTABO_CLIENT_SECRET', + 'api_user': '$CONTABO_API_USER', + 'api_password': '$CONTABO_API_PASSWORD' +} +with open('$config_file', 'w') as f: + json.dump(config, f, indent=2) +" && chmod 600 "$config_file" + + log_info "Credentials verified and saved to $config_file" +} + +# Check if SSH key is registered with Contabo +contabo_check_ssh_key() { + local fingerprint="$1" + local existing_keys + existing_keys=$(contabo_api GET "/compute/secrets") + echo "$existing_keys" | grep -q "$fingerprint" +} + +# Register SSH key with Contabo as a secret +contabo_register_ssh_key() { + local key_name="$1" + local pub_path="$2" + local pub_key + pub_key=$(cat "$pub_path") + local json_pub_key + json_pub_key=$(json_escape "$pub_key") + local register_body="{\"name\":\"$key_name\",\"type\":\"ssh\",\"value\":$json_pub_key}" + local register_response + register_response=$(contabo_api POST "/compute/secrets" "$register_body") + + if echo "$register_response" | grep -q '"error"'; then + local error_msg + error_msg=$(echo "$register_response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('message','Unknown error'))" 2>/dev/null || echo "$register_response") + log_error "API Error: $error_msg" + log_error "" + log_error "Common causes:" + log_error " - SSH key already registered with this name" + log_error " - Invalid SSH key format" + return 1 + fi + + return 0 +} + +# Ensure SSH key exists locally and is registered with Contabo +ensure_ssh_key() { + ensure_ssh_key_with_provider contabo_check_ssh_key contabo_register_ssh_key "Contabo" +} + +# Get server name from env var or prompt +get_server_name() { + local server_name + server_name=$(get_resource_name "CONTABO_SERVER_NAME" "Enter server name: ") || return 1 + + if ! validate_server_name "$server_name"; then + return 1 + fi + + echo "$server_name" +} + +# Create a Contabo instance with cloud-init +create_server() { + local name="$1" + + # Use env vars or defaults + local region="${CONTABO_REGION:-EU}" + local product_id="${CONTABO_PRODUCT_ID:-V45}" # VPS S SSD (2 vCPU, 8 GB RAM) + local image_id="${CONTABO_IMAGE_ID:-ubuntu-24.04}" + local period="${CONTABO_PERIOD:-1}" # 1 month + + log_warn "Creating Contabo instance '$name' (product: $product_id, region: $region)..." + + # Get all SSH secret IDs + local ssh_secrets_response + ssh_secrets_response=$(contabo_api GET "/compute/secrets") + local ssh_secret_ids + ssh_secret_ids=$(echo "$ssh_secrets_response" | python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +secrets = [s['secretId'] for s in data.get('data', []) if s.get('type') == 'ssh'] +print(json.dumps(secrets)) +" 2>/dev/null || echo "[]") + + # Get cloud-init userdata + local userdata + userdata=$(get_cloud_init_userdata) + + # Build request body + local body + body=$(echo "$userdata" | python3 -c " +import json, sys +userdata = sys.stdin.read() +body = { + 'displayName': '$name', + 'productId': '$product_id', + 'region': '$region', + 'imageId': '$image_id', + 'period': $period, + 'sshKeys': $ssh_secret_ids, + 'userData': userdata, + 'defaultUser': 'root' +} +print(json.dumps(body)) +") + + local response + response=$(contabo_api POST "/compute/instances" "$body") + + # Check for errors + if echo "$response" | grep -q '"error"' || ! echo "$response" | grep -q '"instanceId"'; then + log_error "Failed to create Contabo instance" + local error_msg + error_msg=$(echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('message','Unknown error'))" 2>/dev/null || echo "$response") + log_error "API Error: $error_msg" + log_error "" + log_error "Common issues:" + log_error " - Insufficient account balance" + log_error " - Product/region unavailable" + log_error " - Account limits reached" + return 1 + fi + + # Extract instance ID + CONTABO_INSTANCE_ID=$(echo "$response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('data',[{}])[0].get('instanceId',''))") + export CONTABO_INSTANCE_ID + + log_info "Instance created: ID=$CONTABO_INSTANCE_ID" + log_info "Waiting for instance to be provisioned..." + + # Wait for instance to be running and get IP + local max_attempts=60 + local attempt=1 + while [[ $attempt -le $max_attempts ]]; do + sleep 5 + local instance_info + instance_info=$(contabo_api GET "/compute/instances/$CONTABO_INSTANCE_ID") + + local status + status=$(echo "$instance_info" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('data',[{}])[0].get('status',''))") + + if [[ "$status" == "running" ]]; then + CONTABO_SERVER_IP=$(echo "$instance_info" | python3 -c " +import json, sys +data = json.loads(sys.stdin.read()).get('data',[{}])[0] +ip = data.get('ipConfig', {}).get('v4', {}).get('ip', '') +print(ip) +") + export CONTABO_SERVER_IP + log_info "Instance running with IP: $CONTABO_SERVER_IP" + return 0 + fi + + log_info "Instance status: $status (attempt $attempt/$max_attempts)" + attempt=$((attempt + 1)) + done + + log_error "Instance failed to reach running state within timeout" + return 1 +} + +# Wait for SSH connectivity +verify_server_connectivity() { + local ip="$1" + local max_attempts=${2:-30} + generic_ssh_wait "root" "$ip" "$SSH_OPTS -o ConnectTimeout=5" "echo ok" "SSH connectivity" "$max_attempts" 5 +} + +# Run a command on the server +run_server() { + local ip="$1" + local cmd="$2" + # shellcheck disable=SC2086 + ssh $SSH_OPTS "root@$ip" "$cmd" +} + +# Upload a file to the server +upload_file() { + local ip="$1" + local local_path="$2" + local remote_path="$3" + # shellcheck disable=SC2086 + scp $SSH_OPTS "$local_path" "root@$ip:$remote_path" +} + +# Start an interactive SSH session +interactive_session() { + local ip="$1" + local cmd="$2" + # shellcheck disable=SC2086 + ssh -t $SSH_OPTS "root@$ip" "$cmd" +} + +# Destroy a Contabo instance +destroy_server() { + local instance_id="$1" + + log_warn "Destroying instance $instance_id..." + local response + response=$(contabo_api DELETE "/compute/instances/$instance_id") + + if echo "$response" | grep -q '"error"'; then + log_error "Failed to destroy instance: $response" + return 1 + fi + + log_info "Instance $instance_id destroyed" +} + +# List all Contabo instances +list_servers() { + local response + response=$(contabo_api GET "/compute/instances") + + python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +instances = data.get('data', []) +if not instances: + print('No instances found') + sys.exit(0) +print(f\"{'NAME':<25} {'ID':<15} {'STATUS':<12} {'IP':<16} {'PRODUCT':<10}\") +print('-' * 78) +for inst in instances: + name = inst.get('displayName', 'N/A') + iid = str(inst.get('instanceId', 'N/A')) + status = inst.get('status', 'N/A') + ip = inst.get('ipConfig', {}).get('v4', {}).get('ip', 'N/A') + product = inst.get('productId', 'N/A') + print(f'{name:<25} {iid:<15} {status:<12} {ip:<16} {product:<10}') +" <<< "$response" +} diff --git a/contabo/openclaw.sh b/contabo/openclaw.sh new file mode 100644 index 00000000..ae98ba0a --- /dev/null +++ b/contabo/openclaw.sh @@ -0,0 +1,66 @@ +#!/bin/bash +set -eo pipefail + +# Source common functions - try local file first, fall back to remote +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +# shellcheck source=contabo/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/contabo/lib/common.sh)" +fi + +log_info "OpenClaw on Contabo" +echo "" + +# 1. Resolve Contabo credentials +ensure_contabo_credentials + +# 2. Generate + register SSH key +ensure_ssh_key + +# 3. Get server name and create server +SERVER_NAME=$(get_server_name) +create_server "${SERVER_NAME}" + +# 4. Wait for SSH and cloud-init +verify_server_connectivity "${CONTABO_SERVER_IP}" +wait_for_cloud_init "${CONTABO_SERVER_IP}" 60 + +# 5. Install OpenClaw +log_warn "Installing OpenClaw..." +run_server "${CONTABO_SERVER_IP}" "bun install -g openclaw" + +# 6. Get OpenRouter API key +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 + +# 7. Get model ID +log_info "OpenClaw natively supports OpenRouter" +printf "Enter model ID [openrouter/auto]: " +MODEL_ID=$(safe_read) || MODEL_ID="" +MODEL_ID="${MODEL_ID:-openrouter/auto}" + +log_warn "Setting up environment variables..." +inject_env_vars_ssh "${CONTABO_SERVER_IP}" upload_file run_server \ + "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" \ + "ANTHROPIC_API_KEY=${OPENROUTER_API_KEY}" \ + "ANTHROPIC_BASE_URL=https://openrouter.ai/api" + +echo "" +log_info "Contabo instance setup completed successfully!" +log_info "Instance: ${SERVER_NAME} (ID: ${CONTABO_INSTANCE_ID}, IP: ${CONTABO_SERVER_IP})" +log_info "Starting OpenClaw gateway in background..." +run_server "${CONTABO_SERVER_IP}" "nohup openclaw gateway > /tmp/openclaw-gateway.log 2>&1 &" +sleep 2 + +echo "" +# 8. Start OpenClaw TUI interactively +log_warn "Starting OpenClaw TUI..." +sleep 1 +clear +interactive_session "${CONTABO_SERVER_IP}" "source ~/.zshrc && openclaw tui" diff --git a/manifest.json b/manifest.json index b9a512f3..29862dbc 100644 --- a/manifest.json +++ b/manifest.json @@ -642,7 +642,7 @@ "disk_size": 20, "location": "us/las" }, - "notes": "Budget European cloud provider with minute-based billing starting at $2/month. Requires IONOS_USERNAME (email) and IONOS_PASSWORD (API key) from https://dcd.ionos.com/ → Management → Users & Keys. Datacenters are created automatically if needed." + "notes": "Budget European cloud provider with minute-based billing starting at $2/month. Requires IONOS_USERNAME (email) and IONOS_PASSWORD (API key) from https://dcd.ionos.com/ \u2192 Management \u2192 Users & Keys. Datacenters are created automatically if needed." }, "exoscale": { "name": "Exoscale", @@ -659,6 +659,22 @@ "template": "Linux Ubuntu 24.04 LTS 64-bit" }, "notes": "European cloud provider with per-second billing. Multiple zones available: ch-gva-2, ch-dk-2, de-fra-1, de-muc-1, at-vie-1, at-vie-2, bg-sof-1. Uses 'ubuntu' user for SSH. Requires exo CLI (auto-installed). Create API keys at https://portal.exoscale.com/iam/api-keys" + }, + "contabo": { + "name": "Contabo", + "description": "Contabo budget VPS cloud via REST API with OAuth", + "url": "https://contabo.com/", + "type": "api", + "auth": "CONTABO_CLIENT_ID + CONTABO_CLIENT_SECRET + CONTABO_API_USER + CONTABO_API_PASSWORD", + "provision_method": "POST /v1/compute/instances with userData (cloud-init)", + "exec_method": "ssh root@IP", + "interactive_method": "ssh -t root@IP", + "defaults": { + "product": "V45", + "region": "EU", + "image": "ubuntu-24.04" + }, + "notes": "Budget European VPS provider starting at $4.95/mo. Uses OAuth2 password grant (client credentials + user credentials). Requires 4 values from https://my.contabo.com/api/details: Client ID, Client Secret, API User, API Password." } }, "matrix": { @@ -1066,6 +1082,21 @@ "exoscale/opencode": "implemented", "exoscale/plandex": "implemented", "exoscale/kilocode": "implemented", - "exoscale/continue": "implemented" + "exoscale/continue": "implemented", + "contabo/claude": "implemented", + "contabo/openclaw": "implemented", + "contabo/nanoclaw": "missing", + "contabo/aider": "implemented", + "contabo/goose": "missing", + "contabo/codex": "missing", + "contabo/interpreter": "missing", + "contabo/gemini": "missing", + "contabo/amazonq": "missing", + "contabo/cline": "missing", + "contabo/gptme": "missing", + "contabo/opencode": "missing", + "contabo/plandex": "missing", + "contabo/kilocode": "missing", + "contabo/continue": "missing" } -} +} \ No newline at end of file