Files
mercadodevida/project/scripts/monolith.sh
2026-08-19 13:15:30 +02:00

409 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# Manage the complete MercadoDeVida monolith on one development/LAN host.
# Usage: ./scripts/monolith.sh <dev|prod> <start|restart|status|stop|logs|urls>
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
MODE="${1:-}"
ACTION="${2:-}"
RUNTIME_ROOT="${MDV_RUNTIME_DIR:-$PROJECT_DIR/.runtime}"
RUNTIME_DIR="$RUNTIME_ROOT/$MODE"
BACKEND_PORT="${BACKEND_PORT:-3000}"
FRONTEND_PORT="${FRONTEND_PORT:-3003}"
ADMIN_PORT="${ADMIN_PORT:-3004}"
STOREFRONT_PORT="${STOREFRONT_PORT:-3005}"
START_TIMEOUT="${START_TIMEOUT:-90}"
WATCH_INTERVAL="${WATCH_INTERVAL:-5}"
SERVICES=(backend frontend admin storefront)
usage() {
cat <<'EOF'
Usage: ./scripts/monolith.sh <dev|prod> <command>
Commands:
start Install/build when needed, migrate, and start every service
restart Stop managed processes, then start every service
status Show PID, process state, HTTP status, and URLs
stop Gracefully stop every managed HTTP process
logs Follow all service logs (Ctrl-C exits without stopping services)
urls Print localhost and LAN URLs
watch Auto-respawn any dead service every WATCH_INTERVAL seconds
Environment overrides:
LAN_IP, BACKEND_PORT, FRONTEND_PORT, ADMIN_PORT, STOREFRONT_PORT
MDV_RUNTIME_DIR, START_TIMEOUT
EOF
}
if [[ "$MODE" != "dev" && "$MODE" != "prod" ]]; then
usage >&2
exit 2
fi
case "$ACTION" in
start|restart|status|stop|logs|urls|watch) ;;
*) usage >&2; exit 2 ;;
esac
mkdir -p "$RUNTIME_DIR"
lan_ip() {
if [[ -n "${LAN_IP:-}" ]]; then
printf '%s\n' "$LAN_IP"
return
fi
local iface ip
if command -v route >/dev/null 2>&1 && command -v ipconfig >/dev/null 2>&1; then
iface="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')"
if [[ -n "$iface" ]]; then
ip="$(ipconfig getifaddr "$iface" 2>/dev/null || true)"
if [[ -n "$ip" ]]; then printf '%s\n' "$ip"; return; fi
fi
fi
if command -v hostname >/dev/null 2>&1; then
ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
if [[ -n "$ip" ]]; then printf '%s\n' "$ip"; return; fi
fi
printf '127.0.0.1\n'
}
LAN_ADDRESS="$(lan_ip)"
API_PUBLIC_URL="${API_PUBLIC_URL:-http://$LAN_ADDRESS:$BACKEND_PORT}"
service_port() {
case "$1" in
backend) echo "$BACKEND_PORT" ;;
frontend) echo "$FRONTEND_PORT" ;;
admin) echo "$ADMIN_PORT" ;;
storefront) echo "$STOREFRONT_PORT" ;;
esac
}
service_path() {
case "$1" in
backend) echo "/health" ;;
*) echo "/" ;;
esac
}
service_url() {
local service="$1" host="${2:-127.0.0.1}"
printf 'http://%s:%s%s\n' "$host" "$(service_port "$service")" "$(service_path "$service")"
}
pid_file() { printf '%s/%s.pid\n' "$RUNTIME_DIR" "$1"; }
log_file() { printf '%s/%s.log\n' "$RUNTIME_DIR" "$1"; }
read_pid() {
local file
file="$(pid_file "$1")"
[[ -f "$file" ]] && tr -dc '0-9' < "$file" || true
}
is_running() {
local pid
pid="$(read_pid "$1")"
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
}
port_pid() {
local port="$1"
if command -v lsof >/dev/null 2>&1; then
lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | head -1 || true
fi
}
assert_port_available() {
local service="$1" port existing managed
port="$(service_port "$service")"
existing="$(port_pid "$port")"
managed="$(read_pid "$service")"
if [[ -n "$existing" && "$existing" != "$managed" ]]; then
echo "[FAIL] $service port $port is already used by unmanaged PID $existing" >&2
echo " Stop it explicitly before retrying; this script never broad-kills processes." >&2
return 1
fi
}
wait_http() {
local service="$1" url deadline code
url="$(service_url "$service")"
deadline=$((SECONDS + START_TIMEOUT))
while (( SECONDS < deadline )); do
if ! is_running "$service"; then
echo "[FAIL] $service exited during startup. Last log lines:" >&2
tail -40 "$(log_file "$service")" >&2 || true
return 1
fi
code="$(curl --max-time 3 -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"
if [[ "$code" =~ ^[23] ]]; then
echo "[OK] $service ready ($code) — $url"
return 0
fi
sleep 1
done
echo "[FAIL] $service did not become healthy at $url in ${START_TIMEOUT}s" >&2
tail -40 "$(log_file "$service")" >&2 || true
return 1
}
ensure_env() {
if [[ ! -f "$PROJECT_DIR/.env" ]]; then
cat > "$PROJECT_DIR/.env" <<'EOF'
DATABASE_URL=postgres://mdv:mdv_dev_only@localhost:5432/mercadodevida
REDIS_URL=redis://localhost:6379
COOKIE_SECURE=false
EOF
echo "[INFO] Created project/.env for local development"
fi
}
start_infrastructure() {
command -v docker >/dev/null 2>&1 || { echo '[FAIL] Docker is required' >&2; exit 1; }
echo '[INFO] Starting PostgreSQL and Redis...'
local service container
for service in postgres redis; do
container="mdv-dev-$service"
if docker inspect "$container" >/dev/null 2>&1; then
docker start "$container" >/dev/null
else
(cd "$PROJECT_DIR" && docker compose up -d "$service")
fi
done
local deadline=$((SECONDS + 60))
until docker exec mdv-dev-postgres pg_isready -U mdv -d mercadodevida >/dev/null 2>&1; do
(( SECONDS < deadline )) || { echo '[FAIL] PostgreSQL did not become ready' >&2; exit 1; }
sleep 1
done
}
install_dependencies() {
local install_command=(npm install)
[[ "$MODE" == "prod" ]] && install_command=(npm ci)
for dir in "$PROJECT_DIR" "$PROJECT_DIR/apps/admin" "$PROJECT_DIR/frontend" "$PROJECT_DIR/storefront"; do
echo "[INFO] ${install_command[*]}${dir#$PROJECT_DIR/}"
(cd "$dir" && "${install_command[@]}")
done
}
migrate() {
echo '[INFO] Applying database migrations...'
(cd "$PROJECT_DIR" && node --env-file-if-exists=.env node_modules/node-pg-migrate/bin/node-pg-migrate.js up --migrations-dir migrations)
}
build_prod() {
echo '[INFO] Building backend...'
(cd "$PROJECT_DIR" && npm run build)
echo '[INFO] Building admin...'
(cd "$PROJECT_DIR/apps/admin" && NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" npm run build)
echo '[INFO] Building customer frontend...'
(cd "$PROJECT_DIR/frontend" && NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" npm run build)
echo '[INFO] Building SEO storefront...'
(cd "$PROJECT_DIR/storefront" && API_BASE_URL="$API_PUBLIC_URL" NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" npm run build)
sync_uploads
}
sync_uploads() {
# Mirror admin's public/uploads to frontend and storefront so that product
# images are reachable from any of the three apps.
local src="$PROJECT_DIR/apps/admin/public/uploads"
local peer1="$PROJECT_DIR/frontend/public/uploads"
local peer2="$PROJECT_DIR/storefront/public/uploads"
if [[ ! -d "$src" ]]; then
return 0
fi
for peer in "$peer1" "$peer2"; do
mkdir -p "$peer"
# copy every file present in source but missing in peer
while IFS= read -r -d '' f; do
local name
name="$(basename "$f")"
if [[ ! -f "$peer/$name" ]]; then
cp "$f" "$peer/$name" 2>/dev/null || true
fi
done < <(find "$src" -maxdepth 1 -type f -print0 2>/dev/null)
done
}
spawn_service() {
local service="$1" dir log pidfile port
dir="$PROJECT_DIR"
log="$(log_file "$service")"
pidfile="$(pid_file "$service")"
port="$(service_port "$service")"
: > "$log"
case "$MODE:$service" in
dev:backend)
(cd "$dir" && nohup env HOST=0.0.0.0 PORT="$port" NODE_ENV=development node --env-file=.env node_modules/tsx/dist/cli.mjs watch src/infrastructure/http/server.ts >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile")
;;
prod:backend)
(cd "$dir" && nohup env HOST=0.0.0.0 PORT="$port" NODE_ENV=production node --env-file=.env dist/infrastructure/http/server.js >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile")
;;
dev:admin)
(cd "$PROJECT_DIR/apps/admin" && nohup env NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next dev --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile")
;;
prod:admin)
(cd "$PROJECT_DIR/apps/admin" && nohup env NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next start --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile")
;;
dev:frontend)
(cd "$PROJECT_DIR/frontend" && nohup env NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next dev --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile")
;;
prod:frontend)
(cd "$PROJECT_DIR/frontend" && nohup env NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next start --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile")
;;
dev:storefront)
(cd "$PROJECT_DIR/storefront" && nohup env API_BASE_URL="$API_PUBLIC_URL" NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next dev --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile")
;;
prod:storefront)
(cd "$PROJECT_DIR/storefront" && nohup env API_BASE_URL="$API_PUBLIC_URL" NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next start --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile")
;;
esac
sleep 1
wait_http "$service"
local listener_pid
listener_pid="$(port_pid "$port")"
if [[ -n "$listener_pid" ]]; then
echo "$listener_pid" > "$pidfile"
fi
}
start_all() {
ensure_env
for service in "${SERVICES[@]}"; do
if is_running "$service"; then
echo "[FAIL] $service is already managed in $MODE mode; use restart" >&2
exit 1
fi
rm -f "$(pid_file "$service")"
assert_port_available "$service"
done
start_infrastructure
install_dependencies
migrate
[[ "$MODE" == "prod" ]] && build_prod
sync_uploads
for service in "${SERVICES[@]}"; do spawn_service "$service"; done
echo
print_urls
}
stop_service() {
local service="$1" pid deadline
pid="$(read_pid "$service")"
if [[ -z "$pid" ]]; then
rm -f "$(pid_file "$service")"
return
fi
if ! kill -0 "$pid" 2>/dev/null; then
rm -f "$(pid_file "$service")"
return
fi
echo "[INFO] Stopping $service (PID $pid)..."
kill -TERM "$pid" 2>/dev/null || true
deadline=$((SECONDS + 15))
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 1; done
if kill -0 "$pid" 2>/dev/null; then
echo "[WARN] $service did not stop gracefully; sending KILL"
kill -KILL "$pid" 2>/dev/null || true
fi
rm -f "$(pid_file "$service")"
}
stop_all() {
local i
for ((i=${#SERVICES[@]}-1; i>=0; i--)); do stop_service "${SERVICES[$i]}"; done
}
status_all() {
ensure_alive silent
local service pid state code url lan_url failed=0
printf '%-11s %-8s %-10s %-6s %s\n' SERVICE PID PROCESS HTTP URL
for service in "${SERVICES[@]}"; do
pid="$(read_pid "$service")"
state='stopped'
[[ -n "$pid" ]] || pid='-'
if [[ "$pid" != '-' ]] && kill -0 "$pid" 2>/dev/null; then state='running'; fi
url="$(service_url "$service")"
code="$(curl --max-time 3 -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"
[[ -n "$code" && "$code" != '000' ]] || code='-'
lan_url="$(service_url "$service" "$LAN_ADDRESS")"
printf '%-11s %-8s %-10s %-6s %s\n' "$service" "$pid" "$state" "$code" "$lan_url"
[[ "$state" == 'running' && "$code" =~ ^[23] ]] || failed=1
done
echo
(cd "$PROJECT_DIR" && docker compose ps) || true
return "$failed"
}
# ensure_alive [silent|verbose]
# Re-spawn any service whose tracked PID is not running. Used by `status`
# so an operator who runs `monolith.sh status` always sees live processes
# even after a backend crash with no error trace.
ensure_alive() {
local verbose="${1:-silent}" service pid
[[ "$verbose" == "verbose" ]] || verbose='silent'
for service in "${SERVICES[@]}"; do
pid="$(read_pid "$service")"
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
continue
fi
if [[ "$verbose" == "verbose" ]]; then
echo "[WATCHDOG] $service not running (last PID: ${pid:-none}); respawning..."
fi
rm -f "$(pid_file "$service")"
# Only respawn if infrastructure is reachable; otherwise let `start` handle it.
if (cd "$PROJECT_DIR" && docker ps --format '{{.Names}}' 2>/dev/null | grep -qE '^(mdv-dev-postgres|mdv-dev-redis)$'); then
spawn_service "$service" || true
fi
done
}
watch_loop() {
echo "[WATCHDOG] watching services every ${WATCH_INTERVAL}s (Ctrl+C to stop)"
ensure_alive verbose
while true; do
sleep "$WATCH_INTERVAL"
ensure_alive verbose
done
}
print_urls() {
cat <<EOF
Mode: $MODE
LAN IP: $LAN_ADDRESS
Customer frontend: http://$LAN_ADDRESS:$FRONTEND_PORT/
Admin backoffice: http://$LAN_ADDRESS:$ADMIN_PORT/
SEO storefront: http://$LAN_ADDRESS:$STOREFRONT_PORT/
Backend health: http://$LAN_ADDRESS:$BACKEND_PORT/health
Swagger/OpenAPI: http://$LAN_ADDRESS:$BACKEND_PORT/docs
Local equivalents use 127.0.0.1 with the same ports.
EOF
}
follow_logs() {
local files=() service
for service in "${SERVICES[@]}"; do
touch "$(log_file "$service")"
files+=("$(log_file "$service")")
done
tail -n 100 -F "${files[@]}"
}
case "$ACTION" in
start) start_all ;;
restart) stop_all; start_all ;;
status) status_all ;;
stop) stop_all ;;
logs) follow_logs ;;
urls) print_urls ;;
watch) watch_loop ;;
esac