#!/usr/bin/env bash # Toggle script for Pulse mock mode. # # Goals: # - `./scripts/toggle-mock.sh` toggles mode (run again toggles back) # - Rebuild frontend + backend every switch # - Stop and restart the active runtime (systemd, hot-dev, or standalone) # - Work on macOS and Linux set -euo pipefail RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" HOT_DEV_BG_PATH="${HOT_DEV_BG_PATH:-${ROOT_DIR}/scripts/hot-dev-bg.sh}" DEV_DATA_DIR="${ROOT_DIR}/tmp/dev-config" MOCK_DATA_DIR="${ROOT_DIR}/tmp/mock-data" DEV_KEY_FILE="${DEV_DATA_DIR}/.encryption.key" MOCK_ENV_FILE="${MOCK_ENV_FILE:-${DEV_DATA_DIR}/.env}" RUNTIME_MOCK_ENV_FILE="${RUNTIME_MOCK_ENV_FILE:-${MOCK_DATA_DIR}/.env}" LEGACY_ROOT_MOCK_ENV_FILE="${LEGACY_ROOT_MOCK_ENV_FILE:-${ROOT_DIR}/mock.env}" LEGACY_DEV_MOCK_ENV_FILE="${LEGACY_DEV_MOCK_ENV_FILE:-${DEV_DATA_DIR}/mock.env}" LEGACY_RUNTIME_MOCK_ENV_FILE="${LEGACY_RUNTIME_MOCK_ENV_FILE:-${MOCK_DATA_DIR}/mock.env}" HOT_DEV_LOG="/tmp/pulse-hot-dev.log" STANDALONE_LOG="/tmp/pulse-standalone.log" DEFAULT_PORT=7655 LOCK_DIR="/tmp/pulse-toggle-mock.lock" LOCK_PID_FILE="${LOCK_DIR}/pid" log_info() { echo -e "${BLUE}[toggle-mock]${NC} $*" } log_warn() { echo -e "${YELLOW}[toggle-mock] WARNING:${NC} $*" } log_error() { echo -e "${RED}[toggle-mock] ERROR:${NC} $*" } log_success() { echo -e "${GREEN}[toggle-mock]${NC} $*" } acquire_lock() { if mkdir "${LOCK_DIR}" 2>/dev/null; then echo "$$" > "${LOCK_PID_FILE}" trap 'release_lock' EXIT return fi if [[ -f "${LOCK_PID_FILE}" ]]; then local owner_pid owner_pid="$(cat "${LOCK_PID_FILE}" 2>/dev/null || true)" if [[ -n "${owner_pid}" ]] && kill -0 "${owner_pid}" 2>/dev/null; then log_error "Another toggle run is in progress (pid: ${owner_pid}). Try again in a few seconds." exit 1 fi fi rm -rf "${LOCK_DIR}" 2>/dev/null || true if mkdir "${LOCK_DIR}" 2>/dev/null; then echo "$$" > "${LOCK_PID_FILE}" trap 'release_lock' EXIT return fi log_error "Failed to acquire toggle lock at ${LOCK_DIR}" exit 1 } release_lock() { rm -rf "${LOCK_DIR}" 2>/dev/null || true } launch_detached() { local log_file="$1" shift if command -v python3 >/dev/null 2>&1; then python3 - "$log_file" "$@" <<'PY' import subprocess import sys log_path = sys.argv[1] cmd = sys.argv[2:] with open(log_path, "ab", buffering=0) as log: proc = subprocess.Popen( cmd, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, ) print(proc.pid) PY return fi nohup "$@" >"${log_file}" 2>&1 & echo $! } is_port_listening() { local port="$1" lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1 } wait_for_port() { local port="$1" local timeout_seconds="${2:-20}" local checks=$((timeout_seconds * 2)) while (( checks > 0 )); do if is_port_listening "$port"; then return 0 fi sleep 0.5 checks=$((checks - 1)) done return 1 } run_hot_dev_bg() { "${HOT_DEV_BG_PATH}" "$@" } hot_dev_bg_status_output() { run_hot_dev_bg status 2>/dev/null || true } hot_dev_bg_is_running() { local output output="$(hot_dev_bg_status_output)" [[ "${output}" == *"[hot-dev-bg] Running"* ]] } prefer_hot_dev_runtime() { local prefer="${PULSE_TOGGLE_PREFER_HOT_DEV:-auto}" case "${prefer}" in true) return 0 ;; false) return 1 ;; auto) if [[ "$(uname -s)" == "Darwin" ]]; then return 0 fi return 1 ;; *) log_warn "Unknown PULSE_TOGGLE_PREFER_HOT_DEV=${prefer}; using auto" if [[ "$(uname -s)" == "Darwin" ]]; then return 0 fi return 1 ;; esac } use_isolated_mock_data_dir() { local value="${PULSE_TOGGLE_ISOLATE_MOCK_DATA:-false}" case "${value}" in true|TRUE|1|yes|YES|on|ON) return 0 ;; *) return 1 ;; esac } verify_mock_state_via_frontend_proxy() { local target_mode="$1" local expected="false" local url="${PULSE_TOGGLE_FRONTEND_VERIFY_URL:-http://127.0.0.1:5173/api/system/mock-mode}" local retries=40 if [[ "${target_mode}" == "true" ]]; then expected="true" fi while (( retries > 0 )); do local resp resp="$(curl -fsS --max-time 2 "${url}" 2>/dev/null || true)" if [[ -n "${resp}" ]] && echo "${resp}" | grep -q "\"enabled\":${expected}"; then return 0 fi sleep 0.5 retries=$((retries - 1)) done return 1 } sed_inplace() { local expr="$1" local file="$2" if [[ "$(uname -s)" == "Darwin" ]]; then sed -i '' "$expr" "$file" else sed -i "$expr" "$file" fi } load_env_file() { local env_file="$1" if [[ -f "$env_file" ]]; then set +u set -a # shellcheck disable=SC1090 source "$env_file" set +a set -u fi } mock_default_entries() { cat <<'ENVEOF' PULSE_MOCK_MODE=false PULSE_MOCK_NODES=3 PULSE_MOCK_VMS_PER_NODE=3 PULSE_MOCK_LXCS_PER_NODE=3 PULSE_MOCK_DOCKER_HOSTS=2 PULSE_MOCK_DOCKER_CONTAINERS=5 PULSE_MOCK_GENERIC_HOSTS=2 PULSE_MOCK_K8S_CLUSTERS=1 PULSE_MOCK_K8S_NODES=3 PULSE_MOCK_K8S_PODS=10 PULSE_MOCK_K8S_DEPLOYMENTS=4 PULSE_MOCK_RANDOM_METRICS=true PULSE_MOCK_STOPPED_PERCENT=6 ENVEOF } ensure_parent_dir() { mkdir -p "$(dirname "$1")" } ensure_env_file_exists() { local file="$1" ensure_parent_dir "$file" if [[ ! -f "$file" ]]; then : > "$file" fi } env_has_key() { local file="$1" local key="$2" [[ -f "$file" ]] && grep -Eq "^[[:space:]]*${key}=" "$file" } read_env_value() { local file="$1" local key="$2" if [[ ! -f "$file" ]]; then return 1 fi awk -F= -v lookup="$key" ' $1 ~ "^[[:space:]]*" lookup "$" { value = substr($0, index($0, "=") + 1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) print value } ' "$file" | tail -n 1 } set_env_value() { local file="$1" local key="$2" local value="$3" ensure_env_file_exists "$file" if env_has_key "$file" "$key"; then sed_inplace "s|^[[:space:]]*${key}=.*|${key}=${value}|" "$file" else printf '\n%s=%s\n' "$key" "$value" >> "$file" fi } ensure_env_value() { local file="$1" local key="$2" local value="$3" ensure_env_file_exists "$file" if ! env_has_key "$file" "$key"; then printf '%s=%s\n' "$key" "$value" >> "$file" fi } sync_mock_entries_to_file() { local file="$1" local line key value ensure_env_file_exists "$file" while IFS='=' read -r key value; do [[ -n "${key}" ]] || continue value="$(read_env_value "$MOCK_ENV_FILE" "$key" || printf '%s' "$value")" set_env_value "$file" "$key" "$value" done < <(mock_default_entries) } ensure_mock_env_file() { local legacy_file key value local created=false if [[ ! -f "$MOCK_ENV_FILE" ]]; then ensure_env_file_exists "$MOCK_ENV_FILE" created=true fi if ! env_has_key "$MOCK_ENV_FILE" "PULSE_MOCK_MODE"; then for legacy_file in \ "$LEGACY_DEV_MOCK_ENV_FILE" \ "$LEGACY_ROOT_MOCK_ENV_FILE" \ "$LEGACY_RUNTIME_MOCK_ENV_FILE" do [[ -f "$legacy_file" ]] || continue while IFS='=' read -r key value; do [[ "${key}" == PULSE_MOCK_* ]] || continue set_env_value "$MOCK_ENV_FILE" "$key" "$value" done < <(grep -E '^[[:space:]]*PULSE_MOCK_[A-Z0-9_]*=' "$legacy_file" || true) log_info "Migrated legacy mock settings from ${legacy_file} to ${MOCK_ENV_FILE}" break done fi while IFS='=' read -r key value; do ensure_env_value "$MOCK_ENV_FILE" "$key" "$value" done < <(mock_default_entries) if [[ "$created" == "true" ]]; then log_info "Created ${MOCK_ENV_FILE}" fi } get_mock_mode() { ensure_mock_env_file if [[ "$(read_env_value "$MOCK_ENV_FILE" "PULSE_MOCK_MODE" || true)" == "true" ]]; then echo "true" else echo "false" fi } set_mock_mode() { local enabled="$1" ensure_mock_env_file set_env_value "$MOCK_ENV_FILE" "PULSE_MOCK_MODE" "$enabled" sync_mock_entries_to_file "$DEV_DATA_DIR/.env" sync_mock_entries_to_file "$RUNTIME_MOCK_ENV_FILE" sync_mock_entries_to_file "$LEGACY_DEV_MOCK_ENV_FILE" sync_mock_entries_to_file "$LEGACY_RUNTIME_MOCK_ENV_FILE" sync_mock_entries_to_file "$LEGACY_ROOT_MOCK_ENV_FILE" touch "$MOCK_ENV_FILE" } detect_runtime_mode() { if command -v systemctl >/dev/null 2>&1; then if systemctl is-active --quiet pulse-hot-dev 2>/dev/null; then echo "systemd-hot-dev" return fi if systemctl is-active --quiet pulse 2>/dev/null; then echo "systemd-pulse" return fi fi if hot_dev_bg_is_running; then echo "managed-hot-dev" return fi if pgrep -f "hot-dev.sh" >/dev/null 2>&1; then echo "hot-dev" return fi if pgrep -f "${ROOT_DIR}/pulse|(^|[[:space:]])\./pulse([[:space:]]|$)" >/dev/null 2>&1; then echo "standalone" return fi if pgrep -x pulse >/dev/null 2>&1; then echo "pulse-name" return fi echo "none" } kill_with_grace() { local pattern="$1" local signal="${2:-TERM}" if ! pgrep -f "$pattern" >/dev/null 2>&1; then return fi pkill -"$signal" -f "$pattern" 2>/dev/null || true } wait_for_exit() { local pattern="$1" local retries=20 while (( retries > 0 )); do if ! pgrep -f "$pattern" >/dev/null 2>&1; then return fi sleep 0.25 retries=$((retries - 1)) done pkill -9 -f "$pattern" 2>/dev/null || true } stop_hot_dev_runtime() { local runtime_mode="${1:-hot-dev}" local hot_dev_pattern="${ROOT_DIR}/scripts/hot-dev.sh|scripts/hot-dev.sh|hot-dev.sh" local pulse_pattern="${ROOT_DIR}/pulse|(^|[[:space:]])\\./pulse([[:space:]]|$)" local vite_pattern="${ROOT_DIR}/frontend-modern.*vite|vite.*${ROOT_DIR}/frontend-modern|vite --config vite.config.ts" if [[ "${runtime_mode}" == "managed-hot-dev" ]] && hot_dev_bg_is_running; then log_info "Stopping managed hot-dev runtime..." run_hot_dev_bg stop >/dev/null return fi log_info "Stopping legacy hot-dev runtime..." kill_with_grace "$hot_dev_pattern" TERM wait_for_exit "$hot_dev_pattern" kill_with_grace "$pulse_pattern" TERM wait_for_exit "$pulse_pattern" kill_with_grace "$vite_pattern" TERM wait_for_exit "$vite_pattern" } stop_standalone_runtime() { local allow_name_kill="${1:-false}" local pulse_pattern="${ROOT_DIR}/pulse|(^|[[:space:]])\\./pulse([[:space:]]|$)" log_info "Stopping standalone Pulse runtime..." kill_with_grace "$pulse_pattern" TERM wait_for_exit "$pulse_pattern" if [[ "$allow_name_kill" == "true" ]] && pgrep -x pulse >/dev/null 2>&1; then log_warn "Found remaining 'pulse' process by name; stopping it" pkill -TERM -x pulse 2>/dev/null || true sleep 1 pkill -9 -x pulse 2>/dev/null || true fi } stop_systemd_runtime() { local mode="$1" case "$mode" in systemd-hot-dev) log_info "Stopping systemd service: pulse-hot-dev" sudo systemctl stop pulse-hot-dev ;; systemd-pulse) log_info "Stopping systemd service: pulse" sudo systemctl stop pulse ;; esac } has_frontend_artifacts() { [[ -f "${ROOT_DIR}/frontend-modern/dist/index.html" ]] && [[ -f "${ROOT_DIR}/internal/api/frontend-modern/dist/index.html" ]] } has_backend_artifact() { [[ -x "${ROOT_DIR}/pulse" ]] } rebuild_frontend_backend() { # Rebuild strategy: # - auto (default): only rebuild when required artifacts are missing # - always: force full frontend+backend rebuild # - never: skip rebuild completely local rebuild_mode="${PULSE_TOGGLE_REBUILD:-auto}" local do_frontend=false local do_backend=false case "$rebuild_mode" in always) do_frontend=true do_backend=true ;; never) log_info "Skipping rebuild (PULSE_TOGGLE_REBUILD=never)" return ;; auto) if ! has_frontend_artifacts; then do_frontend=true # Backend embeds frontend assets, so rebuild backend after frontend build. do_backend=true fi if ! has_backend_artifact; then do_backend=true fi ;; *) log_warn "Unknown PULSE_TOGGLE_REBUILD=${rebuild_mode}; using auto" if ! has_frontend_artifacts; then do_frontend=true do_backend=true fi if ! has_backend_artifact; then do_backend=true fi ;; esac if [[ "$do_frontend" == "false" ]] && [[ "$do_backend" == "false" ]]; then log_info "Build artifacts already present; skipping rebuild" return fi ( cd "$ROOT_DIR" if [[ "$do_frontend" == "true" ]]; then log_info "Building frontend..." make frontend fi if [[ "$do_backend" == "true" ]]; then log_info "Building backend..." make backend fi ) log_success "Build complete" } ensure_dev_key() { mkdir -p "$DEV_DATA_DIR" if [[ ! -f "$DEV_KEY_FILE" ]]; then openssl rand -base64 32 > "$DEV_KEY_FILE" chmod 600 "$DEV_KEY_FILE" log_info "Generated dev encryption key at ${DEV_KEY_FILE}" fi } sync_production_config() { if [[ -x "${ROOT_DIR}/scripts/sync-production-config.sh" ]]; then log_info "Syncing production configuration into dev config" DEV_DIR="$DEV_DATA_DIR" "${ROOT_DIR}/scripts/sync-production-config.sh" else log_warn "sync-production-config.sh not found or not executable; skipping sync" fi } start_hot_dev_runtime() { log_info "Starting managed hot-dev runtime..." if ! run_hot_dev_bg start --takeover; then log_error "Managed hot-dev failed to start" return 1 fi log_success "Managed hot-dev runtime is running" } start_standalone_runtime() { local target_mock_mode="$1" local pid local startup_timeout="${PULSE_TOGGLE_STANDALONE_START_TIMEOUT:-20}" local data_dir="$DEV_DATA_DIR" log_info "Starting standalone Pulse" cd "$ROOT_DIR" load_env_file "${ROOT_DIR}/.env" load_env_file "${ROOT_DIR}/.env.local" load_env_file "${ROOT_DIR}/.env.dev" if [[ "$target_mock_mode" == "true" ]] && use_isolated_mock_data_dir; then data_dir="$MOCK_DATA_DIR" fi export PULSE_DATA_DIR="$data_dir" mkdir -p "$PULSE_DATA_DIR" load_env_file "${PULSE_DATA_DIR}/.env" if [[ "$PULSE_DATA_DIR" == "$MOCK_DATA_DIR" ]]; then log_warn "Using isolated mock data dir (${MOCK_DATA_DIR}); production metrics history will pause while mock mode is on" else ensure_dev_key if [[ -z "${PULSE_ENCRYPTION_KEY:-}" ]]; then export PULSE_ENCRYPTION_KEY="$(<"$DEV_KEY_FILE")" fi fi # Keep audit persistence inside the selected runtime data directory so # local macOS runs don't attempt to write /var/lib/pulse. export PULSE_AUDIT_DIR="${PULSE_AUDIT_DIR:-$PULSE_DATA_DIR}" export PORT="${PORT:-$DEFAULT_PORT}" export FRONTEND_PORT="${FRONTEND_PORT:-$PORT}" pid="$(launch_detached "${STANDALONE_LOG}" "${ROOT_DIR}/pulse")" sleep 2 if kill -0 "$pid" 2>/dev/null; then if ! wait_for_port "${PORT}" "${startup_timeout}"; then log_error "Pulse process started but port ${PORT} did not become ready within ${startup_timeout}s" return 1 fi log_success "Pulse started (pid: ${pid}, port: ${PORT}, log: ${STANDALONE_LOG})" else log_error "Pulse failed to start. Check ${STANDALONE_LOG}" return 1 fi } start_systemd_runtime() { local mode="$1" case "$mode" in systemd-hot-dev) log_info "Starting systemd service: pulse-hot-dev" sudo systemctl start pulse-hot-dev ;; systemd-pulse) log_info "Starting systemd service: pulse" sudo systemctl start pulse ;; esac } apply_mode() { local target_mode="$1" local runtime_mode local start_mode local allow_fallback="${PULSE_TOGGLE_ALLOW_STANDALONE_FALLBACK:-false}" runtime_mode="$(detect_runtime_mode)" start_mode="${runtime_mode}" log_info "Detected runtime mode: ${runtime_mode}" if [[ "$target_mode" == "true" ]]; then log_info "Switching to mock mode" if use_isolated_mock_data_dir; then mkdir -p "$MOCK_DATA_DIR" fi else log_info "Switching to production-node mode" sync_production_config fi set_mock_mode "$target_mode" case "$runtime_mode" in systemd-hot-dev|systemd-pulse) stop_systemd_runtime "$runtime_mode" ;; managed-hot-dev|hot-dev) stop_hot_dev_runtime "$runtime_mode" ;; standalone) stop_standalone_runtime "false" ;; pulse-name) stop_standalone_runtime "true" ;; none) stop_standalone_runtime "false" ;; esac # macOS default: keep development on hot-dev so frontend changes at 127.0.0.1:5173 # are always reflected immediately after toggling. if [[ "${runtime_mode}" == "none" ]] || [[ "${runtime_mode}" == "standalone" ]] || [[ "${runtime_mode}" == "pulse-name" ]]; then if prefer_hot_dev_runtime; then start_mode="managed-hot-dev" fi fi # hot-dev already handles build/reload on startup; avoid duplicate builds here for fast toggles. if [[ "$start_mode" == "managed-hot-dev" ]] || [[ "$start_mode" == "hot-dev" ]] || [[ "$start_mode" == "systemd-hot-dev" ]]; then log_info "Skipping pre-rebuild for hot-dev runtime (managed by hot-dev startup)" else rebuild_frontend_backend fi case "$start_mode" in systemd-hot-dev|systemd-pulse) start_systemd_runtime "$start_mode" ;; managed-hot-dev|hot-dev) if ! start_hot_dev_runtime; then if [[ "${allow_fallback}" == "true" ]]; then log_warn "hot-dev restart failed or is unhealthy; falling back to standalone backend on port ${DEFAULT_PORT}" stop_hot_dev_runtime "$start_mode" || true start_standalone_runtime "$target_mode" start_mode="standalone" else log_error "hot-dev restart failed; refusing to fallback so frontend dev state stays explicit" return 1 fi fi ;; standalone|pulse-name|none) start_standalone_runtime "$target_mode" ;; esac if [[ "$start_mode" == "managed-hot-dev" ]] || [[ "$start_mode" == "hot-dev" ]]; then if ! verify_mock_state_via_frontend_proxy "$target_mode"; then log_error "Frontend proxy at 127.0.0.1:5173 did not report expected mock state (${target_mode})" return 1 fi fi if [[ "$target_mode" == "true" ]]; then log_success "Mock mode is now enabled" else log_success "Mock mode is now disabled (real nodes mode)" fi } show_status() { local runtime_mode runtime_mode="$(detect_runtime_mode)" ensure_mock_env_file set +u # shellcheck disable=SC1090 source "$MOCK_ENV_FILE" set -u echo "Mock Data Mode Control for Pulse" echo "" local status_data_dir="$DEV_DATA_DIR" if [[ "${PULSE_MOCK_MODE:-false}" == "true" ]] && use_isolated_mock_data_dir; then status_data_dir="$MOCK_DATA_DIR" fi if [[ "${PULSE_MOCK_MODE:-false}" == "true" ]]; then echo -e "${GREEN}Mock Mode: ENABLED${NC}" echo " Runtime: ${runtime_mode}" echo " Nodes: ${PULSE_MOCK_NODES:-0}" echo " VMs per node: ${PULSE_MOCK_VMS_PER_NODE:-0}" echo " LXCs per node: ${PULSE_MOCK_LXCS_PER_NODE:-0}" echo " Generic hosts: ${PULSE_MOCK_GENERIC_HOSTS:-0}" echo " Docker hosts: ${PULSE_MOCK_DOCKER_HOSTS:-0}" echo " Docker containers/host: ${PULSE_MOCK_DOCKER_CONTAINERS:-0}" echo " K8s clusters: ${PULSE_MOCK_K8S_CLUSTERS:-0}" echo " Data dir: ${status_data_dir}" else echo -e "${BLUE}Mock Mode: DISABLED${NC}" echo " Runtime: ${runtime_mode}" echo " Data dir: ${status_data_dir}" fi } edit_config() { ensure_mock_env_file log_info "Opening ${MOCK_ENV_FILE}" ${EDITOR:-nano} "$MOCK_ENV_FILE" } sync_config_only() { sync_production_config if [[ "$(detect_runtime_mode)" != "none" ]]; then log_info "Runtime detected; restarting current mode to apply synced config" apply_mode "$(get_mock_mode)" else log_info "No active runtime detected; sync complete" fi } usage() { echo "Mock Data Mode Control for Pulse" echo "" echo "Usage: $0 [toggle|on|off|status|edit|sync]" echo "" echo " toggle - Toggle between mock mode and production-node mode (default)" echo " on - Enable mock mode and restart/rebuild stack" echo " off - Disable mock mode, sync real nodes config, restart/rebuild stack" echo " status - Show current mock mode + runtime status" echo " edit - Edit the runtime .env mock settings" echo " sync - Sync production config to dev config and restart if running" } main() { local cmd="${1:-toggle}" local needs_lock=false case "$cmd" in on|enable) needs_lock=true ;; off|disable) needs_lock=true ;; toggle) needs_lock=true ;; sync) needs_lock=true ;; esac if [[ "$needs_lock" == "true" ]]; then acquire_lock fi case "$cmd" in on|enable) apply_mode "true" ;; off|disable) apply_mode "false" ;; toggle) if [[ "$(get_mock_mode)" == "true" ]]; then apply_mode "false" else apply_mode "true" fi ;; status) show_status ;; edit) edit_config ;; sync) sync_config_only ;; -h|--help|help) usage ;; *) usage echo "" log_error "Unknown command: $cmd" exit 1 ;; esac } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi