diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 8bb59790..338dbc20 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -122,7 +122,7 @@ for script in nocodb-init.sh gitea-init.sh mkdocs-entrypoint.sh \ backup.sh restore.sh \ upgrade.sh upgrade-check.sh upgrade-watcher.sh \ uninstall.sh test-deployment.sh \ - validate-env.sh pangolin-teardown.sh; do + validate-env.sh pangolin-teardown.sh ccp-deregister.sh; do if [[ -f "$PROJECT_DIR/scripts/$script" ]]; then cp "$PROJECT_DIR/scripts/$script" "$STAGE_DIR/scripts/" fi diff --git a/scripts/ccp-deregister.sh b/scripts/ccp-deregister.sh new file mode 100755 index 00000000..4e42329d --- /dev/null +++ b/scripts/ccp-deregister.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# ============================================================================= +# ccp-deregister.sh — Remove this host's Instance from the CCP +# +# Pairs with scripts/pangolin-teardown.sh. Use when wiping a test instance +# that was phone-home-registered with a CCP. Without this, the CCP retains +# a stale Instance row after the underlying stack is gone and will block +# re-registration of the same slug (CCP's slug-collision behaviour). +# +# Credentials are read from .env (CCP_URL, CCP_AGENT_URL) and a CCP admin +# token, unless overridden by flags. +# +# Usage: +# ./scripts/ccp-deregister.sh --token CCP_ADMIN_TOKEN +# ./scripts/ccp-deregister.sh --token T --yes # execute (default: dry-run) +# ./scripts/ccp-deregister.sh --ccp-url URL --agent-url URL --token T --yes +# +# Matching: by default, matches Instances whose agentUrl equals this host's +# CCP_AGENT_URL. Pass --slug SLUG or --instance-id ID for explicit targeting. +# +# Exit codes: 0 success, 1 error, 2 nothing matched +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +ENV_FILE="${PROJECT_DIR}/.env" + +RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' + +CCP_URL=""; CCP_AGENT_URL=""; TOKEN=""; SLUG=""; INSTANCE_ID="" +CONFIRM=false + +usage() { + sed -n '2,22p' "$0" | sed 's/^# \?//' + exit 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --yes|-y) CONFIRM=true; shift ;; + --ccp-url) CCP_URL="$2"; shift 2 ;; + --agent-url) CCP_AGENT_URL="$2"; shift 2 ;; + --token) TOKEN="$2"; shift 2 ;; + --slug) SLUG="$2"; shift 2 ;; + --instance-id) INSTANCE_ID="$2"; shift 2 ;; + -h|--help) usage ;; + *) echo "Unknown flag: $1"; exit 1 ;; + esac +done + +# Load missing fields from .env +if [[ -z "$CCP_URL" || -z "$CCP_AGENT_URL" ]]; then + if [[ -f "$ENV_FILE" ]]; then + CCP_URL="${CCP_URL:-$(grep -E '^CCP_URL=' "$ENV_FILE" | head -1 | cut -d= -f2-)}" + CCP_AGENT_URL="${CCP_AGENT_URL:-$(grep -E '^CCP_AGENT_URL=' "$ENV_FILE" | head -1 | cut -d= -f2-)}" + fi +fi + +# Token may also come from env var +TOKEN="${TOKEN:-${CCP_ADMIN_TOKEN:-}}" + +if [[ -z "$CCP_URL" ]]; then + echo -e "${RED}ERROR:${NC} CCP_URL not set. Pass --ccp-url or set in .env." + exit 1 +fi +if [[ -z "$TOKEN" ]]; then + echo -e "${RED}ERROR:${NC} No CCP admin token. Pass --token or set CCP_ADMIN_TOKEN env var." + echo " Obtain via: curl -X POST \$CCP_URL/api/auth/login -d @login.json" + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo -e "${RED}ERROR:${NC} python3 is required for JSON parsing" + exit 1 +fi + +echo -e "${BOLD}CCP deregister${NC}" +echo " CCP: $CCP_URL" +[[ -n "$CCP_AGENT_URL" ]] && echo " Agent URL: $CCP_AGENT_URL (match target)" +[[ -n "$SLUG" ]] && echo " Slug: $SLUG (match target)" +[[ -n "$INSTANCE_ID" ]] && echo " Instance ID: $INSTANCE_ID (explicit)" +[[ "$CONFIRM" == "false" ]] && echo -e " ${YELLOW}Mode: DRY RUN${NC} (pass --yes to execute)" +echo "" + +# Fetch instances +RAW=$(curl -sf -H "Authorization: Bearer $TOKEN" "$CCP_URL/api/instances" 2>&1 || true) +if [[ -z "$RAW" ]]; then + echo -e "${RED}ERROR:${NC} Could not fetch /api/instances (check token + URL)" + exit 1 +fi + +# Build match list — the API may wrap instances in {data: [...]} or return raw []. +# Also: the /api/instances list omits pangolinEndpoint for brevity, so match fields are scoped to slug/agentUrl/id. +MATCHES=$(echo "$RAW" | python3 -c " +import sys, json +d = json.load(sys.stdin) +items = d.get('data', d) if isinstance(d, dict) else d +target_slug = '${SLUG}' or None +target_id = '${INSTANCE_ID}' or None +target_agent = '${CCP_AGENT_URL}' or None + +for inst in items: + if target_id: + if inst.get('id') == target_id: + print(f\"{inst['id']}\t{inst.get('slug','?')}\t{inst.get('agentUrl','?')}\") + elif target_slug: + if inst.get('slug') == target_slug: + print(f\"{inst['id']}\t{inst.get('slug','?')}\t{inst.get('agentUrl','?')}\") + elif target_agent: + if inst.get('agentUrl') == target_agent: + print(f\"{inst['id']}\t{inst.get('slug','?')}\t{inst.get('agentUrl','?')}\") +") + +MATCH_COUNT=$(echo -n "$MATCHES" | grep -c . || true) +echo -e "${CYAN}Matches: $MATCH_COUNT${NC}" +if [[ "$MATCH_COUNT" -eq 0 ]]; then + echo " (no instances matched the criteria)" + exit 2 +fi +echo "$MATCHES" | awk -F'\t' '{printf " - [%s] slug=%s agentUrl=%s\n", $1, $2, $3}' + +if [[ "$CONFIRM" == "false" ]]; then + echo "" + echo -e "${YELLOW}Dry run complete.${NC} Re-run with --yes to actually delete." + exit 0 +fi + +echo "" +echo -e "${BOLD}Deleting...${NC}" + +FAILURES=0 +while IFS=$'\t' read -r iid slug aurl; do + [[ -z "$iid" ]] && continue + code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ + "$CCP_URL/api/instances/$iid" \ + -H "Authorization: Bearer $TOKEN") + if [[ "$code" == "200" || "$code" == "204" ]]; then + echo -e " ${GREEN}OK${NC} instance $iid ($slug) deleted" + else + echo -e " ${RED}FAIL${NC} instance $iid ($slug) HTTP $code" + FAILURES=$((FAILURES + 1)) + fi +done <<< "$MATCHES" + +if [[ $FAILURES -gt 0 ]]; then + echo -e "${YELLOW}Completed with $FAILURES failure(s).${NC}" + exit 2 +fi +echo -e "${GREEN}Deregister complete.${NC}"