install: preflight + teardown tooling + CCP tunnel cleanup on delete
Fixes surfaced by three rounds of fresh-install testing on marcelle: - config.sh: add host-port preflight check (ss -tln) to catch cockpit-on-9090 style collisions before compose up; add --skip-port-check escape hatch; add --install-watcher / --no-install-watcher / --install-backup-timer / --no-install-backup-timer flags; -y --enable-all now installs both systemd units by default (previously silently skipped); print resolved admin email in Configuration Complete block. - scripts/validate-env.sh: new section 5b "Host Port Availability" using ss-based detection, with process-name surfacing when run as root. - scripts/pangolin-teardown.sh: new wrapper. Reads credentials from .env or takes --api-url/--api-key/--org-id flags. Dry-run by default; --yes to execute. Deletes resources before sites (avoids orphans). --keep-site-ids for safety. - scripts/build-release.sh: include validate-env.sh and pangolin-teardown.sh in release tarball whitelist. - CCP instances.service.ts: deleteInstance() now calls teardownTunnel() before composeDown when pangolinSiteId is set. Previously an admin clicking "Delete Instance" orphaned the Pangolin site + all its resources. Best-effort with try/catch matching the existing Docker-cleanup tolerance pattern. - CLAUDE.md: sync drift — 44 → 50 migrations, 186 → 192 models, 40 → 44 modules. Bunker Admin
This commit is contained in:
parent
13513aeca5
commit
f9d566bd84
14
CLAUDE.md
14
CLAUDE.md
@ -63,8 +63,8 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker
|
|||||||
changemaker.lite/
|
changemaker.lite/
|
||||||
├── api/ # Dual API servers (Express + Fastify)
|
├── api/ # Dual API servers (Express + Fastify)
|
||||||
│ ├── prisma/
|
│ ├── prisma/
|
||||||
│ │ ├── schema.prisma # 186 models: User, Campaign, Location, Shift, Payment, Social, etc.
|
│ │ ├── schema.prisma # 192 models: User, Campaign, Location, Shift, Payment, Social, etc.
|
||||||
│ │ ├── migrations/ # 44 Prisma migrations (full schema history)
|
│ │ ├── migrations/ # 50 Prisma migrations (full schema history)
|
||||||
│ │ └── seed.ts # Admin user, settings, page blocks
|
│ │ └── seed.ts # Admin user, settings, page blocks
|
||||||
│ ├── Dockerfile.media # Fastify media server container
|
│ ├── Dockerfile.media # Fastify media server container
|
||||||
│ └── src/
|
│ └── src/
|
||||||
@ -73,7 +73,7 @@ changemaker.lite/
|
|||||||
│ ├── config/
|
│ ├── config/
|
||||||
│ │ └── env.ts # Zod-validated environment config (100+ vars)
|
│ │ └── env.ts # Zod-validated environment config (100+ vars)
|
||||||
│ ├── middleware/ # auth, rbac, rate-limit, validate, error-handler
|
│ ├── middleware/ # auth, rbac, rate-limit, validate, error-handler
|
||||||
│ ├── modules/ # 40 modules total
|
│ ├── modules/ # 44 modules total
|
||||||
│ │ ├── auth/ # JWT login, register, refresh, logout
|
│ │ ├── auth/ # JWT login, register, refresh, logout
|
||||||
│ │ ├── users/ # User CRUD + pagination + search
|
│ │ ├── users/ # User CRUD + pagination + search
|
||||||
│ │ ├── settings/ # Site settings singleton (20+ feature flags)
|
│ │ ├── settings/ # Site settings singleton (20+ feature flags)
|
||||||
@ -631,11 +631,11 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
|
|||||||
- API clients: `{ api }` from `lib/api.ts`, `mediaApi` from `lib/media-api.ts`
|
- API clients: `{ api }` from `lib/api.ts`, `mediaApi` from `lib/media-api.ts`
|
||||||
|
|
||||||
### Database ORM
|
### Database ORM
|
||||||
- **Prisma** (both APIs): 186 models in single `schema.prisma`. Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays
|
- **Prisma** (both APIs): 192 models in single `schema.prisma`. Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays
|
||||||
|
|
||||||
### Prisma Migration Workflow
|
### Prisma Migration Workflow
|
||||||
- **Always use `prisma migrate dev`** for schema changes (not `prisma db push`) — `db push` applies changes directly but doesn't create migration files, causing drift
|
- **Always use `prisma migrate dev`** for schema changes (not `prisma db push`) — `db push` applies changes directly but doesn't create migration files, causing drift
|
||||||
- **Migration history:** 44 migrations in `api/prisma/migrations/` fully cover the schema
|
- **Migration history:** 50 migrations in `api/prisma/migrations/` fully cover the schema
|
||||||
- **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`)
|
- **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`)
|
||||||
|
|
||||||
### Key Gotchas
|
### Key Gotchas
|
||||||
@ -772,8 +772,8 @@ V1 code has been removed from the repo. History preserved as `v1-archive` git ta
|
|||||||
- `config.sh` — Interactive setup wizard (14 steps, release-mode aware)
|
- `config.sh` — Interactive setup wizard (14 steps, release-mode aware)
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
- `api/prisma/schema.prisma` — Main schema (186 Prisma models)
|
- `api/prisma/schema.prisma` — Main schema (192 Prisma models)
|
||||||
- `api/prisma/migrations/` — 44 migration files (full schema history)
|
- `api/prisma/migrations/` — 50 migration files (full schema history)
|
||||||
- `api/prisma/seed.ts` — Database seeding
|
- `api/prisma/seed.ts` — Database seeding
|
||||||
|
|
||||||
### Nginx
|
### Nginx
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { getDriverForInstance, AgentUnreachableError } from '../../services/exec
|
|||||||
import { provision } from './provisioner';
|
import { provision } from './provisioner';
|
||||||
import { CreateInstanceInput, UpdateInstanceInput, RegisterInstanceInput, ReconfigureInstanceInput, ConfigureTunnelInput } from './instances.schemas';
|
import { CreateInstanceInput, UpdateInstanceInput, RegisterInstanceInput, ReconfigureInstanceInput, ConfigureTunnelInput } from './instances.schemas';
|
||||||
import { buildTemplateContext, renderAllTemplates, clearTemplateCache } from '../../services/template-engine';
|
import { buildTemplateContext, renderAllTemplates, clearTemplateCache } from '../../services/template-engine';
|
||||||
|
import { teardownTunnel } from '../../services/tunnel.service';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@ -283,6 +284,18 @@ export async function deleteInstance(id: string, userId: string, ipAddress?: str
|
|||||||
data: { status: InstanceStatus.DESTROYING, statusMessage: 'Shutting down containers...' },
|
data: { status: InstanceStatus.DESTROYING, statusMessage: 'Shutting down containers...' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tear down Pangolin site + resources first. If we crashed after composeDown
|
||||||
|
// but before this, the Pangolin entities would leak for the lifetime of the org.
|
||||||
|
// Best effort — matches the Docker-cleanup tolerance below.
|
||||||
|
if (instance.pangolinSiteId) {
|
||||||
|
try {
|
||||||
|
await teardownTunnel(id, userId, ipAddress ?? null);
|
||||||
|
logger.info(`[instances] ${instance.slug}: Pangolin tunnel torn down`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[instances] ${instance.slug}: Pangolin teardown warning: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stop containers and remove volumes
|
// Stop containers and remove volumes
|
||||||
try {
|
try {
|
||||||
const driver = await getDriverForInstance(instance);
|
const driver = await getDriverForInstance(instance);
|
||||||
|
|||||||
94
config.sh
94
config.sh
@ -19,6 +19,12 @@ NI_ADMIN_EMAIL=""
|
|||||||
NI_ADMIN_PASSWORD=""
|
NI_ADMIN_PASSWORD=""
|
||||||
NI_PRODUCTION=true
|
NI_PRODUCTION=true
|
||||||
NI_ENABLE_ALL=false
|
NI_ENABLE_ALL=false
|
||||||
|
SKIP_PORT_CHECK=false
|
||||||
|
|
||||||
|
# Systemd unit install opt-in ("", "yes", "no")
|
||||||
|
# Empty means "use default for this mode": skipped in NI unless --enable-all.
|
||||||
|
NI_INSTALL_WATCHER=""
|
||||||
|
NI_INSTALL_BACKUP=""
|
||||||
|
|
||||||
# SMTP flags
|
# SMTP flags
|
||||||
NI_SMTP_HOST=""
|
NI_SMTP_HOST=""
|
||||||
@ -52,6 +58,11 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--admin-password) NI_ADMIN_PASSWORD="$2"; shift 2 ;;
|
--admin-password) NI_ADMIN_PASSWORD="$2"; shift 2 ;;
|
||||||
--development) NI_PRODUCTION=false; shift ;;
|
--development) NI_PRODUCTION=false; shift ;;
|
||||||
--enable-all) NI_ENABLE_ALL=true; shift ;;
|
--enable-all) NI_ENABLE_ALL=true; shift ;;
|
||||||
|
--skip-port-check) SKIP_PORT_CHECK=true; shift ;;
|
||||||
|
--install-watcher) NI_INSTALL_WATCHER="yes"; shift ;;
|
||||||
|
--no-install-watcher) NI_INSTALL_WATCHER="no"; shift ;;
|
||||||
|
--install-backup-timer) NI_INSTALL_BACKUP="yes"; shift ;;
|
||||||
|
--no-install-backup-timer) NI_INSTALL_BACKUP="no"; shift ;;
|
||||||
# SMTP
|
# SMTP
|
||||||
--smtp-host) NI_SMTP_HOST="$2"; shift 2 ;;
|
--smtp-host) NI_SMTP_HOST="$2"; shift 2 ;;
|
||||||
--smtp-port) NI_SMTP_PORT="$2"; shift 2 ;;
|
--smtp-port) NI_SMTP_PORT="$2"; shift 2 ;;
|
||||||
@ -80,7 +91,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo " --admin-email EMAIL Set admin email (default: admin@DOMAIN)"
|
echo " --admin-email EMAIL Set admin email (default: admin@DOMAIN)"
|
||||||
echo " --admin-password PASS Set admin password (must meet policy: 12+ chars, upper+lower+digit)"
|
echo " --admin-password PASS Set admin password (must meet policy: 12+ chars, upper+lower+digit)"
|
||||||
echo " --development Set NODE_ENV=development (default: production)"
|
echo " --development Set NODE_ENV=development (default: production)"
|
||||||
echo " --enable-all Enable all optional features"
|
echo " --enable-all Enable all optional features + install systemd units"
|
||||||
|
echo " --skip-port-check Skip host port availability check (not recommended)"
|
||||||
|
echo ""
|
||||||
|
echo "Systemd Units (default in -y mode: skipped, unless --enable-all):"
|
||||||
|
echo " --install-watcher Install upgrade watcher systemd unit"
|
||||||
|
echo " --no-install-watcher Skip upgrade watcher even with --enable-all"
|
||||||
|
echo " --install-backup-timer Install daily backup timer systemd unit"
|
||||||
|
echo " --no-install-backup-timer Skip backup timer even with --enable-all"
|
||||||
echo ""
|
echo ""
|
||||||
echo "SMTP:"
|
echo "SMTP:"
|
||||||
echo " --smtp-host HOST SMTP server hostname"
|
echo " --smtp-host HOST SMTP server hostname"
|
||||||
@ -263,6 +281,32 @@ check_prerequisites() {
|
|||||||
ok=false
|
ok=false
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Host port availability check — catches cockpit on :9090, other stray listeners.
|
||||||
|
# Not fatal on its own; we warn here and let validate-env.sh surface details later.
|
||||||
|
if command -v ss &>/dev/null; then
|
||||||
|
local host_conflicts=""
|
||||||
|
for port in 3000 4000 4100 5433 3001 3030 9090 8091 8025 9001 5678 8888; do
|
||||||
|
if ss -Htln 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {found=1} END{exit !found}'; then
|
||||||
|
host_conflicts+="$port "
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ -n "$host_conflicts" ]]; then
|
||||||
|
warn "Host ports already in use: $host_conflicts"
|
||||||
|
warn "This will break 'docker compose up -d' on affected services."
|
||||||
|
warn "Common: cockpit.socket owns :9090 — 'sudo systemctl disable --now cockpit.socket'"
|
||||||
|
warn "Run './scripts/validate-env.sh' after setup for a full report."
|
||||||
|
if [[ "$NON_INTERACTIVE" == "true" ]]; then
|
||||||
|
error "Refusing to continue in non-interactive mode with host port conflicts."
|
||||||
|
error "Free the ports or pass --skip-port-check to override."
|
||||||
|
[[ "$SKIP_PORT_CHECK" != "true" ]] && ok=false
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
success "Host ports available"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "ss not installed — skipping host port check"
|
||||||
|
fi
|
||||||
|
|
||||||
$ok || { echo ""; error "Missing prerequisites. Install them and re-run."; exit 1; }
|
$ok || { echo ""; error "Missing prerequisites. Install them and re-run."; exit 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -421,6 +465,7 @@ configure_admin() {
|
|||||||
update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email"
|
update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email"
|
||||||
update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password"
|
update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password"
|
||||||
update_env_var "N8N_USER_EMAIL" "$admin_email"
|
update_env_var "N8N_USER_EMAIL" "$admin_email"
|
||||||
|
CONFIGURED_ADMIN_EMAIL="$admin_email"
|
||||||
success "Admin credentials configured ($admin_email)"
|
success "Admin credentials configured ($admin_email)"
|
||||||
else
|
else
|
||||||
local default_email="admin@${CONFIGURED_DOMAIN:-cmlite.org}"
|
local default_email="admin@${CONFIGURED_DOMAIN:-cmlite.org}"
|
||||||
@ -449,6 +494,7 @@ configure_admin() {
|
|||||||
update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email"
|
update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email"
|
||||||
update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password"
|
update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password"
|
||||||
update_env_var "N8N_USER_EMAIL" "$admin_email"
|
update_env_var "N8N_USER_EMAIL" "$admin_email"
|
||||||
|
CONFIGURED_ADMIN_EMAIL="$admin_email"
|
||||||
success "Admin credentials configured"
|
success "Admin credentials configured"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@ -1978,20 +2024,32 @@ fix_container_permissions() {
|
|||||||
install_upgrade_watcher() {
|
install_upgrade_watcher() {
|
||||||
header "System Upgrade Watcher"
|
header "System Upgrade Watcher"
|
||||||
|
|
||||||
|
# Ensure upgrade IPC directory exists regardless of install decision
|
||||||
|
mkdir -p "$SCRIPT_DIR/data/upgrade"
|
||||||
|
|
||||||
|
# Resolve whether to install in non-interactive mode:
|
||||||
|
# --install-watcher => yes
|
||||||
|
# --no-install-watcher => no
|
||||||
|
# --enable-all (no override) => yes (new default)
|
||||||
|
# otherwise => no (preserve legacy behaviour)
|
||||||
|
local should_install="ask"
|
||||||
if [[ "$NON_INTERACTIVE" == "true" ]]; then
|
if [[ "$NON_INTERACTIVE" == "true" ]]; then
|
||||||
mkdir -p "$SCRIPT_DIR/data/upgrade"
|
case "$NI_INSTALL_WATCHER" in
|
||||||
info "Skipping systemd watcher install (run manually later)"
|
yes) should_install="yes" ;;
|
||||||
UPGRADE_WATCHER="skipped"
|
no) should_install="no" ;;
|
||||||
return
|
"") if [[ "$NI_ENABLE_ALL" == "true" ]]; then should_install="yes"; else should_install="no"; fi ;;
|
||||||
|
esac
|
||||||
|
if [[ "$should_install" == "no" ]]; then
|
||||||
|
info "Skipping systemd watcher install (pass --install-watcher or --enable-all to install)"
|
||||||
|
UPGRADE_WATCHER="skipped"
|
||||||
|
return
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "The upgrade watcher lets you trigger upgrades from the admin Settings page."
|
info "The upgrade watcher lets you trigger upgrades from the admin Settings page."
|
||||||
info "It installs a systemd path watcher that monitors for trigger files."
|
info "It installs a systemd path watcher that monitors for trigger files."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Ensure upgrade IPC directory exists
|
|
||||||
mkdir -p "$SCRIPT_DIR/data/upgrade"
|
|
||||||
|
|
||||||
local unit_src="$SCRIPT_DIR/scripts/systemd"
|
local unit_src="$SCRIPT_DIR/scripts/systemd"
|
||||||
if [[ ! -f "$unit_src/changemaker-upgrade.path" ]] || [[ ! -f "$unit_src/changemaker-upgrade.service" ]]; then
|
if [[ ! -f "$unit_src/changemaker-upgrade.path" ]] || [[ ! -f "$unit_src/changemaker-upgrade.service" ]]; then
|
||||||
warn "Systemd unit templates not found in scripts/systemd/ — skipping"
|
warn "Systemd unit templates not found in scripts/systemd/ — skipping"
|
||||||
@ -2005,7 +2063,7 @@ install_upgrade_watcher() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if prompt_yes_no "Install the upgrade watcher (requires sudo)?"; then
|
if [[ "$should_install" == "yes" ]] || prompt_yes_no "Install the upgrade watcher (requires sudo)?"; then
|
||||||
# Generate units with correct paths substituted
|
# Generate units with correct paths substituted
|
||||||
local tmp_path tmp_service
|
local tmp_path tmp_service
|
||||||
tmp_path=$(mktemp)
|
tmp_path=$(mktemp)
|
||||||
@ -2041,10 +2099,18 @@ install_upgrade_watcher() {
|
|||||||
install_backup_timer() {
|
install_backup_timer() {
|
||||||
header "Automated Backups"
|
header "Automated Backups"
|
||||||
|
|
||||||
|
local should_install="ask"
|
||||||
if [[ "$NON_INTERACTIVE" == "true" ]]; then
|
if [[ "$NON_INTERACTIVE" == "true" ]]; then
|
||||||
info "Skipping backup timer install (run manually later)"
|
case "$NI_INSTALL_BACKUP" in
|
||||||
BACKUP_TIMER="skipped"
|
yes) should_install="yes" ;;
|
||||||
return
|
no) should_install="no" ;;
|
||||||
|
"") if [[ "$NI_ENABLE_ALL" == "true" ]]; then should_install="yes"; else should_install="no"; fi ;;
|
||||||
|
esac
|
||||||
|
if [[ "$should_install" == "no" ]]; then
|
||||||
|
info "Skipping backup timer install (pass --install-backup-timer or --enable-all to install)"
|
||||||
|
BACKUP_TIMER="skipped"
|
||||||
|
return
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "Daily automated backups protect against data loss."
|
info "Daily automated backups protect against data loss."
|
||||||
@ -2064,7 +2130,7 @@ install_backup_timer() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if prompt_yes_no "Install daily automated backups (requires sudo)?" "y"; then
|
if [[ "$should_install" == "yes" ]] || prompt_yes_no "Install daily automated backups (requires sudo)?" "y"; then
|
||||||
local tmp_timer tmp_service
|
local tmp_timer tmp_service
|
||||||
tmp_timer=$(mktemp)
|
tmp_timer=$(mktemp)
|
||||||
tmp_service=$(mktemp)
|
tmp_service=$(mktemp)
|
||||||
@ -2101,7 +2167,7 @@ print_summary() {
|
|||||||
header "Configuration Complete"
|
header "Configuration Complete"
|
||||||
|
|
||||||
echo -e " ${BOLD}Domain:${NC} ${CONFIGURED_DOMAIN:-cmlite.org}"
|
echo -e " ${BOLD}Domain:${NC} ${CONFIGURED_DOMAIN:-cmlite.org}"
|
||||||
echo -e " ${BOLD}Admin email:${NC} (see .env: INITIAL_ADMIN_EMAIL)"
|
echo -e " ${BOLD}Admin email:${NC} ${CONFIGURED_ADMIN_EMAIL:-admin@${CONFIGURED_DOMAIN:-cmlite.org}}"
|
||||||
echo -e " ${BOLD}Admin password:${NC} [set]"
|
echo -e " ${BOLD}Admin password:${NC} [set]"
|
||||||
echo -e " ${BOLD}SMTP:${NC} ${SMTP_MODE:-mailhog}"
|
echo -e " ${BOLD}SMTP:${NC} ${SMTP_MODE:-mailhog}"
|
||||||
echo -e " ${BOLD}Media Manager:${NC} ${MEDIA_ENABLED:-no}"
|
echo -e " ${BOLD}Media Manager:${NC} ${MEDIA_ENABLED:-no}"
|
||||||
|
|||||||
@ -117,7 +117,8 @@ cp "$PROJECT_DIR/api/prisma/init-gancio-db.sh" "$STAGE_DIR/scripts/"
|
|||||||
for script in nocodb-init.sh gitea-init.sh mkdocs-entrypoint.sh \
|
for script in nocodb-init.sh gitea-init.sh mkdocs-entrypoint.sh \
|
||||||
backup.sh restore.sh \
|
backup.sh restore.sh \
|
||||||
upgrade.sh upgrade-check.sh upgrade-watcher.sh \
|
upgrade.sh upgrade-check.sh upgrade-watcher.sh \
|
||||||
uninstall.sh test-deployment.sh; do
|
uninstall.sh test-deployment.sh \
|
||||||
|
validate-env.sh pangolin-teardown.sh; do
|
||||||
if [[ -f "$PROJECT_DIR/scripts/$script" ]]; then
|
if [[ -f "$PROJECT_DIR/scripts/$script" ]]; then
|
||||||
cp "$PROJECT_DIR/scripts/$script" "$STAGE_DIR/scripts/"
|
cp "$PROJECT_DIR/scripts/$script" "$STAGE_DIR/scripts/"
|
||||||
fi
|
fi
|
||||||
|
|||||||
172
scripts/pangolin-teardown.sh
Executable file
172
scripts/pangolin-teardown.sh
Executable file
@ -0,0 +1,172 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# pangolin-teardown.sh — Delete all Pangolin resources + sites for an org
|
||||||
|
#
|
||||||
|
# Use when wiping a test environment before a fresh install. Idempotent.
|
||||||
|
#
|
||||||
|
# Credentials are read from .env (PANGOLIN_API_URL/PANGOLIN_API_KEY/PANGOLIN_ORG_ID)
|
||||||
|
# unless overridden by flags.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/pangolin-teardown.sh # dry-run (preview only)
|
||||||
|
# ./scripts/pangolin-teardown.sh --yes # actually delete
|
||||||
|
# ./scripts/pangolin-teardown.sh --yes \
|
||||||
|
# --api-url https://api.bnkserve.org/v1 \
|
||||||
|
# --api-key KEY --org-id cursed-knowledge
|
||||||
|
#
|
||||||
|
# Exit codes: 0 success, 1 error, 2 partial failure
|
||||||
|
# =============================================================================
|
||||||
|
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'
|
||||||
|
|
||||||
|
API_URL=""; API_KEY=""; ORG_ID=""
|
||||||
|
CONFIRM=false
|
||||||
|
KEEP_SITE_IDS=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,20p' "$0" | sed 's/^# \?//'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--yes|-y) CONFIRM=true; shift ;;
|
||||||
|
--api-url) API_URL="$2"; shift 2 ;;
|
||||||
|
--api-key) API_KEY="$2"; shift 2 ;;
|
||||||
|
--org-id) ORG_ID="$2"; shift 2 ;;
|
||||||
|
--keep-site-ids) KEEP_SITE_IDS="$2"; shift 2 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) echo "Unknown flag: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Load from .env if any field is missing
|
||||||
|
if [[ -z "$API_URL" || -z "$API_KEY" || -z "$ORG_ID" ]]; then
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
API_URL="${API_URL:-$(grep -E '^PANGOLIN_API_URL=' "$ENV_FILE" | head -1 | cut -d= -f2-)}"
|
||||||
|
API_KEY="${API_KEY:-$(grep -E '^PANGOLIN_API_KEY=' "$ENV_FILE" | head -1 | cut -d= -f2-)}"
|
||||||
|
ORG_ID="${ORG_ID:-$(grep -E '^PANGOLIN_ORG_ID=' "$ENV_FILE" | head -1 | cut -d= -f2-)}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$API_URL" || -z "$API_KEY" || -z "$ORG_ID" ]]; then
|
||||||
|
echo -e "${RED}ERROR:${NC} Missing Pangolin credentials."
|
||||||
|
echo " Provide via flags (--api-url/--api-key/--org-id) or set in .env:"
|
||||||
|
echo " PANGOLIN_API_URL, PANGOLIN_API_KEY, PANGOLIN_ORG_ID"
|
||||||
|
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
|
||||||
|
|
||||||
|
# Normalize keep list into a space-separated lookup string
|
||||||
|
KEEP_LIST=" ${KEEP_SITE_IDS//,/ } "
|
||||||
|
|
||||||
|
echo -e "${BOLD}Pangolin teardown${NC}"
|
||||||
|
echo " API: $API_URL"
|
||||||
|
echo " Org: $ORG_ID"
|
||||||
|
[[ -n "$KEEP_SITE_IDS" ]] && echo " Keep sites: $KEEP_SITE_IDS"
|
||||||
|
[[ "$CONFIRM" == "false" ]] && echo -e " ${YELLOW}Mode: DRY RUN${NC} (pass --yes to execute)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- List resources ---
|
||||||
|
RES_JSON=$(curl -sf -H "Authorization: Bearer $API_KEY" "$API_URL/org/$ORG_ID/resources" || echo '')
|
||||||
|
if [[ -z "$RES_JSON" ]]; then
|
||||||
|
echo -e "${RED}ERROR:${NC} Failed to list resources (check API key + org id)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RESOURCES=$(echo "$RES_JSON" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
for r in d.get('data', {}).get('resources', []):
|
||||||
|
print(f\"{r['resourceId']}\t{r.get('name','?')}\t{r.get('fullDomain','')}\")
|
||||||
|
")
|
||||||
|
|
||||||
|
RESOURCE_COUNT=$(echo -n "$RESOURCES" | grep -c . || true)
|
||||||
|
echo -e "${CYAN}Resources to delete: $RESOURCE_COUNT${NC}"
|
||||||
|
[[ -n "$RESOURCES" ]] && echo "$RESOURCES" | awk -F'\t' '{printf " - [%s] %s %s\n", $1, $2, $3}'
|
||||||
|
|
||||||
|
# --- List sites ---
|
||||||
|
SITES_JSON=$(curl -sf -H "Authorization: Bearer $API_KEY" "$API_URL/org/$ORG_ID/sites" || echo '')
|
||||||
|
SITES=$(echo "$SITES_JSON" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
for s in d.get('data', {}).get('sites', []):
|
||||||
|
print(f\"{s['siteId']}\t{s.get('name','?')}\t{s.get('online','?')}\")
|
||||||
|
")
|
||||||
|
|
||||||
|
# Filter out kept sites
|
||||||
|
FILTERED_SITES=""
|
||||||
|
if [[ -n "$SITES" ]]; then
|
||||||
|
while IFS=$'\t' read -r sid name online; do
|
||||||
|
if [[ "$KEEP_LIST" == *" $sid "* ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
FILTERED_SITES+="${sid}\t${name}\t${online}"$'\n'
|
||||||
|
done <<< "$SITES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SITE_COUNT=$(echo -n "$FILTERED_SITES" | grep -c . || true)
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Sites to delete: $SITE_COUNT${NC}"
|
||||||
|
[[ -n "$FILTERED_SITES" ]] && echo -e "$FILTERED_SITES" | awk -F'\t' 'NF>1 {printf " - [%s] %s online=%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
|
||||||
|
|
||||||
|
# Delete resources
|
||||||
|
if [[ -n "$RESOURCES" ]]; then
|
||||||
|
while IFS=$'\t' read -r rid name domain; do
|
||||||
|
[[ -z "$rid" ]] && continue
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$API_URL/resource/$rid" -H "Authorization: Bearer $API_KEY")
|
||||||
|
if [[ "$code" == "200" || "$code" == "204" ]]; then
|
||||||
|
echo -e " ${GREEN}OK${NC} resource $rid ($name) deleted"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}FAIL${NC} resource $rid ($name) HTTP $code"
|
||||||
|
FAILURES=$((FAILURES + 1))
|
||||||
|
fi
|
||||||
|
done <<< "$RESOURCES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete sites (after resources, since resources reference sites)
|
||||||
|
if [[ -n "$FILTERED_SITES" ]]; then
|
||||||
|
while IFS=$'\t' read -r sid name online; do
|
||||||
|
[[ -z "$sid" ]] && continue
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$API_URL/site/$sid" -H "Authorization: Bearer $API_KEY")
|
||||||
|
if [[ "$code" == "200" || "$code" == "204" ]]; then
|
||||||
|
echo -e " ${GREEN}OK${NC} site $sid ($name) deleted"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}FAIL${NC} site $sid ($name) HTTP $code"
|
||||||
|
FAILURES=$((FAILURES + 1))
|
||||||
|
fi
|
||||||
|
done <<< "$(echo -e "$FILTERED_SITES")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
# Verify empty state
|
||||||
|
REMAINING_RES=$(curl -sf -H "Authorization: Bearer $API_KEY" "$API_URL/org/$ORG_ID/resources" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('data',{}).get('resources',[])))" 2>/dev/null || echo '?')
|
||||||
|
REMAINING_SITES=$(curl -sf -H "Authorization: Bearer $API_KEY" "$API_URL/org/$ORG_ID/sites" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('data',{}).get('sites',[])))" 2>/dev/null || echo '?')
|
||||||
|
echo -e "${CYAN}Remaining:${NC} $REMAINING_RES resources, $REMAINING_SITES sites"
|
||||||
|
|
||||||
|
if [[ $FAILURES -gt 0 ]]; then
|
||||||
|
echo -e "${YELLOW}Completed with $FAILURES failure(s).${NC}"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}Teardown complete.${NC}"
|
||||||
@ -258,6 +258,47 @@ fi
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# --- 5b. Host Port Availability ---
|
||||||
|
# Detects processes already bound to ports we intend to use (e.g. cockpit on :9090).
|
||||||
|
|
||||||
|
echo "5b. Host Port Availability"
|
||||||
|
echo "--------------------------"
|
||||||
|
|
||||||
|
if ! command -v ss >/dev/null 2>&1; then
|
||||||
|
warn "ss not installed — skipping host port check (install iproute2 to enable)"
|
||||||
|
else
|
||||||
|
# Collect unique ports from PORT_MAP keys
|
||||||
|
HOST_CONFLICTS=()
|
||||||
|
for port in "${!PORT_MAP[@]}"; do
|
||||||
|
# ss -H (no header) -t (tcp) -l (listen) -n (numeric); match :PORT at end of local addr
|
||||||
|
# Also matches *:PORT and [::]:PORT
|
||||||
|
if ss -Htln 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {found=1} END{exit !found}'; then
|
||||||
|
owner="${PORT_MAP[$port]}"
|
||||||
|
# Process identification needs root; ss -tlnp includes users:(("name",pid=X,fd=Y))
|
||||||
|
proc=""
|
||||||
|
if [[ $EUID -eq 0 ]]; then
|
||||||
|
proc=$(ss -Htlnp 2>/dev/null | awk -v p=":$port" '$4 ~ p"$" {print; exit}' \
|
||||||
|
| grep -oP 'users:\(\("[^"]+"' | head -1 | sed 's/users:(("//')
|
||||||
|
fi
|
||||||
|
if [[ -n "$proc" ]]; then
|
||||||
|
error "Port $port already in use by '$proc' (want: $owner)"
|
||||||
|
else
|
||||||
|
error "Port $port already in use on host (want: $owner) — run as root to identify the process"
|
||||||
|
fi
|
||||||
|
HOST_CONFLICTS+=("$port")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#HOST_CONFLICTS[@]} -eq 0 ]]; then
|
||||||
|
ok "All configured host ports are available"
|
||||||
|
else
|
||||||
|
info "Common culprits: cockpit.socket (9090), systemd-resolved (53), apache2/nginx host (80/443)"
|
||||||
|
info "Fix: \`sudo systemctl stop <service> && sudo systemctl disable <service>\` or remap the port in .env"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
# --- 6. Feature Flag Consistency ---
|
# --- 6. Feature Flag Consistency ---
|
||||||
|
|
||||||
echo "6. Feature Flag Consistency"
|
echo "6. Feature Flag Consistency"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user