spawn/hostinger/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

304 lines
11 KiB
Bash

#!/bin/bash
set -eo pipefail
# Common bash functions for Hostinger VPS 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
# ============================================================
# Hostinger VPS specific functions
# ============================================================
readonly HOSTINGER_API_BASE="https://api.hostinger.com/vps/v1"
# SSH_OPTS is now defined in shared/common.sh
# Centralized curl wrapper for Hostinger API
hostinger_api() {
local method="$1"
local endpoint="$2"
local body="${3:-}"
# shellcheck disable=SC2154
generic_cloud_api "$HOSTINGER_API_BASE" "$HOSTINGER_API_KEY" "$method" "$endpoint" "$body"
}
test_hostinger_token() {
local response
response=$(hostinger_api GET "/virtual-machines")
if echo "$response" | grep -q '"error"\|"message"'; then
# Parse error details
local error_msg
error_msg=$(echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('message','') or d.get('error','No details available'))" 2>/dev/null || echo "Unable to parse error")
log_error "API Error: $error_msg"
log_error ""
log_error "How to fix:"
log_error " 1. Log into hPanel at: https://hpanel.hostinger.com/"
log_error " 2. Click your Profile icon → Account Information"
log_error " 3. Navigate to API in the sidebar"
log_error " 4. Click 'Generate token' or 'New token'"
log_error " 5. Set token name and expiration, then click Generate"
log_error " 6. Copy the token and set: export HOSTINGER_API_KEY=..."
return 1
fi
return 0
}
# Ensure HOSTINGER_API_KEY is available (env var → config file → prompt+save)
ensure_hostinger_token() {
ensure_api_token_with_provider \
"Hostinger" \
"HOSTINGER_API_KEY" \
"$HOME/.config/spawn/hostinger.json" \
"https://hpanel.hostinger.com/ → Profile → Account Information → API" \
"test_hostinger_token"
}
# Check if SSH key is registered with Hostinger
hostinger_check_ssh_key() {
check_ssh_key_by_fingerprint hostinger_api "/ssh-keys" "$1"
}
# Register SSH key with Hostinger
hostinger_register_ssh_key() {
local key_name="$1"
local pub_path="$2"
local pub_key
pub_key=$(cat "$pub_path")
local json_pub_key json_name
json_pub_key=$(json_escape "$pub_key")
json_name=$(json_escape "$key_name")
local register_body="{\"name\":$json_name,\"public_key\":$json_pub_key}"
local register_response
register_response=$(hostinger_api POST "/ssh-keys" "$register_body")
if echo "$register_response" | grep -q '"error"\|"message".*fail'; then
# Parse error details
local error_msg
error_msg=$(echo "$register_response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('message','') or d.get('error','Unknown error'))" 2>/dev/null || echo "$register_response")
log_error "API Error: $error_msg"
log_error ""
log_error "Common causes:"
log_error " - SSH key already registered with this name"
log_error " - Invalid SSH key format (must be valid ed25519 or RSA public key)"
log_error " - API token lacks write permissions"
return 1
fi
return 0
}
# Ensure SSH key exists locally and is registered with Hostinger
ensure_ssh_key() {
ensure_ssh_key_with_provider hostinger_check_ssh_key hostinger_register_ssh_key "Hostinger"
}
# Get server name from env var or prompt
get_server_name() {
get_validated_server_name "HOSTINGER_SERVER_NAME" "Enter VPS name: "
}
# Fetch available VPS plans
# Outputs: "id|name|vcpus|ram_gb|disk_gb|price" lines
_list_vps_plans() {
local response
response=$(hostinger_api GET "/plans")
echo "$response" | python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
plans = []
for p in data.get('plans', []):
if p.get('available', True):
plan_id = p['id']
name = p.get('name', plan_id)
vcpus = p.get('vcpus', 'N/A')
ram = p.get('ram_mb', 0) / 1024.0
disk = p.get('disk_gb', 'N/A')
price = float(p.get('price_monthly', 0)) / 730.0 # Monthly to hourly
plans.append((price, plan_id, name, vcpus, ram, disk))
plans.sort()
for price, pid, name, vcpus, ram, disk in plans:
print(f'{pid}|{name}|{vcpus} vCPU|{ram:.1f} GB RAM|{disk} GB disk|\${price:.4f}/hr')
"
}
# Fetch available locations
# Outputs: "id|name|country" lines
_list_locations() {
local response
response=$(hostinger_api GET "/locations")
echo "$response" | python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
for loc in sorted(data.get('locations', []), key=lambda l: l.get('name', '')):
loc_id = loc['id']
name = loc.get('name', loc_id)
country = loc.get('country', 'Unknown')
print(f\"{loc_id}|{name}|{country}\")
"
}
# Interactive location picker (skipped if HOSTINGER_LOCATION is set)
_pick_location() {
interactive_pick "HOSTINGER_LOCATION" "eu-central" "locations" _list_locations
}
# Interactive VPS plan picker (skipped if HOSTINGER_PLAN is set)
_pick_plan() {
interactive_pick "HOSTINGER_PLAN" "kvm1" "VPS plans" _list_vps_plans "kvm1"
}
# Build JSON body for Hostinger VPS creation
# Pipes cloud-init userdata via stdin to avoid bash quoting issues
_hostinger_build_create_body() {
local name="$1" plan="$2" location="$3" os_template="$4" ssh_key_ids="$5"
local userdata
userdata=$(get_cloud_init_userdata)
echo "$userdata" | python3 -c "
import json, sys
userdata = sys.stdin.read()
name, plan, location, os_template, ssh_key_ids = sys.argv[1:6]
body = {
'hostname': name,
'plan': plan,
'location': location,
'os_template': os_template,
'ssh_keys': json.loads(ssh_key_ids),
'cloud_init': userdata,
'start_after_create': True
}
print(json.dumps(body))
" "$name" "$plan" "$location" "$os_template" "$ssh_key_ids"
}
# Check Hostinger API response for errors and log diagnostics
# Returns 0 if error detected, 1 if no error
_hostinger_handle_create_error() {
local response="$1"
if echo "$response" | grep -q '"error"\|"message".*fail'; then
log_error "Failed to create Hostinger VPS"
local error_msg
error_msg=$(echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('message','') or d.get('error','Unknown error'))" 2>/dev/null || echo "$response")
log_error "API Error: $error_msg"
log_error ""
log_error "Common issues:"
log_error " - Insufficient account balance or payment method required"
log_error " - Plan/location unavailable (try different HOSTINGER_PLAN or HOSTINGER_LOCATION)"
log_error " - VPS limit reached for your account"
log_error " - Invalid cloud-init userdata"
log_error ""
log_error "Check your account status: https://hpanel.hostinger.com/"
return 0
fi
return 1
}
# Create a Hostinger VPS with cloud-init
create_server() {
local name="$1"
# Interactive location + plan selection (skipped if env vars are set)
local location
location=$(_pick_location)
local plan
plan=$(_pick_plan)
local os_template="${HOSTINGER_OS_TEMPLATE:-ubuntu-24.04}"
# Validate inputs to prevent injection into Python code
validate_resource_name "$plan" || { log_error "Invalid HOSTINGER_PLAN"; return 1; }
validate_region_name "$location" || { log_error "Invalid HOSTINGER_LOCATION"; return 1; }
validate_resource_name "$os_template" || { log_error "Invalid HOSTINGER_OS_TEMPLATE"; return 1; }
log_step "Creating Hostinger VPS '$name' (plan: $plan, location: $location)..."
# Get all SSH key IDs
local ssh_keys_response
ssh_keys_response=$(hostinger_api GET "/ssh-keys")
local ssh_key_ids
ssh_key_ids=$(extract_ssh_key_ids "$ssh_keys_response" "keys")
local body
body=$(_hostinger_build_create_body "$name" "$plan" "$location" "$os_template" "$ssh_key_ids")
local response
response=$(hostinger_api POST "/virtual-machines" "$body")
if _hostinger_handle_create_error "$response"; then
return 1
fi
# Extract VPS ID and IP
HOSTINGER_VPS_ID=$(echo "$response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('id',''))")
HOSTINGER_VPS_IP=$(echo "$response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('ipv4',''))")
export HOSTINGER_VPS_ID HOSTINGER_VPS_IP
log_info "VPS created: ID=$HOSTINGER_VPS_ID, IP=$HOSTINGER_VPS_IP"
}
# SSH operations — delegates to shared helpers (SSH_USER defaults to root)
verify_server_connectivity() { ssh_verify_connectivity "$@"; }
run_server() { ssh_run_server "$@"; }
upload_file() { ssh_upload_file "$@"; }
interactive_session() { ssh_interactive_session "$@"; }
# Destroy a Hostinger VPS
destroy_server() {
local vps_id="$1"
log_step "Destroying VPS $vps_id..."
local response
response=$(hostinger_api DELETE "/virtual-machines/$vps_id")
if echo "$response" | grep -q '"error"\|"message".*fail'; then
log_error "Failed to destroy VPS $vps_id"
local error_msg
error_msg=$(echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('message','') or d.get('error','Unknown error'))" 2>/dev/null || echo "$response")
log_error "API Error: $error_msg"
log_error ""
log_error "The VPS may still be running and incurring charges."
log_error "Delete it manually at: https://hpanel.hostinger.com/"
return 1
fi
log_info "VPS $vps_id destroyed"
}
# List all Hostinger VPSs
list_servers() {
local response
response=$(hostinger_api GET "/virtual-machines")
python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
vpss = data.get('virtual_machines', [])
if not vpss:
print('No VPS instances found')
sys.exit(0)
print(f\"{'NAME':<25} {'ID':<12} {'STATUS':<12} {'IP':<16} {'PLAN':<10}\")
print('-' * 75)
for v in vpss:
name = v.get('hostname', 'N/A')
vid = str(v.get('id', 'N/A'))
status = v.get('status', 'unknown')
ip = v.get('ipv4', 'N/A')
plan = v.get('plan', 'N/A')
print(f'{name:<25} {vid:<12} {status:<12} {ip:<16} {plan:<10}')
" <<< "$response"
}