spawn/netcup/lib/common.sh
A d25cdd0da6
fix: use log_step for progress messages in netcup scripts (#441)
All netcup agent scripts were using log_warn (yellow) for routine
progress messages like "Installing...", "Setting up...", "Starting...".
These should use log_step (cyan) which was added specifically for
progress/status messages, reserving log_warn for actual warnings.

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-11 04:45:44 -08:00

456 lines
14 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
# 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=$(python3 -c "
import json, sys
print(json.dumps({
'action': 'login',
'param': {
'customernumber': sys.argv[1],
'apikey': sys.argv[2],
'apipassword': sys.argv[3]
}
}))
" "$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
}
# Extract session ID (apisessionid)
local session_id
session_id=$(echo "$response" | python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
if data.get('status') == 'success':
print(data['responsedata']['apisessionid'])
else:
sys.exit(1)
except:
sys.exit(1)
" 2>/dev/null) || {
log_error "Failed to authenticate with Netcup API"
log_error "Response: $response"
return 1
}
echo "$session_id"
}
# 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() {
local config_file="$HOME/.config/spawn/netcup.json"
# Try loading from env vars first
if [[ -n "${NETCUP_CUSTOMER_NUMBER:-}" && -n "${NETCUP_API_KEY:-}" && -n "${NETCUP_API_PASSWORD:-}" ]]; then
if test_netcup_credentials; then
return 0
fi
fi
# Try loading from config file (single python3 call instead of 3)
local creds
if creds=$(_load_json_config_fields "$config_file" customer_number api_key api_password); then
local saved_num saved_key saved_pass
{ read -r saved_num; read -r saved_key; read -r saved_pass; } <<< "${creds}"
if [[ -n "$saved_num" ]] && [[ -n "$saved_key" ]] && [[ -n "$saved_pass" ]]; then
log_info "Loading Netcup credentials from $config_file"
export NETCUP_CUSTOMER_NUMBER="$saved_num" NETCUP_API_KEY="$saved_key" NETCUP_API_PASSWORD="$saved_pass"
if test_netcup_credentials; then
return 0
fi
fi
fi
# Prompt for credentials
log_info "Netcup credentials not found"
log_info "Get your API credentials at: https://ccp.netcup.net/ → Settings → API"
log_info ""
NETCUP_CUSTOMER_NUMBER=$(safe_read "Enter Netcup customer number: ") || return 1
NETCUP_API_KEY=$(safe_read "Enter Netcup API key: ") || return 1
NETCUP_API_PASSWORD=$(safe_read "Enter Netcup API password: ") || return 1
export NETCUP_CUSTOMER_NUMBER NETCUP_API_KEY NETCUP_API_PASSWORD
# Test credentials
if ! test_netcup_credentials; then
log_error "Invalid Netcup credentials"
return 1
fi
_save_json_config "$config_file" \
customer_number "$NETCUP_CUSTOMER_NUMBER" \
api_key "$NETCUP_API_KEY" \
api_password "$NETCUP_API_PASSWORD"
return 0
}
# 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() {
local server_name
server_name=$(get_resource_name "NETCUP_SERVER_NAME" "Enter server name: ") || return 1
if ! validate_server_name "$server_name"; then
return 1
fi
echo "$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
_pick_vps_product() {
if [[ -n "${NETCUP_VPS_PRODUCT:-}" ]]; then
echo "$NETCUP_VPS_PRODUCT"
return
fi
log_info "Fetching available VPS products..."
local products
products=$(_list_vps_products)
if [[ -z "$products" ]]; then
log_warn "Could not fetch VPS products, using default: VPS 200 G10"
echo "VPS 200 G10"
return
fi
log_info "Available VPS products:"
local i=1
local names=()
while IFS='|' read -r name cores ram disk price; do
printf " %2d) %-15s %-8s %-12s %-13s %s\n" "$i" "$name" "$cores" "$ram" "$disk" "$price" >&2
names+=("$name")
i=$((i + 1))
done <<< "$products"
local choice
printf "\n" >&2
choice=$(safe_read "Select VPS product [1]: ") || choice=""
choice="${choice:-1}"
if [[ "$choice" -ge 1 && "$choice" -le "${#names[@]}" ]] 2>/dev/null; then
echo "${names[$((choice - 1))]}"
else
log_warn "Invalid choice, using default: VPS 200 G10"
echo "VPS 200 G10"
fi
}
# List available datacenters
_list_datacenters() {
# Netcup datacenters are in Nuremberg and Vienna
echo "Nuremberg|DE|Germany"
echo "Vienna|AT|Austria"
}
# Interactive datacenter picker
_pick_datacenter() {
if [[ -n "${NETCUP_DATACENTER:-}" ]]; then
echo "$NETCUP_DATACENTER"
return
fi
log_info "Available datacenters:"
local datacenters
datacenters=$(_list_datacenters)
local i=1
local names=()
while IFS='|' read -r name country_code country; do
printf " %2d) %-12s %s (%s)\n" "$i" "$name" "$country" "$country_code" >&2
names+=("$name")
i=$((i + 1))
done <<< "$datacenters"
local choice
printf "\n" >&2
choice=$(safe_read "Select datacenter [1]: ") || choice=""
choice="${choice:-1}"
if [[ "$choice" -ge 1 && "$choice" -le "${#names[@]}" ]] 2>/dev/null; then
echo "${names[$((choice - 1))]}"
else
log_warn "Invalid choice, using default: Nuremberg"
echo "Nuremberg"
fi
}
# 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_info "Waiting for IP assignment..."
local ip=""
local attempts=0
while [[ -z "$ip" ]] && [[ $attempts -lt 60 ]]; do
sleep 5
local info_response
info_response=$(netcup_api "getVServerInfo" "{\"vserverid\": \"$NETCUP_SERVER_ID\"}")
ip=$(echo "$info_response" | python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
if data.get('status') == 'success':
print(data['responsedata'].get('ipv4', ''))
except:
pass
" 2>/dev/null || echo "")
attempts=$((attempts + 1))
done
if [[ -z "$ip" ]]; then
log_error "Timeout waiting for IP assignment"
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")
# Check for errors
local status
status=$(echo "$response" | python3 -c "import json, sys; print(json.loads(sys.stdin.read()).get('status', 'error'))")
if [[ "$status" != "success" ]]; then
log_error "Failed to create Netcup VPS"
local error_msg
error_msg=$(echo "$response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('longmessage','Unknown error'))" 2>/dev/null || echo "$response")
log_error "API Error: $error_msg"
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=$(echo "$response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['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_warn "Destroying VPS $server_id..."
local response
response=$(netcup_api "deleteVServer" "{\"vserverid\": \"$server_id\"}")
local status
status=$(echo "$response" | python3 -c "import json, sys; print(json.loads(sys.stdin.read()).get('status', 'error'))")
if [[ "$status" != "success" ]]; then
log_error "Failed to destroy VPS: $response"
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"
}