Run the Sites as rootless Podman Quadlets, and build with podman

The fleet on emily is rootless Podman with Quadlet units under
~/.config/containers/systemd and state in ~/podman-data, all behind one shared
Caddy unit. This brings the menus in line instead of leaving them the last
Docker deployment. The build moves with them: podman reads a Containerfile as
readily as a Dockerfile, so the name now says which toolchain is real.

The app is one .container and one .network file. Two details carry it.
UserNS=keep-id maps the image's own node account onto the user that owns the
units, so data/ and site.env stay owned by tanshu: plain tar backups, no
subuids. provision-site-env.sh writes site.env with the same mapping rather
than --user, which under rootless Podman would have produced a subuid-owned
file the app could not read. And the Site registers its own network with Caddy
by writing a drop-in beside caddy.container, so Caddy never lists its upstreams
and never changes when a Site is added. The unit keeps the Docker-era container
name for the scripts and muscle memory that look it up, while the Caddyfile
proxies to a dotless NetworkAlias, because a container name with dots in it is
a liability in DNS.

The unit keeps podman's Pull=missing, so a restart or a reboot never depends on
the registry being reachable, while the play pulls explicitly at deploy time.
That is also where a missing `podman login` for the private registry now fails,
with the instruction, rather than as a unit that will not start.

copy-site-data.sh moves a Site's data across, and is not a plain rsync:
/var/lib/menu.* on monoco belongs to uid 1000 while the ssh user there is 1001,
so the read needs sudo; monoco cannot ssh to emily, so the workstation relays
the tar; and the extraction runs as tanshu on emily, which is what makes the
files land owned by the user keep-id maps the app onto. It proves the copy by
comparing a SHA-256 of the whole tree against the source and checking every
file's owner.

