From c167bc4f00a302e8ad45e9aeb78ca4cb7cf17731 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:51:34 -0800 Subject: [PATCH] feat: Add IONOS Cloud provider with minute-based billing (#304) - Budget European provider starting at $2/month for 1 vCore/1GB RAM - REST API via CloudAPI v6 with Basic Auth (username + password) - Datacenter-based resource organization (auto-creates if needed) - Volume-based boot disks with cloud-init support - Implemented 3 agents: claude, aider, goose - 11 agents marked as missing for future implementation Agent: cloud-scout-1 Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- ionos/README.md | 101 +++++++++ ionos/aider.sh | 75 +++++++ ionos/claude.sh | 76 +++++++ ionos/goose.sh | 67 ++++++ ionos/lib/common.sh | 521 ++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 34 ++- 6 files changed, 873 insertions(+), 1 deletion(-) create mode 100644 ionos/README.md create mode 100644 ionos/aider.sh create mode 100644 ionos/claude.sh create mode 100644 ionos/goose.sh create mode 100644 ionos/lib/common.sh diff --git a/ionos/README.md b/ionos/README.md new file mode 100644 index 00000000..a7debbcd --- /dev/null +++ b/ionos/README.md @@ -0,0 +1,101 @@ +# IONOS Cloud + +IONOS Cloud is a budget-friendly European cloud provider offering VPS and cloud servers with flexible minute-based billing. + +## Features + +- **Ultra-cheap pricing**: Starting at $2/month for basic VPS (1 vCore, 1GB RAM, 10GB SSD) +- **Minute-based billing**: Pay only for what you use with Cloud Cubes +- **REST API**: Full CloudAPI v6 for programmatic control +- **Global locations**: Multiple datacenters across US and Europe +- **Root SSH access**: Full control over your servers +- **Unlimited traffic**: No bandwidth overage charges on Cloud VPS plans + +## Authentication + +IONOS Cloud uses Basic Authentication with your account credentials: + +1. Go to [IONOS Data Center Designer (DCD)](https://dcd.ionos.com/) +2. Navigate to **Management → Users & Keys** +3. Create or retrieve your API credentials: + - **Username**: Your IONOS account email + - **Password**: Your API password/key + +Set these as environment variables: + +```bash +export IONOS_USERNAME="your-email@example.com" +export IONOS_PASSWORD="your-api-password" +``` + +Alternatively, spawn will prompt for them on first use and save to `~/.config/spawn/ionos.json`. + +## Configuration + +### Environment Variables + +- `IONOS_USERNAME` (required): Your IONOS account email +- `IONOS_PASSWORD` (required): Your IONOS API password/key +- `IONOS_SERVER_NAME` (optional): Server name (will prompt if not set) +- `IONOS_CORES` (default: 2): Number of CPU cores +- `IONOS_RAM` (default: 2048): RAM in MB +- `IONOS_DISK_SIZE` (default: 20): Boot disk size in GB +- `IONOS_LOCATION` (default: us/las): Datacenter location + +### Available Locations + +- **US**: `us/las` (Las Vegas), `us/ewr` (Newark) +- **Europe**: `de/fra` (Frankfurt), `de/fkb` (Karlsruhe), `gb/lhr` (London) + +## Usage + +```bash +# Run Claude Code on IONOS Cloud +spawn claude ionos + +# Run with custom resources +IONOS_CORES=4 IONOS_RAM=4096 spawn claude ionos + +# Run Aider +spawn aider ionos + +# Run Goose +spawn goose ionos +``` + +## Pricing + +IONOS offers two main compute options: + +### Cloud VPS (Fixed monthly plans) +- **Basic**: $2/month - 1 vCore, 1GB RAM, 10GB SSD, unlimited traffic +- **Standard**: $5/month - 2 vCores, 2GB RAM, 80GB SSD, unlimited traffic + +### Cloud Cubes (Flexible pay-as-you-go) +- Minute-based billing +- Scale resources on demand +- Perfect for short-lived AI agent sessions + +Spawn creates servers in the Cloud Cubes model by default for maximum flexibility. + +## Notes + +- **Datacenter Management**: IONOS organizes resources into "datacenters" (logical containers). Spawn will create one automatically if you don't have any. +- **Provisioning Time**: Initial datacenter + server creation can take 3-5 minutes. Subsequent servers in the same datacenter provision faster (~2 minutes). +- **SSH Keys**: SSH keys are registered per-datacenter. Spawn handles this automatically. +- **Cloud-init Support**: Full cloud-init support via `userData` in volume creation. +- **Volume-based Boot**: Servers boot from volumes (similar to AWS EBS). The boot volume is created first, then attached to the server. + +## API Reference + +- [IONOS Cloud API v6 Documentation](https://api.ionos.com/docs/cloud/v6/) +- [DCD (Data Center Designer)](https://dcd.ionos.com/) +- [IONOS Cloud Console](https://cloud.ionos.com/) + +## Implemented Agents + +- ✅ Claude Code (`ionos/claude.sh`) +- ✅ Aider (`ionos/aider.sh`) +- ✅ Goose (`ionos/goose.sh`) + +See `manifest.json` for the full list of implemented and missing agents. diff --git a/ionos/aider.sh b/ionos/aider.sh new file mode 100644 index 00000000..daeab4e1 --- /dev/null +++ b/ionos/aider.sh @@ -0,0 +1,75 @@ +#!/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=ionos/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/ionos/lib/common.sh)" +fi + +log_info "Aider on IONOS Cloud" +echo "" + +# 1. Resolve IONOS credentials +ensure_ionos_credentials + +# 2. Generate 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 "${IONOS_SERVER_IP}" +wait_for_cloud_init "${IONOS_SERVER_IP}" 60 + +# 5. Verify Aider is installed (fallback to manual install) +log_warn "Verifying Aider installation..." +if ! run_server "${IONOS_SERVER_IP}" "command -v aider" >/dev/null 2>&1; then + log_warn "Aider not found, installing manually..." + run_server "${IONOS_SERVER_IP}" "pip install aider-chat" +fi + +# Verify installation succeeded +if ! run_server "${IONOS_SERVER_IP}" "command -v aider &> /dev/null && aider --version &> /dev/null"; then + log_error "Aider installation verification failed" + log_error "The 'aider' command is not available or not working properly on server ${IONOS_SERVER_IP}" + exit 1 +fi +log_info "Aider 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 5181) +fi + +# 7. Get model ID +log_info "Model selection for Aider" +echo "" +log_info "Aider supports OpenRouter models via the openrouter/MODEL_ID format" +log_info "Examples: openrouter/auto, openrouter/anthropic/claude-3-5-sonnet" +echo "" +MODEL_ID=$(get_interactive_value "MODEL_ID" "Enter model ID" "openrouter/auto") || return 1 + +log_warn "Setting up environment variables..." +inject_env_vars_ssh "${IONOS_SERVER_IP}" upload_file run_server \ + "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" \ + "MODEL_ID=${MODEL_ID}" + +echo "" +log_info "IONOS server setup completed successfully!" +log_info "Server: ${SERVER_NAME} (ID: ${IONOS_SERVER_ID}, IP: ${IONOS_SERVER_IP})" +echo "" + +# 8. Start Aider interactively +log_warn "Starting Aider..." +sleep 1 +clear +interactive_session "${IONOS_SERVER_IP}" "source ~/.zshrc && aider --model \${MODEL_ID}" diff --git a/ionos/claude.sh b/ionos/claude.sh new file mode 100644 index 00000000..759973a0 --- /dev/null +++ b/ionos/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=ionos/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/ionos/lib/common.sh)" +fi + +log_info "Claude Code on IONOS Cloud" +echo "" + +# 1. Resolve IONOS credentials +ensure_ionos_credentials + +# 2. Generate 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 "${IONOS_SERVER_IP}" +wait_for_cloud_init "${IONOS_SERVER_IP}" 60 + +# 5. Verify Claude Code is installed (fallback to manual install) +log_warn "Verifying Claude Code installation..." +if ! run_server "${IONOS_SERVER_IP}" "command -v claude" >/dev/null 2>&1; then + log_warn "Claude Code not found, installing manually..." + run_server "${IONOS_SERVER_IP}" "curl -fsSL https://claude.ai/install.sh | bash" +fi + +# Verify installation succeeded +if ! run_server "${IONOS_SERVER_IP}" "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 ${IONOS_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 "${IONOS_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 ${IONOS_SERVER_IP}" \ + "run_server ${IONOS_SERVER_IP}" + +echo "" +log_info "IONOS server setup completed successfully!" +log_info "Server: ${SERVER_NAME} (ID: ${IONOS_SERVER_ID}, IP: ${IONOS_SERVER_IP})" +echo "" + +# 9. Start Claude Code interactively +log_warn "Starting Claude Code..." +sleep 1 +clear +interactive_session "${IONOS_SERVER_IP}" "source ~/.zshrc && claude" diff --git a/ionos/goose.sh b/ionos/goose.sh new file mode 100644 index 00000000..36cb9325 --- /dev/null +++ b/ionos/goose.sh @@ -0,0 +1,67 @@ +#!/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=ionos/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/ionos/lib/common.sh)" +fi + +log_info "Goose on IONOS Cloud" +echo "" + +# 1. Resolve IONOS credentials +ensure_ionos_credentials + +# 2. Generate 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 "${IONOS_SERVER_IP}" +wait_for_cloud_init "${IONOS_SERVER_IP}" 60 + +# 5. Verify Goose is installed (fallback to manual install) +log_warn "Verifying Goose installation..." +if ! run_server "${IONOS_SERVER_IP}" "command -v goose" >/dev/null 2>&1; then + log_warn "Goose not found, installing manually..." + run_server "${IONOS_SERVER_IP}" "CONFIGURE=false curl -fsSL https://github.com/block/goose/releases/latest/download/download_cli.sh | bash" +fi + +# Verify installation succeeded +if ! run_server "${IONOS_SERVER_IP}" "command -v goose &> /dev/null && goose --version &> /dev/null"; then + log_error "Goose installation verification failed" + log_error "The 'goose' command is not available or not working properly on server ${IONOS_SERVER_IP}" + exit 1 +fi +log_info "Goose 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 5182) +fi + +log_warn "Setting up environment variables..." +inject_env_vars_ssh "${IONOS_SERVER_IP}" upload_file run_server \ + "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" \ + "GOOSE_PROVIDER=openrouter" + +echo "" +log_info "IONOS server setup completed successfully!" +log_info "Server: ${SERVER_NAME} (ID: ${IONOS_SERVER_ID}, IP: ${IONOS_SERVER_IP})" +echo "" + +# 7. Start Goose interactively +log_warn "Starting Goose..." +sleep 1 +clear +interactive_session "${IONOS_SERVER_IP}" "source ~/.zshrc && goose" diff --git a/ionos/lib/common.sh b/ionos/lib/common.sh new file mode 100644 index 00000000..cf097b57 --- /dev/null +++ b/ionos/lib/common.sh @@ -0,0 +1,521 @@ +#!/bin/bash +set -eo pipefail +# Common bash functions for IONOS Cloud 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 + +# ============================================================ +# IONOS Cloud specific functions +# ============================================================ + +readonly IONOS_API_BASE="https://api.ionos.com/cloudapi/v6" +# SSH_OPTS is now defined in shared/common.sh + +# Centralized curl wrapper for IONOS API +# IONOS uses Basic Auth with username (email) and password (API token) +ionos_api() { + local method="$1" + local endpoint="$2" + local body="${3:-}" + + # IONOS API uses Basic Auth + local auth_header="Authorization: Basic $(printf '%s:%s' "${IONOS_USERNAME}" "${IONOS_PASSWORD}" | base64)" + + local response + if [[ "$method" == "GET" || "$method" == "DELETE" ]]; then + response=$(curl -s -X "$method" \ + -H "$auth_header" \ + -H "Content-Type: application/json" \ + "${IONOS_API_BASE}${endpoint}") + else + response=$(curl -s -X "$method" \ + -H "$auth_header" \ + -H "Content-Type: application/json" \ + -d "$body" \ + "${IONOS_API_BASE}${endpoint}") + fi + + echo "$response" +} + +test_ionos_credentials() { + local response + response=$(ionos_api GET "/datacenters?depth=1&limit=1") + if echo "$response" | grep -q '"httpStatus"'; then + # Parse error details + local error_msg + error_msg=$(echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('messages',[{}])[0].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. Verify your credentials at: https://dcd.ionos.com/ → Management → Users & Keys" + log_error " 2. Ensure IONOS_USERNAME is your account email" + log_error " 3. Ensure IONOS_PASSWORD is your valid API password/token" + return 1 + fi + return 0 +} + +# Ensure IONOS credentials are available (env vars → config file → prompt+save) +ensure_ionos_credentials() { + local config_file="$HOME/.config/spawn/ionos.json" + + # Check environment variables first + if [[ -z "${IONOS_USERNAME:-}" ]] || [[ -z "${IONOS_PASSWORD:-}" ]]; then + # Try loading from config file + if [[ -f "$config_file" ]]; then + log_info "Loading IONOS credentials from $config_file" + IONOS_USERNAME=$(python3 -c "import json; print(json.load(open('$config_file')).get('username',''))" 2>/dev/null || echo "") + IONOS_PASSWORD=$(python3 -c "import json; print(json.load(open('$config_file')).get('password',''))" 2>/dev/null || echo "") + export IONOS_USERNAME IONOS_PASSWORD + fi + fi + + # If still missing, prompt user + if [[ -z "${IONOS_USERNAME:-}" ]] || [[ -z "${IONOS_PASSWORD:-}" ]]; then + log_warn "IONOS Cloud credentials not found" + echo "" + log_info "Get credentials at: https://dcd.ionos.com/ → Management → Users & Keys" + echo "" + + IONOS_USERNAME=$(safe_read "Enter IONOS username (email): ") || return 1 + IONOS_PASSWORD=$(safe_read "Enter IONOS password/API key: ") || return 1 + export IONOS_USERNAME IONOS_PASSWORD + + # Test credentials before saving + log_info "Testing IONOS credentials..." + if ! test_ionos_credentials; then + log_error "Invalid IONOS credentials" + return 1 + fi + + # Save to config file + mkdir -p "$(dirname "$config_file")" + python3 -c "import json; json.dump({'username': '$IONOS_USERNAME', 'password': '$IONOS_PASSWORD'}, open('$config_file', 'w'))" + chmod 600 "$config_file" + log_info "Credentials saved to $config_file" + else + # Test existing credentials + log_info "Testing IONOS credentials..." + if ! test_ionos_credentials; then + log_error "Invalid IONOS credentials. Please check IONOS_USERNAME and IONOS_PASSWORD." + return 1 + fi + fi + + return 0 +} + +# Check if SSH key is registered with IONOS +ionos_check_ssh_key() { + local fingerprint="$1" + # IONOS doesn't provide SSH key listing in CloudAPI v6 + # We'll skip the check and try to register + return 1 +} + +# Register SSH key with IONOS datacenter +ionos_register_ssh_key() { + local key_name="$1" + local pub_path="$2" + local datacenter_id="$3" + + local pub_key + pub_key=$(cat "$pub_path") + local json_pub_key + json_pub_key=$(json_escape "$pub_key") + + local register_body + register_body=$(python3 -c " +import json +body = { + 'properties': { + 'name': '$key_name', + 'publicKey': json.loads($json_pub_key) + } +} +print(json.dumps(body)) +") + + local register_response + register_response=$(ionos_api POST "/datacenters/${datacenter_id}/sshkeys" "$register_body") + + if echo "$register_response" | grep -q '"httpStatus"'; then + # Parse error details + local error_msg + error_msg=$(echo "$register_response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); msgs=d.get('messages',[]); print(msgs[0].get('message','Unknown error') if msgs else '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 (must be valid ed25519 public key)" + log_error " - API credentials lack write permissions" + return 1 + fi + + return 0 +} + +# Ensure SSH key exists locally and is registered with IONOS +ensure_ssh_key() { + local key_path="$HOME/.ssh/spawn_ed25519" + local pub_path="${key_path}.pub" + + generate_ssh_key_if_missing "$key_path" + + local fingerprint + fingerprint=$(get_ssh_fingerprint "$pub_path") + log_info "SSH key fingerprint: $fingerprint" +} + +# Get server name from env var or prompt +get_server_name() { + local server_name + server_name=$(get_resource_name "IONOS_SERVER_NAME" "Enter server name: ") || return 1 + + if ! validate_server_name "$server_name"; then + return 1 + fi + + echo "$server_name" +} + +# Ensure datacenter exists or create one +ensure_datacenter() { + local location="${IONOS_LOCATION:-us/las}" + + log_warn "Checking for existing IONOS datacenter..." + + # List datacenters + local response + response=$(ionos_api GET "/datacenters?depth=1") + + # Check if we have any datacenters + local dc_count + dc_count=$(echo "$response" | python3 -c "import json,sys; print(len(json.loads(sys.stdin.read()).get('items',[])))" 2>/dev/null || echo "0") + + if [[ "$dc_count" -gt 0 ]]; then + # Use the first datacenter + IONOS_DATACENTER_ID=$(echo "$response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['items'][0]['id'])") + local dc_name + dc_name=$(echo "$response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['items'][0]['properties']['name'])") + log_info "Using existing datacenter: $dc_name (ID: $IONOS_DATACENTER_ID)" + else + # Create a new datacenter + log_warn "No datacenter found, creating new datacenter..." + + local dc_body + dc_body=$(python3 -c " +import json +body = { + 'properties': { + 'name': 'spawn-datacenter', + 'description': 'Spawn datacenter for AI agents', + 'location': '$location' + } +} +print(json.dumps(body)) +") + + local dc_response + dc_response=$(ionos_api POST "/datacenters" "$dc_body") + + if echo "$dc_response" | grep -q '"httpStatus"'; then + log_error "Failed to create datacenter" + local error_msg + error_msg=$(echo "$dc_response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); msgs=d.get('messages',[]); print(msgs[0].get('message','Unknown error') if msgs else 'Unknown error')" 2>/dev/null || echo "$dc_response") + log_error "API Error: $error_msg" + return 1 + fi + + IONOS_DATACENTER_ID=$(echo "$dc_response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['id'])") + log_info "Datacenter created: $IONOS_DATACENTER_ID" + fi + + export IONOS_DATACENTER_ID + return 0 +} + +# Create a IONOS server with cloud-init +create_server() { + local name="$1" + local cores="${IONOS_CORES:-2}" + local ram="${IONOS_RAM:-2048}" + local disk_size="${IONOS_DISK_SIZE:-20}" + + # Validate env var inputs + validate_resource_name "$name" || { log_error "Invalid server name"; return 1; } + + log_warn "Creating IONOS server '$name' (cores: $cores, ram: ${ram}MB, disk: ${disk_size}GB)..." + + # Ensure we have a datacenter + ensure_datacenter || return 1 + + # Get Ubuntu 24.04 image ID + log_info "Finding Ubuntu 24.04 image..." + local images_response + images_response=$(ionos_api GET "/images?depth=2") + + local image_id + image_id=$(echo "$images_response" | python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +for img in data.get('items', []): + props = img.get('properties', {}) + name = props.get('name', '').lower() + image_type = props.get('imageType', '') + if 'ubuntu' in name and '24' in name and image_type == 'HDD': + print(img['id']) + break +" 2>/dev/null) + + if [[ -z "$image_id" ]]; then + log_error "Could not find Ubuntu 24.04 image" + return 1 + fi + + log_info "Using image ID: $image_id" + + # Get cloud-init userdata + local userdata + userdata=$(get_cloud_init_userdata) + local userdata_json + userdata_json=$(echo "$userdata" | python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))") + + # Create volume first + log_warn "Creating boot volume..." + local volume_body + volume_body=$(python3 -c " +import json +body = { + 'properties': { + 'name': '${name}-boot', + 'type': 'HDD', + 'size': $disk_size, + 'availabilityZone': 'AUTO', + 'image': '$image_id', + 'imagePassword': 'TempPass123!', + 'userData': json.loads($userdata_json) + } +} +print(json.dumps(body)) +") + + local volume_response + volume_response=$(ionos_api POST "/datacenters/${IONOS_DATACENTER_ID}/volumes" "$volume_body") + + if echo "$volume_response" | grep -q '"httpStatus"'; then + log_error "Failed to create volume" + local error_msg + error_msg=$(echo "$volume_response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); msgs=d.get('messages',[]); print(msgs[0].get('message','Unknown error') if msgs else 'Unknown error')" 2>/dev/null || echo "$volume_response") + log_error "API Error: $error_msg" + return 1 + fi + + local volume_id + volume_id=$(echo "$volume_response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['id'])") + log_info "Volume created: $volume_id" + + # Wait for volume to be ready + log_warn "Waiting for volume provisioning..." + local max_wait=120 + local waited=0 + while [[ $waited -lt $max_wait ]]; do + local vol_status + vol_status=$(ionos_api GET "/datacenters/${IONOS_DATACENTER_ID}/volumes/${volume_id}") + local state + state=$(echo "$vol_status" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('metadata',{}).get('state',''))" 2>/dev/null || echo "") + + if [[ "$state" == "AVAILABLE" ]]; then + log_info "Volume ready" + break + fi + + sleep 5 + waited=$((waited + 5)) + done + + # Register SSH key with datacenter + log_warn "Registering SSH key..." + local key_path="$HOME/.ssh/spawn_ed25519" + local pub_path="${key_path}.pub" + ionos_register_ssh_key "spawn-key-$(date +%s)" "$pub_path" "${IONOS_DATACENTER_ID}" || log_warn "SSH key registration failed, continuing anyway..." + + # Create server + log_warn "Creating server instance..." + local server_body + server_body=$(python3 -c " +import json +body = { + 'properties': { + 'name': '$name', + 'cores': $cores, + 'ram': $ram, + 'availabilityZone': 'AUTO', + 'cpuFamily': 'AMD_OPTERON' + } +} +print(json.dumps(body)) +") + + local server_response + server_response=$(ionos_api POST "/datacenters/${IONOS_DATACENTER_ID}/servers" "$server_body") + + if echo "$server_response" | grep -q '"httpStatus"'; then + log_error "Failed to create server" + local error_msg + error_msg=$(echo "$server_response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); msgs=d.get('messages',[]); print(msgs[0].get('message','Unknown error') if msgs else 'Unknown error')" 2>/dev/null || echo "$server_response") + log_error "API Error: $error_msg" + return 1 + fi + + IONOS_SERVER_ID=$(echo "$server_response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['id'])") + log_info "Server created: $IONOS_SERVER_ID" + export IONOS_SERVER_ID + + # Attach volume to server + log_warn "Attaching volume to server..." + local attach_response + attach_response=$(ionos_api POST "/datacenters/${IONOS_DATACENTER_ID}/servers/${IONOS_SERVER_ID}/volumes" "{\"id\": \"${volume_id}\"}") + + if echo "$attach_response" | grep -q '"httpStatus"'; then + log_error "Failed to attach volume" + return 1 + fi + + log_info "Volume attached successfully" + + # Wait for server to get an IP + log_warn "Waiting for server to get IP address..." + max_wait=180 + waited=0 + while [[ $waited -lt $max_wait ]]; do + local server_status + server_status=$(ionos_api GET "/datacenters/${IONOS_DATACENTER_ID}/servers/${IONOS_SERVER_ID}?depth=3") + + IONOS_SERVER_IP=$(echo "$server_status" | python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +entities = data.get('entities', {}) +nics = entities.get('nics', {}).get('items', []) +for nic in nics: + props = nic.get('properties', {}) + ips = props.get('ips', []) + if ips: + print(ips[0]) + break +" 2>/dev/null || echo "") + + if [[ -n "$IONOS_SERVER_IP" ]]; then + log_info "Server IP: $IONOS_SERVER_IP" + export IONOS_SERVER_IP + break + fi + + sleep 5 + waited=$((waited + 5)) + done + + if [[ -z "$IONOS_SERVER_IP" ]]; then + log_error "Failed to get server IP address" + return 1 + fi + + log_info "Server created successfully: ID=$IONOS_SERVER_ID, IP=$IONOS_SERVER_IP" +} + +# Wait for SSH connectivity +verify_server_connectivity() { + local ip="$1" + local max_attempts=${2:-60} + # SSH_OPTS is defined in shared/common.sh + # shellcheck disable=SC2154 + 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 IONOS server +destroy_server() { + local datacenter_id="$1" + local server_id="$2" + + log_warn "Destroying server $server_id in datacenter $datacenter_id..." + local response + response=$(ionos_api DELETE "/datacenters/${datacenter_id}/servers/${server_id}") + + if echo "$response" | grep -q '"httpStatus"'; then + log_error "Failed to destroy server: $response" + return 1 + fi + + log_info "Server $server_id destroyed" +} + +# List all IONOS servers +list_servers() { + local response + response=$(ionos_api GET "/datacenters?depth=3") + + python3 -c " +import json, sys +data = json.loads(sys.stdin.read()) +found_servers = False +for dc in data.get('items', []): + dc_name = dc.get('properties', {}).get('name', 'N/A') + dc_id = dc.get('id', 'N/A') + servers = dc.get('entities', {}).get('servers', {}).get('items', []) + + if servers: + if not found_servers: + print(f\"{'DATACENTER':<25} {'NAME':<20} {'ID':<12} {'CORES':<6} {'RAM':<8}\") + print('-' * 75) + found_servers = True + + for s in servers: + props = s.get('properties', {}) + name = props.get('name', 'N/A') + sid = s.get('id', 'N/A') + cores = props.get('cores', 'N/A') + ram = props.get('ram', 'N/A') + print(f'{dc_name:<25} {name:<20} {sid:<12} {cores:<6} {ram:<8}') + +if not found_servers: + print('No servers found') +" <<< "$response" +} diff --git a/manifest.json b/manifest.json index 3ca06595..3fe3f2c1 100644 --- a/manifest.json +++ b/manifest.json @@ -626,6 +626,23 @@ "runtime": "docker" }, "notes": "Developer-first platform with free Hobby plan (100GB bandwidth, 500 build minutes). Requires API key from https://dashboard.render.com/u/settings/api-keys. SSH not available on free tier." + }, + "ionos": { + "name": "IONOS Cloud", + "description": "IONOS Cloud servers via REST API with minute-based billing", + "url": "https://cloud.ionos.com/", + "type": "api", + "auth": "IONOS_USERNAME + IONOS_PASSWORD", + "provision_method": "POST /cloudapi/v6/datacenters/{id}/servers with volumes", + "exec_method": "ssh root@IP", + "interactive_method": "ssh -t root@IP", + "defaults": { + "cores": 2, + "ram": 2048, + "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." } }, "matrix": { @@ -1003,6 +1020,21 @@ "koyeb/continue": "missing", "northflank/continue": "missing", "railway/continue": "missing", - "render/continue": "missing" + "render/continue": "missing", + "ionos/claude": "implemented", + "ionos/openclaw": "missing", + "ionos/nanoclaw": "missing", + "ionos/aider": "implemented", + "ionos/goose": "implemented", + "ionos/codex": "missing", + "ionos/interpreter": "missing", + "ionos/gemini": "missing", + "ionos/amazonq": "missing", + "ionos/cline": "missing", + "ionos/gptme": "missing", + "ionos/opencode": "missing", + "ionos/plandex": "missing", + "ionos/kilocode": "missing", + "ionos/continue": "missing" } }