mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-08 01:51:14 +00:00
Users on exec-based clouds (Fly, Render, Koyeb, Northflank, Railway, Modal, Daytona, E2B, CodeSandbox, GitHub Codespaces) got no warning when their session ended that their service was still running and incurring charges. This adds: - _show_exec_post_session_summary() in shared/common.sh for non-SSH providers that use CLI exec commands instead of direct SSH - SPAWN_DASHBOARD_URL for all 10 exec-based clouds so users get actionable dashboard links - Post-session summary calls in each cloud's interactive_session() - 33 new tests covering the exec post-session summary feature 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>
285 lines
8.8 KiB
Bash
285 lines
8.8 KiB
Bash
#!/bin/bash
|
|
# Common bash functions for Render spawn scripts
|
|
# Uses Render CLI for provisioning and SSH access
|
|
|
|
# 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
|
|
|
|
# Note: Provider-agnostic functions (logging, OAuth, browser, nc_listen) are now in shared/common.sh
|
|
|
|
# ============================================================
|
|
# Render specific functions
|
|
# ============================================================
|
|
|
|
RENDER_API_BASE="https://api.render.com/v1"
|
|
SPAWN_DASHBOARD_URL="https://dashboard.render.com/"
|
|
|
|
# Centralized API wrapper for Render — delegates to generic_cloud_api
|
|
# for automatic retry with exponential backoff on 429/503/network errors.
|
|
# Usage: render_api METHOD ENDPOINT [BODY]
|
|
render_api() {
|
|
local method="$1"
|
|
local endpoint="$2"
|
|
local body="${3:-}"
|
|
generic_cloud_api "$RENDER_API_BASE" "$RENDER_API_KEY" "$method" "$endpoint" "$body"
|
|
}
|
|
|
|
# Ensure Render CLI is installed
|
|
ensure_render_cli() {
|
|
if command -v render &>/dev/null; then
|
|
log_info "Render CLI available"
|
|
return 0
|
|
fi
|
|
|
|
log_step "Installing Render CLI..."
|
|
|
|
# Render CLI installation via npm
|
|
local node_runtime
|
|
node_runtime=$(find_node_runtime)
|
|
|
|
if [[ -z "$node_runtime" ]]; then
|
|
log_error "Render CLI requires Node.js or Bun"
|
|
log_error "Install one of:"
|
|
log_error " - Bun: curl -fsSL https://bun.sh/install | bash"
|
|
log_error " - Node.js: https://nodejs.org/"
|
|
return 1
|
|
fi
|
|
|
|
# Install using the detected runtime
|
|
if [[ "$node_runtime" == "bun" ]]; then
|
|
if ! bun install -g @render-oss/cli; then
|
|
log_error "Failed to install Render CLI via Bun"
|
|
return 1
|
|
fi
|
|
else
|
|
if ! npm install -g @render-oss/cli; then
|
|
log_error "Failed to install Render CLI via npm"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
# Verify installation
|
|
if ! command -v render &>/dev/null; then
|
|
log_error "Render CLI not found in PATH after installation"
|
|
log_error "Try adding ~/.bun/bin or npm global bin to PATH"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Render CLI installed"
|
|
}
|
|
|
|
# Ensure RENDER_API_KEY is available (env var -> config file -> prompt+save)
|
|
ensure_render_api_key() {
|
|
ensure_api_token_with_provider \
|
|
"Render" \
|
|
"RENDER_API_KEY" \
|
|
"$HOME/.config/spawn/render.json" \
|
|
"https://dashboard.render.com/u/settings/api-keys" \
|
|
""
|
|
}
|
|
|
|
# Generate a unique server name for Render (must be lowercase alphanumeric + hyphens)
|
|
get_server_name() {
|
|
local prefix="${1:-spawn}"
|
|
local timestamp=$(date +%s)
|
|
local random_suffix=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
|
echo "${prefix}-${timestamp}-${random_suffix}" | tr '[:upper:]' '[:lower:]'
|
|
}
|
|
|
|
# Create a Render web service using the API
|
|
# Usage: _render_create_service SERVICE_NAME
|
|
# Sets: RENDER_SERVICE_ID
|
|
_render_create_service() {
|
|
local service_name="$1"
|
|
log_step "Creating Render web service: $service_name"
|
|
|
|
# Build JSON body safely via Python to prevent injection
|
|
local body
|
|
body=$(printf '%s' "$service_name" | python3 -c "
|
|
import json, sys
|
|
name = sys.stdin.read()
|
|
body = {
|
|
'type': 'web_service',
|
|
'name': name,
|
|
'runtime': 'docker',
|
|
'dockerfilePath': './Dockerfile',
|
|
'repo': 'https://github.com/render-examples/docker-hello-world',
|
|
'autoDeploy': 'yes',
|
|
'serviceDetails': {
|
|
'plan': 'starter',
|
|
'region': 'oregon',
|
|
'healthCheckPath': '/',
|
|
'env': 'docker',
|
|
'disk': None
|
|
}
|
|
}
|
|
print(json.dumps(body))
|
|
")
|
|
|
|
local create_response
|
|
create_response=$(render_api POST "/services" "$body") || {
|
|
log_error "Failed to create Render service"
|
|
return 1
|
|
}
|
|
|
|
RENDER_SERVICE_ID=$(_extract_json_field "$create_response" "d.get('service',{}).get('id','')")
|
|
|
|
if [[ -z "$RENDER_SERVICE_ID" ]]; then
|
|
log_error "Failed to get Render service ID from response"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Render service created: $service_name (ID: $RENDER_SERVICE_ID)"
|
|
}
|
|
|
|
# Wait for Render service to become live
|
|
# Usage: _render_wait_for_service SERVICE_ID [MAX_ATTEMPTS]
|
|
_render_wait_for_service() {
|
|
local service_id="$1"
|
|
local max_attempts=${2:-60}
|
|
local attempt=1
|
|
local poll_delay="${INSTANCE_STATUS_POLL_DELAY:-5}"
|
|
|
|
log_step "Waiting for service to become live..."
|
|
while [[ "$attempt" -le "$max_attempts" ]]; do
|
|
local response
|
|
response=$(render_api GET "/services/${service_id}" 2>/dev/null) || true
|
|
|
|
local status
|
|
status=$(_extract_json_field "$response" "d.get('service',{}).get('serviceDetails',{}).get('deployStatus','')" "unknown")
|
|
|
|
if [[ "$status" == "live" ]]; then
|
|
log_info "Service is live"
|
|
return 0
|
|
fi
|
|
|
|
if [[ "$status" == "failed" ]]; then
|
|
log_error "Service deployment failed"
|
|
return 1
|
|
fi
|
|
|
|
log_step "Service status: $status ($attempt/$max_attempts)"
|
|
sleep "$poll_delay"
|
|
attempt=$((attempt + 1))
|
|
done
|
|
|
|
log_error "Service did not become live after $max_attempts attempts"
|
|
log_warn "The service may still be deploying. You can:"
|
|
log_warn " 1. Re-run the command to try again"
|
|
log_warn " 2. Check the service status in the Render dashboard"
|
|
return 1
|
|
}
|
|
|
|
# Create a Render service
|
|
# Sets: RENDER_SERVICE_NAME, RENDER_SERVICE_ID
|
|
create_server() {
|
|
local name="${1:-$(get_server_name)}"
|
|
|
|
RENDER_SERVICE_NAME="${name}"
|
|
|
|
_render_create_service "$RENDER_SERVICE_NAME" || return 1
|
|
_render_wait_for_service "$RENDER_SERVICE_ID" || return 1
|
|
}
|
|
|
|
# Run a command on the Render service via SSH
|
|
# SECURITY: Uses printf %q to properly escape commands to prevent injection
|
|
run_server() {
|
|
local cmd="$1"
|
|
|
|
if [[ -z "$RENDER_SERVICE_ID" ]]; then
|
|
log_error "No service ID set. Call create_server first."
|
|
return 1
|
|
fi
|
|
|
|
local escaped_cmd
|
|
escaped_cmd=$(printf '%q' "$cmd")
|
|
render ssh --service "$RENDER_SERVICE_ID" -- bash -c "$escaped_cmd"
|
|
}
|
|
|
|
# Upload a file to the Render service via base64 encoding over SSH
|
|
upload_file() {
|
|
local local_path="$1"
|
|
local remote_path="$2"
|
|
|
|
if [[ ! -f "$local_path" ]]; then
|
|
log_error "Local file not found: $local_path"
|
|
return 1
|
|
fi
|
|
|
|
# SECURITY: Strict allowlist validation — only safe path characters
|
|
if [[ ! "${remote_path}" =~ ^[a-zA-Z0-9/_.~-]+$ ]]; then
|
|
log_error "Invalid remote path (must contain only alphanumeric, /, _, ., ~, -): ${remote_path}"
|
|
return 1
|
|
fi
|
|
|
|
# Read file content and encode (base64 output is safe for shell embedding)
|
|
local content
|
|
content=$(base64 < "$local_path")
|
|
|
|
# Write file on remote service
|
|
run_server "printf '%s' '${content}' | base64 -d > '${remote_path}'"
|
|
}
|
|
|
|
# Wait for basic system readiness (Render services are pre-configured)
|
|
wait_for_cloud_init() {
|
|
log_info "Render service ready (Docker image pre-configured)"
|
|
# Render services are Docker-based and already configured
|
|
}
|
|
|
|
# Inject environment variables into shell config
|
|
# Writes to a temp file and uploads to avoid shell interpolation of values
|
|
inject_env_vars() {
|
|
log_step "Injecting environment variables..."
|
|
|
|
local env_temp
|
|
env_temp=$(mktemp)
|
|
chmod 600 "${env_temp}"
|
|
track_temp_file "${env_temp}"
|
|
|
|
generate_env_config "$@" > "${env_temp}"
|
|
|
|
# Upload and append to .bashrc
|
|
upload_file "${env_temp}" "/tmp/env_config"
|
|
run_server "cat /tmp/env_config >> /root/.bashrc && rm /tmp/env_config"
|
|
|
|
log_info "Environment variables configured"
|
|
}
|
|
|
|
# Start an interactive session
|
|
interactive_session() {
|
|
local launch_cmd="${1:-bash}"
|
|
|
|
if [[ -z "$RENDER_SERVICE_ID" ]]; then
|
|
log_error "No service ID set. Call create_server first."
|
|
return 1
|
|
fi
|
|
|
|
log_step "Starting interactive session..."
|
|
# SECURITY: Properly escape command to prevent injection
|
|
local escaped_cmd
|
|
escaped_cmd=$(printf '%q' "$launch_cmd")
|
|
local session_exit=0
|
|
render ssh --service "$RENDER_SERVICE_ID" -- bash -c "$escaped_cmd" || session_exit=$?
|
|
SERVER_NAME="${RENDER_SERVICE_NAME:-}" _show_exec_post_session_summary
|
|
return "${session_exit}"
|
|
}
|
|
|
|
# Cleanup: delete the service
|
|
cleanup_server() {
|
|
if [[ -n "${RENDER_SERVICE_ID:-}" ]]; then
|
|
log_step "Deleting Render service: $RENDER_SERVICE_NAME"
|
|
render_api DELETE "/services/${RENDER_SERVICE_ID}" >/dev/null 2>&1 || true
|
|
fi
|
|
}
|