mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-23 11:15:48 +00:00
## Summary Fixes #485 — the app version was derived from `package.json` via Node.js, which is a meaningless artifact for this Go project. This caused version mismatches (e.g., v3.3.0 release showing "3.2.0") when someone forgot to bump `package.json`. ## Changes ### `manage.sh` - **Line 43**: Replace `node -p "require('./package.json').version"` with `git describe --tags --match "v*"` — version is now derived automatically from git tags - **Line 515**: Add `--force` to `git fetch origin --tags` in setup command - **Line 1320**: Add `--force` to `git fetch origin --tags` in update command — prevents "would clobber existing tag" errors when tags are moved ### `package.json` - Version field set to `0.0.0-use-git-tags` to make it clear this is not the source of truth. File kept because npm scripts and devDependencies are still used for testing. ## How it works `git describe --tags --match "v*"` produces: - `v3.3.0` — when on an exact tag - `v3.3.0-3-gabcdef1` — when 3 commits after a tag (useful for debugging) - Falls back to `unknown` if no tags exist ## Testing - All Go tests pass (`cmd/server`, `cmd/ingestor`) - All frontend unit tests pass (254/254) - No changes to application logic — only build-time version derivation Co-authored-by: you <you@example.com>
1600 lines
51 KiB
Bash
Executable File
1600 lines
51 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
|
|
|
|
# Auto-fix CRLF in .env if detected
|
|
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
|
|
|
|
# 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
|
|
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)
|
|
|
|
# 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}"; }
|
|
|
|
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 /api/stats response (Go backend loads packets into memory — may take 60s+)
|
|
info "Waiting for server to respond..."
|
|
local healthy=false
|
|
for i in $(seq 1 45); do
|
|
if docker exec "$container" wget -qO- http://localhost:3000/api/stats &>/dev/null; then
|
|
healthy=true
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
if ! $healthy; then
|
|
err "Server did not respond after 90 seconds."
|
|
warn "Check logs: ./manage.sh logs"
|
|
return 1
|
|
fi
|
|
log "Server is responding."
|
|
|
|
# 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/stats" &>/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
|
|
|
|
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
|