diff --git a/scripts/build-release.sh b/scripts/build-release.sh index ef989ed3..a7025c6f 100644 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -168,11 +168,15 @@ rm -rf "$BUILD_DIR/frontend/node_modules" find "$BUILD_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$BUILD_DIR" -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true find "$BUILD_DIR" -type f -name '*.pyc' -delete 2>/dev/null || true -# Never strip the built SPA bundle: Vite emits real image assets into -# frontend/dist/assets (extension icons, etc.) that the panel 404s without. +# Never strip anything under frontend/: dist/assets holds the images Vite +# emitted (extension icons, etc.) that the panel 404s without, and src/assets +# holds the ones the JS imports — delete those and the shipped tree can no +# longer rebuild its own frontend (`serverkit doctor --fix` on 1.9.x died on +# 18 unresolved png imports, issue #127). The tar excludes above already keep +# docs/dev screenshots out; this pass only catches stragglers elsewhere. # (Use ! -path, not -prune: -delete implies -depth, which disables -prune.) find "$BUILD_DIR" -type f \( -name '*.png' -o -name '*.jpeg' -o -name '*.jpg' \) \ - ! -path "$BUILD_DIR/frontend/dist/*" -delete 2>/dev/null || true + ! -path "$BUILD_DIR/frontend/*" -delete 2>/dev/null || true # Runtime directory the app expects to exist. mkdir -p "$BUILD_DIR/backend/instance" diff --git a/scripts/test/test_cli.sh b/scripts/test/test_cli.sh index d9bf9812..015be427 100644 --- a/scripts/test/test_cli.sh +++ b/scripts/test/test_cli.sh @@ -101,8 +101,10 @@ fi # T2 — L6: cmd_start must warn (not raw-abort) when systemctl/docker fail # (partial installs, non-systemd boxes). cmd_stop already had these guards. # -------------------------------------------------------------------------- -t="$WORK/t2"; mkdir -p "$t/bin" "$t/install" +t="$WORK/t2"; mkdir -p "$t/bin" "$t/install" "$t/nginx/sites-enabled" CALL_LOG="$t/calls.log"; : > "$CALL_LOG" +# Legacy layout (nginx proxies / to the container) so the docker half runs. +printf 'server {\n location / { proxy_pass http://127.0.0.1:8080; }\n}\n' > "$t/nginx/sites-enabled/serverkit.conf" cat > "$t/bin/systemctl" <> "$CALL_LOG" @@ -119,13 +121,14 @@ out="$( export PATH="$t/bin:$PATH" check_root() { :; } INSTALL_DIR="$t/install" + NGINX_DIR="$t/nginx" cmd_start 2>&1 )" rc=$? if [ "$rc" -eq 0 ] \ && printf '%s' "$out" | grep -q 'Could not start backend' \ && printf '%s' "$out" | grep -q 'Could not start frontend' \ - && grep -q 'docker compose up -d' "$CALL_LOG"; then + && grep -q 'docker compose --profile legacy-frontend up -d frontend' "$CALL_LOG"; then ok "start survives failing systemctl/docker with warnings (no raw abort)" else bad "start aborted or lost a warning: rc=$rc out=[$out]" @@ -168,8 +171,9 @@ fi # T4 — L7: `serverkit logs` on a journal-less box must not die before showing # the Docker logs that would have worked. # -------------------------------------------------------------------------- -t="$WORK/t4"; mkdir -p "$t/bin" "$t/install" +t="$WORK/t4"; mkdir -p "$t/bin" "$t/install" "$t/nginx/sites-enabled" CALL_LOG="$t/calls.log"; : > "$CALL_LOG" +printf 'server {\n location / { proxy_pass http://127.0.0.1:8080; }\n}\n' > "$t/nginx/sites-enabled/serverkit.conf" cat > "$t/bin/journalctl" <> "$CALL_LOG" @@ -185,12 +189,13 @@ out="$( set -Eeuo pipefail export PATH="$t/bin:$PATH" INSTALL_DIR="$t/install" + NGINX_DIR="$t/nginx" cmd_logs all 2>&1 )" rc=$? if [ "$rc" -eq 0 ] \ && printf '%s' "$out" | grep -q 'Could not read the backend journal' \ - && grep -q 'docker compose logs --tail=50' "$CALL_LOG"; then + && grep -q 'docker compose --profile legacy-frontend logs --tail=50 frontend' "$CALL_LOG"; then ok "logs falls through a failing journalctl to the Docker-logs half" else bad "logs died before the Docker half: rc=$rc out=[$out]" @@ -442,6 +447,101 @@ else bad "doctor healthy path broken: rc=$rc out=[$out]" fi +# -------------------------------------------------------------------------- +# T12b — issue #127: on a host install (nginx serves frontend/dist from disk, +# the install.sh default) doctor must recognise the static layout, pass, and +# never reach for `docker compose` — that built the whole panel image on +# the operator's box and died on 18 missing png imports. +# -------------------------------------------------------------------------- +t="$WORK/t12b" +mkdir -p "$t/bin" "$t/install/backend/instance" "$t/install/frontend/dist" \ + "$t/nginx/sites-available" "$t/nginx/sites-enabled" "$t/nginx/serverkit-locations" +printf 'x\n' > "$t/install/backend/instance/serverkit.db" +printf '\n' > "$t/install/frontend/dist/index.html" +cat > "$t/nginx/sites-available/serverkit.conf" < "$t/bin/docker" <> "$t/docker.log" +exit 0 +EOF +cat > "$t/bin/ss" <<'EOF' +#!/usr/bin/env bash +printf 'LISTEN 0 128 127.0.0.1:5000 users:(("gunicorn",pid=1,fd=1))\n' +exit 0 +EOF +chmod +x "$t/bin"/* +out="$( + set -Eeuo pipefail + export PATH="$t/bin:$PATH" + INSTALL_DIR="$t/install" + NGINX_DIR="$t/nginx" + cmd_doctor 2>&1 +)" +rc=$? +if [ "$rc" -eq 0 ] \ + && printf '%s' "$out" | grep -q 'Static bundle present' \ + && ! printf '%s' "$out" | grep -q 'is not running' \ + && ! grep -q 'compose' "$t/docker.log" 2>/dev/null; then + ok "doctor: static host install passes; no container check, no docker compose" +else + bad "doctor static layout: rc=$rc compose=[$(cat "$t/docker.log" 2>/dev/null)] out=[$out]" +fi + +# -------------------------------------------------------------------------- +# T12c — issue #127: static layout with the bundle missing. `--fix` must +# report it, point at `serverkit update`, and still never run compose. +# -------------------------------------------------------------------------- +rm -f "$t/install/frontend/dist/index.html" "$t/docker.log" +out="$( + set -Eeuo pipefail + export PATH="$t/bin:$PATH" + INSTALL_DIR="$t/install" + NGINX_DIR="$t/nginx" + check_root() { :; } # --fix wants root; the sandbox is not + cmd_doctor --fix 2>&1 +)" +rc=$? +if [ "$rc" -ne 0 ] \ + && printf '%s' "$out" | grep -q 'Frontend bundle missing' \ + && printf '%s' "$out" | grep -q 'serverkit update' \ + && ! grep -q 'compose' "$t/docker.log" 2>/dev/null; then + ok "doctor --fix: missing static bundle suggests 'serverkit update', never runs compose" +else + bad "doctor --fix static layout: rc=$rc compose=[$(cat "$t/docker.log" 2>/dev/null)] out=[$out]" +fi + +# -------------------------------------------------------------------------- +# T12d — frontend_mode reads the layout off the vhost: proxy_pass on / is the +# legacy container profile, a dist root is static, nothing at all is unknown. +# The legacy fix path must target the profile's service, not a bare +# `docker compose up -d` (which starts the all-in-one panel instead). +# -------------------------------------------------------------------------- +u="$WORK/t12d"; mkdir -p "$u/nginx/sites-enabled" "$u/install" +m_unknown="$( INSTALL_DIR="$u/install"; NGINX_DIR="$u/nginx"; frontend_mode )" +printf 'server {\n location / { proxy_pass http://127.0.0.1:8080; }\n}\n' > "$u/nginx/sites-enabled/serverkit.conf" +m_container="$( INSTALL_DIR="$u/install"; NGINX_DIR="$u/nginx"; frontend_mode )" +printf 'server {\n root /opt/serverkit/frontend/dist;\n location /api { proxy_pass http://127.0.0.1:5000; }\n}\n' > "$u/nginx/sites-enabled/serverkit.conf" +m_static="$( INSTALL_DIR="$u/install"; NGINX_DIR="$u/nginx"; frontend_mode )" +if [ "$m_unknown" = unknown ] && [ "$m_container" = container ] && [ "$m_static" = static ] \ + && grep -q 'docker compose --profile legacy-frontend "\$@" frontend' "$CLI" \ + && [ "$(awk '/INSTALL_DIR/ {p=1; next} /^[[:space:]]*$/ {next} p && /docker compose (up|down|stop|restart|logs|ps)/ && !/--profile/ {c++} {p=0} END {print c+0}' "$CLI")" -eq 0 ]; then + ok "frontend_mode: unknown/container/static detected; legacy path targets the profile service" +else + bad "frontend_mode wrong: unknown=[$m_unknown] container=[$m_container] static=[$m_static], or a bare 'docker compose up -d' is back in the CLI" +fi + # -------------------------------------------------------------------------- # T13 — L12: list-sites port extraction without GNU grep -oP; conf.d layouts # are listed too. diff --git a/scripts/test/test_release_workflow.sh b/scripts/test/test_release_workflow.sh index 21aca2ea..2165db2f 100644 --- a/scripts/test/test_release_workflow.sh +++ b/scripts/test/test_release_workflow.sh @@ -74,6 +74,16 @@ expect_text "$RELEASE" 'Refusing to overwrite existing release' \ expect_text "$RELEASE" 'overwrite_files: false' \ 'release assets cannot be overwritten' +# The release tarball must be able to rebuild its own frontend: build-release +# used to strip every png outside frontend/dist, which emptied +# frontend/src/assets and made any rebuild from /opt/serverkit die on +# unresolved imports (issue #127). +BUILD_RELEASE="$ROOT/scripts/build-release.sh" +expect_text "$BUILD_RELEASE" '! -path "$BUILD_DIR/frontend/*"' \ + 'release image cleanup leaves the whole frontend/ tree alone' +expect_absent "$BUILD_RELEASE" '! -path "$BUILD_DIR/frontend/dist/*" -delete' \ + 'release image cleanup no longer strips frontend/src assets' + if [ "$failures" -ne 0 ]; then printf '\n%d release workflow guard assertion(s) failed\n' "$failures" >&2 exit 1 diff --git a/serverkit b/serverkit index d3860740..f599398a 100644 --- a/serverkit +++ b/serverkit @@ -4,7 +4,8 @@ # # Architecture: # - Backend: systemd service (runs directly on host) -# - Frontend: Docker container (nginx) +# - Frontend: static bundle served by host nginx (or, on the legacy +# layout, the serverkit-frontend container — see frontend_mode) # # Usage: serverkit [options] # @@ -40,6 +41,60 @@ NGINX_DIR="${SERVERKIT_NGINX_DIR:-/etc/nginx}" BACKEND_SERVICE="serverkit" FRONTEND_CONTAINER="serverkit-frontend" +# How the SPA is served on this box. install.sh has host nginx serve the +# prebuilt bundle straight from frontend/dist; the `serverkit-frontend` +# container only exists under the opt-in `legacy-frontend` compose profile. +# Read the answer off the nginx vhost instead of assuming either layout — +# a plain `docker compose up -d` on a host install does not start that +# container, it builds the whole all-in-one panel image (issue #127). +# +# static nginx `root .../frontend/dist` (install.sh, the default) +# container nginx proxies `location /` to a local port (legacy profile) +# unknown no serverkit vhost found +serverkit_vhost() { + local conf + for conf in "$NGINX_DIR/sites-enabled/serverkit.conf" \ + "$NGINX_DIR/sites-available/serverkit.conf" \ + "$NGINX_DIR/conf.d/serverkit.conf"; do + if [ -f "$conf" ]; then + echo "$conf" + return 0 + fi + done + return 1 +} + +# The directory nginx serves the SPA from (static mode only). +frontend_static_root() { + local conf + conf="$(serverkit_vhost)" || return 1 + sed -n 's/^[[:space:]]*root[[:space:]]\{1,\}\([^;]*\);.*/\1/p' "$conf" \ + | grep 'frontend/dist' | head -1 +} + +frontend_mode() { + local conf + if conf="$(serverkit_vhost)"; then + if grep -Eq '^[[:space:]]*root[[:space:]]+[^;]*frontend/dist' "$conf"; then + echo static + elif grep -Eq 'proxy_pass[[:space:]]+http://127\.0\.0\.1:[0-9]+' "$conf"; then + echo container + else + echo unknown + fi + elif [ -f "$INSTALL_DIR/frontend/dist/index.html" ]; then + # No vhost yet but the bundle is on disk: install.sh layout. + echo static + else + echo unknown + fi +} + +# The compose invocation that actually starts the legacy frontend container. +compose_frontend() { + docker compose --profile legacy-frontend "$@" frontend +} + # Helper functions print_header() { local ver @@ -356,9 +411,13 @@ cmd_start() { # readable warning, not a raw set -e abort. systemctl start $BACKEND_SERVICE || print_warning "Could not start backend service '$BACKEND_SERVICE' (is systemd available?)" - # Start frontend (Docker) - cd "$INSTALL_DIR" - docker compose up -d || print_warning "Could not start frontend container (is Docker running?)" + # Frontend: static files need nginx up; only the legacy layout has a container. + if [ "$(frontend_mode)" = container ]; then + cd "$INSTALL_DIR" + compose_frontend up -d || print_warning "Could not start frontend container (is Docker running?)" + else + systemctl start nginx || print_warning "Could not start nginx (it serves the frontend)" + fi sleep 3 print_success "ServerKit started" @@ -373,9 +432,12 @@ cmd_stop() { # Stop backend (systemd) systemctl stop $BACKEND_SERVICE || true - # Stop frontend (Docker) - cd "$INSTALL_DIR" - docker compose down || true + # Legacy frontend container only. nginx stays up on a host install: it + # also fronts every managed site, not just the panel. + if [ "$(frontend_mode)" = container ]; then + cd "$INSTALL_DIR" + compose_frontend stop || true + fi print_success "ServerKit stopped" } @@ -389,9 +451,12 @@ cmd_restart() { # manager is reported, never a raw abort. systemctl restart $BACKEND_SERVICE || print_warning "Could not restart backend service '$BACKEND_SERVICE' (is systemd available?)" - # Restart frontend (Docker) - cd "$INSTALL_DIR" - docker compose restart || print_warning "Could not restart frontend container (is Docker running?)" + if [ "$(frontend_mode)" = container ]; then + cd "$INSTALL_DIR" + compose_frontend restart || print_warning "Could not restart frontend container (is Docker running?)" + else + systemctl reload nginx || print_warning "Could not reload nginx (it serves the frontend)" + fi sleep 3 print_success "ServerKit restarted" @@ -410,11 +475,22 @@ cmd_status_brief() { fi # Frontend status - if docker ps --format '{{.Names}}' | grep -q $FRONTEND_CONTAINER; then - print_success "Frontend: running" - else - print_error "Frontend: stopped" - fi + case "$(frontend_mode)" in + container) + if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "$FRONTEND_CONTAINER"; then + print_success "Frontend: running (container)" + else + print_error "Frontend: stopped (container)" + fi + ;; + *) + if systemctl is-active --quiet nginx 2>/dev/null; then + print_success "Frontend: served by nginx" + else + print_error "Frontend: nginx is not running" + fi + ;; + esac # API health if curl -s http://127.0.0.1:5000/api/v1/system/health > /dev/null 2>&1; then @@ -434,10 +510,17 @@ cmd_status() { systemctl status $BACKEND_SERVICE --no-pager -l 2>/dev/null || print_warning "Backend service not found" echo "" - echo "Frontend Container (Docker):" - echo "─────────────────────────────" - cd "$INSTALL_DIR" - docker compose ps 2>/dev/null || print_warning "Frontend container not found" + if [ "$(frontend_mode)" = container ]; then + echo "Frontend Container (Docker):" + echo "─────────────────────────────" + cd "$INSTALL_DIR" + compose_frontend ps 2>/dev/null || print_warning "Frontend container not found" + else + echo "Frontend (static, served by nginx):" + echo "─────────────────────────────" + echo " root: $(frontend_static_root 2>/dev/null || echo "$INSTALL_DIR/frontend/dist")" + systemctl status nginx --no-pager -l 2>/dev/null | head -5 || print_warning "nginx service not found" + fi echo "" cmd_status_brief @@ -456,8 +539,14 @@ cmd_logs() { fi ;; frontend) - cd "$INSTALL_DIR" - docker compose logs -f || print_warning "Could not read frontend container logs (is Docker running?)" + if [ "$(frontend_mode)" = container ]; then + cd "$INSTALL_DIR" + compose_frontend logs -f || print_warning "Could not read frontend container logs (is Docker running?)" + else + print_info "The frontend is static files served by nginx; following nginx logs..." + tail -f /var/log/nginx/access.log /var/log/nginx/error.log 2>/dev/null \ + || print_warning "Could not read /var/log/nginx/*.log" + fi ;; all|*) print_info "Showing backend logs (Ctrl+C to switch to frontend)..." @@ -471,10 +560,16 @@ cmd_logs() { print_warning "journalctl not found — skipping backend logs" fi echo "" - print_info "Showing frontend logs..." - echo "" - cd "$INSTALL_DIR" - docker compose logs --tail=50 || print_warning "Could not read frontend container logs (is Docker running?)" + if [ "$(frontend_mode)" = container ]; then + print_info "Showing frontend logs..." + echo "" + cd "$INSTALL_DIR" + compose_frontend logs --tail=50 || print_warning "Could not read frontend container logs (is Docker running?)" + else + print_info "Showing nginx logs (the frontend is static files served by nginx)..." + echo "" + tail -n 50 /var/log/nginx/error.log 2>/dev/null || print_warning "Could not read /var/log/nginx/error.log" + fi echo "" print_info "For live logs, use: serverkit logs backend OR serverkit logs frontend" ;; @@ -1103,6 +1198,13 @@ cmd_doctor() { print_warning "SKIP: $1 (run with --fix to auto-fix)" } + # For issues doctor can diagnose but must not fix on its own (a missing + # release bundle, a layout it cannot tell apart): say what to run instead + # of promising an auto-fix that does not exist. + doctor_hint() { + echo " Try: $1" + } + # ── 1. Backend service ────────────────────────────────────── echo "Backend" echo "───────────────────────────────────────" @@ -1131,15 +1233,39 @@ cmd_doctor() { echo "" - # ── 2. Frontend container ─────────────────────────────────── + # ── 2. Frontend ───────────────────────────────────────────── + # Two layouts exist (see frontend_mode). Check the one this box actually + # uses; never "fix" a static install by starting containers. FRONTEND_RUNNING=false FRONTEND_PORT="" + FRONTEND_MODE="$(frontend_mode)" - if command -v docker >/dev/null 2>&1; then - echo "Frontend" - echo "───────────────────────────────────────" + echo "Frontend" + echo "───────────────────────────────────────" - if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "$FRONTEND_CONTAINER"; then + case "$FRONTEND_MODE" in + static) + STATIC_ROOT="$(frontend_static_root 2>/dev/null || true)" + [ -n "$STATIC_ROOT" ] || STATIC_ROOT="$INSTALL_DIR/frontend/dist" + if [ -f "$STATIC_ROOT/index.html" ]; then + doctor_ok "Static bundle present, served by nginx from $STATIC_ROOT" + else + doctor_issue "Frontend bundle missing: $STATIC_ROOT/index.html" + echo " The host install serves a prebuilt bundle through nginx; there is no container to start." + doctor_hint "serverkit update (reinstalls the release, bundle included)" + fi + # A leftover legacy container is harmless but wastes a port and confuses + # port-mismatch checks; point at it, do not touch it. + if command -v docker >/dev/null 2>&1 \ + && docker ps --format '{{.Names}}' 2>/dev/null | grep -q "$FRONTEND_CONTAINER"; then + print_warning "Container '$FRONTEND_CONTAINER' is running but nginx serves the frontend from disk; it is unused" + doctor_hint "cd $INSTALL_DIR && docker compose --profile legacy-frontend stop frontend" + fi + ;; + container) + if ! command -v docker >/dev/null 2>&1; then + doctor_issue "nginx proxies the frontend to a container but Docker is not installed or not in PATH" + elif docker ps --format '{{.Names}}' 2>/dev/null | grep -q "$FRONTEND_CONTAINER"; then FRONTEND_RUNNING=true # Get the actual host port the container is mapped to FRONTEND_PORT=$(docker port "$FRONTEND_CONTAINER" 80 2>/dev/null | head -1 | sed -n 's/.*:\([0-9][0-9]*\).*/\1/p' || true) @@ -1148,14 +1274,14 @@ cmd_doctor() { doctor_issue "Container '$FRONTEND_CONTAINER' is not running" if [ "$FIX_MODE" = "--fix" ]; then cd "$INSTALL_DIR" - docker compose up -d 2>/dev/null && { + compose_frontend up -d 2>/dev/null && { sleep 3 FRONTEND_PORT=$(docker port "$FRONTEND_CONTAINER" 80 2>/dev/null | head -1 | sed -n 's/.*:\([0-9][0-9]*\).*/\1/p' || true) FRONTEND_RUNNING=true doctor_fixed "Started frontend container (port $FRONTEND_PORT)" } || print_error " Could not start container" else - doctor_skip "docker compose up -d" + doctor_skip "docker compose --profile legacy-frontend up -d frontend" fi fi @@ -1166,11 +1292,12 @@ cmd_doctor() { doctor_issue "Container running but not responding on port $FRONTEND_PORT" fi fi - else - echo "Frontend" - echo "───────────────────────────────────────" - doctor_issue "Docker not installed or not in PATH; skipping frontend checks" - fi + ;; + *) + doctor_issue "Cannot tell how the frontend is served: no serverkit vhost under $NGINX_DIR and no $INSTALL_DIR/frontend/dist" + doctor_hint "serverkit update (reinstalls the release and its nginx config)" + ;; + esac echo ""