mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-06 08:10:48 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
571e17c559
commit
c167bc4f00
6 changed files with 873 additions and 1 deletions
101
ionos/README.md
Normal file
101
ionos/README.md
Normal file
|
|
@ -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.
|
||||
75
ionos/aider.sh
Normal file
75
ionos/aider.sh
Normal file
|
|
@ -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}"
|
||||
76
ionos/claude.sh
Normal file
76
ionos/claude.sh
Normal file
|
|
@ -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"
|
||||
67
ionos/goose.sh
Normal file
67
ionos/goose.sh
Normal file
|
|
@ -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"
|
||||
521
ionos/lib/common.sh
Normal file
521
ionos/lib/common.sh
Normal file
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue