spawn/upcloud/lib/common.sh
A f121b60d80
fix(ux): show post-session summary with server status and reconnect info (#1037)
After an interactive SSH session ends, users are now shown:
- A warning that their server is still running (and may incur charges)
- A link to the cloud provider's dashboard to manage/delete it
- The SSH command to reconnect

This prevents users from unknowingly leaving servers running after
exiting their agent session. Covers all 25 SSH-based cloud providers.

Agent: ux-engineer

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-13 20:06:40 -05:00

307 lines
11 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"
SPAWN_DASHBOARD_URL="https://hub.upcloud.com/"
# Configurable timeout/delay constants
INSTANCE_STATUS_POLL_DELAY=${INSTANCE_STATUS_POLL_DELAY:-5}
# UpCloud API wrapper using Basic Auth with retry logic
# Usage: upcloud_api METHOD ENDPOINT [BODY] [MAX_RETRIES]
upcloud_api() {
local method="$1"
local endpoint="$2"
local body="${3:-}"
local max_retries="${4:-3}"
generic_cloud_api_custom_auth "$UPCLOUD_API_BASE" "$method" "$endpoint" "$body" "$max_retries" \
-u "${UPCLOUD_USERNAME}:${UPCLOUD_PASSWORD}"
}
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_error "How to fix:"
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
}
# Try loading UpCloud credentials from config file
# Returns 0 if loaded, 1 otherwise
# Ensure UpCloud credentials are available (env var -> config file -> prompt+save)
ensure_upcloud_credentials() {
ensure_multi_credentials "UpCloud" "$HOME/.config/spawn/upcloud.json" \
"https://hub.upcloud.com/people/account" test_upcloud_credentials \
"UPCLOUD_USERNAME:username:API Username" \
"UPCLOUD_PASSWORD:password:API Password"
}
# Get server name from env var or prompt
get_server_name() {
get_validated_server_name "UPCLOUD_SERVER_NAME" "Enter 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"
log_error ""
log_error "How to fix:"
log_error " - Ubuntu templates may not be available in zone ${UPCLOUD_ZONE:-de-fra1}"
log_error " - Try a different UPCLOUD_ZONE (e.g., de-fra1, us-chi1, fi-hel1)"
log_error " - Check available templates at: https://hub.upcloud.com/"
return 1
fi
echo "${template_uuid}"
}
# Wait for UpCloud server to become started and get its public IP
# Sets: UPCLOUD_SERVER_IP
# Usage: _wait_for_upcloud_server_ip SERVER_UUID [MAX_ATTEMPTS]
_wait_for_upcloud_server_ip() {
local server_uuid="$1"
local max_attempts=${2:-60}
generic_wait_for_instance upcloud_api "/server/${server_uuid}" \
"started" "d['server']['state']" \
"next((i['address'] for i in d['server'].get('ip_addresses',{}).get('ip_address',[]) if i.get('access')=='public' and i.get('family')=='IPv4'), '')" \
UPCLOUD_SERVER_IP "Server" "${max_attempts}"
}
# Build JSON request body for UpCloud server creation
# Usage: _build_upcloud_server_body NAME ZONE PLAN TEMPLATE_UUID JSON_SSH_KEY
_build_upcloud_server_body() {
local name="$1" zone="$2" plan="$3" template_uuid="$4" json_ssh_key="$5"
python3 -c "
import json, sys
ssh_key = json.loads(sys.stdin.read()).strip()
name, zone, plan, template_uuid = sys.argv[1:5]
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]
}
}
}
}
print(json.dumps(body))
" "$name" "$zone" "$plan" "$template_uuid" <<< "$json_ssh_key"
}
# Parse server UUID from create response, or log error and return 1
# Sets UPCLOUD_SERVER_UUID on success
_upcloud_handle_create_response() {
local response="$1"
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 "Check your dashboard: https://hub.upcloud.com/"
return 1
fi
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"
}
# Create an UpCloud server
create_server() {
local name="$1"
local plan="${UPCLOUD_PLAN:-1xCPU-2GB}"
local zone="${UPCLOUD_ZONE:-de-fra1}"
# Validate env var inputs to prevent injection into Python code
validate_resource_name "$plan" || { log_error "Invalid UPCLOUD_PLAN"; return 1; }
validate_region_name "$zone" || { log_error "Invalid UPCLOUD_ZONE"; return 1; }
log_step "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 - pass SSH key safely via stdin
local json_ssh_key
json_ssh_key=$(json_escape "$ssh_pub_key")
local body
body=$(_build_upcloud_server_body "$name" "$zone" "$plan" "$template_uuid" "$json_ssh_key")
local response
response=$(upcloud_api POST "/server" "$body")
_upcloud_handle_create_response "$response" || return 1
_wait_for_upcloud_server_ip "$UPCLOUD_SERVER_UUID"
}
# 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 an UpCloud server
destroy_server() {
local server_uuid="$1"
log_step "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_step "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 $server_uuid"
log_error "API Error: $(extract_api_error_message "$response" "$response")"
log_error ""
log_error "The server may still be running and incurring charges."
log_error "Delete it manually at: https://hub.upcloud.com/"
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_step "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_step "Installing Bun..."
run_server "$ip" "curl -fsSL https://bun.sh/install | bash"
log_step "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"
}