mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-04 23:00:20 +00:00
* feat: add CloudSigma cloud provider Add CloudSigma as a new cloud provider with API-first architecture: - Create cloudsigma/lib/common.sh with HTTP Basic Auth support - Implement cloudsigma/claude.sh and cloudsigma/aider.sh agent scripts - Add CloudSigma to manifest.json (38th cloud provider) - Add matrix entries for all 15 agents (2 implemented, 13 missing) - Update test/record.sh with CloudSigma endpoints and auth handling - Update test/mock.sh with URL-stripping for CloudSigma API - Add cloudsigma/README.md with usage documentation CloudSigma features: - API v2.0 with HTTP Basic Auth (email:password) - Regions: ZRH (Zurich), WDC (Washington DC), LVS (Las Vegas) - Granular resource control (CPU/RAM/Disk independently configurable) - Ubuntu 24.04 cloned from public library drives - SSH access via cloudsigma user - Pay-as-you-go pricing starting at ~$14/month Agent: cloud-scout Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: address security review comments for CloudSigma provider - [CRITICAL] Fix command injection in credential saving: use sys.argv instead of raw shell interpolation in Python strings - [CRITICAL] Fix shell injection in create_cloudsigma_drive: pass name and size via sys.argv instead of inline interpolation - [CRITICAL] Fix shell injection in SSH key fingerprint lookups: pass fingerprint via sys.argv - [HIGH] Replace hardcoded VNC password with random generation via openssl rand -hex 8 - [MEDIUM] Fix config file path injection: pass via sys.argv Agent: pr-maintainer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: B (Discovery Team) <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
463 lines
14 KiB
Bash
463 lines
14 KiB
Bash
#!/bin/bash
|
|
# Common bash functions for CloudSigma spawn scripts
|
|
|
|
# Bash safety flags
|
|
set -eo pipefail
|
|
|
|
# ============================================================
|
|
# 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
|
|
|
|
# ============================================================
|
|
# CloudSigma specific functions
|
|
# ============================================================
|
|
|
|
# CloudSigma API endpoints by region
|
|
# Default to Zurich (zrh), can be overridden with CLOUDSIGMA_REGION env var
|
|
readonly CLOUDSIGMA_REGION_DEFAULT="zrh"
|
|
readonly CLOUDSIGMA_API_VERSION="2.0"
|
|
|
|
# Get API base URL for the selected region
|
|
get_cloudsigma_api_base() {
|
|
local region="${CLOUDSIGMA_REGION:-$CLOUDSIGMA_REGION_DEFAULT}"
|
|
echo "https://${region}.cloudsigma.com/api/${CLOUDSIGMA_API_VERSION}"
|
|
}
|
|
|
|
# Configurable timeout/delay constants
|
|
INSTANCE_STATUS_POLL_DELAY=${INSTANCE_STATUS_POLL_DELAY:-5} # Delay between instance status checks
|
|
|
|
# CloudSigma API call using HTTP Basic Auth
|
|
cloudsigma_api() {
|
|
local method="$1"
|
|
local endpoint="$2"
|
|
local body="${3:-}"
|
|
|
|
local api_base
|
|
api_base=$(get_cloudsigma_api_base)
|
|
|
|
# CloudSigma uses HTTP Basic Auth with email:password
|
|
# The credentials are passed as CLOUDSIGMA_EMAIL and CLOUDSIGMA_PASSWORD
|
|
local auth_header="Authorization: Basic $(printf '%s:%s' "${CLOUDSIGMA_EMAIL}" "${CLOUDSIGMA_PASSWORD}" | base64)"
|
|
|
|
if [[ -n "$body" ]]; then
|
|
curl -sS -X "$method" \
|
|
"${api_base}${endpoint}" \
|
|
-H "$auth_header" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$body"
|
|
else
|
|
curl -sS -X "$method" \
|
|
"${api_base}${endpoint}" \
|
|
-H "$auth_header"
|
|
fi
|
|
}
|
|
|
|
test_cloudsigma_credentials() {
|
|
local response
|
|
response=$(cloudsigma_api GET "/balance/")
|
|
if echo "$response" | grep -q '"balance"'; then
|
|
log_info "CloudSigma credentials validated"
|
|
return 0
|
|
else
|
|
log_error "API Error: $(extract_api_error_message "$response" "Unable to authenticate")"
|
|
log_warn "Remediation steps:"
|
|
log_warn " 1. Verify credentials at: https://${CLOUDSIGMA_REGION:-zrh}.cloudsigma.com/"
|
|
log_warn " 2. Ensure email and password are correct"
|
|
log_warn " 3. Check account is active and not suspended"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
ensure_cloudsigma_credentials() {
|
|
# CloudSigma uses email + password for API auth
|
|
if [[ -z "${CLOUDSIGMA_EMAIL:-}" ]]; then
|
|
if [[ -f "$HOME/.config/spawn/cloudsigma.json" ]]; then
|
|
CLOUDSIGMA_EMAIL=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('email', ''))" "$HOME/.config/spawn/cloudsigma.json" 2>/dev/null || echo "")
|
|
CLOUDSIGMA_PASSWORD=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('password', ''))" "$HOME/.config/spawn/cloudsigma.json" 2>/dev/null || echo "")
|
|
fi
|
|
fi
|
|
|
|
if [[ -z "${CLOUDSIGMA_EMAIL:-}" ]] || [[ -z "${CLOUDSIGMA_PASSWORD:-}" ]]; then
|
|
log_warn "CloudSigma credentials not found in environment or config file"
|
|
echo ""
|
|
log_info "Get your credentials at: https://${CLOUDSIGMA_REGION:-zrh}.cloudsigma.com/"
|
|
echo ""
|
|
printf "Enter your CloudSigma email: "
|
|
CLOUDSIGMA_EMAIL=$(safe_read "")
|
|
printf "Enter your CloudSigma password: "
|
|
CLOUDSIGMA_PASSWORD=$(safe_read "")
|
|
|
|
# Save credentials
|
|
mkdir -p "$HOME/.config/spawn"
|
|
python3 -c "
|
|
import json, sys
|
|
with open(sys.argv[1], 'w') as f:
|
|
json.dump({'email': sys.argv[2], 'password': sys.argv[3]}, f)
|
|
" "$HOME/.config/spawn/cloudsigma.json" "$CLOUDSIGMA_EMAIL" "$CLOUDSIGMA_PASSWORD"
|
|
chmod 600 "$HOME/.config/spawn/cloudsigma.json"
|
|
fi
|
|
|
|
test_cloudsigma_credentials
|
|
}
|
|
|
|
# Check if SSH key is registered with CloudSigma
|
|
cloudsigma_check_ssh_key() {
|
|
local fingerprint="$1"
|
|
local response
|
|
response=$(cloudsigma_api GET "/keypairs/")
|
|
|
|
# CloudSigma stores SSH keys in keypairs with a fingerprint field
|
|
if echo "$response" | python3 -c "
|
|
import sys, json
|
|
data = json.load(sys.stdin)
|
|
fingerprint = sys.argv[1].replace(':', '').lower()
|
|
for kp in data.get('objects', []):
|
|
kp_fp = kp.get('fingerprint', '').replace(':', '').lower()
|
|
if kp_fp == fingerprint:
|
|
sys.exit(0)
|
|
sys.exit(1)
|
|
" "$fingerprint" 2>/dev/null; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Register SSH key with CloudSigma
|
|
cloudsigma_register_ssh_key() {
|
|
local key_name="$1"
|
|
local pub_path="$2"
|
|
local pub_key
|
|
pub_key=$(cat "$pub_path")
|
|
|
|
# CloudSigma accepts the public key directly
|
|
local body
|
|
body=$(python3 -c "
|
|
import json, sys
|
|
print(json.dumps({
|
|
'name': sys.argv[1],
|
|
'public_key': sys.argv[2]
|
|
}))
|
|
" "$key_name" "$pub_key")
|
|
|
|
local response
|
|
response=$(cloudsigma_api POST "/keypairs/" "$body")
|
|
|
|
if echo "$response" | grep -q '"uuid"'; then
|
|
return 0
|
|
else
|
|
log_error "API Error: $(extract_api_error_message "$response" "$response")"
|
|
log_warn "Common causes:"
|
|
log_warn " - SSH key already registered with this name"
|
|
log_warn " - Invalid SSH key format"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
ensure_ssh_key() {
|
|
ensure_ssh_key_with_provider cloudsigma_check_ssh_key cloudsigma_register_ssh_key "CloudSigma"
|
|
}
|
|
|
|
get_server_name() {
|
|
get_validated_server_name "CLOUDSIGMA_SERVER_NAME" "Enter server name: "
|
|
}
|
|
|
|
# Create a CloudSigma drive (disk) for the server
|
|
# Returns the drive UUID in CLOUDSIGMA_DRIVE_UUID
|
|
create_cloudsigma_drive() {
|
|
local name="$1"
|
|
local size_gb="${CLOUDSIGMA_DISK_SIZE_GB:-20}"
|
|
local size_bytes=$((size_gb * 1024 * 1024 * 1024))
|
|
|
|
log_step "Creating drive '${name}-disk' (${size_gb}GB)..."
|
|
|
|
# Clone from Ubuntu 24.04 image
|
|
# First, find the Ubuntu 24.04 image UUID
|
|
local image_response
|
|
image_response=$(cloudsigma_api GET "/libdrives/?limit=1000")
|
|
|
|
local ubuntu_image_uuid
|
|
ubuntu_image_uuid=$(echo "$image_response" | python3 -c "
|
|
import sys, json
|
|
data = json.load(sys.stdin)
|
|
for drive in data.get('objects', []):
|
|
name = drive.get('name', '').lower()
|
|
if 'ubuntu' in name and ('24.04' in name or '24-04' in name):
|
|
print(drive['uuid'])
|
|
break
|
|
" 2>/dev/null || echo "")
|
|
|
|
if [[ -z "$ubuntu_image_uuid" ]]; then
|
|
log_error "Could not find Ubuntu 24.04 image in CloudSigma library"
|
|
return 1
|
|
fi
|
|
|
|
log_step "Cloning Ubuntu 24.04 image: $ubuntu_image_uuid"
|
|
|
|
# Clone the image to create a new drive
|
|
local clone_body
|
|
clone_body=$(python3 -c "
|
|
import json, sys
|
|
print(json.dumps({
|
|
'name': sys.argv[1] + '-disk',
|
|
'size': int(sys.argv[2]),
|
|
'media': 'disk'
|
|
}))
|
|
" "$name" "$size_bytes")
|
|
|
|
local clone_response
|
|
clone_response=$(cloudsigma_api POST "/libdrives/${ubuntu_image_uuid}/action/?do=clone" "$clone_body")
|
|
|
|
CLOUDSIGMA_DRIVE_UUID=$(echo "$clone_response" | python3 -c "
|
|
import sys, json
|
|
data = json.load(sys.stdin)
|
|
for obj in data.get('objects', [data]):
|
|
if 'uuid' in obj:
|
|
print(obj['uuid'])
|
|
break
|
|
" 2>/dev/null || echo "")
|
|
|
|
if [[ -z "$CLOUDSIGMA_DRIVE_UUID" ]]; then
|
|
log_error "Failed to create drive: $clone_response"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Drive created: $CLOUDSIGMA_DRIVE_UUID"
|
|
}
|
|
|
|
# Build JSON request body for CloudSigma server creation
|
|
_cloudsigma_build_server_body() {
|
|
local name="$1"
|
|
local cpu_mhz="$2"
|
|
local mem_bytes="$3"
|
|
local drive_uuid="$4"
|
|
local ssh_key_uuid="$5"
|
|
|
|
python3 -c "
|
|
import json, sys
|
|
name, cpu_mhz, mem_bytes, drive_uuid, ssh_key_uuid, vnc_pass = sys.argv[1:7]
|
|
|
|
body = {
|
|
'name': name,
|
|
'cpu': int(cpu_mhz),
|
|
'mem': int(mem_bytes),
|
|
'smp': 1,
|
|
'cpu_type': 'amd',
|
|
'hypervisor': 'kvm',
|
|
'vnc_password': vnc_pass,
|
|
'drives': [
|
|
{
|
|
'boot_order': 1,
|
|
'dev_channel': '0:0',
|
|
'device': 'virtio',
|
|
'drive': drive_uuid
|
|
}
|
|
],
|
|
'nics': [
|
|
{
|
|
'ip_v4_conf': {
|
|
'conf': 'dhcp',
|
|
'ip': None
|
|
},
|
|
'model': 'virtio'
|
|
}
|
|
]
|
|
}
|
|
|
|
if ssh_key_uuid:
|
|
body['pubkeys'] = [{'uuid': ssh_key_uuid}]
|
|
|
|
print(json.dumps(body))
|
|
" "$name" "$cpu_mhz" "$mem_bytes" "$drive_uuid" "$ssh_key_uuid" "$(openssl rand -hex 8)"
|
|
}
|
|
|
|
# Wait for CloudSigma server to become running and get its IP
|
|
# Sets: CLOUDSIGMA_SERVER_IP
|
|
_wait_for_cloudsigma_server() {
|
|
local server_uuid="$1"
|
|
local max_attempts=${2:-60}
|
|
|
|
log_step "Waiting for server to get IP address..."
|
|
|
|
local attempt=0
|
|
while [[ $attempt -lt $max_attempts ]]; do
|
|
attempt=$((attempt + 1))
|
|
|
|
local response
|
|
response=$(cloudsigma_api GET "/servers/${server_uuid}/")
|
|
|
|
local status
|
|
status=$(echo "$response" | python3 -c "
|
|
import sys, json
|
|
data = json.load(sys.stdin)
|
|
print(data.get('status', 'unknown'))
|
|
" 2>/dev/null || echo "unknown")
|
|
|
|
local ip
|
|
ip=$(echo "$response" | python3 -c "
|
|
import sys, json
|
|
data = json.load(sys.stdin)
|
|
for nic in data.get('nics', []):
|
|
ip_conf = nic.get('ip_v4_conf', {})
|
|
ip = ip_conf.get('ip', {})
|
|
if isinstance(ip, dict):
|
|
uuid = ip.get('uuid', '')
|
|
if uuid:
|
|
print(uuid)
|
|
break
|
|
elif ip:
|
|
print(ip)
|
|
break
|
|
" 2>/dev/null || echo "")
|
|
|
|
if [[ "$status" == "running" && -n "$ip" ]]; then
|
|
# If IP is a UUID, we need to fetch the actual IP address
|
|
if [[ "$ip" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
|
|
local ip_response
|
|
ip_response=$(cloudsigma_api GET "/ips/${ip}/")
|
|
ip=$(echo "$ip_response" | python3 -c "
|
|
import sys, json
|
|
data = json.load(sys.stdin)
|
|
# CloudSigma IP resources use 'uuid' as the IP address string
|
|
addr = data.get('uuid', '')
|
|
print(addr)
|
|
" 2>/dev/null || echo "")
|
|
fi
|
|
|
|
CLOUDSIGMA_SERVER_IP="$ip"
|
|
log_info "Server is running with IP: $ip"
|
|
return 0
|
|
fi
|
|
|
|
log_step "Server status: $status (attempt $attempt/$max_attempts)"
|
|
sleep "$INSTANCE_STATUS_POLL_DELAY"
|
|
done
|
|
|
|
log_error "Server did not become ready within expected time"
|
|
return 1
|
|
}
|
|
|
|
create_server() {
|
|
local name="$1"
|
|
local cpu_mhz="${CLOUDSIGMA_CPU_MHZ:-1000}" # 1 GHz
|
|
local mem_gb="${CLOUDSIGMA_MEMORY_GB:-2}"
|
|
local mem_bytes=$((mem_gb * 1024 * 1024 * 1024))
|
|
|
|
log_step "Creating CloudSigma server '$name'..."
|
|
log_step " CPU: ${cpu_mhz} MHz, Memory: ${mem_gb}GB"
|
|
|
|
# Create drive first
|
|
create_cloudsigma_drive "$name"
|
|
|
|
# Get SSH key UUID
|
|
local ssh_key_uuid=""
|
|
local keypairs_response
|
|
keypairs_response=$(cloudsigma_api GET "/keypairs/")
|
|
|
|
local fingerprint
|
|
fingerprint=$(get_ssh_fingerprint "$HOME/.ssh/id_ed25519.pub")
|
|
|
|
ssh_key_uuid=$(echo "$keypairs_response" | python3 -c "
|
|
import sys, json
|
|
data = json.load(sys.stdin)
|
|
fingerprint = sys.argv[1].replace(':', '').lower()
|
|
for kp in data.get('objects', []):
|
|
kp_fp = kp.get('fingerprint', '').replace(':', '').lower()
|
|
if kp_fp == fingerprint:
|
|
print(kp['uuid'])
|
|
break
|
|
" "$fingerprint" 2>/dev/null || echo "")
|
|
|
|
# Build server creation request
|
|
local server_body
|
|
server_body=$(_cloudsigma_build_server_body "$name" "$cpu_mhz" "$mem_bytes" "$CLOUDSIGMA_DRIVE_UUID" "$ssh_key_uuid")
|
|
|
|
log_step "Creating server instance..."
|
|
local create_response
|
|
create_response=$(cloudsigma_api POST "/servers/" "$server_body")
|
|
|
|
CLOUDSIGMA_SERVER_UUID=$(echo "$create_response" | python3 -c "
|
|
import sys, json
|
|
data = json.load(sys.stdin)
|
|
print(data.get('uuid', ''))
|
|
" 2>/dev/null || echo "")
|
|
|
|
if [[ -z "$CLOUDSIGMA_SERVER_UUID" ]]; then
|
|
log_error "Failed to create server: $create_response"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Server created: $CLOUDSIGMA_SERVER_UUID"
|
|
|
|
# Start the server
|
|
log_step "Starting server..."
|
|
cloudsigma_api POST "/servers/${CLOUDSIGMA_SERVER_UUID}/action/?do=start" "{}"
|
|
|
|
_wait_for_cloudsigma_server "$CLOUDSIGMA_SERVER_UUID"
|
|
}
|
|
|
|
# Upload file to CloudSigma server via SCP
|
|
upload_file() {
|
|
local server_ip="$1"
|
|
local local_path="$2"
|
|
local remote_path="${3:-$(basename "$local_path")}"
|
|
|
|
# CloudSigma uses cloudsigma user by default for SSH keys
|
|
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
"$local_path" "cloudsigma@${server_ip}:${remote_path}"
|
|
}
|
|
|
|
# Run command on CloudSigma server via SSH
|
|
run_server() {
|
|
local server_ip="$1"
|
|
shift
|
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
"cloudsigma@${server_ip}" "$@"
|
|
}
|
|
|
|
# Start interactive SSH session on CloudSigma server
|
|
interactive_session() {
|
|
local server_ip="$1"
|
|
local command="${2:-bash}"
|
|
|
|
log_step "Connecting to CloudSigma server..."
|
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
-t "cloudsigma@${server_ip}" "$command"
|
|
}
|
|
|
|
# Verify SSH connectivity to the server
|
|
verify_server_connectivity() {
|
|
local server_ip="$1"
|
|
generic_ssh_wait "cloudsigma@${server_ip}" 60
|
|
}
|
|
|
|
# Wait for cloud-init to complete on the server
|
|
wait_for_cloud_init() {
|
|
local server_ip="$1"
|
|
local max_wait="${2:-300}"
|
|
|
|
log_step "Waiting for cloud-init to complete..."
|
|
|
|
local elapsed=0
|
|
while [[ $elapsed -lt $max_wait ]]; do
|
|
if run_server "$server_ip" "cloud-init status --wait" 2>/dev/null; then
|
|
log_info "Cloud-init completed"
|
|
return 0
|
|
fi
|
|
sleep 5
|
|
elapsed=$((elapsed + 5))
|
|
done
|
|
|
|
log_warn "Cloud-init did not complete within ${max_wait}s, continuing anyway"
|
|
return 0
|
|
}
|