spawn/upcloud/lib/common.sh
A cbcf79d376
refactor: Extract common API retry interval update logic to reduce duplication (#70)
- 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>
2026-02-09 03:36:08 -08:00

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"
}