mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-10 12:20:07 +00:00
Consistently use log_step for progress/status messages ("Waiting for...",
"Fetching...", "Creating...") and reserve log_info for success/completion
messages. This gives users a clear visual distinction between operations
that are still running (cyan) vs operations that have completed (green).
Also adds periodic progress updates to silent polling loops in ramnode,
cherry, and netcup IP wait functions so users see activity during long waits.
Agent: ux-engineer
Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
291 lines
10 KiB
Bash
291 lines
10 KiB
Bash
#!/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
|
|
# Delegates to generic_cloud_api for retry logic and error handling
|
|
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
|
|
|
|
generic_cloud_api "$CONTABO_API_BASE" "$CONTABO_ACCESS_TOKEN" "$method" "$endpoint" "$body"
|
|
}
|
|
|
|
# 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() {
|
|
ensure_multi_credentials "Contabo" "$HOME/.config/spawn/contabo.json" \
|
|
"https://my.contabo.com/api/details" test_contabo_credentials \
|
|
"CONTABO_CLIENT_ID:client_id:Client ID" \
|
|
"CONTABO_CLIENT_SECRET:client_secret:Client Secret" \
|
|
"CONTABO_API_USER:api_user:API User (email)" \
|
|
"CONTABO_API_PASSWORD:api_password:API Password"
|
|
}
|
|
|
|
# Check if SSH key is registered with Contabo
|
|
contabo_check_ssh_key() {
|
|
check_ssh_key_by_fingerprint contabo_api "/compute/secrets" "$1"
|
|
}
|
|
|
|
# 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() {
|
|
get_validated_server_name "CONTABO_SERVER_NAME" "Enter server name: "
|
|
}
|
|
|
|
# Get all SSH secret IDs from Contabo
|
|
_contabo_get_ssh_secret_ids() {
|
|
local ssh_secrets_response
|
|
ssh_secrets_response=$(contabo_api GET "/compute/secrets")
|
|
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 "[]"
|
|
}
|
|
|
|
# Build Contabo instance creation request body
|
|
# $1=name $2=product_id $3=region $4=image_id $5=period $6=ssh_secret_ids
|
|
_contabo_build_instance_body() {
|
|
local name="$1" product_id="$2" region="$3" image_id="$4" period="$5" ssh_secret_ids="$6"
|
|
|
|
local userdata
|
|
userdata=$(get_cloud_init_userdata)
|
|
|
|
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))
|
|
"
|
|
}
|
|
|
|
# Poll Contabo API until instance is running, then extract IP
|
|
# Sets CONTABO_SERVER_IP on success
|
|
_contabo_wait_for_instance() {
|
|
local instance_id="$1"
|
|
generic_wait_for_instance contabo_api "/compute/instances/${instance_id}" \
|
|
"running" "d.get('data',[{}])[0].get('status','')" \
|
|
"d.get('data',[{}])[0].get('ipConfig',{}).get('v4',{}).get('ip','')" \
|
|
CONTABO_SERVER_IP "Instance" 60
|
|
}
|
|
|
|
# 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
|
|
|
|
# Validate inputs to prevent injection into Python code
|
|
validate_resource_name "$product_id" || { log_error "Invalid CONTABO_PRODUCT_ID"; return 1; }
|
|
validate_region_name "$region" || { log_error "Invalid CONTABO_REGION"; return 1; }
|
|
validate_resource_name "$image_id" || { log_error "Invalid CONTABO_IMAGE_ID"; return 1; }
|
|
if [[ ! "$period" =~ ^[0-9]+$ ]]; then
|
|
log_error "Invalid CONTABO_PERIOD: must be a positive integer"
|
|
return 1
|
|
fi
|
|
|
|
log_step "Creating Contabo instance '$name' (product: $product_id, region: $region)..."
|
|
|
|
local ssh_secret_ids
|
|
ssh_secret_ids=$(_contabo_get_ssh_secret_ids)
|
|
|
|
local body
|
|
body=$(_contabo_build_instance_body "$name" "$product_id" "$region" "$image_id" "$period" "$ssh_secret_ids")
|
|
|
|
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_step "Waiting for instance to be provisioned..."
|
|
|
|
_contabo_wait_for_instance "$CONTABO_INSTANCE_ID"
|
|
}
|
|
|
|
# SSH operations — delegates to shared helpers (SSH_USER defaults to root)
|
|
verify_server_connectivity() { ssh_verify_connectivity "$@"; }
|
|
run_server() { ssh_run_server "$@"; }
|
|
upload_file() { ssh_upload_file "$@"; }
|
|
interactive_session() { ssh_interactive_session "$@"; }
|
|
|
|
# Destroy a Contabo instance
|
|
destroy_server() {
|
|
local instance_id="$1"
|
|
|
|
log_step "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"
|
|
}
|