mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-29 04:19:30 +00:00
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>
447 lines
14 KiB
Bash
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"
|
|
}
|