spawn/upcloud/lib/common.sh
A bbbe815035
refactor: Security fixes, complexity reduction, and UX improvements (#58)
Security:
- Fix command injection in modal/lib/common.sh (run_server, upload_file, interactive_session)
- Fix command injection in fly/lib/common.sh (run_server, upload_file, interactive_session)
- All container providers now use printf '%q' for proper shell escaping

Complexity:
- Extract _api_should_retry_on_error() helper in shared/common.sh (-19 lines)
- Refactor scaleway_api and upcloud_api to use shared retry helper (-24 lines)
- Extract _save_fly_token() helper in fly/lib/common.sh (-11 lines)
- Extract validateAndGetAgent() in commands.ts, reducing cmdRun/cmdAgentInfo duplication
- Refactor cmdList column width calculation to use calculateColumnWidth()

UX:
- Add actionable next steps to error messages in shared/common.sh
- Improve CLI bash fallback error messages with guidance (spawn.sh)
- Add OAuth progress indicator during browser authentication wait
- Show invalid model ID value and link to openrouter.ai/models
- Add troubleshooting steps for agent installation failures

Tests:
- Update test assertions in test/run.sh to match refactored patterns
- All tests passing: 74 TypeScript + 75 bash = 149 total, 0 failures

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-08 17:09:27 -08:00

447 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
interval=$((interval * 2))
if [[ "${interval}" -gt "${max_interval}" ]]; then
interval="${max_interval}"
fi
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
interval=$((interval * 2))
if [[ "${interval}" -gt "${max_interval}" ]]; then
interval="${max_interval}"
fi
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"
}