Files
menu/scripts/provision-site-env.sh
T
tanshu 537a4f1e46 Match the deploy system to hops and greatbear
menu-admin was the odd one out: it built from the working directory with a date
tag and a multi-arch manifest, deployed with `-e tag=`, and kept the Docker-era
container name. The other two apps build from `git archive HEAD` and tag with the
commit, so what runs on the host is exactly what git knows about; build only for
the architecture emily actually runs; and use one slug for the container, its
network and its data directory.

The build is now their three lines: `podman build --platform linux/arm64` from
`git archive HEAD`, tagged `:<commit>` and `:latest`, pushed with `podman push`
rather than a manifest. `deploy.sh` pushes the commit first, then refuses a dirty
tracked tree or a HEAD that is not origin/main — which is what makes the commit
tag trustworthy — takes the same `--force` and `--no-push`, and ends in the same
`-u tanshu ... -e image_tag=<commit>`. Its own lint/typecheck/test gate stays,
but behind `--checks`, so a plain deploy behaves as theirs does.

The registry credential moves into vars/default.yml and the role logs in, pulls
and logs out around it, as their roles do. The image is only pulled at deploy
time, so the unit keeps podman's default `Pull=missing` and a reboot never needs
the registry. This is the same plaintext credential the other two repos carry.

One real bug goes with it: the healthcheck gate ran before the handler that
restarts a changed unit, so a run that only changed the unit checked the stale
container. `meta: flush_handlers` before the gate is the fix the sibling repos
already made.

The container, network and data directory are all menu-<site> now. The dotted
Docker-era name and the NetworkAlias it needed are gone, so `podman logs
menu-<site>` and `systemctl --user status menu-<site>` are the same two commands
across all three apps. The Dockerfile keeps its name for the same reason: podman
reads it as readily as a Containerfile, and the siblings use that name too.
2026-10-16 11:10:59 +05:30

143 lines
4.9 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Writes the one file that never goes in git: the admin password hash and the
# session secret. Run this on the server, once per Site, as the user that owns
# the rootless Podman units — tanshu:
#
# scripts/provision-site-env.sh ~/podman-data/menu-hopsngrains
#
# The hash is produced by the app image itself, so the server needs no Node and
# the password is never visible in the process list.
set -euo pipefail
usage() {
cat <<'USAGE'
usage: provision-site-env.sh <data-root> [image] [--force]
<data-root> directory the app mounts as MENU_ROOT, e.g.
~/podman-data/menu-hopsngrains
[image] app image to borrow Node from (default: the running app's)
--force overwrite an existing site.env
USAGE
}
DATA_ROOT="${1:-}"
if [[ -z "$DATA_ROOT" || "$DATA_ROOT" == "-h" || "$DATA_ROOT" == "--help" ]]; then
usage
exit 1
fi
shift || true
IMAGE=""
FORCE=0
for argument in "$@"; do
case "$argument" in
--force) FORCE=1 ;;
*) IMAGE="$argument" ;;
esac
done
ENV_FILE="$DATA_ROOT/site.env"
if [[ -f "$ENV_FILE" && "$FORCE" -ne 1 ]]; then
echo "$ENV_FILE already exists; pass --force to replace it" >&2
exit 1
fi
if ! command -v podman >/dev/null 2>&1; then
echo "podman is required" >&2
exit 1
fi
# The image is only needed to borrow node for hashing, so take the one an app
# container is already running rather than guessing a commit tag. The playbook
# names every Site's container and unit menu-<site>. All Sites share the image,
# so which one answers does not matter.
if [[ -z "$IMAGE" ]]; then
IMAGE="$(podman ps --filter 'name=^menu-' --format '{{.Image}}' | head -n 1 || true)"
fi
if [[ -z "$IMAGE" ]]; then
echo "could not tell which image to use: no running app container to borrow one from." >&2
echo "Pass it explicitly: $0 $DATA_ROOT registry.tanshu.com/menu-admin:<commit>" >&2
exit 1
fi
if ! podman image inspect "$IMAGE" >/dev/null 2>&1; then
echo "image $IMAGE is not present on this host." >&2
echo "Pass the one the app runs: $0 $DATA_ROOT registry.tanshu.com/menu-admin:<tag>" >&2
exit 1
fi
echo "hashing with the app's image: $IMAGE"
# The password is read from the terminal itself, so the script works when it is
# piped in, and refuses clearly instead of hanging when there is no terminal at
# all — which is what `ssh host bash script` gives you.
if ! read -r -s -p "Admin password: " PASSWORD < /dev/tty 2>/dev/null; then
echo "Could not read a password: this needs a terminal." >&2
echo "Run it with one: ssh -t <host> bash $0 $DATA_ROOT" >&2
exit 1
fi
echo
if ! read -r -s -p "Repeat password: " REPEAT < /dev/tty 2>/dev/null; then
echo "Could not read the password a second time." >&2
exit 1
fi
echo
if [[ -z "$PASSWORD" ]]; then
echo "the password cannot be empty" >&2
exit 1
fi
if [[ "$PASSWORD" != "$REPEAT" ]]; then
echo "the passwords do not match" >&2
exit 1
fi
HASH="$(printf '%s' "$PASSWORD" | podman run --rm -i --entrypoint node "$IMAGE" \
--input-type=module \
-e "let s='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>s+=c);process.stdin.on('end',async()=>{const {pathToFileURL}=await import('node:url');const m=await import(pathToFileURL('/app/src/auth.ts').href);process.stdout.write(await m.hashPassword(s))})")"
SECRET="$(podman run --rm --entrypoint node "$IMAGE" --input-type=module \
-e "import {randomBytes} from 'node:crypto';process.stdout.write(randomBytes(32).toString('base64url'))")"
if [[ -z "$HASH" || -z "$SECRET" ]]; then
echo "the image failed to produce the hash or the secret" >&2
exit 1
fi
# The file is written by a container running as the app's own uid, not by the
# SSH user, so its ownership is right the first time and there is no "re-run the
# playbook afterwards" step.
#
# Rootless Podman maps container uid 1000 onto a subuid by default, which would
# produce a file the host user cannot read and a tar backup would drop. keep-id
# maps it onto this script's user instead — the same mapping the app's Quadlet
# unit uses (UserNS=keep-id in roles/menu-app), which is what keeps both sides
# agreeing on who owns site.env.
if [[ ! -d "$DATA_ROOT" ]]; then
echo "$DATA_ROOT does not exist; run the menu-admin playbook first" >&2
exit 1
fi
APP_UID="${APP_UID:-1000}"
APP_GID="${APP_GID:-1000}"
USERNS="keep-id:uid=$APP_UID,gid=$APP_GID"
if ! printf 'ADMIN_PASSWORD_HASH=%s\nSESSION_SECRET=%s\n' "$HASH" "$SECRET" | podman run --rm -i \
--userns="$USERNS" -v "$DATA_ROOT:/data" --entrypoint sh "$IMAGE" \
-c 'umask 077 && cat > /data/site.env'; then
echo "could not write $ENV_FILE as the app's uid" >&2
exit 1
fi
unset PASSWORD REPEAT HASH SECRET
podman run --rm --userns="$USERNS" -v "$DATA_ROOT:/data" --entrypoint sh "$IMAGE" \
-c 'ls -l /data/site.env'
echo "wrote $ENV_FILE, owned by $(id -un)"
echo "restart the app to pick it up: systemctl --user restart menu-<site>"
echo "changing the password later means running this again with --force"