Not yet done: the copies themselves and the DNS move, which need a human.
This commit is contained in:
tanshu committed 2026-10-16 09:54:52 +05:30
1 parent c412181159
commit e68f8ab00d
23 files changed
+876 -389

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
#
# Copies one Site's data from the Docker deployment on monoco to the rootless
# Podman deployment on emily.
#
# scripts/copy-site-data.sh hopsngrains
# scripts/copy-site-data.sh hopsngrains --live # copy while the app keeps serving
#
# Run it from a machine that can ssh to both hosts (the ssh aliases monoco and
# emily, overridable with MONOCO= and EMILY=). This is the one-off part of the
# move: the playbook deploys the container, this hands it the content.
#
# By default it stops the old container first, so the copy is of quiesced data,
# and starts it again afterwards — monoco keeps serving until DNS moves. Use
# --live to skip the stop; the app writes its manifests atomically and its images
# are immutable once written, so a live copy is usually clean, but an upload
# landing mid-copy is missed. Nothing on monoco is removed either way.
#
# Two details are load-bearing:
#
# * monoco cannot ssh to emily, so this machine is the relay — the tar streams
# through it and nothing large is written locally.
# * sudo on monoco, because /var/lib/menu.<domain> belongs to uid 1000 (the
# ubuntu account) while the ssh user there is 1001. The extraction runs as
# tanshu on emily, which is the uid keep-id maps the app onto, so the files
# land owned by the right user rather than by root or a subuid.
set -euo pipefail
usage() {
cat <<'USAGE'
usage: copy-site-data.sh <site> [--live]
<site> inventory name from ansible/hosts: hopsngrains, greatbear, hngmohali
--live copy without stopping the old container (not quiesced)
Environment overrides:
MONOCO=host ssh target holding the Docker deployment (default: monoco)
EMILY=host ssh target holding the rootless Podman deployment (default: emily)
SRC_ROOT=dir absolute data root on monoco (default: /var/lib)
DEST_ROOT=dir absolute data root on emily (default: <emily home>/podman-data,
resolved over ssh — ~ and $HOME are not expanded for you)
USAGE
}
SITE="${1:-}"
if [[ -z "$SITE" || "$SITE" == "-h" || "$SITE" == "--help" ]]; then
usage
[[ -n "$SITE" ]] || exit 1
exit 0
fi
shift
LIVE=0
for argument in "$@"; do
case "$argument" in
--live) LIVE=1 ;;
*) echo "unknown argument: $argument" >&2; usage >&2; exit 1 ;;
esac
done
cd "$(dirname "${BASH_SOURCE[0]}")/.."
# The repo is the source of truth for which Sites exist and what they answer.
VARS="ansible/vars/$SITE.yml"
if ! grep -qE "^$SITE[[:space:]]" ansible/hosts || [[ ! -f "$VARS" ]]; then
echo "'$SITE' is not a Site in ansible/hosts" >&2
exit 1
fi
TITLE="$(sed -n 's/^title:[[:space:]]*//p' "$VARS" | head -n 1)"
HOST="menu.$TITLE"
CONTAINER="menu-app.$HOST"
MONOCO="${MONOCO:-monoco}"
EMILY="${EMILY:-emily}"
SRC_ROOT="${SRC_ROOT:-/var/lib}"
for tool in ssh tar; do
command -v "$tool" >/dev/null 2>&1 || { echo "$tool is required" >&2; exit 1; }
done
# Resolved rather than left as a literal ~ or $HOME, so every path below is
# absolute and can be quoted safely on the remote side.
if [[ -z "${DEST_ROOT:-}" ]]; then
DEST_ROOT="$(ssh -n "$EMILY" 'printf %s "$HOME"')/podman-data"
fi
SRC="$SRC_ROOT/$HOST"
DEST="$DEST_ROOT/menu-$SITE"
echo "==> $SITE: $MONOCO:$SRC -> $EMILY:$DEST"
# Start the old container again however this exits, so a failed copy never
# leaves the live Site down.
restart_needed=0
restart_old() {
if [[ "$restart_needed" -eq 1 ]]; then
echo "==> starting $CONTAINER again on $MONOCO"
ssh -n "$MONOCO" docker start "$CONTAINER" >/dev/null || true
restart_needed=0
fi
}
trap restart_old EXIT
# -n on every monoco call: ssh would otherwise eat this script's stdin, and the
# copy below is a pipeline whose input matters.
if [[ "$LIVE" -eq 0 ]]; then
echo "==> stopping $CONTAINER on $MONOCO"
ssh -n "$MONOCO" docker stop "$CONTAINER" >/dev/null
restart_needed=1
fi
echo "==> copying"
ssh -n "$MONOCO" "sudo -n tar -C '$SRC' -cf - ." \
| ssh "$EMILY" "mkdir -p '$DEST' && tar -C '$DEST' -xf -"
restart_old
echo "==> verifying"
src_sum="$(ssh -n "$MONOCO" "sudo -n sh -c 'cd $SRC && find . -type f -exec sha256sum {} +' | sort | sha256sum" | awk '{print $1}')"
dst_sum="$(ssh -n "$EMILY" "cd '$DEST' && find . -type f -exec sha256sum {} + | sort | sha256sum" | awk '{print $1}')"
if [[ -z "$src_sum" || "$src_sum" != "$dst_sum" ]]; then
echo "the copy does not match the source: $src_sum != $dst_sum" >&2
exit 1
fi
user="$(id -un)"
strays="$(ssh -n "$EMILY" "find '$DEST' ! -user '$user'" | head -n 5)"
if [[ -n "$strays" ]]; then
echo "the copy holds files $user does not own, so keep-id will not see them:" >&2
echo "$strays" >&2
exit 1
fi
echo
echo "ok ${dst_sum:0:12} $(ssh -n "$EMILY" "du -sb '$DEST'" | awk '{print $1}') bytes, all owned by $user"
echo
echo "next:"
echo " cd ansible && ansible-playbook --inventory=hosts playbook.yml --limit $SITE -e tag=<tag>"
echo " then check the Site on a Host: override before moving DNS to $EMILY"