mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-08 18:39:50 +00:00
~1500 progress messages across 481 files were using log_warn (yellow) for normal status updates like "Installing...", "Setting up...", "Creating server...", etc. This made users think something was wrong when everything was proceeding normally. Changes: - Replace log_warn with log_step for all progress/status messages - Keep log_warn only for actual warnings (errors, remediation hints) - Remove emoji from 3 sprite completion messages Agent: ux-engineer Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
426 lines
14 KiB
Bash
426 lines
14 KiB
Bash
#!/bin/bash
|
|
# Common bash functions for Oracle Cloud Infrastructure (OCI) spawn scripts
|
|
# Uses OCI CLI (oci) — requires oci-cli installed and configured
|
|
|
|
# Bash safety flags
|
|
set -eo pipefail
|
|
|
|
# ============================================================
|
|
# 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
|
|
|
|
# ============================================================
|
|
# OCI specific functions
|
|
# ============================================================
|
|
|
|
# SSH_OPTS is defined in shared/common.sh
|
|
|
|
# Configurable timeout/delay constants
|
|
INSTANCE_STATUS_POLL_DELAY=${INSTANCE_STATUS_POLL_DELAY:-5}
|
|
|
|
ensure_oci_cli() {
|
|
if ! command -v oci &>/dev/null; then
|
|
log_error "OCI CLI is required but not installed."
|
|
log_error ""
|
|
log_error "Install with: pip install oci-cli"
|
|
log_error "Or: bash -c \"\$(curl -L https://raw.githubusercontent.com/oracle/oci-cli/master/scripts/install/install.sh)\""
|
|
log_error ""
|
|
log_error "After installing, run: oci setup config"
|
|
return 1
|
|
fi
|
|
|
|
# Verify config exists
|
|
if [[ ! -f "${HOME}/.oci/config" ]]; then
|
|
log_error "OCI CLI not configured. Run: oci setup config"
|
|
log_error ""
|
|
log_error "You will need:"
|
|
log_error " - Tenancy OCID (from OCI Console > Administration > Tenancy Details)"
|
|
log_error " - User OCID (from OCI Console > Profile > User Settings)"
|
|
log_error " - Compartment OCID (from OCI Console > Identity > Compartments)"
|
|
log_error " - Region (e.g., us-ashburn-1)"
|
|
log_error " - API signing key pair (generated during setup)"
|
|
return 1
|
|
fi
|
|
|
|
# Get compartment ID
|
|
local compartment="${OCI_COMPARTMENT_ID:-}"
|
|
if [[ -z "${compartment}" ]]; then
|
|
# Try to get tenancy OCID as default compartment (root compartment)
|
|
compartment=$(oci iam compartment list --compartment-id-in-subtree true --all \
|
|
--query 'data[0]."compartment-id"' --raw-output 2>/dev/null || true)
|
|
if [[ -z "${compartment}" ]]; then
|
|
log_error "OCI_COMPARTMENT_ID not set and could not detect compartment."
|
|
log_error ""
|
|
log_error "Set it with: export OCI_COMPARTMENT_ID=ocid1.compartment.oc1....."
|
|
log_error "Find it in: OCI Console > Identity > Compartments"
|
|
return 1
|
|
fi
|
|
fi
|
|
export OCI_COMPARTMENT_ID="${compartment}"
|
|
log_info "Using OCI compartment: ${compartment}"
|
|
}
|
|
|
|
ensure_ssh_key() {
|
|
local key_path="${HOME}/.ssh/id_ed25519"
|
|
|
|
# Generate key if needed
|
|
generate_ssh_key_if_missing "${key_path}"
|
|
|
|
# OCI handles SSH keys via instance metadata during create
|
|
log_info "SSH key ready"
|
|
}
|
|
|
|
get_server_name() {
|
|
get_resource_name "OCI_INSTANCE_NAME" "Enter OCI instance name: "
|
|
}
|
|
|
|
get_cloud_init_userdata() {
|
|
cat << 'CLOUD_INIT_EOF'
|
|
#!/bin/bash
|
|
apt-get update -y
|
|
apt-get install -y curl unzip git zsh python3
|
|
# Install Bun
|
|
su - ubuntu -c 'curl -fsSL https://bun.sh/install | bash' || true
|
|
# Install Claude Code
|
|
su - ubuntu -c 'curl -fsSL https://claude.ai/install.sh | bash' || true
|
|
# Configure PATH
|
|
echo 'export PATH="${HOME}/.claude/local/bin:${HOME}/.bun/bin:${PATH}"' >> /home/ubuntu/.bashrc
|
|
echo 'export PATH="${HOME}/.claude/local/bin:${HOME}/.bun/bin:${PATH}"' >> /home/ubuntu/.zshrc
|
|
touch /home/ubuntu/.cloud-init-complete
|
|
chown ubuntu:ubuntu /home/ubuntu/.cloud-init-complete
|
|
CLOUD_INIT_EOF
|
|
}
|
|
|
|
_get_ubuntu_image_id() {
|
|
local compartment="${OCI_COMPARTMENT_ID}"
|
|
local shape="${1:-VM.Standard.E2.1.Micro}"
|
|
|
|
# Determine OS for the shape - ARM shapes need aarch64
|
|
local os_match="Canonical Ubuntu"
|
|
local os_version="24.04"
|
|
|
|
local image_id
|
|
image_id=$(oci compute image list \
|
|
--compartment-id "${compartment}" \
|
|
--operating-system "${os_match}" \
|
|
--operating-system-version "${os_version}" \
|
|
--shape "${shape}" \
|
|
--sort-by TIMECREATED \
|
|
--sort-order DESC \
|
|
--limit 1 \
|
|
--query 'data[0].id' \
|
|
--raw-output 2>/dev/null || true)
|
|
|
|
if [[ -z "${image_id}" || "${image_id}" == "null" ]]; then
|
|
# Fallback: try without shape filter
|
|
image_id=$(oci compute image list \
|
|
--compartment-id "${compartment}" \
|
|
--operating-system "${os_match}" \
|
|
--operating-system-version "${os_version}" \
|
|
--sort-by TIMECREATED \
|
|
--sort-order DESC \
|
|
--limit 1 \
|
|
--query 'data[0].id' \
|
|
--raw-output 2>/dev/null || true)
|
|
fi
|
|
|
|
if [[ -z "${image_id}" || "${image_id}" == "null" ]]; then
|
|
log_error "Could not find Ubuntu 24.04 image for shape ${shape}"
|
|
log_error "Check available images: oci compute image list --compartment-id ${compartment} --all"
|
|
return 1
|
|
fi
|
|
|
|
echo "${image_id}"
|
|
}
|
|
|
|
_get_availability_domain() {
|
|
local compartment="${OCI_COMPARTMENT_ID}"
|
|
|
|
local ad
|
|
ad=$(oci iam availability-domain list \
|
|
--compartment-id "${compartment}" \
|
|
--query 'data[0].name' \
|
|
--raw-output 2>/dev/null || true)
|
|
|
|
if [[ -z "${ad}" || "${ad}" == "null" ]]; then
|
|
log_error "Could not list availability domains"
|
|
return 1
|
|
fi
|
|
|
|
echo "${ad}"
|
|
}
|
|
|
|
_create_vcn() {
|
|
local compartment="${1}"
|
|
|
|
local vcn_id
|
|
vcn_id=$(oci network vcn create \
|
|
--compartment-id "${compartment}" \
|
|
--cidr-blocks '["10.0.0.0/16"]' \
|
|
--display-name "spawn-vcn" \
|
|
--dns-label "spawnvcn" \
|
|
--wait-for-state AVAILABLE \
|
|
--query 'data.id' \
|
|
--raw-output 2>/dev/null)
|
|
|
|
if [[ -z "${vcn_id}" || "${vcn_id}" == "null" ]]; then
|
|
log_error "Failed to create VCN"
|
|
return 1
|
|
fi
|
|
|
|
echo "${vcn_id}"
|
|
}
|
|
|
|
_setup_vcn_networking() {
|
|
local compartment="${1}"
|
|
local vcn_id="${2}"
|
|
|
|
# Create internet gateway
|
|
local igw_id
|
|
igw_id=$(oci network internet-gateway create \
|
|
--compartment-id "${compartment}" \
|
|
--vcn-id "${vcn_id}" \
|
|
--display-name "spawn-igw" \
|
|
--is-enabled true \
|
|
--wait-for-state AVAILABLE \
|
|
--query 'data.id' \
|
|
--raw-output 2>/dev/null)
|
|
|
|
# Add default route to internet gateway
|
|
local rt_id
|
|
rt_id=$(oci network route-table list \
|
|
--compartment-id "${compartment}" \
|
|
--vcn-id "${vcn_id}" \
|
|
--query 'data[0].id' \
|
|
--raw-output 2>/dev/null)
|
|
|
|
if [[ -n "${rt_id}" && "${rt_id}" != "null" && -n "${igw_id}" && "${igw_id}" != "null" ]]; then
|
|
oci network route-table update \
|
|
--rt-id "${rt_id}" \
|
|
--route-rules "[{\"destination\":\"0.0.0.0/0\",\"networkEntityId\":\"${igw_id}\",\"destinationType\":\"CIDR_BLOCK\"}]" \
|
|
--force \
|
|
--wait-for-state AVAILABLE >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
# Add SSH ingress rule to default security list
|
|
local sl_id
|
|
sl_id=$(oci network security-list list \
|
|
--compartment-id "${compartment}" \
|
|
--vcn-id "${vcn_id}" \
|
|
--query 'data[0].id' \
|
|
--raw-output 2>/dev/null)
|
|
|
|
if [[ -n "${sl_id}" && "${sl_id}" != "null" ]]; then
|
|
oci network security-list update \
|
|
--security-list-id "${sl_id}" \
|
|
--ingress-security-rules '[{"source":"0.0.0.0/0","protocol":"6","tcpOptions":{"destinationPortRange":{"min":22,"max":22}},"isStateless":false}]' \
|
|
--egress-security-rules '[{"destination":"0.0.0.0/0","protocol":"all","isStateless":false}]' \
|
|
--force \
|
|
--wait-for-state AVAILABLE >/dev/null 2>&1 || true
|
|
fi
|
|
}
|
|
|
|
_create_subnet() {
|
|
local compartment="${1}"
|
|
local vcn_id="${2}"
|
|
|
|
local ad
|
|
ad=$(_get_availability_domain)
|
|
|
|
local subnet_id
|
|
subnet_id=$(oci network subnet create \
|
|
--compartment-id "${compartment}" \
|
|
--vcn-id "${vcn_id}" \
|
|
--cidr-block "10.0.1.0/24" \
|
|
--display-name "spawn-subnet" \
|
|
--availability-domain "${ad}" \
|
|
--dns-label "spawnsubnet" \
|
|
--wait-for-state AVAILABLE \
|
|
--query 'data.id' \
|
|
--raw-output 2>/dev/null)
|
|
|
|
if [[ -z "${subnet_id}" || "${subnet_id}" == "null" ]]; then
|
|
log_error "Failed to create subnet"
|
|
return 1
|
|
fi
|
|
|
|
echo "${subnet_id}"
|
|
}
|
|
|
|
_get_subnet_id() {
|
|
local compartment="${OCI_COMPARTMENT_ID}"
|
|
|
|
# Try to find an existing public subnet
|
|
local subnet_id
|
|
subnet_id=$(oci network subnet list \
|
|
--compartment-id "${compartment}" \
|
|
--query 'data[?("prohibit-public-ip-on-vnic"==`false`)].id | [0]' \
|
|
--raw-output 2>/dev/null || true)
|
|
|
|
if [[ -n "${subnet_id}" && "${subnet_id}" != "null" ]]; then
|
|
echo "${subnet_id}"
|
|
return 0
|
|
fi
|
|
|
|
# No public subnet found - create VCN with networking and subnet
|
|
log_step "No public subnet found. Creating VCN and subnet..."
|
|
|
|
local vcn_id
|
|
vcn_id=$(_create_vcn "${compartment}") || return 1
|
|
_setup_vcn_networking "${compartment}" "${vcn_id}"
|
|
_create_subnet "${compartment}" "${vcn_id}"
|
|
}
|
|
|
|
_get_instance_public_ip() {
|
|
local instance_id="${1}"
|
|
|
|
local vnic_id
|
|
vnic_id=$(oci compute vnic-attachment list \
|
|
--compartment-id "${OCI_COMPARTMENT_ID}" \
|
|
--instance-id "${instance_id}" \
|
|
--query 'data[0]."vnic-id"' \
|
|
--raw-output 2>/dev/null)
|
|
|
|
if [[ -z "${vnic_id}" || "${vnic_id}" == "null" ]]; then
|
|
log_error "Could not get VNIC for instance"
|
|
return 1
|
|
fi
|
|
|
|
local ip
|
|
ip=$(oci network vnic get \
|
|
--vnic-id "${vnic_id}" \
|
|
--query 'data."public-ip"' \
|
|
--raw-output 2>/dev/null || true)
|
|
|
|
if [[ -z "${ip}" || "${ip}" == "null" ]]; then
|
|
log_error "Could not get public IP for instance"
|
|
return 1
|
|
fi
|
|
|
|
echo "${ip}"
|
|
}
|
|
|
|
# Encode cloud-init userdata as base64 (macOS and Linux compatible)
|
|
_encode_userdata_b64() {
|
|
get_cloud_init_userdata | base64 -w0 2>/dev/null || get_cloud_init_userdata | base64
|
|
}
|
|
|
|
# Launch an OCI compute instance and return its OCID on stdout
|
|
# Usage: instance_id=$(_launch_oci_instance NAME SHAPE IMAGE_ID AD SUBNET_ID USERDATA_B64)
|
|
_launch_oci_instance() {
|
|
local name="${1}" shape="${2}" image_id="${3}" ad="${4}" subnet_id="${5}" userdata_b64="${6}"
|
|
|
|
# Build shape config for flex shapes
|
|
local shape_config_args=()
|
|
if [[ "${shape}" == *".Flex" || "${shape}" == *".Flex."* ]]; then
|
|
local ocpus="${OCI_OCPUS:-1}"
|
|
local memory="${OCI_MEMORY_GB:-4}"
|
|
shape_config_args=(--shape-config "{\"ocpus\": ${ocpus}, \"memoryInGBs\": ${memory}}")
|
|
fi
|
|
|
|
local instance_id
|
|
instance_id=$(oci compute instance launch \
|
|
--compartment-id "${OCI_COMPARTMENT_ID}" \
|
|
--availability-domain "${ad}" \
|
|
--shape "${shape}" \
|
|
"${shape_config_args[@]}" \
|
|
--image-id "${image_id}" \
|
|
--subnet-id "${subnet_id}" \
|
|
--display-name "${name}" \
|
|
--assign-public-ip true \
|
|
--ssh-authorized-keys-file "${HOME}/.ssh/id_ed25519.pub" \
|
|
--user-data "${userdata_b64}" \
|
|
--wait-for-state RUNNING \
|
|
--query 'data.id' \
|
|
--raw-output 2>/dev/null) || true
|
|
|
|
if [[ -z "${instance_id}" || "${instance_id}" == "null" ]]; then
|
|
log_error "Failed to create OCI instance"
|
|
log_error "Check your quota and compartment permissions"
|
|
return 1
|
|
fi
|
|
|
|
echo "${instance_id}"
|
|
}
|
|
|
|
create_server() {
|
|
local name="${1}"
|
|
local shape="${OCI_SHAPE:-VM.Standard.E2.1.Micro}"
|
|
|
|
log_step "Creating OCI instance '${name}' (shape: ${shape})..."
|
|
|
|
local image_id
|
|
image_id=$(_get_ubuntu_image_id "${shape}") || return 1
|
|
|
|
local ad
|
|
ad=$(_get_availability_domain) || return 1
|
|
|
|
local subnet_id="${OCI_SUBNET_ID:-}"
|
|
if [[ -z "${subnet_id}" ]]; then
|
|
subnet_id=$(_get_subnet_id) || return 1
|
|
fi
|
|
|
|
local userdata_b64
|
|
userdata_b64=$(_encode_userdata_b64)
|
|
|
|
local instance_id
|
|
instance_id=$(_launch_oci_instance "${name}" "${shape}" "${image_id}" "${ad}" "${subnet_id}" "${userdata_b64}") || return 1
|
|
|
|
export OCI_INSTANCE_ID="${instance_id}"
|
|
export OCI_INSTANCE_NAME_ACTUAL="${name}"
|
|
|
|
local server_ip
|
|
server_ip=$(_get_instance_public_ip "${instance_id}") || return 1
|
|
|
|
export OCI_SERVER_IP="${server_ip}"
|
|
log_info "Instance created: IP=${OCI_SERVER_IP}"
|
|
}
|
|
|
|
# OCI Ubuntu images use 'ubuntu' user
|
|
SSH_USER="ubuntu"
|
|
|
|
# SSH operations — delegates to shared helpers
|
|
verify_server_connectivity() { ssh_verify_connectivity "$@"; }
|
|
run_server() { ssh_run_server "$@"; }
|
|
upload_file() { ssh_upload_file "$@"; }
|
|
interactive_session() { ssh_interactive_session "$@"; }
|
|
|
|
wait_for_cloud_init() {
|
|
local ip="${1}"
|
|
local max_attempts=${2:-60}
|
|
|
|
# First ensure SSH connectivity
|
|
ssh_verify_connectivity "${ip}" 30 5 || return 1
|
|
|
|
# Then wait for cloud-init completion marker
|
|
generic_ssh_wait "ubuntu" "${ip}" "${SSH_OPTS}" "test -f /home/ubuntu/.cloud-init-complete" "cloud-init" "${max_attempts}" 5
|
|
}
|
|
|
|
destroy_server() {
|
|
local instance_id="${1:-${OCI_INSTANCE_ID:-}}"
|
|
if [[ -z "${instance_id}" ]]; then
|
|
log_error "No instance ID provided. Usage: destroy_server INSTANCE_OCID"
|
|
return 1
|
|
fi
|
|
log_step "Terminating OCI instance ${instance_id}..."
|
|
oci compute instance terminate \
|
|
--instance-id "${instance_id}" \
|
|
--preserve-boot-volume false \
|
|
--force >/dev/null 2>&1
|
|
log_info "Instance terminated"
|
|
}
|
|
|
|
list_servers() {
|
|
oci compute instance list \
|
|
--compartment-id "${OCI_COMPARTMENT_ID}" \
|
|
--query 'data[?("lifecycle-state"!=`TERMINATED`)].{"Name":"display-name","State":"lifecycle-state","Shape":"shape","Created":"time-created"}' \
|
|
--output table 2>/dev/null
|
|
}
|