spawn/cherry/lib/common.sh
A fdc5d5e58b
refactor: extract shared SSH helpers to eliminate ~410 lines of duplication (#429)
Add ssh_run_server, ssh_upload_file, ssh_interactive_session, and
ssh_verify_connectivity to shared/common.sh. These four functions
were copy-pasted identically across 21 cloud provider lib files,
differing only in SSH username (root vs ubuntu).

Providers now set SSH_USER and delegate to the shared helpers via
one-line wrappers, reducing each provider's lib by ~20 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>
2026-02-11 03:45:18 -08:00

273 lines
7.9 KiB
Bash
Executable file

#!/bin/bash
# Cherry Servers-specific functions for Spawn
# Source shared provider-agnostic functions
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
# ============================================================
# Cherry Servers Configuration
# ============================================================
CHERRY_API_BASE="https://api.cherryservers.com/v1"
CHERRY_DEFAULT_PLAN="${CHERRY_DEFAULT_PLAN:-cloud_vps_1}"
CHERRY_DEFAULT_REGION="${CHERRY_DEFAULT_REGION:-eu_nord_1}"
CHERRY_DEFAULT_IMAGE="${CHERRY_DEFAULT_IMAGE:-Ubuntu 24.04 64bit}"
# ============================================================
# JSON Helpers
# ============================================================
# Extract a field from a JSON object via stdin
# Usage: echo '{"id": 123}' | _cherry_json_field "id"
_cherry_json_field() {
local field="$1"
python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
print(data.get(sys.argv[1], ''))
except:
pass
" "$field" 2>&1
}
# Extract the primary IP address from a Cherry server info response
# Usage: echo '{"ip_addresses":[...]}' | _cherry_extract_primary_ip
_cherry_extract_primary_ip() {
python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
for addr in data.get('ip_addresses', []):
if addr.get('type') == 'primary-ip':
print(addr.get('address', ''))
break
except:
pass
" 2>&1
}
# ============================================================
# API Wrapper
# ============================================================
# Cherry Servers API wrapper - delegates to generic_cloud_api for retry logic
cherry_api() {
local method="$1"
local endpoint="$2"
local body="${3:-}"
generic_cloud_api "$CHERRY_API_BASE" "$CHERRY_AUTH_TOKEN" "$method" "$endpoint" "$body"
}
# ============================================================
# Authentication
# ============================================================
# Test Cherry Servers API token
test_cherry_token() {
local response
response=$(cherry_api GET "/projects")
printf '%s' "$response" | grep -q '"id"'
}
# Get Cherry Servers API token
ensure_cherry_token() {
ensure_api_token_with_provider \
"Cherry Servers" \
"CHERRY_AUTH_TOKEN" \
"$HOME/.config/spawn/cherry.json" \
"https://portal.cherryservers.com/" \
test_cherry_token
}
# ============================================================
# SSH Key Management
# ============================================================
# Check if SSH key is registered with Cherry Servers
cherry_check_ssh_key() {
local fingerprint="$1"
local existing_keys
existing_keys=$(cherry_api GET "/ssh-keys")
printf '%s' "$existing_keys" | grep -q "$fingerprint"
}
# Register SSH key with Cherry Servers
cherry_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="{\"label\":\"$key_name\",\"key\":$json_pub_key}"
local register_response
register_response=$(cherry_api POST "/ssh-keys" "$register_body")
if printf '%s' "$register_response" | grep -q '"id"'; then
return 0
else
log_error "Failed to register SSH key"
log_error "Response: $register_response"
return 1
fi
}
# Ensure SSH key exists and is registered with Cherry Servers
ensure_ssh_key() {
ensure_ssh_key_with_provider cherry_check_ssh_key cherry_register_ssh_key "Cherry Servers"
}
# ============================================================
# Server Management
# ============================================================
# Get project ID (required for server creation)
get_cherry_project_id() {
check_python_available
local projects
projects=$(cherry_api GET "/projects")
local project_id
project_id=$(printf '%s' "$projects" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
if isinstance(data, list) and len(data) > 0:
print(data[0].get('id', ''))
except: pass
" 2>&1)
if [[ -z "$project_id" ]]; then
log_error "No project found in Cherry Servers account"
log_error "Create a project at https://portal.cherryservers.com/"
return 1
fi
printf '%s' "$project_id"
}
# Get server name (generate or prompt)
get_server_name() {
local server_name="${CHERRY_SERVER_NAME:-}"
if [[ -z "$server_name" ]]; then
server_name="spawn-$(date +%s)"
fi
printf '%s' "$server_name"
}
# Create server
# Sets CHERRY_SERVER_ID and CHERRY_SERVER_IP as exports
# Poll the Cherry Servers API until the server has an IP address
# Sets CHERRY_SERVER_IP on success
_cherry_wait_for_ip() {
local server_id="$1"
log_info "Waiting for IP address assignment..."
local ip_address=""
local attempts=0
local max_attempts=60
while [[ -z "$ip_address" ]] && [[ $attempts -lt $max_attempts ]]; do
sleep "${POLL_INTERVAL}"
local server_info
server_info=$(cherry_api GET "/servers/${server_id}")
ip_address=$(printf '%s' "$server_info" | _cherry_extract_primary_ip)
attempts=$((attempts + 1))
done
if [[ -z "$ip_address" ]]; then
log_error "Failed to get server IP address"
return 1
fi
log_info "Server IP: $ip_address"
CHERRY_SERVER_IP="$ip_address"
export CHERRY_SERVER_IP
}
create_server() {
local hostname="$1"
local plan="${CHERRY_DEFAULT_PLAN}"
local region="${CHERRY_DEFAULT_REGION}"
local image="${CHERRY_DEFAULT_IMAGE}"
check_python_available
# Validate env var inputs to prevent injection into Python code
validate_resource_name "$hostname" || { log_error "Invalid hostname"; return 1; }
validate_resource_name "$plan" || { log_error "Invalid CHERRY_DEFAULT_PLAN"; return 1; }
validate_resource_name "$region" || { log_error "Invalid CHERRY_DEFAULT_REGION"; return 1; }
local project_id
project_id=$(get_cherry_project_id) || return 1
log_info "Creating Cherry Servers server..."
log_info "Plan: $plan, Region: $region, Image: $image"
local payload
payload=$(python3 -c "
import json, sys
data = {
'plan': sys.argv[1],
'region': sys.argv[2],
'image': sys.argv[3],
'hostname': sys.argv[4],
'ssh_keys': [int(sys.argv[5])]
}
print(json.dumps(data))
" "$plan" "$region" "$image" "$hostname" "${CHERRY_SSH_KEY_ID}")
local response
response=$(cherry_api POST "/projects/${project_id}/servers" "$payload")
local server_id
server_id=$(printf '%s' "$response" | _cherry_json_field "id")
if [[ -z "$server_id" ]]; then
log_error "Failed to create server"
log_error "Response: $response"
return 1
fi
log_info "Server created with ID: $server_id"
CHERRY_SERVER_ID="$server_id"
export CHERRY_SERVER_ID
# Wait for IP assignment
_cherry_wait_for_ip "$server_id"
}
# ============================================================
# Execution Functions
# ============================================================
# SSH operations — delegates to shared helpers (SSH_USER defaults to root)
run_server() { ssh_run_server "$@"; }
upload_file() { ssh_upload_file "$@"; }
interactive_session() { ssh_interactive_session "$@"; }
verify_server_connectivity() { ssh_verify_connectivity "$@"; }
# Wait for cloud-init to complete
wait_for_cloud_init() {
local ip="$1"
local timeout="${2:-300}"
log_info "Waiting for system initialization..."
if ! run_server "$ip" "cloud-init status --wait --long" 2>/dev/null; then
log_warn "cloud-init wait timed out or not available, proceeding anyway"
else
log_info "System initialization complete"
fi
}