diff --git a/install.sh b/install.sh index b1d7e5e..2342312 100755 --- a/install.sh +++ b/install.sh @@ -11,7 +11,7 @@ Usage: bash install.sh [options] Core options: - --ctid Force CT ID (optional). If omitted, a customer-safe CTID is generated. + --ctid Force CT ID (optional). If omitted, customer-safe CTID is generated. --cores (default: 2) --memory (default: 4096) --swap (default: 512) @@ -19,10 +19,11 @@ Core options: --bridge (default: vmbr0) --storage (default: local-zfs) --ip (default: dhcp) + --vlan VLAN tag on net0 (default: 90). Use 0/empty to disable tagging. --privileged Create privileged CT (default: unprivileged) - --tg-token Telegram bot token (optional) - --tg-chat Telegram chat id (optional) + --base-domain e.g. userman.de (default: userman.de) --help Show help + EOF } @@ -33,16 +34,12 @@ MEMORY="4096" SWAP="512" DISK="50" BRIDGE="vmbr0" -VLAN="90" STORAGE="local-zfs" IPCFG="dhcp" +VLAN="90" UNPRIV="1" -TG_TOKEN="" -TG_CHAT="" +BASE_DOMAIN="userman.de" -# --------------------------- -# Arg parsing -# --------------------------- while [[ $# -gt 0 ]]; do case "$1" in --ctid) CTID="${2:-}"; shift 2 ;; @@ -51,24 +48,24 @@ while [[ $# -gt 0 ]]; do --swap) SWAP="${2:-}"; shift 2 ;; --disk) DISK="${2:-}"; shift 2 ;; --bridge) BRIDGE="${2:-}"; shift 2 ;; - --vlan) VLAN="${2:-}"; shift 2 ;; --storage) STORAGE="${2:-}"; shift 2 ;; --ip) IPCFG="${2:-}"; shift 2 ;; + --vlan) VLAN="${2:-}"; shift 2 ;; --privileged) UNPRIV="0"; shift 1 ;; - --tg-token) TG_TOKEN="${2:-}"; shift 2 ;; - --tg-chat) TG_CHAT="${2:-}"; shift 2 ;; + --base-domain) BASE_DOMAIN="${2:-}"; shift 2 ;; --help|-h) usage; exit 0 ;; *) die "Unknown option: $1 (use --help)" ;; esac done -# Basic validation +# Validate [[ "$CORES" =~ ^[0-9]+$ ]] || die "--cores must be integer" -[[ "$VLAN" =~ ^[0-9]+$ ]] || die "--vlan must be integer" [[ "$MEMORY" =~ ^[0-9]+$ ]] || die "--memory must be integer" [[ "$SWAP" =~ ^[0-9]+$ ]] || die "--swap must be integer" [[ "$DISK" =~ ^[0-9]+$ ]] || die "--disk must be integer" [[ "$UNPRIV" == "0" || "$UNPRIV" == "1" ]] || die "internal: UNPRIV invalid" +[[ -z "$VLAN" || "$VLAN" =~ ^[0-9]+$ ]] || die "--vlan must be integer (or empty)" +[[ -n "$BASE_DOMAIN" ]] || die "--base-domain must not be empty" if [[ "$IPCFG" != "dhcp" ]]; then [[ "$IPCFG" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$ ]] || die "--ip must be dhcp or CIDR (e.g. 192.168.45.171/24)" @@ -76,53 +73,45 @@ fi info "Argument-Parsing OK" -# --------------------------- -# Preflight Proxmox -# --------------------------- -need_cmd pct pvesm pveam pvesh grep sed awk sort date +need_cmd pct pvesm pveam date awk grep sed +# Preflight pve_storage_exists "$STORAGE" || die "Storage not found: $STORAGE" pve_bridge_exists "$BRIDGE" || die "Bridge not found: $BRIDGE" TEMPLATE="$(pve_template_ensure_debian12 "$STORAGE")" info "Template OK: ${TEMPLATE}" -# Hostname based on unix time +# Hostname (wie gehabt) CT_HOSTNAME="sb-$(date +%s)" # CTID selection if [[ -n "$CTID" ]]; then [[ "$CTID" =~ ^[0-9]+$ ]] || die "--ctid must be integer" - if pve_vmid_exists_cluster "$CTID"; then - die "Forced CTID=${CTID} already exists in cluster" + if pct status "$CTID" >/dev/null 2>&1; then + die "Forced CTID=${CTID} already exists locally" fi else CTID="$(pve_select_customer_ctid)" fi +# FQDN ableiten (ohne extra Option; du weißt es erst nach Erstellung -> passt so) +FQDN="${CT_HOSTNAME}.${BASE_DOMAIN}" + info "CTID selected: ${CTID}" info "SCRIPT_DIR=${SCRIPT_DIR}" info "CT_HOSTNAME=${CT_HOSTNAME}" +info "FQDN=${FQDN}" info "cores=${CORES} memory=${MEMORY}MB swap=${SWAP}MB disk=${DISK}GB" -info "bridge=${BRIDGE} storage=${STORAGE} ip=${IPCFG} unprivileged=${UNPRIV}" -info "vlan=${VLAN}" +info "bridge=${BRIDGE} storage=${STORAGE} ip=${IPCFG} vlan=${VLAN:-0} unprivileged=${UNPRIV}" -if [[ -n "$TG_TOKEN" && -n "$TG_CHAT" ]]; then - info "Telegram enabled (chat=${TG_CHAT})" -else - info "Telegram disabled" -fi - -# --------------------------- # Step 5: Create CT -# --------------------------- NET0="$(pve_build_net0 "$BRIDGE" "$IPCFG" "$VLAN")" ROOTFS="${STORAGE}:${DISK}" FEATURES="nesting=1,keyctl=1,fuse=1" info "Step 5: Create CT" info "Creating CT ${CTID} (${CT_HOSTNAME}) from ${TEMPLATE}" - pct create "${CTID}" "${TEMPLATE}" \ --hostname "${CT_HOSTNAME}" \ --cores "${CORES}" \ @@ -145,130 +134,145 @@ info "Step 5 OK: LXC erstellt + IP ermittelt" info "CT_HOSTNAME=${CT_HOSTNAME}" info "CT_IP=${CT_IP}" -# --------------------------- -# Step 6: Provision inside CT (Locales + Docker + base stack folder) -# --------------------------- -info "Step 6: Provisioning im CT (Locales + Docker + Stack)" +# Step 6: Docker + locales + base dirs +info "Step 6: Provisioning im CT (Docker + Locales + Base)" -# 6.0 Fix locales early (prevents perl/locale warnings during installs) pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get update -y" -pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y locales" - -# Generate both (safe), set default to de_DE.UTF-8 (matches your /etc/default/locale goal) -pct_exec "${CTID}" "sed -i 's/^# *\\(de_DE.UTF-8 UTF-8\\)/\\1/' /etc/locale.gen" -pct_exec "${CTID}" "sed -i 's/^# *\\(en_US.UTF-8 UTF-8\\)/\\1/' /etc/locale.gen" -pct_exec "${CTID}" "locale-gen >/dev/null" -pct_exec "${CTID}" "update-locale LANG=de_DE.UTF-8 LC_ALL=de_DE.UTF-8" - -# 6.1 Minimal base packages pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y ca-certificates curl gnupg lsb-release" -# 6.2 Docker official repo (Debian 12 / bookworm) +# Docker repo (Debian 12/bookworm) pct_exec "${CTID}" "install -m 0755 -d /etc/apt/keyrings" pct_exec "${CTID}" "curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg" pct_exec "${CTID}" "chmod a+r /etc/apt/keyrings/docker.gpg" pct_exec "${CTID}" "echo \"deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable\" > /etc/apt/sources.list.d/docker.list" pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get update -y" - pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin" -# Enable docker service -pct_exec "${CTID}" "systemctl enable --now docker >/dev/null || true" +# locales fix (de_DE.UTF-8) +pct_fix_locales "${CTID}" "de_DE.UTF-8" -# 6.3 Create stack directories -pct_exec "${CTID}" "mkdir -p /opt/customer-stack /opt/customer-stack/volumes" - -# 6.4 Copy placeholder compose if present (will be replaced in later steps) -if [[ -f "${SCRIPT_DIR}/templates/docker-compose.yml" ]]; then - pct exec "${CTID}" -- bash -lc "cat > /opt/customer-stack/docker-compose.yml <<'YML' -$(cat "${SCRIPT_DIR}/templates/docker-compose.yml") -YML" -fi +pct_exec "${CTID}" "mkdir -p /opt/customer-stack/volumes/postgres/data /opt/customer-stack/volumes/n8n-data /opt/customer-stack/sql" info "Step 6 OK: Docker + Compose Plugin installiert, Locales gesetzt, Basis-Verzeichnisse erstellt" info "Next: Schritt 7 (finales docker-compose + Secrets + n8n/supabase up + Healthchecks)" - -# --------------------------- -# Step 7: Finalize stack + secrets + up + checks -# --------------------------- +# Step 7: stack deploy info "Step 7: Stack finalisieren + Secrets + Up + Checks" -# ---- Host/IP für URL bauen -# Wenn du später Reverse Proxy nutzt, werden diese Werte angepasst. -STACK_DIR="/opt/customer-stack" +# Secrets (ohne tr broken pipe) +PG_DB="customer" +PG_USER="customer" +PG_PASSWORD="$(gen_urlsafe 24)" +N8N_ENCRYPTION_KEY="$(gen_hex 32)" # 64 hex chars -N8N_PORT="5678" +# URL / Cookies +# Wenn du später via OPNsense/NGINX TLS machst, setze PROTOCOL=https und SECURE_COOKIE=true. +# Hier leiten wir's anhand FQDN ab (du nutzt HTTPS extern). N8N_PROTOCOL="http" +N8N_SECURE_COOKIE="false" +N8N_PORT="5678" N8N_HOST="${CT_IP}" -N8N_EDITOR_BASE_URL="${N8N_PROTOCOL}://${N8N_HOST}:${N8N_PORT}/" -WEBHOOK_URL="${N8N_EDITOR_BASE_URL}" +N8N_EDITOR_BASE_URL="https://${FQDN}/" +WEBHOOK_URL="https://${FQDN}/" -# ---- Secrets generieren (einmalig pro CT) -# Wichtig: nicht jedes Mal neu erzeugen, sonst ist n8n "kaputt" (encryption key ändert sich) -pct_exec "${CTID}" "test -f ${STACK_DIR}/.env || ( \ - umask 077; \ - PG_DB='n8n'; \ - PG_USER='n8n'; \ - PG_PASSWORD=\"\$(tr -dc 'A-Za-z0-9' ${STACK_DIR}/.env < /opt/customer-stack/.env <<'ENV' +PG_DB=${PG_DB} +PG_USER=${PG_USER} +PG_PASSWORD=${PG_PASSWORD} -# ---- Dateien rüberkopieren (compose + sql) -# docker-compose.yml -if [[ -f "${SCRIPT_DIR}/templates/docker-compose.yml" ]]; then - COMPOSE_CONTENT="$(cat "${SCRIPT_DIR}/templates/docker-compose.yml")" - pct exec "${CTID}" -- bash -lc "cat > ${STACK_DIR}/docker-compose.yml <<'YML' -${COMPOSE_CONTENT} -YML" -else - die "Missing template: ${SCRIPT_DIR}/templates/docker-compose.yml" -fi +N8N_PORT=${N8N_PORT} +N8N_PROTOCOL=${N8N_PROTOCOL} +N8N_HOST=${N8N_HOST} +N8N_EDITOR_BASE_URL=${N8N_EDITOR_BASE_URL} +WEBHOOK_URL=${WEBHOOK_URL} +N8N_SECURE_COOKIE=${N8N_SECURE_COOKIE} -# sql init -pct_exec "${CTID}" "mkdir -p ${STACK_DIR}/sql" -if [[ -f "${SCRIPT_DIR}/sql/init_pgvector.sql" ]]; then - SQL_CONTENT="$(cat "${SCRIPT_DIR}/sql/init_pgvector.sql")" - pct exec "${CTID}" -- bash -lc "cat > ${STACK_DIR}/sql/init_pgvector.sql <<'SQL' -${SQL_CONTENT} +N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} + +# Telemetrie/Background Calls aus (n8n docs) +N8N_DIAGNOSTICS_ENABLED=false +N8N_VERSION_NOTIFICATIONS_ENABLED=false +N8N_TEMPLATES_ENABLED=false +ENV" + +# pgvector init +pct_exec "${CTID}" "cat > /opt/customer-stack/sql/init_pgvector.sql <<'SQL' +CREATE EXTENSION IF NOT EXISTS vector; +CREATE EXTENSION IF NOT EXISTS pgcrypto; SQL" -else - die "Missing sql file: ${SCRIPT_DIR}/sql/init_pgvector.sql" -fi -# ---- Volumes + Rechte (wichtig!) -pct_exec "${CTID}" "mkdir -p ${STACK_DIR}/volumes/n8n-data ${STACK_DIR}/volumes/postgres/data" -# n8n läuft als node (uid 1000), postgres i.d.R. uid 999 -pct_exec "${CTID}" "chown -R 1000:1000 ${STACK_DIR}/volumes/n8n-data" -pct_exec "${CTID}" "chown -R 999:999 ${STACK_DIR}/volumes/postgres/data" +# docker-compose.yml +pct_exec "${CTID}" "cat > /opt/customer-stack/docker-compose.yml <<'YML' +services: + postgres: + image: pgvector/pgvector:pg16 + container_name: customer-postgres + restart: unless-stopped + environment: + POSTGRES_DB: \${PG_DB} + POSTGRES_USER: \${PG_USER} + POSTGRES_PASSWORD: \${PG_PASSWORD} + volumes: + - ./volumes/postgres/data:/var/lib/postgresql/data + - ./sql:/docker-entrypoint-initdb.d:ro + healthcheck: + test: [\"CMD-SHELL\", \"pg_isready -U \${PG_USER} -d \${PG_DB} || exit 1\"] + interval: 10s + timeout: 5s + retries: 20 + networks: + - customer-net -# ---- Start stack -pct_exec "${CTID}" "cd ${STACK_DIR} && docker compose pull" -pct_exec "${CTID}" "cd ${STACK_DIR} && docker compose up -d" + n8n: + image: n8nio/n8n:latest + container_name: n8n + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + ports: + - \"\${N8N_PORT}:5678\" + environment: + N8N_PORT: 5678 + N8N_PROTOCOL: \${N8N_PROTOCOL} + N8N_HOST: \${N8N_HOST} + N8N_EDITOR_BASE_URL: \${N8N_EDITOR_BASE_URL} + WEBHOOK_URL: \${WEBHOOK_URL} + N8N_SECURE_COOKIE: \${N8N_SECURE_COOKIE} -# ---- Minimal Checks -pct_exec "${CTID}" "cd ${STACK_DIR} && docker compose ps" -pct_exec "${CTID}" "cd ${STACK_DIR} && docker logs --tail=30 customer-postgres || true" -pct_exec "${CTID}" "cd ${STACK_DIR} && docker logs --tail=30 n8n || true" + DB_TYPE: postgresdb + DB_POSTGRESDB_HOST: postgres + DB_POSTGRESDB_PORT: 5432 + DB_POSTGRESDB_DATABASE: \${PG_DB} + DB_POSTGRESDB_USER: \${PG_USER} + DB_POSTGRESDB_PASSWORD: \${PG_PASSWORD} + + GENERIC_TIMEZONE: Europe/Berlin + TZ: Europe/Berlin + N8N_ENCRYPTION_KEY: \${N8N_ENCRYPTION_KEY} + + # Telemetrie/Background Calls aus (n8n docs) + N8N_DIAGNOSTICS_ENABLED: \${N8N_DIAGNOSTICS_ENABLED} + N8N_VERSION_NOTIFICATIONS_ENABLED: \${N8N_VERSION_NOTIFICATIONS_ENABLED} + N8N_TEMPLATES_ENABLED: \${N8N_TEMPLATES_ENABLED} + + volumes: + - ./volumes/n8n-data:/home/node/.n8n + networks: + - customer-net + +networks: + customer-net: + driver: bridge +YML" + +# Pull + Up + Status/Checks +pct_exec "${CTID}" "cd /opt/customer-stack && docker compose pull" +pct_exec "${CTID}" "cd /opt/customer-stack && docker compose up -d" +pct_exec "${CTID}" "cd /opt/customer-stack && docker compose ps" info "Step 7 OK: Stack deployed" -info "n8n: ${N8N_EDITOR_BASE_URL}" -info "Hinweis: Ohne Reverse-Proxy/TLS ist N8N_SECURE_COOKIE=false gesetzt. Später bei HTTPS wieder true." +info "n8n intern: http://${CT_IP}:5678/" +info "n8n extern (geplant via OPNsense): https://${FQDN}" +info "Hinweis: Telemetrie/Template/Versionschecks sind deaktiviert (n8n docs)." diff --git a/libsupabase.sh b/libsupabase.sh index 2ea925e..74c64d6 100755 --- a/libsupabase.sh +++ b/libsupabase.sh @@ -2,17 +2,27 @@ set -Eeuo pipefail # --------------------------- -# Logging / helpers +# Logging / Errors / Traps # --------------------------- _ts() { date '+%F %T'; } -_is_tty() { [[ -t 1 ]]; } - +# log to stderr (wichtig: damit Funktions-Rückgaben via stdout sauber bleiben) log() { echo "[$(_ts)] $*" >&2; } info() { log "INFO: $*"; } warn() { log "WARN: $*"; } die() { log "ERROR: $*"; exit 1; } +on_error() { + local lineno="$1" + local cmd="$2" + local code="$3" + die "Failed at line ${lineno}: ${cmd} (exit=${code})" +} + +setup_traps() { + trap 'on_error "$LINENO" "$BASH_COMMAND" "$?"' ERR +} + need_cmd() { local c for c in "$@"; do @@ -20,13 +30,30 @@ need_cmd() { done } -on_error() { - local lineno="$1" cmd="$2" code="$3" - die "Failed at line ${lineno}: ${cmd} (exit=${code})" +# --------------------------- +# Safe secret generators (NO tr|head pipelines) +# --------------------------- +# Hex secret, length = bytes*2 chars +gen_hex() { + local bytes="${1:-32}" + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex "$bytes" + return 0 + fi + # fallback python + python3 - </dev/null 2>&1; then warn "pveam storage '${store}' not available for templates; falling back to 'local'" store="local" fi - # Update list and download if missing pveam update >/dev/null 2>&1 || true - if ! pveam list "$store" | awk '{print $2}' | grep -qx "$tpl"; then - info "Downloading CT template to ${store}: ${tpl}" - pveam download "$store" "$tpl" >/dev/null + # vorhanden? + if ! pveam list "$store" | awk '{print $2}' | grep -qx "$desired"; then + # check available list + if pveam available | awk '{print $2}' | grep -qx "$desired"; then + info "Downloading CT template to ${store}: ${desired}" + pveam download "$store" "$desired" >/dev/null + else + die "Template not available via pveam: ${desired}" + fi + else + # optional: trotzdem loggen + : fi - echo "${store}:vztmpl/${tpl}" + echo "${store}:vztmpl/${desired}" } pve_build_net0() { - local bridge="$1" ip="$2" vlan="${3:-}" - if [[ "$ip" == "dhcp" ]]; then - if [[ -n "$vlan" ]]; then - echo "name=eth0,bridge=${bridge},tag=${vlan},ip=dhcp" - else - echo "name=eth0,bridge=${bridge},ip=dhcp" - fi - else - if [[ -n "$vlan" ]]; then - echo "name=eth0,bridge=${bridge},tag=${vlan},ip=${ip}" - else - echo "name=eth0,bridge=${bridge},ip=${ip}" - fi + local bridge="$1" + local ipcfg="$2" + local vlan="${3:-}" + + local base="name=eth0,bridge=${bridge},ip=${ipcfg}" + if [[ -n "${vlan}" && "${vlan}" != "0" ]]; then + base="${base},tag=${vlan}" fi + echo "$base" } -# Execute inside CT with sane locale env to avoid perl warnings mid-run. -pct_exec() { - local ctid="$1"; shift - local cmd="$*" - need_cmd pct - pct exec "$ctid" -- bash -lc "export LANG=C.UTF-8 LC_ALL=C.UTF-8; ${cmd}" +# CTID nach "Unixzeit - 1e9" (Kundensicher bis 2038) + lokale Kollisionsprüfung +pve_select_customer_ctid() { + need_cmd date pct awk sort tail + + local base + base="$(($(date +%s) - 1000000000))" + + # Falls lokal belegt, hochzählen bis frei + local ctid="$base" + while pct status "$ctid" >/dev/null 2>&1; do + ctid="$((ctid + 1))" + done + echo "$ctid" } pct_wait_for_ip() { local ctid="$1" - local i ip + local tries="${2:-60}" + local i=0 - for i in {1..60}; do - # Try eth0 first (standard CT) - ip="$(pct exec "$ctid" -- bash -lc "ip -4 -o addr show dev eth0 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | head -n1" 2>/dev/null || true)" + need_cmd pct awk grep + + while (( i < tries )); do + # pct exec ip -4 addr show dev eth0 → robust bei DHCP + local ip="" + ip="$(pct exec "$ctid" -- bash -lc "ip -4 -o addr show dev eth0 | awk '{print \$4}' | cut -d/ -f1 | head -n1" 2>/dev/null || true)" if [[ -n "$ip" ]]; then echo "$ip" return 0 fi sleep 1 + ((i++)) done return 1 } -# --------------------------- -# Cluster VMID discovery (robust) -# --------------------------- -# Outputs one VMID per line (containers + VMs), cluster-wide if possible. -pve_cluster_vmids() { - need_cmd pvesh awk sed grep - - # Try cluster resources; output-format json gives stable machine output. - # If pvesh fails (no cluster or temporary glitch), fall back to local IDs. - if pvesh get /cluster/resources --output-format json >/dev/null 2>&1; then - # No python/jq parsing. Extract "vmid": safely via awk/grep. - pvesh get /cluster/resources --output-format json \ - | tr -d '\n' \ - | sed 's/},{/}\n{/g' \ - | grep -oE '"vmid":[0-9]+' \ - | grep -oE '[0-9]+' \ - | sort -n -u - return 0 - fi - - # Fallback: local node only - { - pct list 2>/dev/null | awk 'NR>1 {print $1}' - qm list 2>/dev/null | awk 'NR>1 {print $1}' - } | sort -n -u +pct_exec() { + local ctid="$1"; shift + pct exec "$ctid" -- bash -lc "$*" } -pve_vmid_exists_cluster() { - local id="$1" - pve_cluster_vmids | awk -v x="$id" '$0==x {found=1} END{exit(found?0:1)}' +# Locale fix (Debian 12) +pct_fix_locales() { + local ctid="$1" + local locale="${2:-de_DE.UTF-8}" + pct_exec "$ctid" "export DEBIAN_FRONTEND=noninteractive; apt-get update -y" + pct_exec "$ctid" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y locales" + pct_exec "$ctid" "sed -i 's/^# *${locale} UTF-8/${locale} UTF-8/' /etc/locale.gen || true" + pct_exec "$ctid" "locale-gen ${locale} >/dev/null" + pct_exec "$ctid" "update-locale LANG=${locale} LC_ALL=${locale}" } -# Customer CTID: unix_time - 1_000_000_000 (good until 2038), -# then increment until free cluster-wide. -pve_select_customer_ctid() { - local base now ctid tries - now="$(date +%s)" - base=$(( now - 1000000000 )) - # keep it within a sane positive range - if (( base < 100 )); then - base=100 - fi - - ctid="$base" - for tries in {1..2000}; do - if ! pve_vmid_exists_cluster "$ctid"; then - echo "$ctid" - return 0 - fi - ctid=$((ctid + 1)) - done - - die "CTID selection failed: tried ${base}..$((base+1999)) (all occupied?)" -}