mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-02 22:00:19 +00:00
- Add `_update_retry_interval()` helper in shared/common.sh to eliminate repeated backoff interval calculation and cap logic (was copied 10+ times across cloud provider API wrappers) - Refactor generic_cloud_api() to use new helper, reducing from 83 to 70 lines - Refactor scaleway_api() to use new helper, reducing from 66 to 53 lines - Refactor upcloud_api() to use new helper, reducing from 65 to 52 lines This reduces cyclomatic complexity by eliminating nested if statements for interval updates and consolidates the retry backoff logic in one place, making future maintenance easier and reducing bugs from copy-paste errors. Agent: complexity-hunter Co-authored-by: A <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
441 lines
14 KiB
Bash
441 lines
14 KiB
Bash
#!/bin/bash
|
|
set -eo pipefail
|
|
# Common bash functions for UpCloud 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
|
|
|
|
# ============================================================
|
|
# UpCloud specific functions
|
|
# ============================================================
|
|
|
|
readonly UPCLOUD_API_BASE="https://api.upcloud.com/1.3"
|
|
|
|
# Configurable timeout/delay constants
|
|
INSTANCE_STATUS_POLL_DELAY=${INSTANCE_STATUS_POLL_DELAY:-5}
|
|
|
|
# UpCloud API wrapper using Basic Auth
|
|
# Usage: upcloud_api METHOD ENDPOINT [BODY] [MAX_RETRIES]
|
|
upcloud_api() {
|
|
local method="$1"
|
|
local endpoint="$2"
|
|
local body="${3:-}"
|
|
local max_retries="${4:-3}"
|
|
|
|
local attempt=1
|
|
local interval=2
|
|
local max_interval=30
|
|
|
|
while [[ "${attempt}" -le "${max_retries}" ]]; do
|
|
local args=(
|
|
-s
|
|
-w "\n%{http_code}"
|
|
-X "${method}"
|
|
-u "${UPCLOUD_USERNAME}:${UPCLOUD_PASSWORD}"
|
|
-H "Content-Type: application/json"
|
|
)
|
|
|
|
if [[ -n "${body}" ]]; then
|
|
args+=(-d "${body}")
|
|
fi
|
|
|
|
local response
|
|
response=$(curl "${args[@]}" "${UPCLOUD_API_BASE}${endpoint}" 2>&1)
|
|
local curl_exit_code=$?
|
|
|
|
local http_code
|
|
http_code=$(printf '%s' "${response}" | tail -1)
|
|
local response_body
|
|
response_body=$(printf '%s' "${response}" | head -n -1)
|
|
|
|
if [[ ${curl_exit_code} -ne 0 ]]; then
|
|
if ! _api_should_retry_on_error "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "UpCloud API network error"; then
|
|
log_error "UpCloud API network error after ${max_retries} attempts: curl exit code ${curl_exit_code}"
|
|
return 1
|
|
fi
|
|
_update_retry_interval "interval" "max_interval"
|
|
attempt=$((attempt + 1))
|
|
continue
|
|
fi
|
|
|
|
if [[ "${http_code}" == "429" ]] || [[ "${http_code}" == "503" ]]; then
|
|
if ! _api_should_retry_on_error "${attempt}" "${max_retries}" "${interval}" "${max_interval}" "UpCloud API returned HTTP ${http_code}"; then
|
|
log_error "UpCloud API returned HTTP ${http_code} after ${max_retries} attempts"
|
|
echo "${response_body}"
|
|
return 1
|
|
fi
|
|
_update_retry_interval "interval" "max_interval"
|
|
attempt=$((attempt + 1))
|
|
continue
|
|
fi
|
|
|
|
echo "${response_body}"
|
|
return 0
|
|
done
|
|
|
|
log_error "UpCloud API retry logic exhausted"
|
|
return 1
|
|
}
|
|
|
|
test_upcloud_credentials() {
|
|
local response
|
|
response=$(upcloud_api GET "/account")
|
|
if echo "$response" | grep -q '"account"'; then
|
|
return 0
|
|
else
|
|
local error_msg
|
|
error_msg=$(echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('error',{}).get('error_message','No details available'))" 2>/dev/null || echo "Unable to parse error")
|
|
log_error "API Error: $error_msg"
|
|
log_warn "Remediation steps:"
|
|
log_warn " 1. Verify credentials at: https://hub.upcloud.com/people/account"
|
|
log_warn " 2. Ensure your sub-account has API access enabled"
|
|
log_warn " 3. Check username and password are correct"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Ensure UpCloud credentials are available (env var -> config file -> prompt+save)
|
|
ensure_upcloud_credentials() {
|
|
check_python_available || return 1
|
|
|
|
# 1. Check environment variables
|
|
if [[ -n "${UPCLOUD_USERNAME:-}" ]] && [[ -n "${UPCLOUD_PASSWORD:-}" ]]; then
|
|
log_info "Using UpCloud credentials from environment"
|
|
if ! test_upcloud_credentials; then
|
|
return 1
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
# 2. Check config file
|
|
local config_file="$HOME/.config/spawn/upcloud.json"
|
|
if [[ -f "${config_file}" ]]; then
|
|
local saved_username saved_password
|
|
saved_username=$(python3 -c "import json, sys; print(json.load(open(sys.argv[1])).get('username',''))" "${config_file}" 2>/dev/null)
|
|
saved_password=$(python3 -c "import json, sys; print(json.load(open(sys.argv[1])).get('password',''))" "${config_file}" 2>/dev/null)
|
|
if [[ -n "${saved_username}" ]] && [[ -n "${saved_password}" ]]; then
|
|
export UPCLOUD_USERNAME="${saved_username}"
|
|
export UPCLOUD_PASSWORD="${saved_password}"
|
|
log_info "Using UpCloud credentials from ${config_file}"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# 3. Prompt and save
|
|
echo ""
|
|
log_warn "UpCloud API Credentials Required"
|
|
log_warn "Create API credentials at: https://hub.upcloud.com/people/account"
|
|
echo ""
|
|
|
|
local username
|
|
username=$(safe_read "Enter your UpCloud API username: ") || return 1
|
|
if [[ -z "${username}" ]]; then
|
|
log_error "Username is required"
|
|
return 1
|
|
fi
|
|
|
|
local password
|
|
password=$(safe_read "Enter your UpCloud API password: ") || return 1
|
|
if [[ -z "${password}" ]]; then
|
|
log_error "Password is required"
|
|
return 1
|
|
fi
|
|
|
|
export UPCLOUD_USERNAME="${username}"
|
|
export UPCLOUD_PASSWORD="${password}"
|
|
|
|
if ! test_upcloud_credentials; then
|
|
unset UPCLOUD_USERNAME UPCLOUD_PASSWORD
|
|
return 1
|
|
fi
|
|
|
|
# Save to config file
|
|
local config_dir
|
|
config_dir=$(dirname "${config_file}")
|
|
mkdir -p "${config_dir}"
|
|
cat > "${config_file}" << EOF
|
|
{
|
|
"username": "${username}",
|
|
"password": "${password}"
|
|
}
|
|
EOF
|
|
chmod 600 "${config_file}"
|
|
log_info "Credentials saved to ${config_file}"
|
|
}
|
|
|
|
# Get server name from env var or prompt
|
|
get_server_name() {
|
|
local server_name
|
|
server_name=$(get_resource_name "UPCLOUD_SERVER_NAME" "Enter server name: ") || return 1
|
|
|
|
if ! validate_server_name "$server_name"; then
|
|
return 1
|
|
fi
|
|
|
|
echo "$server_name"
|
|
}
|
|
|
|
# Find Ubuntu 24.04 template UUID
|
|
find_ubuntu_template() {
|
|
local response
|
|
response=$(upcloud_api GET "/storage/template")
|
|
|
|
local template_uuid
|
|
template_uuid=$(echo "$response" | python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
storages = data.get('storages', {}).get('storage', [])
|
|
for s in storages:
|
|
title = s.get('title', '').lower()
|
|
if 'ubuntu' in title and '24.04' in title:
|
|
print(s['uuid'])
|
|
break
|
|
else:
|
|
# Fallback to any Ubuntu template
|
|
for s in storages:
|
|
title = s.get('title', '').lower()
|
|
if 'ubuntu' in title:
|
|
print(s['uuid'])
|
|
break
|
|
" 2>/dev/null)
|
|
|
|
if [[ -z "${template_uuid}" ]]; then
|
|
log_error "Could not find Ubuntu template"
|
|
return 1
|
|
fi
|
|
|
|
echo "${template_uuid}"
|
|
}
|
|
|
|
# Create an UpCloud server
|
|
create_server() {
|
|
local name="$1"
|
|
local plan="${UPCLOUD_PLAN:-1xCPU-2GB}"
|
|
local zone="${UPCLOUD_ZONE:-de-fra1}"
|
|
|
|
log_warn "Creating UpCloud server '$name' (plan: $plan, zone: $zone)..."
|
|
|
|
# Find Ubuntu template
|
|
local template_uuid
|
|
template_uuid=$(find_ubuntu_template) || return 1
|
|
log_info "Using Ubuntu template: $template_uuid"
|
|
|
|
# Read SSH public key
|
|
local key_path="${HOME}/.ssh/id_ed25519.pub"
|
|
if [[ ! -f "${key_path}" ]]; then
|
|
log_error "SSH public key not found at ${key_path}"
|
|
return 1
|
|
fi
|
|
local ssh_pub_key
|
|
ssh_pub_key=$(cat "${key_path}")
|
|
|
|
# Build request body
|
|
local body
|
|
body=$(python3 -c "
|
|
import json
|
|
ssh_key = '''$ssh_pub_key'''
|
|
body = {
|
|
'server': {
|
|
'zone': '$zone',
|
|
'title': '$name',
|
|
'hostname': '$name',
|
|
'plan': '$plan',
|
|
'storage_devices': {
|
|
'storage_device': [
|
|
{
|
|
'action': 'clone',
|
|
'storage': '$template_uuid',
|
|
'title': '$name-os',
|
|
'size': 25,
|
|
'tier': 'maxiops'
|
|
}
|
|
]
|
|
},
|
|
'login_user': {
|
|
'username': 'root',
|
|
'create_password': 'no',
|
|
'ssh_keys': {
|
|
'ssh_key': [ssh_key.strip()]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
print(json.dumps(body))
|
|
")
|
|
|
|
local response
|
|
response=$(upcloud_api POST "/server" "$body")
|
|
|
|
# Check for errors
|
|
if echo "$response" | grep -q '"error"'; then
|
|
log_error "Failed to create UpCloud server"
|
|
local error_msg
|
|
error_msg=$(echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('error',{}).get('error_message','Unknown error'))" 2>/dev/null || echo "$response")
|
|
log_error "API Error: $error_msg"
|
|
log_warn "Common issues:"
|
|
log_warn " - Insufficient account balance"
|
|
log_warn " - Plan not available in zone (try different UPCLOUD_PLAN or UPCLOUD_ZONE)"
|
|
log_warn " - Server limit reached"
|
|
log_warn "Remediation: Check https://hub.upcloud.com/"
|
|
return 1
|
|
fi
|
|
|
|
# Extract server UUID and wait for IP
|
|
UPCLOUD_SERVER_UUID=$(echo "$response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['server']['uuid'])")
|
|
export UPCLOUD_SERVER_UUID
|
|
|
|
log_info "Server created: UUID=$UPCLOUD_SERVER_UUID"
|
|
|
|
# Wait for server to become started and get IP
|
|
log_warn "Waiting for server to become active..."
|
|
local max_attempts=60
|
|
local attempt=1
|
|
while [[ "$attempt" -le "$max_attempts" ]]; do
|
|
local status_response
|
|
status_response=$(upcloud_api GET "/server/$UPCLOUD_SERVER_UUID")
|
|
local status
|
|
status=$(echo "$status_response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['server']['state'])" 2>/dev/null || echo "unknown")
|
|
|
|
if [[ "$status" == "started" ]]; then
|
|
UPCLOUD_SERVER_IP=$(echo "$status_response" | python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
for iface in data['server'].get('ip_addresses', {}).get('ip_address', []):
|
|
if iface.get('access') == 'public' and iface.get('family') == 'IPv4':
|
|
print(iface['address'])
|
|
break
|
|
" 2>/dev/null)
|
|
export UPCLOUD_SERVER_IP
|
|
|
|
if [[ -n "${UPCLOUD_SERVER_IP}" ]]; then
|
|
log_info "Server active: IP=$UPCLOUD_SERVER_IP"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
log_warn "Server status: $status ($attempt/$max_attempts)"
|
|
sleep "${INSTANCE_STATUS_POLL_DELAY}"
|
|
attempt=$((attempt + 1))
|
|
done
|
|
|
|
log_error "Server did not become active in time"
|
|
return 1
|
|
}
|
|
|
|
# Wait for SSH connectivity
|
|
verify_server_connectivity() {
|
|
local ip="$1"
|
|
local max_attempts=${2:-30}
|
|
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 an UpCloud server
|
|
destroy_server() {
|
|
local server_uuid="$1"
|
|
|
|
log_warn "Stopping server $server_uuid..."
|
|
upcloud_api POST "/server/$server_uuid/stop" '{"stop_server":{"stop_type":"soft","timeout":"60"}}' >/dev/null 2>&1 || true
|
|
|
|
# Wait for server to stop
|
|
local max_attempts=30
|
|
local attempt=1
|
|
while [[ "$attempt" -le "$max_attempts" ]]; do
|
|
local status_response
|
|
status_response=$(upcloud_api GET "/server/$server_uuid")
|
|
local status
|
|
status=$(echo "$status_response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['server']['state'])" 2>/dev/null || echo "unknown")
|
|
if [[ "$status" == "stopped" ]]; then
|
|
break
|
|
fi
|
|
sleep 2
|
|
attempt=$((attempt + 1))
|
|
done
|
|
|
|
log_warn "Destroying server $server_uuid..."
|
|
local response
|
|
response=$(upcloud_api DELETE "/server/$server_uuid?storages=1")
|
|
|
|
if echo "$response" | grep -q '"error"'; then
|
|
log_error "Failed to destroy server: $response"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Server $server_uuid destroyed"
|
|
}
|
|
|
|
# List all UpCloud servers
|
|
list_servers() {
|
|
local response
|
|
response=$(upcloud_api GET "/server")
|
|
|
|
python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
servers = data.get('servers', {}).get('server', [])
|
|
if not servers:
|
|
print('No servers found')
|
|
sys.exit(0)
|
|
print(f\"{'TITLE':<25} {'UUID':<40} {'STATE':<12} {'ZONE':<12}\")
|
|
print('-' * 89)
|
|
for s in servers:
|
|
title = s.get('title', 'N/A')
|
|
uuid = s.get('uuid', 'N/A')
|
|
state = s.get('state', 'N/A')
|
|
zone = s.get('zone', 'N/A')
|
|
print(f'{title:<25} {uuid:<40} {state:<12} {zone:<12}')
|
|
" <<< "$response"
|
|
}
|
|
|
|
# Install base tools on server (cloud-init equivalent for UpCloud)
|
|
install_base_tools() {
|
|
local ip="$1"
|
|
|
|
log_warn "Installing base tools..."
|
|
run_server "$ip" "apt-get update -qq && apt-get install -y -qq curl unzip git zsh python3 > /dev/null 2>&1"
|
|
|
|
log_warn "Installing Bun..."
|
|
run_server "$ip" "curl -fsSL https://bun.sh/install | bash"
|
|
|
|
log_warn "Installing Node.js..."
|
|
run_server "$ip" "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 && apt-get install -y -qq nodejs > /dev/null 2>&1"
|
|
|
|
# Configure PATH
|
|
run_server "$ip" "printf '%s\n' 'export PATH=\"\${HOME}/.bun/bin:\${HOME}/.claude/local/bin:\${PATH}\"' >> /root/.bashrc"
|
|
run_server "$ip" "printf '%s\n' 'export PATH=\"\${HOME}/.bun/bin:\${HOME}/.claude/local/bin:\${PATH}\"' >> /root/.zshrc"
|
|
|
|
log_info "Base tools installed"
|
|
}
|