diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index 3a154076..d0085296 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -44,6 +44,8 @@ source "$SCRIPT_DIR/officer-setup/lib/preflight.sh" source "$SCRIPT_DIR/officer-setup/lib/repo.sh" # shellcheck source=officer-setup/lib/layout.sh source "$SCRIPT_DIR/officer-setup/lib/layout.sh" +# shellcheck source=officer-setup/lib/postgres.sh +source "$SCRIPT_DIR/officer-setup/lib/postgres.sh" trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ OFFICER SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR @@ -326,10 +328,119 @@ if ! skip; then step_ok fi +# ============================================================================= +# 5. Database +# ============================================================================= +# +# POSTGRES_URL is set here and written by the environment section below. + +step "Database" +if ! skip; then + echo "" + info "Database — Postgres, the only one Officer has" + echo " It holds the account, passkeys, settings, dashboards, email" + echo " accounts and the job queue. Nothing else in the platform is a" + echo " database." + echo "" + echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')" + echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')" + echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')" + + POSTGRES_URL="" + + # An existing compose file means this ran before. Reuse its password rather + # than minting a new one, which would leave the container and the URL + # disagreeing about the credential. + if pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"; then + POSTGRES_URL="$(pg_url "$PG_EXISTING_PASSWORD")" + echo "" + echo " already provisioned here — reusing the password from $(pg_env_file)" + pg_container_running || { + info " starting it" + pg_compose_up >/dev/null 2>&1 || true + } + if pg_wait_ready; then + ok "postgres answering on 127.0.0.1:${PG_PORT}" + SUMMARY+=("Database: existing Postgres at ${PG_CONTAINER}") + else + warn "the container is not answering — check: docker logs ${PG_CONTAINER}" + ERRORS+=("Database: provisioned but not answering") + fi + + else + echo "" + info "Which Postgres should Officer use?" + echo "" + echo " [1] provision one here" + echo " ${PG_IMAGE} in $(pg_service_dir), bound to 127.0.0.1 only." + echo " Docker publishes ports by writing iptables rules beneath ufw," + echo " so a database published to every interface is reachable from" + echo " the internet whatever the firewall says. Loopback is all the" + echo " platform needs — it runs on this machine." + echo "" + echo " [2] use one you already run" + echo " Give the connection URL. Nothing is provisioned." + echo "" + + DB_PICK="" + while [[ -z "$DB_PICK" ]]; do + if ! read -rp " Which one? (1/2) [1]: " DB_CHOICE; then + echo "" + fail "No answer." + fi + case "${DB_CHOICE:-1}" in + 1) + if ! command -v docker &>/dev/null; then + warn "Docker is not installed, so there is nothing to provision into." + continue + fi + if pg_port_in_use; then + warn "something is already listening on ${PG_PORT} — provisioning here would fail to bind" + echo " If that is a Postgres you already run, pick 2 and give its URL." + continue + fi + DB_PICK=provision + ;; + 2) DB_PICK=existing ;; + *) warn "Pick 1 or 2." ;; + esac + done + + if [[ "$DB_PICK" == provision ]]; then + PG_PASSWORD="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)" + write_pg_compose "$PG_PASSWORD" + ok "compose written to $(pg_compose_file)" + if pg_compose_up && pg_wait_ready; then + POSTGRES_URL="$(pg_url "$PG_PASSWORD")" + ok "postgres answering on 127.0.0.1:${PG_PORT}, database '${PG_DATABASE}'" + SUMMARY+=("Database: provisioned at $(pg_service_dir)") + else + warn "the container did not come up — check: docker logs ${PG_CONTAINER}" + ERRORS+=("Database: container did not start") + SUMMARY+=("Database: provisioning FAILED") + fi + else + echo "" + ask_required POSTGRES_URL "Connection URL" "postgresql://user:password@host:5432/officer" + if pg_url_works "$POSTGRES_URL"; then + ok "reachable" + SUMMARY+=("Database: existing, ${POSTGRES_URL%%:*}://…") + else + # Not fatal. The URL may be right and the database not started yet, and + # refusing to continue over that would be worse than saying so. + warn "could not connect with that URL" + echo " Kept anyway — check it before running the schema step." + ERRORS+=("Database: the given URL did not answer") + SUMMARY+=("Database: existing URL kept, did not answer") + fi + fi + fi + step_ok +fi + # ============================================================================= # NOT BUILT YET # ============================================================================= -# 4 Database Postgres in docker, or one you already run # 5 Environment .env # 6 Schema db:push # 7 Build gen:index diff --git a/scripts/setup/officer-setup/lib/postgres.sh b/scripts/setup/officer-setup/lib/postgres.sh new file mode 100644 index 00000000..5573f9e3 --- /dev/null +++ b/scripts/setup/officer-setup/lib/postgres.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# ============================================================================= +# officer-setup — Postgres +# ============================================================================= +# +# Definitions only. +# +# ── Only Postgres ── +# +# The original offered five containers. Of those, Redis and SearXNG are not +# referenced anywhere in the platform — no import, no environment variable, no +# mention — and Nginx Proxy Manager is a deployment choice rather than something +# a setup script should pick. Mailhog is a development convenience and is offered +# separately. +# +# Postgres is the only one Officer cannot run without: it is the single database, +# holding the account, passkeys, settings, dashboards, email accounts and the +# queue. +# +# ── Where it goes ── +# +# $OFFICER_ROOT/dockers/postgres/, which is the same convention the app store +# uses for anything it provisions: one directory per service, the compose file +# inside it, and RELATIVE bind mounts so the data sits beside the compose file +# where both a human and the platform can find it. + +[[ -n "${OFFICER_SETUP_POSTGRES_LOADED:-}" ]] && return 0 +OFFICER_SETUP_POSTGRES_LOADED=1 + +PG_IMAGE="${PG_IMAGE:-postgres:18-alpine}" +PG_DATABASE="${PG_DATABASE:-officer}" +PG_CONTAINER="${PG_CONTAINER:-officer-postgres}" +PG_PORT="${PG_PORT:-5432}" + +pg_service_dir() { echo "${OFFICER_ROOT}/dockers/postgres"; } +pg_compose_file() { echo "$(pg_service_dir)/docker-compose.yaml"; } +pg_env_file() { echo "$(pg_service_dir)/.env"; } + +pg_compose_exists() { [[ -f "$(pg_compose_file)" ]]; } +pg_container_running() { docker ps --filter "name=^${PG_CONTAINER}$" --format '{{.Names}}' 2>/dev/null | grep -q .; } + +# Is something already answering on the port? A Postgres the user runs their own +# way is a perfectly good answer, and finding out by failing to bind is not. +pg_port_in_use() { ss -ltn 2>/dev/null | grep -qE "127\.0\.0\.1:${PG_PORT}\b|\*:${PG_PORT}\b|0\.0\.0\.0:${PG_PORT}\b"; } + +# Bound to loopback, deliberately. +# +# `ports: "5432:5432"` publishes to every interface, and Docker writes its own +# iptables rules underneath ufw — so a database published that way is reachable +# from the internet whatever the firewall says. 127.0.0.1 is the whole fix, and +# it is enough: the platform runs on the same machine. +write_pg_compose() { + local password="$1" dir + dir="$(pg_service_dir)" + install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$dir" + + cat >"$(pg_compose_file)" <"$(pg_env_file)" < 0)); do + docker exec "$PG_CONTAINER" pg_isready -U postgres >/dev/null 2>&1 && return 0 + sleep 1 + done + return 1 +} + +pg_url() { echo "postgresql://postgres:${1}@127.0.0.1:${PG_PORT}/${PG_DATABASE}"; } + +# Does this URL actually answer? Asked of any URL, provisioned or given, because +# a database nobody can reach is the failure that makes every later section look +# broken for its own reasons. +pg_url_works() { + local url="$1" + as_owner "docker run --rm --network host ${PG_IMAGE} psql '${url}' -c 'select 1' >/dev/null 2>&1" / +} diff --git a/src/servers/app-store/paths.ts b/src/servers/app-store/paths.ts index 25da7da4..1291fd51 100644 --- a/src/servers/app-store/paths.ts +++ b/src/servers/app-store/paths.ts @@ -17,7 +17,6 @@ import { DATA_PATH } from '../data-path'; // than configured separately, because a second environment variable that must agree with the first is a // second thing to get wrong — and on a correct install `data/` is always a direct child of the root. // -// // ── Why this is not `~/dockers` ── // // That is where a seasoned user already keeps their own estate — 47 services on this machine alone. Two