#!/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 # One network for everything Officer provisions, so containers can reach each # other by name. Postgres needs nothing from it today — the platform is a host # process and reaches it over loopback — but a reverse proxy in front of the web # UI, or any app-store service that talks to another, does. Creating it now means # the later ones do not have to migrate onto it. OFFICER_NETWORK="${OFFICER_NETWORK:-officerdev}" PG_IMAGE="${PG_IMAGE:-postgres:18-alpine}" PG_DATABASE="${PG_DATABASE:-officer}" PG_CONTAINER="${PG_CONTAINER:-officer-postgres}" PG_PORT="${PG_PORT:-5432}" # ── The CLIENT, on the host, matching the server in the container ── # # `psql` was on no install. The server runs in Docker, so nothing ever put a client on the # host, and `docker exec officer-postgres psql` is not a substitute for a member: they have # their own Postgres role (`provisionPostgresRole` for Developers) and no access to the # owner's Docker socket. # # The version is derived from PG_IMAGE rather than typed again, because the pairing is not # cosmetic: **pg_dump refuses a server newer than itself** ("server version 18.6, pg_dump # version 16.x — aborting"). Ubuntu 24.04 ships client 16 against this 18 server, so the # archive package is not merely old, it is unusable for dumps. That is also why this lives # beside the server definition rather than in machine-setup's package list — one constant, # one place to bump. pg_client_major() { sed -E 's/^postgres:([0-9]+).*/\1/' <<<"$PG_IMAGE"; } pg_client_installed() { command -v psql >/dev/null 2>&1 && [[ "$(psql --version | grep -oE '[0-9]+' | head -1)" == "$(pg_client_major)" ]] } # PGDG, added the same way docker.sh adds Docker's: key to its own file, one sources.list.d # entry, no add-apt-repository. Non-fatal — an install without psql is a working platform, # just a more annoying one to operate. install_pg_client() { local major codename major="$(pg_client_major)" [[ -n "$major" ]] || { warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client" return 1 } if pg_client_installed; then ok "psql ${major} already installed" return 0 fi codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")" [[ -n "$codename" ]] || { warn "could not work out this release's codename — cannot add the PostgreSQL repository" return 1 } install -d -m 0755 /usr/share/postgresql-common/pgdg curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc || { warn "could not fetch the PostgreSQL signing key" return 1 } chmod a+r /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \ >/etc/apt/sources.list.d/pgdg.list DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq || true DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "postgresql-client-${major}" || { warn "postgresql-client-${major} did not install" return 1 } # The exit status is not the gate — same lesson as rootless Docker and the claude CLI: what # matters is whether the binary is there AND is the version we asked for, because apt can # succeed while holding an older client back. pg_client_installed || { warn "psql is not version ${major} after installing — check: apt-cache policy postgresql-client-${major}" return 1 } ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed for every account on this machine" } docker_network_exists() { docker network inspect "$OFFICER_NETWORK" &>/dev/null; } ensure_docker_network() { docker_network_exists && return 1 docker network create "$OFFICER_NETWORK" >/dev/null 2>&1 } 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, and the reason is worth keeping next to the # line it explains. # # Publishing a port makes Docker write its own DNAT and ACCEPT rules into # iptables, and those are evaluated BEFORE ufw sees the packet. So `ports: # "5432:5432"` is reachable from the internet while `ufw status` reports # everything denied. Binding to 127.0.0.1 sidesteps it entirely: the DNAT rule # only matches traffic arriving on loopback. # # Loopback is not the whole story, though, and the password is not decoration. # Every account ON this machine can open 127.0.0.1:5432 — including the per-user # Linux accounts Officer gives its members. What stops them is that they cannot # authenticate. The password is the boundary between the platform and anyone # with a login here, which is why it is random and why both files holding it are # 0600. 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" / }