Both scripts had real macOS branches already — MACOS_SKIP, Xcode CLT, Docker Desktop
detection — but neither had ever run past its own library load on a real Mac, and
verifying that live is what this commit is. This machine is the Mac.
The root cause was the interpreter, not missing OS coverage: macOS's stock /bin/bash is
3.2.57 (Apple stopped shipping newer once bash went GPLv3) and this machine has no newer
one installed. Both scripts used bash 4+/4.3+ syntax with zero version guard, so a real
run died in seconds — declare -A (base.sh, associative array), local -n (packages.sh,
nameref), mapfile (5 sites), ${var,,} (both scripts' own step() — confirmed --only killed
the ENTIRE run), and machine-setup.sh's own pre-flight hard-refused brew outright ("this
script does not implement yet") even once the bash-4 crash was fixed. Each got rewritten
to its bash-3.2-safe equivalent rather than requiring a newer bash and re-executing under
it — nine call sites, a bounded fix, and one less thing that can go wrong before the
script can report what's wrong.
Past that gate, a live probe run (real account/path prompts, killed before Database/
Services could touch this machine's actual state) reproduced a second bug in the first
few seconds: resolve_user_home used getent (Linux/glibc, absent on macOS) with a
hardcoded /home/<user> fallback, so Layout tried `install -d /home/pastilhas` and failed
with "Operation not supported". Fixed with the portable `eval echo ~user` tilde-expansion
idiom, re-ran the same probe, and it correctly created /Users/pastilhas/officerdev/ and
started a real git clone.
Everything else follows the same shape — an unguarded Linux-only tool with a portable or
brew-based macOS equivalent:
- grep -oP (4 sites: both --list flags, env.sh's tailnet lookup, build.sh's schema
table count) — BSD grep has no -P at all. Portable ERE+sed where regex was enough;
jq where the input was already JSON (tailnet lookup) — jq is already an unconditional
dependency elsewhere in this script, so that's a correctness fix, not just a port.
- ss (postgres.sh, proxy.sh — is anything listening on this port, and is it bound wide
enough for NPM's container to reach) — both silently mis-reported on macOS, since the
"command not found" was swallowed by 2>/dev/null and read as "nothing's listening".
New shared `listening_addrs()` in officer-setup's base.sh, branching to lsof.
- getent hosts (proxy.sh's DNS-wait loop) — same silent-failure shape, would have spun
its full 30-minute timeout regardless of whether the record actually resolved. macOS
branch uses dscacheutil.
- stat -c (layout.sh's ownership check) — BSD stat has no -c, confirmed live ("illegal
option -- c"), and the failure was VISIBLE and made every layout directory report as
wrong-owner. Portable file_owner() tries -c then falls back to -f %Su.
- apt-only Postgres client, NodeSource's Debian-only setup script, lazydocker/lazygit's
installers (one literally named install_update_linux.sh, the other hardcoding
"_Linux_" into the release asset URL) — each gets a brew branch, formula names
verified against the live Homebrew catalog rather than assumed.
- sudo -u "$USERNAME" to become the SAME account the process already is — real on
Linux, where this whole script runs as root and dropping to the owner needs no
password; on macOS the process never escalates in the first place, so re-invoking
sudo for yourself asks for a real password on every call, or fails outright without
sudo rights. machine-setup's dev.sh had ~10 inline call sites (git, bun, deno, claude,
opencode, editor detection) rerouted through a new shared run_as_owner(); officer-
setup's own as_owner() (already the shared entry point there) got the same branch,
and six direct sudo call sites in build.sh/secrets.sh/services.sh that bypassed it
were rerouted through it too.
Three things stay Linux-only rather than getting an unverified macOS implementation,
each skipped with a stated reason at the point it would otherwise run: pm2's boot-
persistence hook (needs a launchd plist generated and loaded, not a systemd unit
printed and eval'd — real, separate work), the Tailscale exit-node NIC/sysctl tuning
(no macOS equivalent concept, and advertising a laptop as an exit node is already the
edge case MACOS_SKIP treats it as), and ufw's bridge-to-host firewall rule (pf is a
different rule language entirely, and Docker Desktop's own VM-based networking may not
even have the problem this rule exists to solve — guessing at a firewall rule felt like
the wrong place to be wrong).
Verified: both --list flags print step names instead of a grep usage error. A bounded
officer-setup.sh run (killed before Database) resolved the real macOS home directory,
created the layout with correct ownership, and began a real git clone — cleaned up
after (rm -rf the test directory, the gitignored .setup-progress file, the stray
install-report). machine-setup.sh's pre-flight now reports "Packages: brew" and reaches
the account/path prompts instead of refusing the OS outright. bash -n clean on all 16
files under bash 3.2, the actual interpreter these scripts run under on a Mac.
Not run further live: Database, Secrets, Services and Proxy would mutate this machine's
real state (a live Postgres, a live set of pm2 processes this session runs through) and
were left for a deliberate, supervised run rather than folded into validation.
269 lines
11 KiB
Bash
269 lines
11 KiB
Bash
#!/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
|
|
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
|
|
|
|
if [[ "$(uname -s)" == "Darwin" ]]; then
|
|
install_pg_client_macos "$major"
|
|
return $?
|
|
fi
|
|
|
|
install_pg_client_linux "$major"
|
|
}
|
|
|
|
# Homebrew's `postgresql@<major>` matches the apt path's own logic: the version has to agree
|
|
# with the server's, not just be "a psql". It is keg-only — Homebrew never links more than one
|
|
# Postgres onto PATH by default, because a dev machine plausibly has several majors installed
|
|
# for different projects — so `brew install` alone does not make `psql` resolve. Symlinking
|
|
# only the four client binaries this platform actually uses, rather than `brew link --force`ing
|
|
# the whole keg, is the same caveat Homebrew's own postgresql formula prints; force-linking
|
|
# would silently steal `psql` from whatever other major was linked before this ran.
|
|
install_pg_client_macos() {
|
|
local major="$1" prefix
|
|
command -v brew &>/dev/null || {
|
|
warn "Homebrew is not installed — cannot install postgresql@${major}. Run machine-setup.sh first."
|
|
return 1
|
|
}
|
|
prefix="$(brew --prefix)"
|
|
|
|
brew install "postgresql@${major}" &>/dev/null || {
|
|
warn "brew install postgresql@${major} failed"
|
|
return 1
|
|
}
|
|
|
|
local bin
|
|
for bin in psql pg_dump pg_dumpall pg_restore; do
|
|
[[ -x "${prefix}/opt/postgresql@${major}/bin/${bin}" ]] || continue
|
|
ln -sf "${prefix}/opt/postgresql@${major}/bin/${bin}" "${prefix}/bin/${bin}"
|
|
done
|
|
|
|
pg_client_installed || {
|
|
warn "psql is not version ${major} after installing — check: brew list postgresql@${major}"
|
|
return 1
|
|
}
|
|
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed via Homebrew"
|
|
}
|
|
|
|
install_pg_client_linux() {
|
|
local major="$1" codename
|
|
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.
|
|
#
|
|
# `ss` is Linux-only; `listening_addrs` (lib/base.sh) is the portable version, branching to
|
|
# `lsof` on macOS. Confirmed empirically to fail silently the old way: `ss` doesn't exist on
|
|
# macOS, its "command not found" was swallowed by `2>/dev/null`, and an empty grep result
|
|
# reported the port as free even when something WAS listening.
|
|
pg_port_in_use() { [[ -n "$(listening_addrs "$PG_PORT")" ]]; }
|
|
|
|
# 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)" <<COMPOSE
|
|
# Written by officer-setup. Officer's database.
|
|
#
|
|
# The port is bound to 127.0.0.1 on purpose. Docker publishes ports by writing
|
|
# iptables rules beneath ufw, so "5432:5432" would be reachable from the internet
|
|
# whatever the firewall reports. The platform runs on this machine, so loopback
|
|
# is all it needs.
|
|
services:
|
|
postgres:
|
|
image: ${PG_IMAGE}
|
|
container_name: ${PG_CONTAINER}
|
|
restart: unless-stopped
|
|
ports:
|
|
- "127.0.0.1:${PG_PORT}:5432"
|
|
environment:
|
|
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
|
|
POSTGRES_DB: ${PG_DATABASE}
|
|
PGDATA: /var/lib/postgresql/data
|
|
volumes:
|
|
- ./data:/var/lib/postgresql/data
|
|
- ./dumps:/dumps
|
|
networks:
|
|
- ${OFFICER_NETWORK}
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
|
|
networks:
|
|
${OFFICER_NETWORK}:
|
|
external: true
|
|
COMPOSE
|
|
|
|
# The password lives beside the compose file rather than inside it, so the
|
|
# compose file can be read, copied or committed without carrying a credential.
|
|
umask 077
|
|
cat >"$(pg_env_file)" <<ENVF
|
|
# Written by officer-setup. Read by docker compose from this directory.
|
|
POSTGRES_PASSWORD=${password}
|
|
ENVF
|
|
chown "${USERNAME}:$(user_group)" "$(pg_compose_file)" "$(pg_env_file)"
|
|
chmod 600 "$(pg_env_file)"
|
|
return 0
|
|
}
|
|
|
|
pg_password_from_env_file() {
|
|
[[ -r "$(pg_env_file)" ]] || return 1
|
|
awk -F= '/^POSTGRES_PASSWORD=/ { print substr($0, index($0, "=") + 1); exit }' "$(pg_env_file)"
|
|
}
|
|
|
|
pg_compose_up() { as_owner "docker compose --project-directory '$(pg_service_dir)' up -d" /; }
|
|
|
|
# Wait for it to answer, rather than assuming `up -d` means ready. Postgres
|
|
# initialises its data directory on first start, which takes several seconds, and
|
|
# everything after this — db:push especially — fails confusingly against a
|
|
# database that is still starting.
|
|
pg_wait_ready() {
|
|
local tries="${1:-30}"
|
|
while ((tries-- > 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" /
|
|
}
|