mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-05 23:50:48 +00:00
OVH: Extract _ovh_extract_public_ipv4() and _ovh_extract_status() from wait_for_ovh_instance() to reduce inline Python and improve readability. Latitude: Extract _latitude_extract_error(), _latitude_get_ssh_key_ids(), and _latitude_build_server_body() from create_server() and latitude_register_ssh_key(). Deduplicates error extraction logic used in two places. Agent: complexity-hunter Co-authored-by: A <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
434 lines
13 KiB
Bash
434 lines
13 KiB
Bash
#!/bin/bash
|
|
set -eo pipefail
|
|
# Common bash functions for Latitude.sh 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
|
|
|
|
# ============================================================
|
|
# Latitude.sh specific functions
|
|
# ============================================================
|
|
|
|
readonly LATITUDE_API_BASE="https://api.latitude.sh"
|
|
|
|
# Centralized curl wrapper for Latitude.sh API
|
|
latitude_api() {
|
|
local method="$1"
|
|
local endpoint="$2"
|
|
local body="${3:-}"
|
|
# shellcheck disable=SC2154
|
|
generic_cloud_api "$LATITUDE_API_BASE" "$LATITUDE_API_KEY" "$method" "$endpoint" "$body"
|
|
}
|
|
|
|
# Test Latitude.sh API token validity
|
|
test_latitude_token() {
|
|
local response
|
|
response=$(latitude_api GET "/projects")
|
|
if echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if 'data' in d else 1)" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
local error_msg
|
|
error_msg=$(echo "$response" | python3 -c "
|
|
import json,sys
|
|
try:
|
|
d=json.loads(sys.stdin.read())
|
|
errors = d.get('errors', d.get('error', {}))
|
|
if isinstance(errors, list) and errors:
|
|
print(errors[0].get('detail', errors[0].get('title', 'Unknown error')))
|
|
elif isinstance(errors, dict):
|
|
print(errors.get('detail', errors.get('message', 'Unknown error')))
|
|
else:
|
|
print('Unknown error')
|
|
except: print('Unable to parse error')
|
|
" 2>/dev/null || echo "Unable to parse error")
|
|
log_error "API Error: $error_msg"
|
|
log_error ""
|
|
log_error "How to fix:"
|
|
log_error " 1. Verify your API key at: https://www.latitude.sh/dashboard → Settings & Billing → API Keys"
|
|
log_error " 2. Ensure the API key has not expired"
|
|
log_error " 3. Check that you have an active project"
|
|
return 1
|
|
}
|
|
|
|
# Ensure LATITUDE_API_KEY is available (env var -> config file -> prompt+save)
|
|
ensure_latitude_token() {
|
|
ensure_api_token_with_provider \
|
|
"Latitude.sh" \
|
|
"LATITUDE_API_KEY" \
|
|
"$HOME/.config/spawn/latitude.json" \
|
|
"https://www.latitude.sh/dashboard → Settings & Billing → API Keys" \
|
|
"test_latitude_token"
|
|
}
|
|
|
|
# Get the default project ID from the Latitude.sh account
|
|
get_latitude_project_id() {
|
|
if [[ -n "${LATITUDE_PROJECT_ID:-}" ]]; then
|
|
echo "$LATITUDE_PROJECT_ID"
|
|
return 0
|
|
fi
|
|
|
|
local response
|
|
response=$(latitude_api GET "/projects")
|
|
local project_id
|
|
project_id=$(echo "$response" | python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
projects = data.get('data', [])
|
|
if not projects:
|
|
sys.exit(1)
|
|
# Use first project
|
|
print(projects[0]['id'])
|
|
" 2>/dev/null)
|
|
|
|
if [[ -z "$project_id" ]]; then
|
|
log_error "No projects found in your Latitude.sh account"
|
|
log_error "Create a project at: https://www.latitude.sh/dashboard"
|
|
return 1
|
|
fi
|
|
|
|
LATITUDE_PROJECT_ID="$project_id"
|
|
export LATITUDE_PROJECT_ID
|
|
log_info "Using Latitude.sh project: $project_id"
|
|
echo "$project_id"
|
|
}
|
|
|
|
# Extract first error detail from Latitude.sh JSON:API error response
|
|
_latitude_extract_error() {
|
|
python3 -c "
|
|
import json,sys
|
|
try:
|
|
d=json.loads(sys.stdin.read())
|
|
errors = d.get('errors', [])
|
|
if isinstance(errors, list) and errors:
|
|
print(errors[0].get('detail', errors[0].get('title', 'Unknown error')))
|
|
else:
|
|
print('Unknown error')
|
|
except: print(sys.stdin.read())
|
|
" 2>/dev/null || cat
|
|
}
|
|
|
|
# Get all SSH key IDs from Latitude.sh account
|
|
_latitude_get_ssh_key_ids() {
|
|
local ssh_keys_response
|
|
ssh_keys_response=$(latitude_api GET "/ssh_keys")
|
|
echo "$ssh_keys_response" | python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
ids = [k['id'] for k in data.get('data', [])]
|
|
print(json.dumps(ids))
|
|
" 2>/dev/null || echo "[]"
|
|
}
|
|
|
|
# Build JSON:API request body for Latitude.sh server creation
|
|
# $1=hostname $2=plan $3=site $4=os $5=project_id $6=ssh_key_ids_json
|
|
_latitude_build_server_body() {
|
|
local hostname="$1" plan="$2" site="$3" os="$4" project_id="$5" ssh_key_ids="$6"
|
|
python3 -c "
|
|
import json
|
|
body = {
|
|
'data': {
|
|
'type': 'servers',
|
|
'attributes': {
|
|
'hostname': '$hostname',
|
|
'plan': '$plan',
|
|
'site': '$site',
|
|
'operating_system': '$os',
|
|
'project': '$project_id',
|
|
'ssh_keys': $ssh_key_ids
|
|
}
|
|
}
|
|
}
|
|
print(json.dumps(body))
|
|
"
|
|
}
|
|
|
|
# Check if SSH key is registered with Latitude.sh
|
|
latitude_check_ssh_key() {
|
|
local fingerprint="$1"
|
|
local existing_keys
|
|
existing_keys=$(latitude_api GET "/ssh_keys")
|
|
echo "$existing_keys" | grep -q "$fingerprint"
|
|
}
|
|
|
|
# Register SSH key with Latitude.sh
|
|
latitude_register_ssh_key() {
|
|
local key_name="$1"
|
|
local pub_path="$2"
|
|
local pub_key
|
|
pub_key=$(cat "$pub_path")
|
|
|
|
local body
|
|
body=$(echo "$pub_key" | python3 -c "
|
|
import json, sys
|
|
pub_key = sys.stdin.read().strip()
|
|
body = {
|
|
'data': {
|
|
'type': 'ssh_keys',
|
|
'attributes': {
|
|
'name': '$key_name',
|
|
'public_key': pub_key
|
|
}
|
|
}
|
|
}
|
|
print(json.dumps(body))
|
|
")
|
|
|
|
local response
|
|
response=$(latitude_api POST "/ssh_keys" "$body")
|
|
|
|
if echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if 'data' in d else 1)" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
|
|
local error_msg
|
|
error_msg=$(echo "$response" | _latitude_extract_error)
|
|
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 public key)"
|
|
log_error " - API key lacks write permissions"
|
|
return 1
|
|
}
|
|
|
|
# Ensure SSH key exists locally and is registered with Latitude.sh
|
|
ensure_ssh_key() {
|
|
ensure_ssh_key_with_provider latitude_check_ssh_key latitude_register_ssh_key "Latitude.sh"
|
|
}
|
|
|
|
# Get server name from env var or prompt
|
|
get_server_name() {
|
|
local server_name
|
|
server_name=$(get_resource_name "LATITUDE_SERVER_NAME" "Enter server name: ") || return 1
|
|
|
|
if ! validate_server_name "$server_name"; then
|
|
return 1
|
|
fi
|
|
|
|
echo "$server_name"
|
|
}
|
|
|
|
# Create a Latitude.sh server
|
|
create_server() {
|
|
local hostname="$1"
|
|
local plan="${LATITUDE_PLAN:-vm.tiny}"
|
|
local site="${LATITUDE_SITE:-DAL2}"
|
|
local os="${LATITUDE_OS:-ubuntu_24_04_x64_lts}"
|
|
|
|
# Validate env var inputs to prevent injection into Python code
|
|
validate_resource_name "$plan" || { log_error "Invalid LATITUDE_PLAN"; return 1; }
|
|
validate_region_name "$site" || { log_error "Invalid LATITUDE_SITE"; return 1; }
|
|
validate_resource_name "$os" || { log_error "Invalid LATITUDE_OS"; return 1; }
|
|
|
|
log_warn "Creating Latitude.sh server '$hostname' (plan: $plan, site: $site)..."
|
|
|
|
# Get project ID
|
|
local project_id
|
|
project_id=$(get_latitude_project_id) || return 1
|
|
|
|
# Get all SSH key IDs
|
|
local ssh_key_ids
|
|
ssh_key_ids=$(_latitude_get_ssh_key_ids)
|
|
|
|
local body
|
|
body=$(_latitude_build_server_body "$hostname" "$plan" "$site" "$os" "$project_id" "$ssh_key_ids")
|
|
|
|
local response
|
|
response=$(latitude_api POST "/servers" "$body")
|
|
|
|
# Check for errors
|
|
if ! echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if 'data' in d else 1)" 2>/dev/null; then
|
|
log_error "Failed to create Latitude.sh server"
|
|
local error_msg
|
|
error_msg=$(echo "$response" | _latitude_extract_error)
|
|
log_error "API Error: $error_msg"
|
|
log_error ""
|
|
log_error "Common issues:"
|
|
log_error " - Insufficient account balance or payment method required"
|
|
log_error " - Plan/site unavailable (try different LATITUDE_PLAN or LATITUDE_SITE)"
|
|
log_error " - Server limit reached for your account"
|
|
log_error ""
|
|
log_error "Check your account status: https://www.latitude.sh/dashboard"
|
|
return 1
|
|
fi
|
|
|
|
# Extract server ID
|
|
LATITUDE_SERVER_ID=$(echo "$response" | python3 -c "import json,sys; print(json.loads(sys.stdin.read())['data']['id'])")
|
|
export LATITUDE_SERVER_ID
|
|
|
|
log_info "Server created: ID=$LATITUDE_SERVER_ID"
|
|
log_warn "Waiting for server provisioning (this may take a few minutes for bare metal)..."
|
|
}
|
|
|
|
# Extract the IPv4 address from a Latitude.sh server API response
|
|
# Checks network.ip, ip_addresses[], and primary_ipv4 fields
|
|
# Usage: extract_latitude_server_ip JSON_RESPONSE
|
|
extract_latitude_server_ip() {
|
|
local response="$1"
|
|
echo "$response" | python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
server = data.get('data', {})
|
|
attrs = server.get('attributes', {})
|
|
# Check for IP in network attributes
|
|
network = attrs.get('network', {})
|
|
if isinstance(network, dict):
|
|
ip = network.get('ip', '')
|
|
if ip:
|
|
print(ip)
|
|
sys.exit(0)
|
|
# Check for IP in relationships or included data
|
|
ips = attrs.get('ip_addresses', [])
|
|
if isinstance(ips, list):
|
|
for ip_obj in ips:
|
|
if isinstance(ip_obj, dict):
|
|
addr = ip_obj.get('address', '')
|
|
if addr and ':' not in addr: # Skip IPv6
|
|
print(addr)
|
|
sys.exit(0)
|
|
elif isinstance(ip_obj, str) and ':' not in ip_obj:
|
|
print(ip_obj)
|
|
sys.exit(0)
|
|
# Fallback: try primary_ipv4
|
|
primary = attrs.get('primary_ipv4', '')
|
|
if primary:
|
|
print(primary)
|
|
sys.exit(0)
|
|
sys.exit(1)
|
|
" 2>/dev/null
|
|
}
|
|
|
|
# Wait for server to become active and get its IP address
|
|
wait_for_server_ready() {
|
|
local server_id="$1"
|
|
local max_attempts=${2:-60}
|
|
local attempt=1
|
|
|
|
log_warn "Waiting for server $server_id to become active..."
|
|
while [[ "$attempt" -le "$max_attempts" ]]; do
|
|
local response
|
|
response=$(latitude_api GET "/servers/$server_id")
|
|
|
|
local status
|
|
status=$(echo "$response" | python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
server = data.get('data', {})
|
|
attrs = server.get('attributes', {})
|
|
print(attrs.get('status', 'unknown'))
|
|
" 2>/dev/null || echo "unknown")
|
|
|
|
if [[ "$status" == "on" ]] || [[ "$status" == "active" ]]; then
|
|
LATITUDE_SERVER_IP=$(extract_latitude_server_ip "$response")
|
|
if [[ -n "$LATITUDE_SERVER_IP" ]]; then
|
|
export LATITUDE_SERVER_IP
|
|
log_info "Server active: IP=$LATITUDE_SERVER_IP"
|
|
return 0
|
|
fi
|
|
log_warn "Server active but IP not yet assigned... (attempt $attempt/$max_attempts)"
|
|
else
|
|
log_warn "Server status: $status (attempt $attempt/$max_attempts)"
|
|
fi
|
|
|
|
sleep 10
|
|
attempt=$((attempt + 1))
|
|
done
|
|
|
|
log_error "Server failed to become active after $max_attempts attempts"
|
|
return 1
|
|
}
|
|
|
|
# Wait for SSH connectivity
|
|
verify_server_connectivity() {
|
|
local ip="$1"
|
|
local max_attempts=${2:-30}
|
|
# shellcheck disable=SC2154
|
|
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 a Latitude.sh server
|
|
destroy_server() {
|
|
local server_id="$1"
|
|
|
|
log_warn "Destroying server $server_id..."
|
|
local response
|
|
response=$(latitude_api DELETE "/servers/$server_id")
|
|
|
|
if echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if d.get('errors') else 1)" 2>/dev/null; then
|
|
log_error "Failed to destroy server: $response"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Server $server_id destroyed"
|
|
}
|
|
|
|
# List all Latitude.sh servers
|
|
list_servers() {
|
|
local response
|
|
response=$(latitude_api GET "/servers")
|
|
|
|
python3 -c "
|
|
import json, sys
|
|
data = json.loads(sys.stdin.read())
|
|
servers = data.get('data', [])
|
|
if not servers:
|
|
print('No servers found')
|
|
sys.exit(0)
|
|
print(f\"{'HOSTNAME':<25} {'ID':<15} {'STATUS':<12} {'PLAN':<15} {'SITE':<10}\")
|
|
print('-' * 77)
|
|
for s in servers:
|
|
attrs = s.get('attributes', {})
|
|
hostname = attrs.get('hostname', 'N/A')
|
|
sid = str(s.get('id', 'N/A'))
|
|
status = attrs.get('status', 'N/A')
|
|
plan = attrs.get('plan', 'N/A')
|
|
site = attrs.get('site', 'N/A')
|
|
print(f'{hostname:<25} {sid:<15} {status:<12} {plan:<15} {site:<10}')
|
|
" <<< "$response"
|
|
}
|
|
|
|
# Install basic tools on the server (cloud-init equivalent for Latitude.sh)
|
|
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 > /dev/null 2>&1"
|
|
log_warn "Installing Bun..."
|
|
run_server "$ip" "curl -fsSL https://bun.sh/install | bash"
|
|
run_server "$ip" "printf '%s\n' 'export PATH=\"\${HOME}/.bun/bin:\${PATH}\"' >> /root/.bashrc"
|
|
run_server "$ip" "printf '%s\n' 'export PATH=\"\${HOME}/.bun/bin:\${PATH}\"' >> /root/.zshrc"
|
|
log_info "Base tools installed"
|
|
}
|