spawn/netcup/lib/common.sh
A 5ebe3e5a13
fix: add actionable guidance to destroy_server failures and service timeouts (#959)
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>
2026-02-13 09:38:58 -08:00

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"
}