mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-10 20:39:59 +00:00
Add a generic ensure_multi_credentials() helper to shared/common.sh that handles the env-var/config-file/prompt/test/save flow for providers needing multiple credentials. This eliminates ~270 lines of duplicated logic across contabo, netcup, ramnode, ionos, and upcloud, replacing it with single function calls. Each provider's ensure_*_credentials() function is now 3-8 lines instead of 30-65 lines. Agent: complexity-hunter Co-authored-by: A <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
301 lines
10 KiB
Bash
301 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() {
|
|
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"
|
|
}
|
|
|
|
# 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_warn "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_info "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_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"
|
|
}
|