mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:37:32 +00:00
Red commit: `7277ca23` ([failing CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40190626951)). Fixes #2229. Management startup now defines logging helpers before normalizing CRLF environment files. Updates refresh version, revision and build time after checking out the target. Deployment verification and all five Compose healthchecks use `/api/healthz`, so an available statistics endpoint cannot report an unfinished startup as healthy. - The actual-script regression passes 15 checks: LF/CRLF/no environment file, repeat startup, all update targets, failed checkout, delayed/failed readiness, HTTPS readiness and unchanged informational statistics. - RED CI reproduced all 11 intended failures; GREEN `7ff9050b` passes the full standalone frontend suite and shell syntax checks. - Browser verified: an isolated Go fixture at http://127.0.0.1:18851 renders Home and returns ready=true from `/api/healthz`. - Git, Docker and network boundaries are mocked in management tests. No real container update was performed locally; CI validates the Compose/build path. - No new configuration, dependencies, schema changes or packet-processing work. Privacy, whitespace and scope checks passed. The external home-directory preflight bundle is unavailable; applicable checks were performed locally. Independent review found no outstanding fixes. [Final CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40191647297) passed on `7ff9050b`, including all three browser shards and both architecture builds.
1604 lines
52 KiB
Bash
Executable File
1604 lines
52 KiB
Bash
Executable File
#!/bin/bash
|
|
# CoreScope — Setup & Management Helper
|
|
# Usage: ./manage.sh [command]
|
|
#
|
|
# All container management goes through docker compose.
|
|
# Container config lives in docker-compose.yml — this script is just a wrapper.
|
|
#
|
|
# Idempotent: safe to cancel and re-run at any point.
|
|
# Each step checks what's already done and skips it.
|
|
set -e
|
|
|
|
IMAGE_NAME="corescope"
|
|
STATE_FILE=".setup-state"
|
|
STAGING_CONTAINER="corescope-staging-go"
|
|
|
|
# Source .env for port/path overrides (same file docker compose reads)
|
|
# Strip \r (Windows line endings) to avoid "$'\r': command not found"
|
|
if [ -f .env ]; then
|
|
set -a
|
|
while IFS='=' read -r key value || [ -n "$key" ]; do
|
|
key=$(printf '%s' "$key" | sed 's/\r$//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
|
|
[[ "$key" =~ ^#.*$ || -z "$key" ]] && continue
|
|
value=$(printf '%s' "$value" | sed 's/\r$//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
|
|
value="${value/#\~/$HOME}"
|
|
export "$key=$value"
|
|
done < .env
|
|
set +a
|
|
fi
|
|
|
|
# Resolved paths for prod/staging data (must match docker-compose.yml)
|
|
PROD_DATA="${PROD_DATA_DIR:-$HOME/meshcore-data}"
|
|
STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
|
|
STAGING_COMPOSE_FILE="docker-compose.staging.yml"
|
|
|
|
# Build metadata — exported so docker compose build picks them up via args
|
|
refresh_build_metadata() {
|
|
export APP_VERSION=$(git describe --tags --match "v*" 2>/dev/null || echo "unknown")
|
|
export GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
|
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
}
|
|
refresh_build_metadata
|
|
|
|
# Docker Compose — detect v2 plugin vs v1 standalone
|
|
if docker compose version &>/dev/null 2>&1; then
|
|
DC="docker compose"
|
|
elif command -v docker-compose &>/dev/null; then
|
|
DC="docker-compose"
|
|
else
|
|
echo "ERROR: Neither '$DC' nor 'docker-compose' found." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Colors
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
NC='\033[0m'
|
|
|
|
log() { printf '%b\n' "${GREEN}✓${NC} $1"; }
|
|
warn() { printf '%b\n' "${YELLOW}⚠${NC} $1"; }
|
|
err() { printf '%b\n' "${RED}✗${NC} $1"; }
|
|
info() { printf '%b\n' "${CYAN}→${NC} $1"; }
|
|
step() { printf '%b\n' "\n${BOLD}[$1/$TOTAL_STEPS] $2${NC}"; }
|
|
|
|
# Auto-fix CRLF only after the logging helpers are available.
|
|
if [ -f .env ] && grep -qP '\r' .env 2>/dev/null; then
|
|
warn ".env has Windows line endings (CRLF) — fixing automatically..."
|
|
sed -i 's/\r$//' .env
|
|
log ".env converted to Unix line endings."
|
|
fi
|
|
|
|
is_true() {
|
|
case "${1:-}" in
|
|
1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
dc_prod() {
|
|
if is_true "${DISABLE_MOSQUITTO:-false}"; then
|
|
$DC -f docker-compose.no-mosquitto.yml "$@"
|
|
else
|
|
$DC "$@"
|
|
fi
|
|
}
|
|
|
|
dc_staging() {
|
|
if is_true "${DISABLE_MOSQUITTO:-false}"; then
|
|
$DC -f docker-compose.staging.no-mosquitto.yml -p corescope-staging "$@"
|
|
else
|
|
$DC -f "$STAGING_COMPOSE_FILE" -p corescope-staging "$@"
|
|
fi
|
|
}
|
|
|
|
confirm() {
|
|
read -p " $1 [y/N] " -n 1 -r
|
|
echo
|
|
[[ $REPLY =~ ^[Yy]$ ]]
|
|
}
|
|
|
|
confirm_yes_default() {
|
|
read -p " $1 [Y/n] " -n 1 -r
|
|
echo
|
|
[[ -z "$REPLY" || $REPLY =~ ^[Yy]$ ]]
|
|
}
|
|
|
|
# State tracking — marks completed steps so re-runs skip them
|
|
mark_done() { echo "$1" >> "$STATE_FILE"; }
|
|
is_done() { [ -f "$STATE_FILE" ] && grep -qx "$1" "$STATE_FILE" 2>/dev/null; }
|
|
|
|
# ─── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
resolve_domain_ipv4() {
|
|
local domain="$1"
|
|
local resolved_ip=""
|
|
|
|
if command -v dig >/dev/null 2>&1; then
|
|
resolved_ip=$(dig +short "$domain" 2>/dev/null | grep -E '^[0-9]+\.' | head -1)
|
|
fi
|
|
if [ -z "$resolved_ip" ] && command -v host >/dev/null 2>&1; then
|
|
resolved_ip=$(host "$domain" 2>/dev/null | awk '/has address/ {print $4; exit}')
|
|
fi
|
|
if [ -z "$resolved_ip" ] && command -v nslookup >/dev/null 2>&1; then
|
|
resolved_ip=$(nslookup "$domain" 2>/dev/null | awk '/^Address: / {print $2}' | grep -E '^[0-9]+\.' | head -1)
|
|
fi
|
|
if [ -z "$resolved_ip" ] && command -v getent >/dev/null 2>&1; then
|
|
resolved_ip=$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | grep -E '^[0-9]+\.' | head -1)
|
|
fi
|
|
|
|
echo "$resolved_ip"
|
|
}
|
|
|
|
has_dns_resolution_tool() {
|
|
command -v dig >/dev/null 2>&1 || \
|
|
command -v host >/dev/null 2>&1 || \
|
|
command -v nslookup >/dev/null 2>&1 || \
|
|
command -v getent >/dev/null 2>&1
|
|
}
|
|
|
|
PORT_CHECK_METHOD=""
|
|
|
|
resolve_port_check_method() {
|
|
if [ -n "$PORT_CHECK_METHOD" ]; then
|
|
return 0
|
|
fi
|
|
|
|
if command -v ss &>/dev/null; then
|
|
PORT_CHECK_METHOD="ss"
|
|
elif command -v lsof &>/dev/null; then
|
|
PORT_CHECK_METHOD="lsof"
|
|
elif command -v netstat &>/dev/null; then
|
|
PORT_CHECK_METHOD="netstat"
|
|
elif command -v nc &>/dev/null; then
|
|
PORT_CHECK_METHOD="nc"
|
|
else
|
|
PORT_CHECK_METHOD="none"
|
|
fi
|
|
}
|
|
|
|
# Returns 0 when in use, 1 when free, 2 when unavailable
|
|
is_port_in_use() {
|
|
local port="$1"
|
|
resolve_port_check_method
|
|
|
|
case "$PORT_CHECK_METHOD" in
|
|
ss)
|
|
ss -tlnp 2>/dev/null | grep -E "[[:space:]]LISTEN[[:space:]].*[:.]${port}([[:space:]]|$)" >/dev/null
|
|
return $?
|
|
;;
|
|
lsof)
|
|
lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
|
|
return $?
|
|
;;
|
|
netstat)
|
|
netstat -tlnp 2>/dev/null | grep -E "[[:space:]]${port}[[:space:]]" >/dev/null
|
|
if [ $? -eq 0 ]; then
|
|
return 0
|
|
fi
|
|
netstat -tlnp 2>/dev/null | grep -E "[:.]${port}[[:space:]]" >/dev/null
|
|
return $?
|
|
;;
|
|
nc)
|
|
local bind_pid=""
|
|
( nc -l 127.0.0.1 "$port" >/dev/null 2>&1 ) &
|
|
bind_pid=$!
|
|
sleep 0.2
|
|
if kill -0 "$bind_pid" 2>/dev/null; then
|
|
kill "$bind_pid" 2>/dev/null || true
|
|
wait "$bind_pid" 2>/dev/null || true
|
|
return 1
|
|
fi
|
|
wait "$bind_pid" 2>/dev/null || true
|
|
return 0
|
|
;;
|
|
*)
|
|
return 2
|
|
;;
|
|
esac
|
|
}
|
|
|
|
port_in_use_details() {
|
|
local port="$1"
|
|
resolve_port_check_method
|
|
|
|
case "$PORT_CHECK_METHOD" in
|
|
ss)
|
|
ss -tlnp 2>/dev/null | grep -E "[[:space:]]LISTEN[[:space:]].*[:.]${port}([[:space:]]|$)" | head -1
|
|
;;
|
|
lsof)
|
|
lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null | sed -n '2p'
|
|
;;
|
|
netstat)
|
|
netstat -tlnp 2>/dev/null | grep -E "[:.]${port}[[:space:]]" | head -1
|
|
;;
|
|
*)
|
|
echo ""
|
|
;;
|
|
esac
|
|
}
|
|
|
|
find_next_available_port() {
|
|
local start="$1"
|
|
local candidate=$((start + 1))
|
|
while [ "$candidate" -le 65535 ]; do
|
|
is_port_in_use "$candidate"
|
|
local rc=$?
|
|
if [ "$rc" -eq 0 ]; then
|
|
candidate=$((candidate + 1))
|
|
continue
|
|
fi
|
|
if [ "$rc" -eq 1 ]; then
|
|
echo "$candidate"
|
|
return 0
|
|
fi
|
|
break
|
|
done
|
|
echo ""
|
|
return 1
|
|
}
|
|
|
|
is_valid_port() {
|
|
local value="$1"
|
|
[[ "$value" =~ ^[0-9]+$ ]] && [ "$value" -ge 1 ] && [ "$value" -le 65535 ]
|
|
}
|
|
|
|
show_env_port_summary() {
|
|
local http_port="$1"
|
|
local https_port="$2"
|
|
local mqtt_port="$3"
|
|
local data_dir="$4"
|
|
local disable_mosquitto="$5"
|
|
echo ""
|
|
echo " Current .env values:"
|
|
echo " PROD_HTTP_PORT=${http_port}"
|
|
echo " PROD_HTTPS_PORT=${https_port}"
|
|
echo " PROD_MQTT_PORT=${mqtt_port}"
|
|
echo " DISABLE_MOSQUITTO=${disable_mosquitto}"
|
|
echo " PROD_DATA_DIR=${data_dir}"
|
|
echo ""
|
|
}
|
|
|
|
get_env_value() {
|
|
local key="$1"
|
|
local env_file="${2:-.env}"
|
|
if [ ! -f "$env_file" ]; then
|
|
echo ""
|
|
return 1
|
|
fi
|
|
sed -n "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*//p" "$env_file" | head -1
|
|
}
|
|
|
|
write_env_managed_values() {
|
|
local http_port="$1"
|
|
local https_port="$2"
|
|
local mqtt_port="$3"
|
|
local data_dir="$4"
|
|
local disable_mosquitto="$5"
|
|
local env_file=".env"
|
|
local tmp_file=".env.tmp.$$"
|
|
|
|
if [ ! -f "$env_file" ]; then
|
|
cp .env.example "$env_file"
|
|
fi
|
|
|
|
local seen_http=0
|
|
local seen_https=0
|
|
local seen_mqtt=0
|
|
local seen_data=0
|
|
local seen_disable_mosquitto=0
|
|
|
|
: > "$tmp_file"
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
case "$line" in
|
|
PROD_HTTP_PORT=*)
|
|
echo "PROD_HTTP_PORT=${http_port}" >> "$tmp_file"
|
|
seen_http=1
|
|
;;
|
|
PROD_HTTPS_PORT=*)
|
|
echo "PROD_HTTPS_PORT=${https_port}" >> "$tmp_file"
|
|
seen_https=1
|
|
;;
|
|
PROD_MQTT_PORT=*)
|
|
echo "PROD_MQTT_PORT=${mqtt_port}" >> "$tmp_file"
|
|
seen_mqtt=1
|
|
;;
|
|
PROD_DATA_DIR=*)
|
|
echo "PROD_DATA_DIR=${data_dir}" >> "$tmp_file"
|
|
seen_data=1
|
|
;;
|
|
DISABLE_MOSQUITTO=*)
|
|
echo "DISABLE_MOSQUITTO=${disable_mosquitto}" >> "$tmp_file"
|
|
seen_disable_mosquitto=1
|
|
;;
|
|
*)
|
|
echo "$line" >> "$tmp_file"
|
|
;;
|
|
esac
|
|
done < "$env_file"
|
|
|
|
[ "$seen_http" -eq 1 ] || echo "PROD_HTTP_PORT=${http_port}" >> "$tmp_file"
|
|
[ "$seen_https" -eq 1 ] || echo "PROD_HTTPS_PORT=${https_port}" >> "$tmp_file"
|
|
[ "$seen_mqtt" -eq 1 ] || echo "PROD_MQTT_PORT=${mqtt_port}" >> "$tmp_file"
|
|
[ "$seen_data" -eq 1 ] || echo "PROD_DATA_DIR=${data_dir}" >> "$tmp_file"
|
|
[ "$seen_disable_mosquitto" -eq 1 ] || echo "DISABLE_MOSQUITTO=${disable_mosquitto}" >> "$tmp_file"
|
|
|
|
mv "$tmp_file" "$env_file"
|
|
}
|
|
|
|
prompt_for_port() {
|
|
local label="$1"
|
|
local current="$2"
|
|
local prompt_default="$3"
|
|
|
|
while true; do
|
|
if [ -n "$prompt_default" ] && [ "$prompt_default" != "$current" ]; then
|
|
read -p " ${label} port [${prompt_default}] (current ${current}): " selected
|
|
selected=${selected:-$prompt_default}
|
|
else
|
|
read -p " ${label} port [${current}]: " selected
|
|
selected=${selected:-$current}
|
|
fi
|
|
|
|
if ! is_valid_port "$selected"; then
|
|
warn "Invalid port '${selected}'. Enter a value between 1 and 65535."
|
|
continue
|
|
fi
|
|
|
|
is_port_in_use "$selected"
|
|
local rc=$?
|
|
if [ "$rc" -eq 0 ]; then
|
|
warn "Port ${selected} is in use."
|
|
local details
|
|
details=$(port_in_use_details "$selected")
|
|
[ -n "$details" ] && echo " ${details}"
|
|
if confirm "Use ${selected} anyway? (start will fail if still occupied)"; then
|
|
echo "$selected"
|
|
return 0
|
|
fi
|
|
continue
|
|
fi
|
|
if [ "$rc" -eq 2 ]; then
|
|
warn "Port detection unavailable on this host. Proceeding with chosen value."
|
|
fi
|
|
|
|
echo "$selected"
|
|
return 0
|
|
done
|
|
}
|
|
|
|
preflight_validate_prod_ports() {
|
|
local http_port="${PROD_HTTP_PORT:-80}"
|
|
local https_port="${PROD_HTTPS_PORT:-443}"
|
|
local mqtt_port=""
|
|
if ! is_true "${DISABLE_MOSQUITTO:-false}"; then
|
|
mqtt_port="${PROD_MQTT_PORT:-1883}"
|
|
fi
|
|
local failed=0
|
|
|
|
info "Preflight: validating configured ports are free..."
|
|
local ports_to_check=("$http_port" "$https_port")
|
|
[ -n "$mqtt_port" ] && ports_to_check+=("$mqtt_port")
|
|
for port in "${ports_to_check[@]}"; do
|
|
if is_port_in_use "$port"; then
|
|
err "Port ${port} is in use."
|
|
local details
|
|
details=$(port_in_use_details "$port")
|
|
[ -n "$details" ] && echo " ${details}"
|
|
failed=1
|
|
fi
|
|
done
|
|
|
|
if [ "$failed" -eq 1 ]; then
|
|
echo ""
|
|
echo " Remediation:"
|
|
echo " • Stop the process using the conflicting port(s)"
|
|
echo " • Or run ./manage.sh setup and re-negotiate ports"
|
|
echo " • Then re-run this command"
|
|
return 1
|
|
fi
|
|
|
|
log "Preflight port validation passed."
|
|
return 0
|
|
}
|
|
|
|
# Check config.json for placeholder values
|
|
check_config_placeholders() {
|
|
local cfg="${1:-$PROD_DATA/config.json}"
|
|
if [ -f "$cfg" ]; then
|
|
if grep -qE 'your-username|your-password|your-secret|example\.com|changeme' "$cfg" 2>/dev/null; then
|
|
warn "config.json contains placeholder values."
|
|
warn "Edit ${cfg} and replace placeholder values before deploying."
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Verify the running container is actually healthy
|
|
verify_health() {
|
|
local container="corescope-prod"
|
|
local use_https=false
|
|
|
|
# Check if Caddyfile has a real domain (not :80)
|
|
if [ -f caddy-config/Caddyfile ]; then
|
|
local caddyfile_domain
|
|
caddyfile_domain=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
|
|
if [ "$caddyfile_domain" != ":80" ] && [ -n "$caddyfile_domain" ]; then
|
|
use_https=true
|
|
fi
|
|
fi
|
|
|
|
# Wait for initialization, not just the HTTP listener (loading can take 60s+).
|
|
info "Waiting for server initialization..."
|
|
local healthy=false
|
|
for i in $(seq 1 45); do
|
|
if docker exec "$container" wget -qO- http://localhost:3000/api/healthz &>/dev/null; then
|
|
healthy=true
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
if ! $healthy; then
|
|
err "Server did not become ready after 90 seconds."
|
|
warn "Check logs: ./manage.sh logs"
|
|
return 1
|
|
fi
|
|
log "Server is ready."
|
|
|
|
# Check for MQTT errors in recent logs
|
|
local mqtt_errors
|
|
mqtt_errors=$(docker logs "$container" --tail 50 2>&1 | grep -i 'mqtt.*error\|mqtt.*fail\|ECONNREFUSED.*1883' || true)
|
|
if [ -n "$mqtt_errors" ]; then
|
|
warn "MQTT errors detected in logs:"
|
|
echo "$mqtt_errors" | head -5 | sed 's/^/ /'
|
|
fi
|
|
|
|
# If HTTPS domain configured, try to verify externally
|
|
if $use_https; then
|
|
info "Checking HTTPS for ${caddyfile_domain}..."
|
|
if command -v curl &>/dev/null; then
|
|
if curl -sf --connect-timeout 5 "https://${caddyfile_domain}/api/healthz" &>/dev/null; then
|
|
log "HTTPS is working: https://${caddyfile_domain}"
|
|
else
|
|
warn "HTTPS not reachable yet for ${caddyfile_domain}"
|
|
warn "It may take a minute for Caddy to provision the certificate."
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# ─── Setup Wizard ─────────────────────────────────────────────────────────
|
|
|
|
TOTAL_STEPS=6
|
|
|
|
cmd_setup() {
|
|
echo ""
|
|
echo "═══════════════════════════════════════"
|
|
echo " CoreScope Setup"
|
|
echo "═══════════════════════════════════════"
|
|
echo ""
|
|
|
|
if [ -f "$STATE_FILE" ]; then
|
|
info "Resuming previous setup. Delete ${STATE_FILE} to start over."
|
|
echo ""
|
|
fi
|
|
|
|
# ── Step 1: Check Docker ──
|
|
step 1 "Checking Docker"
|
|
|
|
if ! command -v docker &> /dev/null; then
|
|
err "Docker is not installed."
|
|
echo ""
|
|
echo " Install it:"
|
|
echo " curl -fsSL https://get.docker.com | sh"
|
|
echo " sudo usermod -aG docker \$USER"
|
|
echo ""
|
|
echo " Then log out, log back in, and run ./manage.sh setup again."
|
|
exit 1
|
|
fi
|
|
|
|
# Check if user can actually run Docker
|
|
if ! docker info &> /dev/null; then
|
|
err "Docker is installed but your user can't run it."
|
|
echo ""
|
|
echo " Fix: sudo usermod -aG docker \$USER"
|
|
echo " Then log out, log back in, and try again."
|
|
exit 1
|
|
fi
|
|
|
|
log "Docker $(docker --version | grep -oP 'version \K[^ ,]+')"
|
|
log "Compose: $DC"
|
|
|
|
# Default to latest release tag (instead of staying on master)
|
|
if ! is_done "version_pin"; then
|
|
git fetch origin --tags --force 2>/dev/null || true
|
|
local latest_tag
|
|
latest_tag=$(git tag -l 'v*' --sort=-v:refname | head -1)
|
|
if [ -n "$latest_tag" ]; then
|
|
local current_ref
|
|
current_ref=$(git describe --tags --exact-match 2>/dev/null || echo "")
|
|
if [ "$current_ref" != "$latest_tag" ]; then
|
|
info "Pinning to latest release: ${latest_tag}"
|
|
git checkout "$latest_tag" 2>/dev/null
|
|
else
|
|
log "Already on latest release: ${latest_tag}"
|
|
fi
|
|
fi
|
|
mark_done "version_pin"
|
|
fi
|
|
|
|
mark_done "docker"
|
|
|
|
# ── Step 2: Config ──
|
|
step 2 "Configuration"
|
|
|
|
if [ -f "$PROD_DATA/config.json" ]; then
|
|
log "config.json found in data directory."
|
|
# Sanity check the JSON
|
|
if ! python3 -c "import json; json.load(open('$PROD_DATA/config.json'))" 2>/dev/null && \
|
|
! node -e "JSON.parse(require('fs').readFileSync('$PROD_DATA/config.json'))" 2>/dev/null; then
|
|
err "config.json has invalid JSON. Fix it and re-run setup."
|
|
exit 1
|
|
fi
|
|
log "config.json is valid JSON."
|
|
check_config_placeholders "$PROD_DATA/config.json"
|
|
elif [ -f config.json ]; then
|
|
# Legacy: config in repo root — move it to data dir
|
|
info "Found config.json in repo root — moving to data directory..."
|
|
mkdir -p "$PROD_DATA"
|
|
cp config.json "$PROD_DATA/config.json"
|
|
log "Config moved to ${PROD_DATA}/config.json"
|
|
check_config_placeholders "$PROD_DATA/config.json"
|
|
else
|
|
info "Creating config.json in data directory from example..."
|
|
mkdir -p "$PROD_DATA"
|
|
cp config.example.json "$PROD_DATA/config.json"
|
|
|
|
# Generate a random API key
|
|
if command -v openssl &> /dev/null; then
|
|
API_KEY=$(openssl rand -hex 16)
|
|
else
|
|
API_KEY=$(head -c 32 /dev/urandom | xxd -p | head -c 32)
|
|
fi
|
|
# Replace the placeholder API key
|
|
if command -v sed &> /dev/null; then
|
|
sed -i "s/your-secret-api-key-here/${API_KEY}/" "$PROD_DATA/config.json"
|
|
fi
|
|
|
|
log "Created config.json with random API key."
|
|
check_config_placeholders "$PROD_DATA/config.json"
|
|
echo ""
|
|
echo " Config saved to: ${PROD_DATA}/config.json"
|
|
echo " Edit with: nano ${PROD_DATA}/config.json"
|
|
echo ""
|
|
fi
|
|
mark_done "config"
|
|
|
|
# ── Step 3: Ports & Networking ──
|
|
step 3 "Ports & Networking"
|
|
|
|
local default_http=80
|
|
local default_https=443
|
|
local default_mqtt=1883
|
|
local selected_http="$default_http"
|
|
local selected_https="$default_https"
|
|
local selected_mqtt="$default_mqtt"
|
|
local selected_disable_mosquitto="${DISABLE_MOSQUITTO:-false}"
|
|
local selected_data_dir="${PROD_DATA_DIR:-$HOME/meshcore-data}"
|
|
|
|
local env_http=""
|
|
local env_https=""
|
|
local env_mqtt=""
|
|
local env_disable_mosquitto=""
|
|
local env_data_dir=""
|
|
|
|
if [ -f .env ]; then
|
|
env_http=$(get_env_value "PROD_HTTP_PORT" ".env")
|
|
env_https=$(get_env_value "PROD_HTTPS_PORT" ".env")
|
|
env_mqtt=$(get_env_value "PROD_MQTT_PORT" ".env")
|
|
env_disable_mosquitto=$(get_env_value "DISABLE_MOSQUITTO" ".env")
|
|
env_data_dir=$(get_env_value "PROD_DATA_DIR" ".env")
|
|
env_data_dir="${env_data_dir/#\~/$HOME}"
|
|
[ -n "$env_data_dir" ] && selected_data_dir="$env_data_dir"
|
|
[ -n "$env_disable_mosquitto" ] && selected_disable_mosquitto="$env_disable_mosquitto"
|
|
show_env_port_summary "${env_http:-<unset>}" "${env_https:-<unset>}" "${env_mqtt:-<unset>}" "${env_data_dir:-<unset>}" "${env_disable_mosquitto:-<unset>}"
|
|
else
|
|
info ".env not found. It will be created from .env.example."
|
|
fi
|
|
|
|
local has_current_ports=false
|
|
if is_true "$selected_disable_mosquitto"; then
|
|
if is_valid_port "$env_http" && is_valid_port "$env_https"; then
|
|
has_current_ports=true
|
|
fi
|
|
elif is_valid_port "$env_http" && is_valid_port "$env_https" && is_valid_port "$env_mqtt"; then
|
|
has_current_ports=true
|
|
fi
|
|
|
|
local renegotiate=true
|
|
if [ -f .env ] && $has_current_ports; then
|
|
if confirm "Keep current ports from .env?"; then
|
|
renegotiate=false
|
|
selected_http="$env_http"
|
|
selected_https="$env_https"
|
|
if is_valid_port "$env_mqtt"; then
|
|
selected_mqtt="$env_mqtt"
|
|
fi
|
|
log "Keeping current ports from .env."
|
|
fi
|
|
fi
|
|
|
|
if $renegotiate; then
|
|
resolve_port_check_method
|
|
if [ "$PORT_CHECK_METHOD" = "none" ]; then
|
|
warn "No supported port detection tool found (ss/lsof/netstat/nc)."
|
|
warn "You'll still be prompted, but conflicts cannot be detected now."
|
|
else
|
|
info "Detecting listeners using ${PORT_CHECK_METHOD}..."
|
|
fi
|
|
|
|
local suggested_http="$default_http"
|
|
local suggested_https="$default_https"
|
|
local suggested_mqtt="$default_mqtt"
|
|
|
|
if is_port_in_use "$default_http"; then
|
|
warn "Port ${default_http} is in use."
|
|
local details_http
|
|
details_http=$(port_in_use_details "$default_http")
|
|
[ -n "$details_http" ] && echo " ${details_http}"
|
|
suggested_http=$(find_next_available_port "$default_http")
|
|
[ -n "$suggested_http" ] && info "Suggested HTTP port: ${suggested_http}"
|
|
fi
|
|
|
|
if is_port_in_use "$default_https"; then
|
|
warn "Port ${default_https} is in use."
|
|
local details_https
|
|
details_https=$(port_in_use_details "$default_https")
|
|
[ -n "$details_https" ] && echo " ${details_https}"
|
|
suggested_https=$(find_next_available_port "$default_https")
|
|
[ -n "$suggested_https" ] && info "Suggested HTTPS port: ${suggested_https}"
|
|
fi
|
|
|
|
selected_http=$(prompt_for_port "HTTP" "$default_http" "$suggested_http")
|
|
selected_https=$(prompt_for_port "HTTPS" "$default_https" "$suggested_https")
|
|
|
|
if confirm_yes_default "Use built-in MQTT broker?"; then
|
|
selected_disable_mosquitto="false"
|
|
if is_port_in_use "$default_mqtt"; then
|
|
warn "Port ${default_mqtt} is in use."
|
|
local details_mqtt
|
|
details_mqtt=$(port_in_use_details "$default_mqtt")
|
|
[ -n "$details_mqtt" ] && echo " ${details_mqtt}"
|
|
suggested_mqtt=$(find_next_available_port "$default_mqtt")
|
|
[ -n "$suggested_mqtt" ] && info "Suggested MQTT port: ${suggested_mqtt}"
|
|
fi
|
|
selected_mqtt=$(prompt_for_port "MQTT" "$default_mqtt" "$suggested_mqtt")
|
|
else
|
|
selected_disable_mosquitto="true"
|
|
log "Internal MQTT broker disabled."
|
|
fi
|
|
fi
|
|
|
|
if [ -f caddy-config/Caddyfile ]; then
|
|
EXISTING_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
|
|
if [ "$EXISTING_DOMAIN" = ":80" ] || [ "$EXISTING_DOMAIN" = ":${selected_http}" ]; then
|
|
log "Caddyfile exists (HTTP only, no HTTPS)."
|
|
else
|
|
log "Caddyfile exists for ${EXISTING_DOMAIN}"
|
|
fi
|
|
else
|
|
mkdir -p caddy-config
|
|
echo ""
|
|
echo " How should the analyzer be accessed?"
|
|
echo ""
|
|
echo " 1) Direct with built-in HTTPS — Caddy auto-provisions a TLS cert"
|
|
echo " (requires ports 80 + 443 open, and a domain pointed at this server)"
|
|
echo ""
|
|
echo " 2) Behind my own reverse proxy — HTTP only"
|
|
echo " (for Cloudflare Tunnel, nginx, Traefik, etc.)"
|
|
echo ""
|
|
read -p " Choose [1/2]: " -n 1 -r
|
|
echo ""
|
|
|
|
case $REPLY in
|
|
1)
|
|
read -p " Enter your domain (e.g., analyzer.example.com): " DOMAIN
|
|
if [ -z "$DOMAIN" ]; then
|
|
err "No domain entered. Re-run setup to try again."
|
|
exit 1
|
|
fi
|
|
|
|
echo "${DOMAIN} {
|
|
reverse_proxy localhost:3000
|
|
}" > caddy-config/Caddyfile
|
|
log "Caddyfile created for ${DOMAIN}"
|
|
|
|
# Validate DNS
|
|
info "Checking DNS..."
|
|
RESOLVED_IP=$(resolve_domain_ipv4 "$DOMAIN")
|
|
MY_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "unknown")
|
|
|
|
if [ -z "$RESOLVED_IP" ]; then
|
|
if has_dns_resolution_tool; then
|
|
warn "${DOMAIN} doesn't resolve yet."
|
|
warn "Create an A record pointing to ${MY_IP}"
|
|
warn "HTTPS won't work until DNS propagates (1-60 min)."
|
|
else
|
|
warn "DNS tool not found; skipping domain resolution check."
|
|
fi
|
|
echo ""
|
|
if ! confirm "Continue anyway?"; then
|
|
echo " Run ./manage.sh setup again when DNS is ready."
|
|
exit 0
|
|
fi
|
|
elif [ "$RESOLVED_IP" = "$MY_IP" ]; then
|
|
log "DNS resolves correctly: ${DOMAIN} → ${MY_IP}"
|
|
else
|
|
warn "${DOMAIN} resolves to ${RESOLVED_IP} but this server is ${MY_IP}"
|
|
warn "HTTPS provisioning will fail if the domain doesn't point here."
|
|
if ! confirm "Continue anyway?"; then
|
|
echo " Fix DNS and run ./manage.sh setup again."
|
|
exit 0
|
|
fi
|
|
fi
|
|
;;
|
|
2)
|
|
echo ":${selected_http} {
|
|
reverse_proxy localhost:3000
|
|
}" > caddy-config/Caddyfile
|
|
log "Caddyfile created (HTTP only on port ${selected_http})."
|
|
echo " Point your reverse proxy or tunnel to this server's port ${selected_http}."
|
|
;;
|
|
*)
|
|
warn "Invalid choice. Defaulting to HTTP only."
|
|
echo ":${selected_http} {
|
|
reverse_proxy localhost:3000
|
|
}" > caddy-config/Caddyfile
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
write_env_managed_values "$selected_http" "$selected_https" "$selected_mqtt" "$selected_data_dir" "$selected_disable_mosquitto"
|
|
log "Saved negotiated ports to .env"
|
|
show_env_port_summary "$selected_http" "$selected_https" "$selected_mqtt" "$selected_data_dir" "$selected_disable_mosquitto"
|
|
|
|
echo " Resolved port mapping:"
|
|
echo " UI HTTP: ${selected_http}"
|
|
echo " UI HTTPS: ${selected_https}"
|
|
if is_true "$selected_disable_mosquitto"; then
|
|
echo " MQTT: disabled (external broker)"
|
|
else
|
|
echo " MQTT: ${selected_mqtt}"
|
|
fi
|
|
echo ""
|
|
if ! confirm "Proceed to build/start with these ports?"; then
|
|
echo " Setup cancelled. Re-run ./manage.sh setup when ready."
|
|
exit 0
|
|
fi
|
|
|
|
export PROD_HTTP_PORT="$selected_http"
|
|
export PROD_HTTPS_PORT="$selected_https"
|
|
export PROD_MQTT_PORT="$selected_mqtt"
|
|
export DISABLE_MOSQUITTO="$selected_disable_mosquitto"
|
|
export PROD_DATA_DIR="$selected_data_dir"
|
|
PROD_DATA="$PROD_DATA_DIR"
|
|
mark_done "caddyfile"
|
|
|
|
# ── Step 4: Build ──
|
|
step 4 "Building Docker image"
|
|
|
|
# Check if image exists and source hasn't changed
|
|
IMAGE_EXISTS=$(docker images -q "$IMAGE_NAME" 2>/dev/null)
|
|
if [ -n "$IMAGE_EXISTS" ] && is_done "build"; then
|
|
log "Image already built."
|
|
if confirm "Rebuild? (only needed if you updated the code)"; then
|
|
dc_prod build prod
|
|
log "Image rebuilt."
|
|
fi
|
|
else
|
|
info "This takes 1-2 minutes the first time..."
|
|
dc_prod build prod
|
|
log "Image built."
|
|
fi
|
|
mark_done "build"
|
|
|
|
# ── Step 5: Start container ──
|
|
step 5 "Starting container"
|
|
|
|
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
|
|
info "Production container already running — skipping preflight port check."
|
|
else
|
|
if ! preflight_validate_prod_ports; then
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Detect existing data directories
|
|
if [ -d "$PROD_DATA" ] && [ -f "$PROD_DATA/meshcore.db" ]; then
|
|
info "Found existing data at $PROD_DATA/ — will use bind mount."
|
|
fi
|
|
|
|
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
|
|
log "Container already running."
|
|
else
|
|
mkdir -p "$PROD_DATA"
|
|
dc_prod up -d prod
|
|
log "Container started."
|
|
fi
|
|
mark_done "container"
|
|
|
|
# ── Step 6: Verify ──
|
|
step 6 "Verifying"
|
|
|
|
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
|
|
verify_health
|
|
|
|
CADDYFILE_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════"
|
|
echo " Setup complete!"
|
|
echo "═══════════════════════════════════════"
|
|
echo ""
|
|
if [ "$CADDYFILE_DOMAIN" != ":80" ] && [ -n "$CADDYFILE_DOMAIN" ]; then
|
|
echo " 🌐 https://${CADDYFILE_DOMAIN}"
|
|
else
|
|
MY_IP=$(curl -s -4 ifconfig.me 2>/dev/null || echo "your-server-ip")
|
|
echo " 🌐 http://${MY_IP}"
|
|
fi
|
|
echo ""
|
|
echo " Next steps:"
|
|
echo " • Connect an observer to start receiving packets"
|
|
echo " • Customize branding in config.json"
|
|
echo " • Set up backups: ./manage.sh backup"
|
|
echo ""
|
|
echo " Useful commands:"
|
|
echo " ./manage.sh status Check health"
|
|
echo " ./manage.sh logs View logs"
|
|
echo " ./manage.sh backup Full backup (DB + config + theme)"
|
|
echo " ./manage.sh update Update to latest version"
|
|
echo ""
|
|
else
|
|
err "Container failed to start."
|
|
echo ""
|
|
echo " Check what went wrong:"
|
|
echo " $DC logs prod"
|
|
echo ""
|
|
echo " Common fixes:"
|
|
echo " • Invalid config.json — check JSON syntax"
|
|
echo " • Port conflict — stop other web servers"
|
|
echo " • Re-run: ./manage.sh setup"
|
|
echo ""
|
|
exit 1
|
|
fi
|
|
|
|
mark_done "verify"
|
|
}
|
|
|
|
# ─── Staging Helpers ──────────────────────────────────────────────────────
|
|
|
|
# Copy production DB to staging data directory
|
|
prepare_staging_db() {
|
|
mkdir -p "$STAGING_DATA"
|
|
if [ -f "$PROD_DATA/meshcore.db" ]; then
|
|
info "Copying production database to staging..."
|
|
cp "$PROD_DATA/meshcore.db" "$STAGING_DATA/meshcore.db" 2>/dev/null || true
|
|
log "Database snapshot copied to ${STAGING_DATA}/meshcore.db"
|
|
else
|
|
warn "No production database found at ${PROD_DATA}/meshcore.db — staging starts empty."
|
|
fi
|
|
}
|
|
|
|
# Copy config.prod.json → config.staging.json with siteName change
|
|
prepare_staging_config() {
|
|
local prod_config="$PROD_DATA/config.json"
|
|
local staging_config="$STAGING_DATA/config.json"
|
|
mkdir -p "$STAGING_DATA"
|
|
|
|
# Docker may have created config.json as a directory
|
|
[ -d "$staging_config" ] && rmdir "$staging_config" 2>/dev/null || true
|
|
|
|
if [ ! -f "$prod_config" ]; then
|
|
warn "No production config at ${prod_config} — staging may use defaults."
|
|
return
|
|
fi
|
|
info "Copying production config to staging..."
|
|
cp "$prod_config" "$staging_config"
|
|
sed -i 's/"siteName":\s*"[^"]*"/"siteName": "CoreScope — STAGING"/' "$staging_config"
|
|
log "Staging config created at ${staging_config} with STAGING site name."
|
|
# Copy Caddyfile for staging (HTTP-only on staging port)
|
|
local staging_caddy="$STAGING_DATA/Caddyfile"
|
|
if [ ! -f "$staging_caddy" ]; then
|
|
info "Creating staging Caddyfile (HTTP-only on port ${STAGING_GO_HTTP_PORT:-82})..."
|
|
echo ":${STAGING_GO_HTTP_PORT:-82} {" > "$staging_caddy"
|
|
echo " reverse_proxy localhost:3000" >> "$staging_caddy"
|
|
echo "}" >> "$staging_caddy"
|
|
log "Staging Caddyfile created at ${staging_caddy}"
|
|
fi
|
|
}
|
|
|
|
# Check if a container is running by name
|
|
container_running() {
|
|
docker ps --format '{{.Names}}' | grep -q "^${1}$"
|
|
}
|
|
|
|
# Get health status of a container
|
|
container_health() {
|
|
docker inspect "$1" --format '{{.State.Health.Status}}' 2>/dev/null || echo "unknown"
|
|
}
|
|
|
|
# ─── Start / Stop / Restart ──────────────────────────────────────────────
|
|
|
|
# Migrate legacy root config.json to data directory path
|
|
migrate_config() {
|
|
local mode="${1:-interactive}"
|
|
local legacy_config="./config.json"
|
|
local target_config="$PROD_DATA/config.json"
|
|
local reply=""
|
|
|
|
mkdir -p "$PROD_DATA"
|
|
|
|
if [ -f "$target_config" ]; then
|
|
return 0
|
|
fi
|
|
|
|
if [ ! -f "$legacy_config" ]; then
|
|
return 0
|
|
fi
|
|
|
|
if [ "$mode" = "auto" ]; then
|
|
echo "→ Migrating config.json from repo root to ${PROD_DATA}/config.json..."
|
|
cp "$legacy_config" "$target_config"
|
|
echo "✓ Config migrated."
|
|
return 0
|
|
fi
|
|
|
|
echo ""
|
|
echo "Found legacy config location:"
|
|
echo " Source: ./config.json (repo root)"
|
|
echo " Destination: ${PROD_DATA}/config.json"
|
|
echo " Note: CoreScope reads config from the data directory at runtime."
|
|
read -p "Move to ${PROD_DATA}/config.json? [Y/n] " reply
|
|
|
|
if [ -z "$reply" ] || [[ "$reply" =~ ^[Yy]$ ]]; then
|
|
cp "$legacy_config" "$target_config"
|
|
log "Copied config.json to ${target_config}"
|
|
return 0
|
|
fi
|
|
|
|
warn "Migration aborted."
|
|
echo "Move ./config.json to ${PROD_DATA}/config.json, then run this command again."
|
|
return 1
|
|
}
|
|
|
|
# Ensure config.json exists in the data directory before starting
|
|
ensure_config() {
|
|
local data_dir="$1"
|
|
local config="$data_dir/config.json"
|
|
mkdir -p "$data_dir"
|
|
|
|
# Docker may have created config.json as a directory from a prior failed mount
|
|
[ -d "$config" ] && rmdir "$config" 2>/dev/null || true
|
|
|
|
if [ -f "$config" ]; then
|
|
return 0
|
|
fi
|
|
|
|
# Try to copy from repo root (legacy location)
|
|
if [ -f ./config.json ]; then
|
|
info "No config in data directory — copying from ./config.json"
|
|
cp ./config.json "$config"
|
|
return 0
|
|
fi
|
|
|
|
# Prompt admin
|
|
echo ""
|
|
warn "No config.json found in ${data_dir}/"
|
|
echo ""
|
|
echo " CoreScope needs a config.json to connect to MQTT brokers."
|
|
echo ""
|
|
echo " Options:"
|
|
echo " 1) Create from example (you'll edit MQTT settings after)"
|
|
echo " 2) I'll put one there myself (abort for now)"
|
|
echo ""
|
|
read -p " Choose [1/2]: " -n 1 -r
|
|
echo ""
|
|
|
|
case $REPLY in
|
|
1)
|
|
cp config.example.json "$config"
|
|
# Generate a random API key
|
|
if command -v openssl &>/dev/null; then
|
|
API_KEY=$(openssl rand -hex 16)
|
|
else
|
|
API_KEY=$(head -c 32 /dev/urandom | xxd -p | head -c 32)
|
|
fi
|
|
sed -i "s/your-secret-api-key-here/${API_KEY}/" "$config" 2>/dev/null || true
|
|
log "Created ${config} from example with random API key."
|
|
warn "Edit MQTT settings before connecting observers:"
|
|
echo " nano ${config}"
|
|
echo ""
|
|
;;
|
|
*)
|
|
echo " Place your config.json at: ${config}"
|
|
echo " Then run this command again."
|
|
exit 0
|
|
;;
|
|
esac
|
|
}
|
|
|
|
cmd_start() {
|
|
local WITH_STAGING=false
|
|
if [ "$1" = "--with-staging" ]; then
|
|
WITH_STAGING=true
|
|
fi
|
|
|
|
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
|
|
info "Production container already running — skipping preflight port check."
|
|
else
|
|
if ! preflight_validate_prod_ports; then
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
migrate_config || exit 1
|
|
# Always check prod config
|
|
ensure_config "$PROD_DATA"
|
|
|
|
if $WITH_STAGING; then
|
|
# Prepare staging data and config
|
|
prepare_staging_db
|
|
prepare_staging_config
|
|
|
|
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
|
info "Starting staging container (${STAGING_CONTAINER}) on port ${STAGING_GO_HTTP_PORT:-82}..."
|
|
dc_prod up -d prod
|
|
dc_staging up -d staging-go
|
|
if is_true "${DISABLE_MOSQUITTO:-false}"; then
|
|
log "Production started on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443} (MQTT disabled)"
|
|
log "Staging started on port ${STAGING_GO_HTTP_PORT:-82} (MQTT disabled)"
|
|
else
|
|
log "Production started on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}/${PROD_MQTT_PORT:-1883}"
|
|
log "Staging started on port ${STAGING_GO_HTTP_PORT:-82} (MQTT: ${STAGING_GO_MQTT_PORT:-1885})"
|
|
fi
|
|
else
|
|
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
|
|
dc_prod up -d prod
|
|
log "Production started. Staging NOT running (use --with-staging to start both)."
|
|
fi
|
|
}
|
|
|
|
cmd_stop() {
|
|
local TARGET="${1:-all}"
|
|
|
|
case "$TARGET" in
|
|
prod)
|
|
info "Stopping production container (corescope-prod)..."
|
|
dc_prod stop prod
|
|
log "Production stopped."
|
|
;;
|
|
staging)
|
|
info "Stopping staging container (${STAGING_CONTAINER})..."
|
|
dc_staging rm -sf staging-go 2>/dev/null || true
|
|
docker rm -f "$STAGING_CONTAINER" meshcore-staging-go corescope-staging meshcore-staging 2>/dev/null || true
|
|
log "Staging stopped and cleaned up."
|
|
;;
|
|
all)
|
|
info "Stopping all containers..."
|
|
dc_prod stop prod
|
|
dc_staging rm -sf staging-go 2>/dev/null || true
|
|
docker rm -f "$STAGING_CONTAINER" meshcore-staging-go corescope-staging meshcore-staging 2>/dev/null || true
|
|
log "All containers stopped."
|
|
;;
|
|
*)
|
|
err "Usage: ./manage.sh stop [prod|staging|all]"
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
cmd_restart() {
|
|
local TARGET="${1:-prod}"
|
|
case "$TARGET" in
|
|
prod)
|
|
info "Restarting production container (corescope-prod)..."
|
|
dc_prod up -d --force-recreate prod
|
|
log "Production restarted."
|
|
;;
|
|
staging)
|
|
info "Restarting staging container (${STAGING_CONTAINER})..."
|
|
# Stop and remove old container
|
|
dc_staging rm -sf staging-go 2>/dev/null || true
|
|
docker rm -f "$STAGING_CONTAINER" 2>/dev/null || true
|
|
# Wait for container to be fully gone and memory to be reclaimed
|
|
# This prevents OOM when old + new containers overlap on small VMs
|
|
for i in $(seq 1 15); do
|
|
if ! docker ps -a --format '{{.Names}}' | grep -q "$STAGING_CONTAINER"; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
sleep 3 # extra pause for OS to reclaim memory
|
|
# Verify config exists before starting
|
|
local staging_config="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}/config.json"
|
|
if [ ! -f "$staging_config" ]; then
|
|
warn "Staging config not found at $staging_config — creating from prod config..."
|
|
prepare_staging_config
|
|
fi
|
|
dc_staging up -d staging-go
|
|
log "Staging restarted."
|
|
;;
|
|
all)
|
|
info "Restarting all containers..."
|
|
dc_prod up -d --force-recreate prod
|
|
dc_staging rm -sf staging-go 2>/dev/null || true
|
|
docker rm -f "$STAGING_CONTAINER" 2>/dev/null || true
|
|
dc_staging up -d staging-go
|
|
log "All containers restarted."
|
|
;;
|
|
*)
|
|
err "Usage: ./manage.sh restart [prod|staging|all]"
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ─── Status ───────────────────────────────────────────────────────────────
|
|
|
|
# Show status for a single container (used in compose mode)
|
|
show_container_status() {
|
|
local NAME="$1"
|
|
local LABEL="$2"
|
|
|
|
if container_running "$NAME"; then
|
|
local health
|
|
health=$(container_health "$NAME")
|
|
log "${LABEL} (${NAME}): Running — Health: ${health}"
|
|
docker ps --filter "name=${NAME}" --format " Ports: {{.Ports}}"
|
|
|
|
# Server stats
|
|
if docker exec "$NAME" wget -qO /dev/null http://localhost:3000/api/stats 2>/dev/null; then
|
|
local stats packets nodes
|
|
stats=$(docker exec "$NAME" wget -qO- http://localhost:3000/api/stats 2>/dev/null)
|
|
packets=$(echo "$stats" | grep -oP '"totalPackets":\K[0-9]+' 2>/dev/null || echo "?")
|
|
nodes=$(echo "$stats" | grep -oP '"totalNodes":\K[0-9]+' 2>/dev/null || echo "?")
|
|
info " ${packets} packets, ${nodes} nodes"
|
|
fi
|
|
else
|
|
if docker ps -a --format '{{.Names}}' | grep -q "^${NAME}$"; then
|
|
warn "${LABEL} (${NAME}): Stopped"
|
|
else
|
|
info "${LABEL} (${NAME}): Not running"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
cmd_status() {
|
|
echo ""
|
|
echo "═══════════════════════════════════════"
|
|
echo " CoreScope Status"
|
|
echo "═══════════════════════════════════════"
|
|
echo ""
|
|
|
|
# Version
|
|
local current_version
|
|
current_version=$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
|
info "Version: ${current_version}"
|
|
echo ""
|
|
|
|
# Production
|
|
show_container_status "corescope-prod" "Production"
|
|
echo ""
|
|
|
|
# Staging
|
|
if container_running "$STAGING_CONTAINER"; then
|
|
show_container_status "$STAGING_CONTAINER" "Staging"
|
|
else
|
|
info "Staging (${STAGING_CONTAINER}): Not running (use --with-staging to start both)"
|
|
fi
|
|
echo ""
|
|
|
|
# Disk usage
|
|
if [ -d "$PROD_DATA" ] && [ -f "$PROD_DATA/meshcore.db" ]; then
|
|
local db_size
|
|
db_size=$(du -h "$PROD_DATA/meshcore.db" 2>/dev/null | cut -f1)
|
|
info "Production DB: ${db_size}"
|
|
fi
|
|
if [ -d "$STAGING_DATA" ] && [ -f "$STAGING_DATA/meshcore.db" ]; then
|
|
local staging_db_size
|
|
staging_db_size=$(du -h "$STAGING_DATA/meshcore.db" 2>/dev/null | cut -f1)
|
|
info "Staging DB: ${staging_db_size}"
|
|
fi
|
|
|
|
echo ""
|
|
}
|
|
|
|
# ─── Logs ─────────────────────────────────────────────────────────────────
|
|
|
|
cmd_logs() {
|
|
local TARGET="${1:-prod}"
|
|
local LINES="${2:-100}"
|
|
case "$TARGET" in
|
|
prod)
|
|
info "Tailing production logs..."
|
|
dc_prod logs -f --tail="$LINES" prod
|
|
;;
|
|
staging)
|
|
if container_running "$STAGING_CONTAINER"; then
|
|
info "Tailing staging logs..."
|
|
dc_staging logs -f --tail="$LINES" staging-go
|
|
else
|
|
err "Staging container is not running."
|
|
info "Start with: ./manage.sh start --with-staging"
|
|
exit 1
|
|
fi
|
|
;;
|
|
*)
|
|
err "Usage: ./manage.sh logs [prod|staging] [lines]"
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ─── Promote ──────────────────────────────────────────────────────────────
|
|
|
|
cmd_promote() {
|
|
echo ""
|
|
info "Promotion Flow: Staging → Production"
|
|
echo ""
|
|
echo "This will:"
|
|
echo " 1. Backup current production database"
|
|
echo " 2. Restart production with latest image (same as staging)"
|
|
echo " 3. Wait for health check"
|
|
echo ""
|
|
|
|
# Show what's currently running
|
|
local staging_image staging_created prod_image prod_created
|
|
staging_image=$(docker inspect "$STAGING_CONTAINER" --format '{{.Config.Image}}' 2>/dev/null || echo "not running")
|
|
staging_created=$(docker inspect "$STAGING_CONTAINER" --format '{{.Created}}' 2>/dev/null || echo "N/A")
|
|
prod_image=$(docker inspect corescope-prod --format '{{.Config.Image}}' 2>/dev/null || echo "not running")
|
|
prod_created=$(docker inspect corescope-prod --format '{{.Created}}' 2>/dev/null || echo "N/A")
|
|
|
|
echo " Staging: ${staging_image} (created ${staging_created})"
|
|
echo " Prod: ${prod_image} (created ${prod_created})"
|
|
echo ""
|
|
|
|
if ! confirm "Proceed with promotion?"; then
|
|
echo " Aborted."
|
|
exit 0
|
|
fi
|
|
|
|
# Backup production DB
|
|
info "Backing up production database..."
|
|
local BACKUP_DIR="./backups/pre-promotion-$(date +%Y%m%d-%H%M%S)"
|
|
mkdir -p "$BACKUP_DIR"
|
|
if [ -f "$PROD_DATA/meshcore.db" ]; then
|
|
cp "$PROD_DATA/meshcore.db" "$BACKUP_DIR/"
|
|
elif container_running "corescope-prod"; then
|
|
docker cp corescope-prod:/app/data/meshcore.db "$BACKUP_DIR/"
|
|
else
|
|
warn "Could not backup production database."
|
|
fi
|
|
log "Backup saved to ${BACKUP_DIR}/"
|
|
|
|
# Restart prod with latest image
|
|
info "Restarting production with latest image..."
|
|
dc_prod up -d --force-recreate prod
|
|
|
|
# Wait for health
|
|
info "Waiting for production health check..."
|
|
local i health
|
|
for i in $(seq 1 30); do
|
|
health=$(container_health "corescope-prod")
|
|
if [ "$health" = "healthy" ]; then
|
|
log "Production healthy after ${i}s"
|
|
break
|
|
fi
|
|
if [ "$i" -eq 30 ]; then
|
|
err "Production failed health check after 30s"
|
|
warn "Check logs: ./manage.sh logs prod"
|
|
warn "Rollback: cp ${BACKUP_DIR}/meshcore.db ${PROD_DATA}/ && ./manage.sh restart prod"
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
log "Promotion complete ✓"
|
|
echo ""
|
|
echo " Production is now running the same image as staging."
|
|
echo " Backup: ${BACKUP_DIR}/"
|
|
echo ""
|
|
}
|
|
|
|
# ─── Update ───────────────────────────────────────────────────────────────
|
|
|
|
cmd_update() {
|
|
local version="${1:-}"
|
|
|
|
info "Fetching latest changes and tags..."
|
|
git fetch origin --tags --force
|
|
|
|
if [ -z "$version" ]; then
|
|
# No arg: checkout latest release tag
|
|
local latest_tag
|
|
latest_tag=$(git tag -l 'v*' --sort=-v:refname | head -1)
|
|
if [ -z "$latest_tag" ]; then
|
|
err "No release tags found. Use './manage.sh update latest' for tip of master."
|
|
exit 1
|
|
fi
|
|
info "Checking out latest release: ${latest_tag}"
|
|
git checkout "$latest_tag" || { err "Failed to checkout tag '${latest_tag}'."; exit 1; }
|
|
elif [ "$version" = "latest" ]; then
|
|
# Explicit opt-in to bleeding edge (tip of master)
|
|
# Note: this creates a detached HEAD at origin/master, which is intentional —
|
|
# we want a read-only snapshot of upstream, not a local tracking branch.
|
|
info "Checking out tip of master (detached HEAD at origin/master)..."
|
|
git checkout origin/master || { err "Failed to checkout origin/master."; exit 1; }
|
|
else
|
|
# Specific tag requested
|
|
if ! git tag -l "$version" | grep -q .; then
|
|
err "Tag '${version}' not found."
|
|
echo ""
|
|
echo " Available releases:"
|
|
git tag -l 'v*' --sort=-v:refname | head -10 | sed 's/^/ /'
|
|
exit 1
|
|
fi
|
|
info "Checking out version: ${version}"
|
|
git checkout "$version" || { err "Failed to checkout '${version}'."; exit 1; }
|
|
fi
|
|
|
|
refresh_build_metadata
|
|
migrate_config auto
|
|
|
|
info "Rebuilding image..."
|
|
dc_prod build prod
|
|
|
|
info "Restarting with new image..."
|
|
dc_prod up -d --force-recreate prod
|
|
|
|
log "Updated and restarted. Data preserved."
|
|
# Show current version
|
|
local current
|
|
current=$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)
|
|
log "Running version: ${current}"
|
|
}
|
|
|
|
# ─── Backup ───────────────────────────────────────────────────────────────
|
|
|
|
cmd_backup() {
|
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
|
BACKUP_DIR="${1:-./backups/corescope-${TIMESTAMP}}"
|
|
mkdir -p "$BACKUP_DIR"
|
|
|
|
info "Backing up to ${BACKUP_DIR}/"
|
|
|
|
# Database
|
|
# Always use bind mount path (from .env or default)
|
|
DB_PATH="$PROD_DATA/meshcore.db"
|
|
if [ -f "$DB_PATH" ]; then
|
|
cp "$DB_PATH" "$BACKUP_DIR/meshcore.db"
|
|
log "Database ($(du -h "$BACKUP_DIR/meshcore.db" | cut -f1))"
|
|
elif container_running "corescope-prod"; then
|
|
docker cp corescope-prod:/app/data/meshcore.db "$BACKUP_DIR/meshcore.db" 2>/dev/null && \
|
|
log "Database (via docker cp)" || warn "Could not backup database"
|
|
else
|
|
warn "Database not found (container not running?)"
|
|
fi
|
|
|
|
# Config (now lives in data dir)
|
|
if [ -f "$PROD_DATA/config.json" ]; then
|
|
cp "$PROD_DATA/config.json" "$BACKUP_DIR/config.json"
|
|
log "config.json"
|
|
elif [ -f config.json ]; then
|
|
cp config.json "$BACKUP_DIR/config.json"
|
|
log "config.json (legacy repo root)"
|
|
fi
|
|
|
|
# Caddyfile
|
|
if [ -f caddy-config/Caddyfile ]; then
|
|
cp caddy-config/Caddyfile "$BACKUP_DIR/Caddyfile"
|
|
log "Caddyfile"
|
|
fi
|
|
|
|
# Theme
|
|
# Always use bind mount path (from .env or default)
|
|
THEME_PATH="$PROD_DATA/theme.json"
|
|
if [ -f "$THEME_PATH" ]; then
|
|
cp "$THEME_PATH" "$BACKUP_DIR/theme.json"
|
|
log "theme.json"
|
|
elif [ -f theme.json ]; then
|
|
cp theme.json "$BACKUP_DIR/theme.json"
|
|
log "theme.json"
|
|
fi
|
|
|
|
# Summary
|
|
TOTAL=$(du -sh "$BACKUP_DIR" | cut -f1)
|
|
FILES=$(ls "$BACKUP_DIR" | wc -l)
|
|
echo ""
|
|
log "Backup complete: ${FILES} files, ${TOTAL} total → ${BACKUP_DIR}/"
|
|
}
|
|
|
|
# ─── Restore ──────────────────────────────────────────────────────────────
|
|
|
|
cmd_restore() {
|
|
if [ -z "$1" ]; then
|
|
err "Usage: ./manage.sh restore <backup-dir-or-db-file>"
|
|
if [ -d "./backups" ]; then
|
|
echo ""
|
|
echo " Available backups:"
|
|
ls -dt ./backups/meshcore-* ./backups/corescope-* 2>/dev/null | head -10 | while read d; do
|
|
if [ -d "$d" ]; then
|
|
echo " $d/ ($(ls "$d" | wc -l) files)"
|
|
elif [ -f "$d" ]; then
|
|
echo " $d ($(du -h "$d" | cut -f1))"
|
|
fi
|
|
done
|
|
fi
|
|
exit 1
|
|
fi
|
|
|
|
# Accept either a directory (full backup) or a single .db file
|
|
if [ -d "$1" ]; then
|
|
DB_FILE="$1/meshcore.db"
|
|
CONFIG_FILE="$1/config.json"
|
|
CADDY_FILE="$1/Caddyfile"
|
|
THEME_FILE="$1/theme.json"
|
|
elif [ -f "$1" ]; then
|
|
DB_FILE="$1"
|
|
CONFIG_FILE=""
|
|
CADDY_FILE=""
|
|
THEME_FILE=""
|
|
else
|
|
err "Not found: $1"
|
|
exit 1
|
|
fi
|
|
|
|
if [ ! -f "$DB_FILE" ]; then
|
|
err "No meshcore.db found in $1"
|
|
exit 1
|
|
fi
|
|
|
|
echo ""
|
|
info "Will restore from: $1"
|
|
[ -f "$DB_FILE" ] && echo " • Database"
|
|
[ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ] && echo " • config.json"
|
|
[ -n "$CADDY_FILE" ] && [ -f "$CADDY_FILE" ] && echo " • Caddyfile"
|
|
[ -n "$THEME_FILE" ] && [ -f "$THEME_FILE" ] && echo " • theme.json"
|
|
echo ""
|
|
|
|
if ! confirm "Continue? (current state will be backed up first)"; then
|
|
echo " Aborted."
|
|
exit 0
|
|
fi
|
|
|
|
# Backup current state first
|
|
info "Backing up current state..."
|
|
cmd_backup "./backups/corescope-pre-restore-$(date +%Y%m%d-%H%M%S)"
|
|
|
|
dc_prod stop prod 2>/dev/null || true
|
|
|
|
# Restore database
|
|
mkdir -p "$PROD_DATA"
|
|
DEST_DB="$PROD_DATA/meshcore.db"
|
|
cp "$DB_FILE" "$DEST_DB"
|
|
log "Database restored"
|
|
|
|
# Restore config if present
|
|
if [ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ]; then
|
|
cp "$CONFIG_FILE" "$PROD_DATA/config.json"
|
|
log "config.json restored to ${PROD_DATA}/"
|
|
fi
|
|
|
|
# Restore Caddyfile if present
|
|
if [ -n "$CADDY_FILE" ] && [ -f "$CADDY_FILE" ]; then
|
|
mkdir -p caddy-config
|
|
cp "$CADDY_FILE" caddy-config/Caddyfile
|
|
log "Caddyfile restored"
|
|
fi
|
|
|
|
# Restore theme if present
|
|
if [ -n "$THEME_FILE" ] && [ -f "$THEME_FILE" ]; then
|
|
DEST_THEME="$PROD_DATA/theme.json"
|
|
cp "$THEME_FILE" "$DEST_THEME"
|
|
log "theme.json restored"
|
|
fi
|
|
|
|
dc_prod up -d prod
|
|
log "Restored and restarted."
|
|
}
|
|
|
|
# ─── MQTT Test ────────────────────────────────────────────────────────────
|
|
|
|
cmd_mqtt_test() {
|
|
if ! container_running "corescope-prod"; then
|
|
err "Container not running. Start with: ./manage.sh start"
|
|
exit 1
|
|
fi
|
|
|
|
info "Listening for MQTT messages (10 second timeout)..."
|
|
MSG=$(docker exec corescope-prod mosquitto_sub -h localhost -t 'meshcore/#' -C 1 -W 10 2>/dev/null)
|
|
if [ -n "$MSG" ]; then
|
|
log "Received MQTT message:"
|
|
echo " $MSG" | head -c 200
|
|
echo ""
|
|
else
|
|
warn "No messages received in 10 seconds."
|
|
echo ""
|
|
echo " This means no observer is publishing packets."
|
|
echo " See the deployment guide for connecting observers."
|
|
fi
|
|
}
|
|
|
|
# ─── Reset ────────────────────────────────────────────────────────────────
|
|
|
|
cmd_reset() {
|
|
echo ""
|
|
warn "This will remove all containers, images, and setup state."
|
|
warn "Your config.json, Caddyfile, and data directory are NOT deleted."
|
|
echo ""
|
|
if ! confirm "Continue?"; then
|
|
echo " Aborted."
|
|
exit 0
|
|
fi
|
|
|
|
dc_prod down --rmi local 2>/dev/null || true
|
|
dc_staging down --rmi local 2>/dev/null || true
|
|
rm -f "$STATE_FILE"
|
|
|
|
log "Reset complete. Run './manage.sh setup' to start over."
|
|
echo " Data directory: $PROD_DATA (not removed)"
|
|
}
|
|
|
|
# ─── Help ─────────────────────────────────────────────────────────────────
|
|
|
|
cmd_help() {
|
|
echo ""
|
|
echo "CoreScope — Management Script"
|
|
echo ""
|
|
echo "Usage: ./manage.sh <command>"
|
|
echo ""
|
|
printf '%b\n' " ${BOLD}Setup${NC}"
|
|
echo " setup First-time setup wizard (safe to re-run)"
|
|
echo " reset Remove container + image (keeps data + config)"
|
|
echo ""
|
|
printf '%b\n' " ${BOLD}Run${NC}"
|
|
echo " start Start production container"
|
|
echo " start --with-staging Start production + staging-go (copies prod DB + config)"
|
|
echo " stop [prod|staging|all] Stop specific or all containers (default: all)"
|
|
echo " restart [prod|staging|all] Restart specific or all containers"
|
|
echo " status Show health, stats, and service status"
|
|
echo " logs [prod|staging] [N] Follow logs (default: prod, last 100 lines)"
|
|
echo ""
|
|
printf '%b\n' " ${BOLD}Maintain${NC}"
|
|
echo " update [version] Update to version (no arg=latest tag, 'latest'=master tip, or e.g. v3.1.0)"
|
|
echo " promote Promote staging → production (backup + restart)"
|
|
echo " backup [dir] Full backup: database + config + theme"
|
|
echo " restore <d> Restore from backup dir or .db file"
|
|
echo " mqtt-test Check if MQTT data is flowing"
|
|
echo ""
|
|
echo "Prod uses docker-compose.yml; staging uses ${STAGING_COMPOSE_FILE}."
|
|
echo ""
|
|
}
|
|
|
|
# ─── Main ─────────────────────────────────────────────────────────────────
|
|
|
|
case "${1:-help}" in
|
|
setup) cmd_setup ;;
|
|
start) cmd_start "$2" ;;
|
|
stop) cmd_stop "$2" ;;
|
|
restart) cmd_restart "$2" ;;
|
|
status) cmd_status ;;
|
|
logs) cmd_logs "$2" "$3" ;;
|
|
update) cmd_update "$2" ;;
|
|
promote) cmd_promote ;;
|
|
backup) cmd_backup "$2" ;;
|
|
restore) cmd_restore "$2" ;;
|
|
mqtt-test) cmd_mqtt_test ;;
|
|
reset) cmd_reset ;;
|
|
help|*) cmd_help ;;
|
|
esac
|