spawn/hyperstack/lib/common.sh
A d88a7d284a
refactor: Decompose Hyperstack create_vm and DigitalOcean create_server (#179)
Extract helpers from the two largest undecomposed provider functions:

- Hyperstack create_vm (104 -> 45 lines): extract _build_vm_request_body
  and _wait_for_vm_active
- DigitalOcean create_server (97 -> 54 lines): extract
  _build_droplet_request_body and _wait_for_droplet_active

Also fixes bash 3.x compat issue: ((attempt++)) -> attempt=$((attempt + 1))

Agent: complexity-hunter

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-10 07:43:19 -08:00

297 lines
9.3 KiB
Bash

#!/bin/bash
set -eo pipefail
# Common bash functions for Hyperstack 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
# ============================================================
# Hyperstack specific functions
# ============================================================
readonly HYPERSTACK_API_BASE="https://infrahub-api.nexgencloud.com/v1"
# SSH_OPTS is now defined in shared/common.sh
# Centralized curl wrapper for Hyperstack API
hyperstack_api() {
local method="$1"
local endpoint="$2"
local body="${3:-}"
# shellcheck disable=SC2154
generic_cloud_api "$HYPERSTACK_API_BASE" "$HYPERSTACK_API_KEY" "$method" "$endpoint" "$body" "api_key"
}
test_hyperstack_api_key() {
local response
response=$(hyperstack_api GET "/core/virtual-machines?per_page=1")
if echo "$response" | grep -q '"status".*4[0-9][0-9]'; 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','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. Verify your API key at: https://infrahub.hyperstack.cloud"
log_error " 2. Ensure the API key has proper permissions"
log_error " 3. Check the key hasn't been revoked"
return 1
fi
return 0
}
# Ensure HYPERSTACK_API_KEY is available (env var → config file → prompt+save)
ensure_hyperstack_api_key() {
ensure_api_token_with_provider \
"Hyperstack" \
"HYPERSTACK_API_KEY" \
"$HOME/.config/spawn/hyperstack.json" \
"https://infrahub.hyperstack.cloud → Settings → API Keys" \
"test_hyperstack_api_key"
}
# Check if SSH key is registered with Hyperstack
hyperstack_check_ssh_key() {
local fingerprint="$1"
local existing_keys
existing_keys=$(hyperstack_api GET "/core/keypairs")
echo "$existing_keys" | grep -q "$fingerprint"
}
# Register SSH key with Hyperstack
hyperstack_register_ssh_key() {
local key_name="$1"
local pub_path="$2"
local pub_key
pub_key=$(cat "$pub_path")
local json_pub_key
json_pub_key=$(json_escape "$pub_key")
local register_body="{\"name\":\"$key_name\",\"public_key\":$json_pub_key}"
local register_response
register_response=$(hyperstack_api POST "/core/keypairs" "$register_body")
if echo "$register_response" | grep -q '"status".*4[0-9][0-9]'; 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','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 key lacks write permissions"
return 1
fi
return 0
}
# Ensure SSH key exists locally and is registered with Hyperstack
ensure_ssh_key() {
ensure_ssh_key_with_provider hyperstack_check_ssh_key hyperstack_register_ssh_key "Hyperstack"
}
# Get VM name from env var or prompt
get_vm_name() {
local vm_name
vm_name=$(get_resource_name "HYPERSTACK_VM_NAME" "Enter VM name: ") || return 1
if ! validate_server_name "$vm_name"; then
return 1
fi
echo "$vm_name"
}
# Get environment name (required for VM creation)
get_environment_name() {
local env_name="${HYPERSTACK_ENVIRONMENT:-}"
if [[ -z "$env_name" ]]; then
log_warn "Fetching available environments..."
local envs_response
envs_response=$(hyperstack_api GET "/core/environments")
local env_list
env_list=$(echo "$envs_response" | python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
envs = data.get('environments', [])
if envs:
for env in envs[:5]:
print(f\" - {env['name']} (region: {env.get('region', 'N/A')})\")
" 2>/dev/null)
if [[ -n "$env_list" ]]; then
log_info "Available environments:"
echo "$env_list" >&2
fi
env_name=$(safe_read "Enter environment name (e.g., default-CANADA-1): ")
fi
echo "$env_name"
}
# Build the JSON request body for Hyperstack VM creation (uses stdin to prevent injection)
# Usage: _build_vm_request_body NAME ENVIRONMENT KEY_NAME IMAGE FLAVOR
_build_vm_request_body() {
local name="$1" environment="$2" key_name="$3" image="$4" flavor="$5"
printf '%s\n%s\n%s\n%s\n%s' "$name" "$environment" "$key_name" "$image" "$flavor" | python3 -c "
import json, sys
lines = sys.stdin.read().split('\n')
body = {
'name': lines[0],
'environment_name': lines[1],
'key_name': lines[2],
'image_name': lines[3],
'flavor_name': lines[4],
'count': 1,
'assign_floating_ip': True,
'security_rules': [
{
'direction': 'ingress',
'ethertype': 'IPv4',
'protocol': 'tcp',
'remote_ip_prefix': '0.0.0.0/0',
'port_range_min': 22,
'port_range_max': 22
}
]
}
print(json.dumps(body))
"
}
# Wait for a Hyperstack VM to become active and set its IP
# Sets: HYPERSTACK_VM_IP, HYPERSTACK_VM_ID (exported)
# Usage: _wait_for_vm_active VM_ID [MAX_WAIT_SECONDS]
_wait_for_vm_active() {
local vm_id="$1"
local max_wait="${2:-300}"
local elapsed=0
log_warn "Waiting for VM to become active..."
while [[ $elapsed -lt $max_wait ]]; do
local vm_info
vm_info=$(hyperstack_api GET "/core/virtual-machines/$vm_id")
local status
status=$(echo "$vm_info" | python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
print(data.get('status', ''))
" 2>/dev/null)
if [[ "$status" == "ACTIVE" ]]; then
HYPERSTACK_VM_IP=$(echo "$vm_info" | python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
print(data.get('floating_ip', ''))
" 2>/dev/null)
if [[ -n "$HYPERSTACK_VM_IP" ]]; then
log_info "VM is active with IP: $HYPERSTACK_VM_IP"
export HYPERSTACK_VM_IP
export HYPERSTACK_VM_ID="$vm_id"
return 0
fi
fi
sleep 5
elapsed=$((elapsed + 5))
done
log_error "VM did not become active within ${max_wait}s"
return 1
}
# Create a Hyperstack VM
create_vm() {
local name="$1"
local environment="${2:-}"
local flavor="${HYPERSTACK_FLAVOR:-n1-cpu-small}"
local image="${HYPERSTACK_IMAGE:-Ubuntu Server 24.04 LTS R5504 UEFI}"
local key_name="${HYPERSTACK_SSH_KEY_NAME:-spawn-key-$(whoami)}"
# Validate env var inputs to prevent injection
validate_resource_name "$flavor" || { log_error "Invalid HYPERSTACK_FLAVOR"; return 1; }
validate_resource_name "$key_name" || { log_error "Invalid HYPERSTACK_SSH_KEY_NAME"; return 1; }
log_warn "Creating Hyperstack VM '$name' (flavor: $flavor, env: $environment)..."
local body
body=$(_build_vm_request_body "$name" "$environment" "$key_name" "$image" "$flavor")
local response
response=$(hyperstack_api POST "/core/virtual-machines" "$body")
# Check for errors
if echo "$response" | grep -q '"status".*4[0-9][0-9]'; then
local error_msg
error_msg=$(echo "$response" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('message','Unknown error'))" 2>/dev/null || echo "$response")
log_error "Failed to create VM: $error_msg"
return 1
fi
# Extract VM ID from response
HYPERSTACK_VM_ID=$(echo "$response" | python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
vms = data.get('virtual_machines', [])
if vms:
print(vms[0].get('id', ''))
")
if [[ -z "$HYPERSTACK_VM_ID" ]]; then
log_error "Failed to extract VM ID from response"
return 1
fi
log_info "VM created with ID: $HYPERSTACK_VM_ID"
_wait_for_vm_active "$HYPERSTACK_VM_ID"
}
# Verify server connectivity via SSH
verify_server_connectivity() {
local ip="$1"
generic_ssh_wait "$ip" "root" 180
}
# Run command on Hyperstack VM via SSH
run_server() {
local ip="$1"
shift
# shellcheck disable=SC2086
ssh $SSH_OPTS "root@$ip" "$@"
}
# Upload file to Hyperstack VM via SCP
upload_file() {
local ip="$1"
local src="$2"
local dst="$3"
# shellcheck disable=SC2086
scp $SSH_OPTS "$src" "root@$ip:$dst"
}
# Start interactive session on Hyperstack VM
interactive_session() {
local ip="$1"
local cmd="${2:-bash}"
# shellcheck disable=SC2086
ssh $SSH_OPTS -t "root@$ip" "$cmd"
}
# Ensure Python 3 is available on local machine
check_python_available