mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-05 15:40:47 +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>
339 lines
10 KiB
Bash
339 lines
10 KiB
Bash
#!/bin/bash
|
|
set -eo pipefail
|
|
# Common bash functions for Netcup cloud 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
|
|
|
|
# Note: Provider-agnostic functions (logging, OAuth, browser, nc_listen) are now in shared/common.sh
|
|
|
|
# ============================================================
|
|
# Netcup Cloud specific functions
|
|
# ============================================================
|
|
|
|
readonly NETCUP_API_BASE="https://ccp.netcup.net/run/webservice/servers/endpoint.php"
|
|
# SSH_OPTS is now defined in shared/common.sh
|
|
|
|
# Check if a Netcup API response indicates success
|
|
# Returns 0 on success, 1 on failure
|
|
_netcup_is_success() {
|
|
local response="$1"
|
|
_extract_json_field "$response" "d.get('status','')" | grep -q "^success$"
|
|
}
|
|
|
|
# Build JSON login request body
|
|
_netcup_build_login_body() {
|
|
python3 -c "
|
|
import json, sys
|
|
print(json.dumps({
|
|
'action': 'login',
|
|
'param': {
|
|
'customernumber': sys.argv[1],
|
|
'apikey': sys.argv[2],
|
|
'apipassword': sys.argv[3]
|
|
}
|
|
}))
|
|
" "$1" "$2" "$3"
|
|
}
|
|
|
|
# Netcup uses session-based authentication with API credentials
|
|
# Get session token from API credentials
|
|
netcup_get_session() {
|
|
local customer_number="${NETCUP_CUSTOMER_NUMBER:-}"
|
|
local api_key="${NETCUP_API_KEY:-}"
|
|
local api_password="${NETCUP_API_PASSWORD:-}"
|
|
|
|
if [[ -z "$customer_number" || -z "$api_key" || -z "$api_password" ]]; then
|
|
log_error "Missing Netcup credentials"
|
|
return 1
|
|
fi
|
|
|
|
local body
|
|
body=$(_netcup_build_login_body "$customer_number" "$api_key" "$api_password")
|
|
|
|
local response
|
|
response=$(curl -fsSL -X POST "$NETCUP_API_BASE" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$body" 2>&1) || {
|
|
log_error "Failed to connect to Netcup API"
|
|
return 1
|
|
}
|
|
|
|
if ! _netcup_is_success "$response"; then
|
|
log_error "Failed to authenticate with Netcup API"
|
|
log_error "Response: $response"
|
|
return 1
|
|
fi
|
|
|
|
_extract_json_field "$response" "d['responsedata']['apisessionid']"
|
|
}
|
|
|
|
# Centralized API call wrapper for Netcup
|
|
netcup_api() {
|
|
local action="$1"
|
|
local param="${2:-{}}"
|
|
|
|
# Get or reuse session
|
|
if [[ -z "${NETCUP_SESSION_ID:-}" ]]; then
|
|
NETCUP_SESSION_ID=$(netcup_get_session) || return 1
|
|
export NETCUP_SESSION_ID
|
|
fi
|
|
|
|
local body
|
|
body=$(echo "$param" | python3 -c "
|
|
import json, sys
|
|
param = json.loads(sys.stdin.read())
|
|
print(json.dumps({'action': sys.argv[1], 'param': param}))
|
|
" "$action")
|
|
|
|
curl -fsSL -X POST "$NETCUP_API_BASE" \
|
|
-H "Content-Type: application/json" \
|
|
-H "X-API-Session-Id: $NETCUP_SESSION_ID" \
|
|
-d "$body"
|
|
}
|
|
|
|
test_netcup_credentials() {
|
|
local session_id
|
|
session_id=$(netcup_get_session 2>&1)
|
|
if [[ -z "$session_id" ]] || echo "$session_id" | grep -q "error\|ERROR\|failed\|Failed"; then
|
|
log_error "Netcup API authentication failed"
|
|
log_error ""
|
|
log_error "How to fix:"
|
|
log_error " 1. Log in to your Netcup SCP at https://ccp.netcup.net/"
|
|
log_error " 2. Navigate to Settings → API → Create API Key"
|
|
log_error " 3. Set the following environment variables:"
|
|
log_error " - NETCUP_CUSTOMER_NUMBER (your customer number)"
|
|
log_error " - NETCUP_API_KEY (from SCP)"
|
|
log_error " - NETCUP_API_PASSWORD (from SCP)"
|
|
return 1
|
|
fi
|
|
# Store session for reuse
|
|
NETCUP_SESSION_ID="$session_id"
|
|
export NETCUP_SESSION_ID
|
|
return 0
|
|
}
|
|
|
|
# Ensure Netcup credentials are available
|
|
ensure_netcup_credentials() {
|
|
ensure_multi_credentials "Netcup" "$HOME/.config/spawn/netcup.json" \
|
|
"https://ccp.netcup.net/ -> Settings -> API" test_netcup_credentials \
|
|
"NETCUP_CUSTOMER_NUMBER:customer_number:Customer Number" \
|
|
"NETCUP_API_KEY:api_key:API Key" \
|
|
"NETCUP_API_PASSWORD:api_password:API Password"
|
|
}
|
|
|
|
# Check if SSH key is registered with Netcup
|
|
netcup_check_ssh_key() {
|
|
local fingerprint="$1"
|
|
# Netcup doesn't have SSH key management via API - we'll use cloud-init to inject keys
|
|
return 1
|
|
}
|
|
|
|
# Register SSH key with Netcup (no-op - using cloud-init instead)
|
|
netcup_register_ssh_key() {
|
|
# Netcup doesn't support SSH key registration via API
|
|
# We inject SSH keys via cloud-init userdata instead
|
|
return 0
|
|
}
|
|
|
|
# Ensure SSH key exists locally
|
|
ensure_ssh_key() {
|
|
generate_ssh_key_if_missing "$HOME/.ssh/spawn_ed25519"
|
|
}
|
|
|
|
# Get server name from env var or prompt
|
|
get_server_name() {
|
|
get_validated_server_name "NETCUP_SERVER_NAME" "Enter server name: "
|
|
}
|
|
|
|
# List available VPS products
|
|
_list_vps_products() {
|
|
local response
|
|
response=$(netcup_api "getVServerProducts" "{}")
|
|
|
|
echo "$response" | python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
if data.get('status') != 'success':
|
|
sys.exit(1)
|
|
products = data.get('responsedata', {}).get('products', [])
|
|
for p in sorted(products, key=lambda x: float(x.get('price', 999))):
|
|
name = p.get('name', 'Unknown')
|
|
cores = p.get('cores', '?')
|
|
ram = p.get('ram', '?')
|
|
disk = p.get('disk', '?')
|
|
price = p.get('price', '?')
|
|
print(f'{name}|{cores} vCPU|{ram} MB RAM|{disk} GB disk|\${price}/mo')
|
|
"
|
|
}
|
|
|
|
# Interactive VPS product picker (delegates to shared interactive_pick)
|
|
_pick_vps_product() {
|
|
interactive_pick "NETCUP_VPS_PRODUCT" "VPS 200 G10" "VPS products" "_list_vps_products"
|
|
}
|
|
|
|
# List available datacenters
|
|
_list_datacenters() {
|
|
# Netcup datacenters are in Nuremberg and Vienna
|
|
echo "Nuremberg|DE|Germany"
|
|
echo "Vienna|AT|Austria"
|
|
}
|
|
|
|
# Interactive datacenter picker (delegates to shared interactive_pick)
|
|
_pick_datacenter() {
|
|
interactive_pick "NETCUP_DATACENTER" "Nuremberg" "datacenters" "_list_datacenters"
|
|
}
|
|
|
|
# Build JSON request body for Netcup VPS creation
|
|
# Reads cloud-init userdata from stdin
|
|
# Usage: get_cloud_init_userdata | _netcup_build_create_body NAME PRODUCT DATACENTER IMAGE
|
|
_netcup_build_create_body() {
|
|
python3 -c "
|
|
import json, sys
|
|
userdata = sys.stdin.read()
|
|
name, product, datacenter, image = sys.argv[1:5]
|
|
param = {
|
|
'vservername': name,
|
|
'product': product,
|
|
'datacenter': datacenter,
|
|
'image': image,
|
|
'password': 'TempPass123!',
|
|
'userdata': userdata
|
|
}
|
|
print(json.dumps(param))
|
|
" "$@"
|
|
}
|
|
|
|
# Poll the Netcup API until the VPS has an IPv4 address
|
|
# Sets NETCUP_SERVER_IP on success
|
|
_netcup_wait_for_ip() {
|
|
log_step "Waiting for IP assignment..."
|
|
local ip="" attempts=0
|
|
while [[ -z "$ip" ]] && [[ $attempts -lt 60 ]]; do
|
|
sleep 5
|
|
local info_response
|
|
info_response=$(netcup_api "getVServerInfo" "{\"vserverid\": \"$NETCUP_SERVER_ID\"}")
|
|
if _netcup_is_success "$info_response"; then
|
|
ip=$(_extract_json_field "$info_response" "d.get('responsedata',{}).get('ipv4','')")
|
|
fi
|
|
attempts=$((attempts + 1))
|
|
if [[ -z "$ip" ]] && [[ $((attempts % 5)) -eq 0 ]]; then
|
|
log_step "Still waiting for IP assignment... (attempt ${attempts}/60)"
|
|
fi
|
|
done
|
|
|
|
if [[ -z "$ip" ]]; then
|
|
log_error "Timeout waiting for IP assignment after 60 attempts"
|
|
return 1
|
|
fi
|
|
|
|
NETCUP_SERVER_IP="$ip"
|
|
export NETCUP_SERVER_IP
|
|
log_info "Server IP: $NETCUP_SERVER_IP"
|
|
}
|
|
|
|
# Create a Netcup VPS with cloud-init
|
|
create_server() {
|
|
local name="$1"
|
|
|
|
# Interactive selections
|
|
local datacenter
|
|
datacenter=$(_pick_datacenter)
|
|
|
|
local product
|
|
product=$(_pick_vps_product)
|
|
|
|
local image="ubuntu-24.04"
|
|
|
|
log_step "Creating Netcup VPS '$name' (product: $product, datacenter: $datacenter)..."
|
|
|
|
# Get cloud-init userdata and build request body
|
|
local param
|
|
param=$(get_cloud_init_userdata | _netcup_build_create_body "$name" "$product" "$datacenter" "$image")
|
|
|
|
local response
|
|
response=$(netcup_api "createVServer" "$param")
|
|
|
|
if ! _netcup_is_success "$response"; then
|
|
log_error "Failed to create Netcup VPS"
|
|
log_error "API Error: $(_extract_json_field "$response" "d.get('longmessage','Unknown error')")"
|
|
log_error ""
|
|
log_error "Common issues:"
|
|
log_error " - Insufficient account balance"
|
|
log_error " - Product not available in selected datacenter"
|
|
log_error " - Account limits reached"
|
|
log_error ""
|
|
log_error "Check your account: https://ccp.netcup.net/"
|
|
return 1
|
|
fi
|
|
|
|
# Extract server ID
|
|
NETCUP_SERVER_ID=$(_extract_json_field "$response" "d['responsedata']['vserverid']")
|
|
export NETCUP_SERVER_ID
|
|
log_info "VPS created: ID=$NETCUP_SERVER_ID"
|
|
|
|
# Wait for IP assignment
|
|
_netcup_wait_for_ip
|
|
}
|
|
|
|
# SSH operations — delegates to shared helpers (SSH_USER defaults to root)
|
|
# Netcup uses longer timeouts (max 60 attempts, 10s initial interval)
|
|
verify_server_connectivity() { ssh_verify_connectivity "${1}" "${2:-60}" 10; }
|
|
run_server() { ssh_run_server "$@"; }
|
|
upload_file() { ssh_upload_file "$@"; }
|
|
interactive_session() { ssh_interactive_session "$@"; }
|
|
|
|
# Destroy a Netcup VPS
|
|
destroy_server() {
|
|
local server_id="$1"
|
|
|
|
log_step "Destroying VPS $server_id..."
|
|
local response
|
|
response=$(netcup_api "deleteVServer" "{\"vserverid\": \"$server_id\"}")
|
|
|
|
if ! _netcup_is_success "$response"; then
|
|
log_error "Failed to destroy VPS $server_id"
|
|
log_error "API Error: $(_extract_json_field "$response" "d.get('longmessage','Unknown error')")"
|
|
log_error ""
|
|
log_error "The VPS may still be running and incurring charges."
|
|
log_error "Delete it manually at: https://ccp.netcup.net/"
|
|
return 1
|
|
fi
|
|
|
|
log_info "VPS $server_id destroyed"
|
|
}
|
|
|
|
# List all Netcup VPS
|
|
list_servers() {
|
|
local response
|
|
response=$(netcup_api "listVServers" "{}")
|
|
|
|
python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
if data.get('status') != 'success':
|
|
print('Failed to list servers')
|
|
sys.exit(1)
|
|
servers = data.get('responsedata', [])
|
|
if not servers:
|
|
print('No servers found')
|
|
sys.exit(0)
|
|
print(f\"{'NAME':<25} {'ID':<12} {'STATUS':<12} {'IP':<16}\")
|
|
print('-' * 65)
|
|
for s in servers:
|
|
name = s.get('vservername', 'N/A')
|
|
sid = str(s.get('vserverid', 'N/A'))
|
|
status = s.get('status', 'N/A')
|
|
ip = s.get('ipv4', 'N/A')
|
|
print(f'{name:<25} {sid:<12} {status:<12} {ip:<16}')
|
|
" <<< "$response"
|
|
}
|