mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-02 13:50:26 +00:00
When server destruction fails, users are left with a bare error message and no indication that they may still be billed for a running server. This adds dashboard URLs and clear warnings to destroy_server errors across 9 clouds (Hetzner, UpCloud, Contabo, Netcup, RamNode, Hostinger, HOSTKEY, OVH, Latitude). Also improves error messages for Koyeb (app creation, service deployment, deployment timeout, instance ID), GitHub Codespaces (creation failure, readiness timeout), and E2B (sandbox creation failure). 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>
301 lines
10 KiB
Bash
301 lines
10 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 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"
|
|
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"
|
|
}
|