Bwrap build runtime und Installation in Hostsharing Managed Webspace (#4)

* Add the bubblewrap build runtime (step 17, ADR 0007)

BwrapBuildRunner: third runtime behind BuildRunner for hosts without root
and without Docker (e.g. Hostsharing managed webspaces). Shells out to the
bwrap CLI, unpacks a prepared rootfs on demand into
.git/werkator/buildenv/<envKey>/rootfs, reuses the Docker runner's git
metadata mounts, and returns the attached bwrap process for streaming and
cancellation.

Config: bwrap.enabled/rootfs/env on BranchConfig and BwrapOverrides on
BuildDefinition; enabled/rootfs are pinned like the docker sandbox policy.
Docker and bwrap are mutually exclusive per build, rejected in
buildSettings instead of picked silently. DispatchingBuildRunner routes
bwrap; InitCommand template, docs/configuration.md and AGENTS.md in sync.

* bwrap rollout tooling: remote script, prerequisites disk/quota check, absolute workspace binds

- tools/remote: central remote control script with check-prerequisites, install and build commands
- tools/werkator-build-prerequisites.sh: compact PASS/FAIL output, target-dir parameter, free-space and
  group-quota headroom checks against the ~5 GiB build footprint, home-filesystem reference
- BwrapBuildRunner: bind workspace and home at absolute paths resolved against repoDir — a relative
  path made bwrap create mountpoints inside the read-only rootfs (seen on the webspace); regression test
- TestcontainersSmokeTest: gated with enabledIf docker available (skip, never fail, without a daemon)
- docs: configuration reference, step-17 plan notes, PR-doc

* bwrap: bind the repo read-write before the workspace so mountpoints are creatable

bwrap creates mountpoints for bind destinations inside the sandbox; with only a
read-only rootfs bound at /, creating them for the workspace under .git/werkator/
worktrees failed with 'Read-only file system' (seen on the webspace). Binding the
repo dir read-write first provides the base; the git metadata mounts then layer
the usual isolation on top (read-only .git, tmpfs mask over .git/werkator,
read-write worktree admin dir).

* bwrap: pre-create bind mountpoints inside the unpacked rootfs

bwrap mkdirs mountpoints for bind destinations against the sandbox view; with the
rootfs ro-bound at / every destination missing from the rootfs (the repo dir under
/home/storage/... on the webspace) fails with 'Read-only file system'. The rootfs
directory is a plain host dir, so create the mountpoints there before launching
bwrap; it then finds them and has nothing left to create.

* bwrap: skip existing rootfs files when pre-creating bind mountpoints

/etc/resolv.conf is a file the rootfs already ships; createDirectories threw on it.
Only missing directories are created now.

* bwrap: pre-create proc/dev/tmpfs mountpoints in the rootfs too

The rootfs archive ships no /proc or /dev (excluded when packed), so bwrap failed
mkdir'ing their mountpoints against the read-only root.

* bwrap: bind the workspace after the git metadata mounts

The tmpfs mask over .git/werkator shadowed the earlier workspace bind, because the
worktree lives under .git/werkator/worktrees — chdir then failed with ENOENT. The
workspace bind now comes last and shadows the mask at exactly its own path.

* systemd resource limits and webspace start command (step 17, web access)

- server.systemd.memoryMax/tasksMax (empty = directive omitted): on platforms where
  the service runs in a shared memory slice (Hostsharing Managed Webspaces) a
  runaway Gradle build must not starve the whole package; init --systemd reads the
  effective config and bakes the values into the generated unit
- tools/remote werkator start: writes server settings (assigned port, loopback
  bind, publicBaseUrl, nginx off) plus the Apache reverse-proxy .htaccess into
  ~/doms/<domain>/subs/www, runs init --systemd and enables the user unit
- docs/configuration.md documents the new keys

* tools/remote: env-based configuration and background port-forward

All connection and deployment values come from .env in the repository root
(WERKATOR_REMOTE, WERKATOR_PATH, WERKATOR_PORT, WERKATOR_DOMAIN,
WERKATOR_LOCAL_PORT, optional WERKATOR_BRANCH/MEMORY_MAX/TASKS_MAX/ROOTFS);
missing values fail with a pointing error instead of positional parameters.

- port-forward is now 'tools/remote port-forward start|stop' with a detached
  ssh tunnel, pid file under /tmp, and idempotent start
- start restarts the systemd unit after updating the machine config
- control-token generates the token in place when the server has not yet
- the rootfs archive default moves to build/ (already gitignored)
This commit is contained in:
Michael Hönnig
2026-08-31 19:56:04 +02:00
committed by GitHub
parent 516765a717
commit 71f1fc62c6
21 changed files with 1564 additions and 37 deletions
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
#
# Build the Werkator bwrap build environment (rootfs) archive.
#
# The bwrap build runtime (step 17 / ADR 0007) runs each build inside a
# bubblewrap user namespace, chrooted into a *prepared* Debian root filesystem.
# That rootfs is NOT built on the target (the webspace has no root and no
# debootstrap), it is built once on any machine that can — most comfortably a
# machine with Docker — and distributed as an archive, e.g.
# `werkator-buildenv-trixie-java21.tar.zst`.
#
# This script builds exactly that archive: a debootstrap-minbase Debian release
# plus the packages Werkator itself needs to run `./gradlew build` inside the
# sandbox (JDK 21, git, ca-certificates, locales, curl/unzip for the wrapper).
# The whole build runs inside a throwaway Docker container, so no root is
# needed on the machine running this script.
#
# Why it works this way (each quirk learned the hard way):
# - The whole job runs in ONE container whose stdin carries a base64-encoded
# script (no file is bind-mounted for the script — a bind-mounted script
# hit noexec/tmpfs trouble and vanished inside the container).
# - The rootfs is built inside the container's own writable layer, NOT on a
# bind-mounted host directory — debootstrap "Tried to extract package, but
# tar failed" when its target sat on some bind-mounted/special filesystems.
# - Only the final `tar --zstd` writes to stdout; every build step is
# redirected to stderr, so the archive coming out of `docker run` is pure.
#
# Usage: build-bwrap-rootfs.sh [--release trixie] [--mirror URL] [--out path]
# --release Debian release/architecture tail, default "trixie"
# --mirror apt mirror for debootstrap, default http://deb.debian.org/debian
# --out output archive path, default ./werkator-buildenv-<release>.tar.zst
#
set -euo pipefail
die() { echo "ERROR: $*" >&2; exit 1; }
warn() { echo "WARNING: $*" >&2; }
usage() {
echo "usage: build-bwrap-rootfs.sh [--release trixie] [--mirror URL] [--out path]" >&2
exit 1
}
# --------------------------------------------------------------- arguments --
release="trixie"
out=""
while [ $# -gt 0 ]; do
case "$1" in
--release) release="${2:?missing value for --release}"; shift 2 ;;
--mirror) mirror="${2:?missing value for --mirror}"; shift 2 ;;
--out) out="${2:?missing value for --out}"; shift 2 ;;
-*) die "unknown option: $1" ;;
*) usage ;;
esac
done
mirror="${mirror:-http://deb.debian.org/debian}"
[ -n "$out" ] || out="$(pwd)/werkator-buildenv-${release}.tar.zst"
command -v docker >/dev/null 2>&1 || die "docker is required to build the rootfs"
# Rootfs content: Werkator's own build needs a JDK 21 toolchain (Gradle
# toolchain resolution), git, ca-certificates for HTTPS, locales for git, and
# curl/unzip/xz-utils/zstd for the Gradle wrapper and general build hygiene.
# Keep this list additive — project-specific tooling goes on top of this base.
PKGS="openjdk-21-jdk git ca-certificates locales procps file curl unzip xz-utils zstd"
# The chroot step runs inside the freshly debootstrapped rootfs; passed into
# the container as base64 so no nested heredoc corrupts the piped script.
inner="$(printf '%s' '#!/bin/bash
set -euxo pipefail
mount -t proc none /proc
apt-get update -qq
apt-get install -y --no-install-recommends '"${PKGS}"'
apt-get clean
rm -f /etc/localtime
locale-gen en_US.UTF-8 de_DE.UTF-8 >/dev/null 2>&1 || true
update-locale LANG=en_US.UTF-8 >/dev/null 2>&1 || true
' | base64 -w0)"
# The outer script runs inside the Debian container as root. Build noise goes
# to stderr (fd 1 is saved on fd 3 and restored only for the final tar), so
# docker stdout is exactly the archive.
outer="$(printf '%s' '#!/bin/bash
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
exec 3>&1
exec 1>&2
apt-get update -qq
apt-get install -y --no-install-recommends debootstrap zstd ca-certificates
mkdir -p /b/rootfs
debootstrap --variant=minbase --components=main,contrib --include=apt,ca-certificates '"${release}"' /b/rootfs '"${mirror}"'
mount --bind /proc /b/rootfs/proc
mount --bind /sys /b/rootfs/sys
mount --bind /dev /b/rootfs/dev
echo '"${inner}"' | base64 -d > /b/rootfs/inner.sh
chmod +x /b/rootfs/inner.sh
chroot /b/rootfs /bin/bash /inner.sh
umount /b/rootfs/proc; umount /b/rootfs/sys; umount /b/rootfs/dev
exec 1>&3
tar --zstd --exclude=proc --exclude=sys --exclude=dev -C /b/rootfs -cf - .
' | base64 -w0)"
echo "building ${release} rootfs (downloads packages, takes a while; log below)..."
echo "archive → $out"
# Stream the base64-encoded outer script into the container over stdin; the
# archive lands on stdout (redirected to $out), the build log on stderr.
docker run --rm -i --privileged debian:"${release}-slim" \
bash -c 'base64 -d | bash' \
<<<"$outer" >"$out"
echo
echo "OK: build environment written to $out"
echo " Configure it as branches.<name>.bwrap.rootfs (a bare path or a URL)"
echo " on the target Werkator instance to build in this environment."
Executable
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env bash
#
# Central control script for remote Werkator operations (same pattern as the
# `remote` scripts in the other repos): the first argument is the repo selector,
# the second the command. All connection and deployment values come from the
# `.env` file in the repository root — never as command line parameters.
#
# Usage:
# tools/remote werkator check-prerequisites
# tools/remote werkator install
# tools/remote werkator build # WERKATOR_BRANCH to override, default main
# tools/remote werkator start
# tools/remote port-forward start # background tunnel to the Werkator UI
# tools/remote port-forward stop
# tools/remote werkator control-token
#
# Required in .env:
# WERKATOR_REMOTE user@host to operate on, e.g. mih34-werkator@mih34.hostsharing.net
# WERKATOR_PATH target directory on that host, e.g. /home/storage/mih34/users/werkator
#
# Required for `start`:
# WERKATOR_PORT the localhost port assigned by Hostsharing (eigener Serverdienst)
# WERKATOR_DOMAIN the domain served by the managed Apache, e.g. ci.example.de
#
# Required for `port-forward`:
# WERKATOR_LOCAL_PORT the local port the browser uses
# Optional in .env:
# WERKATOR_BRANCH branch for `build` (default: main)
# WERKATOR_MEMORY_MAX systemd MemoryMax for the unit, e.g. 1G (start)
# WERKATOR_TASKS_MAX systemd TasksMax for the unit, e.g. 512 (start)
# WERKATOR_ROOTFS rootfs archive path
# (default: <repo>/build/werkator-buildenv-trixie.tar.zst)
#
# Install layout on the host:
# $WERKATOR_PATH/werkator/ the repository clone
# $WERKATOR_PATH/.werkator/ runtime bundle + rootfs archive
#
# `install` performs, in order:
# 1. check-prerequisites (bwrap capability + disk/quota, aborts on FAIL)
# 2. ensure SSH access (ssh-copy-id on first use; asks for the password)
# 3. upload artifacts (runtime bundle, built locally if missing, + rootfs)
# 4. clone the repository (needs the host SSH key registered at GitHub once —
# the script prints the key and waits)
# 5. `werkator init` + machine-local bwrap configuration
#
set -euo pipefail
REPO="${1:-}"
COMMAND="${2:-}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PREREQ_SCRIPT="$REPO_ROOT/tools/werkator-build-prerequisites.sh"
RUNTIME_BUNDLE="$REPO_ROOT/build/distributions/werkator-runtime-linux-x64.tar.gz"
PID_FILE="/tmp/werkator-port-forward-$(id -u).pid"
LOG_FILE="/tmp/werkator-port-forward-$(id -u).log"
usage() {
sed -n '3,32p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 2
}
require_env() {
local missing=0
for name in "$@"; do
if [ -z "${!name:-}" ]; then
echo "ERROR: $name is not set — define it in $REPO_ROOT/.env" >&2
missing=1
fi
done
[ "$missing" -eq 0 ] || exit 1
}
[ -n "$REPO" ] && [ -n "$COMMAND" ] || usage
# Load the connection and deployment values; explicit environment wins, the
# .env in the repository root fills the rest.
set -a
[ -f "$REPO_ROOT/.env" ] && source "$REPO_ROOT/.env"
set +a
require_env WERKATOR_REMOTE WERKATOR_PATH
HOST="$WERKATOR_REMOTE"
TARGET_DIR="$WERKATOR_PATH"
ROOTFS="${WERKATOR_ROOTFS:-$REPO_ROOT/build/werkator-buildenv-trixie.tar.zst}"
ssh_present() {
ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" true 2>/dev/null
}
ensure_ssh() {
if ssh_present; then
echo "==> SSH access to $HOST: ok"
else
echo "==> No key-based SSH access yet; running ssh-copy-id (password prompt expected)"
ssh-copy-id "$HOST"
ssh_present || { echo "ERROR: SSH access still not working after ssh-copy-id" >&2; exit 1; }
fi
}
# Run the prerequisites script remotely by piping it over stdin; TARGET_DIR and
# ROOTFS_ARCHIVE are passed as arguments to `bash -s --`.
check_prerequisites() {
echo "==> Checking prerequisites on $HOST (target dir: $TARGET_DIR)"
local rootfs_remote="$TARGET_DIR/.werkator/$(basename "$ROOTFS")"
if ! ssh "$HOST" "WERKATOR_SSH_TARGET='$HOST' bash -s -- '$TARGET_DIR' '$rootfs_remote'" < "$PREREQ_SCRIPT"; then
echo "ERROR: prerequisites failed on $HOST — install aborted" >&2
exit 1
fi
}
ensure_local_artifacts() {
if [ ! -f "$RUNTIME_BUNDLE" ]; then
echo "==> Runtime bundle not found; building it locally (./gradlew runtimeBundle)"
(cd "$REPO_ROOT" && ./gradlew runtimeBundle --console=plain -q)
fi
[ -f "$RUNTIME_BUNDLE" ] || { echo "ERROR: runtime bundle missing: $RUNTIME_BUNDLE" >&2; exit 1; }
[ -f "$ROOTFS" ] || {
echo "ERROR: rootfs archive missing: $ROOTFS" >&2
echo " build it with tools/build-bwrap-rootfs.sh or set WERKATOR_ROOTFS" >&2
exit 1
}
}
ensure_github_access() {
# `ssh -T git@github.com` exits 1 even on success ("does not provide shell
# access") — neutralize remotely, then match on the greeting text.
if ssh "$HOST" 'ssh -o BatchMode=yes -o ConnectTimeout=10 -T git@github.com 2>&1 || true' | grep -q "successfully authenticated"; then
echo "==> GitHub SSH access from $HOST: ok"
return 0
fi
echo
echo "==> The host cannot reach GitHub via SSH yet."
echo " Add THIS public key to GitHub (Settings > SSH and GPG keys > New SSH key):"
ssh "$HOST" 'cat ~/.ssh/id_*.pub 2>/dev/null' || {
echo "ERROR: no public key on the host; create one with ssh-keygen -t ed25519" >&2
exit 1
}
read -r -p " Press Enter once the key is registered at GitHub... "
ssh "$HOST" 'ssh -o BatchMode=yes -T git@github.com 2>&1 || true' | grep -q "successfully authenticated" || {
echo "ERROR: GitHub authentication from $HOST still failing" >&2
exit 1
}
echo "==> GitHub SSH access from $HOST: ok"
}
install() {
ensure_ssh
check_prerequisites
ensure_local_artifacts
echo "==> Uploading runtime bundle and rootfs archive"
ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator'"
scp -q "$RUNTIME_BUNDLE" "$HOST:$TARGET_DIR/.werkator/"
scp -q "$ROOTFS" "$HOST:$TARGET_DIR/.werkator/"
echo "==> Unpacking runtime bundle"
ssh "$HOST" "tar xzf '$TARGET_DIR/.werkator/$(basename "$RUNTIME_BUNDLE")' -C '$TARGET_DIR/.werkator'"
ssh "$HOST" "'$TARGET_DIR/.werkator/werkator/bin/werkator' --version"
ensure_github_access
echo "==> Cloning the repository"
if ssh "$HOST" "test -d '$TARGET_DIR/werkator/.git'"; then
echo " (already cloned, skipping)"
else
ssh "$HOST" "git clone git@github.com:mhoennig/werkator.git '$TARGET_DIR/werkator'"
fi
echo "==> Running werkator init"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' init"
echo "==> Writing machine-local bwrap configuration"
ssh "$HOST" "grep -q '^ bwrap:' '$TARGET_DIR/werkator/.git/werkator/.werkator.yml' 2>/dev/null" || ssh "$HOST" "cat >> '$TARGET_DIR/werkator/.git/werkator/.werkator.yml' <<'CFG'
# Build in the bubblewrap sandbox instead of natively (Step 17 / ADR 0007).
# Both keys are pinned: read from this machine config even if a branch sets
# its own values in a committed .werkator.yml.
builds:
default:
bwrap:
enabled: true
rootfs: $TARGET_DIR/.werkator/$(basename "$ROOTFS")
CFG"
echo "==> Verifying the effective configuration"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' config:print 2>/dev/null | grep -A3 'bwrap:' | head -4"
echo
echo "==> Install complete."
echo " Repo: $TARGET_DIR/werkator"
echo " Runtime: $TARGET_DIR/.werkator/werkator/bin/werkator"
echo " Next: tools/remote werkator build"
}
build() {
ensure_ssh
local branch="${WERKATOR_BRANCH:-main}"
echo "==> Running one initial build of branch '$branch' on $HOST (in the bwrap sandbox)"
ssh -t "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' build '$branch'"
}
# Start the server as a systemd user unit behind the managed Apache.
# WERKATOR_MEMORY_MAX / WERKATOR_TASKS_MAX (optional) are written into the
# machine config so `init --systemd` bakes them into the unit.
start() {
ensure_ssh
require_env WERKATOR_PORT WERKATOR_DOMAIN
local machine="$TARGET_DIR/werkator/.git/werkator/.werkator.yml"
local unit="werkator-$(basename "$TARGET_DIR/werkator").service"
local htaccess="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/.htaccess"
echo "==> Writing server settings to the machine config"
if ssh "$HOST" "grep -q '^server:' '$machine' 2>/dev/null"; then
# re-run: update port and publicBaseUrl in place (systemd limits stay as written)
ssh "$HOST" "sed -i 's/^ port: .*/ port: $WERKATOR_PORT/; s|^ publicBaseUrl: .*| publicBaseUrl: \"https://$WERKATOR_DOMAIN/\"|' '$machine'"
else
ssh "$HOST" "cat >> '$machine' <<'CFG'
# Web access: the managed Apache terminates TLS and proxies to the localhost
# port assigned by Hostsharing (eigener Serverdienst); TLS is the domain's
# Let's Encrypt certificate, so Werkator itself stays on 127.0.0.1.
server:
port: $WERKATOR_PORT
bindAddress: 127.0.0.1
publicBaseUrl: \"https://$WERKATOR_DOMAIN/\"
nginx:
enabled: false
systemd:
memoryMax: \"${WERKATOR_MEMORY_MAX:-}\"
tasksMax: \"${WERKATOR_TASKS_MAX:-}\"
CFG"
fi
echo "==> Writing the Apache reverse proxy to $htaccess"
ssh "$HOST" "mkdir -p '$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www' && cat > '$htaccess' <<'HT'
DirectoryIndex disabled
RewriteEngine On
RewriteBase /
RewriteRule .* http://127.0.0.1:$WERKATOR_PORT%{REQUEST_URI} [proxy]
HT"
echo "==> Generating the systemd user unit (init --systemd)"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' init --systemd"
echo "==> Linking the units into ~/.config/systemd/user and enabling the service"
ssh "$HOST" "mkdir -p ~/.config/systemd/user && \
ln -sf '$TARGET_DIR/werkator/.git/werkator/$unit' ~/.config/systemd/user/ && \
ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.service' ~/.config/systemd/user/ && \
ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.timer' ~/.config/systemd/user/ && \
systemctl --user daemon-reload && systemctl --user restart '$unit' && systemctl --user status '$unit' --no-pager -l | head -12"
echo
echo "==> Server started. Verify: https://$WERKATOR_DOMAIN/"
echo " Logs: ssh $HOST -- systemctl --user status '$unit'"
}
# Background SSH tunnel to the Werkator server, so the browser reaches the UI
# at http://localhost:<WERKATOR_LOCAL_PORT> without keeping a terminal busy.
# `start` runs ssh -N -L detached with a pid file; `stop` kills it.
port_forward() {
require_env WERKATOR_LOCAL_PORT
local remote_port
remote_port="$(ssh "$HOST" "awk '/^server:/{f=1;next} f && /^ port:/{print \$2; exit}' '$TARGET_DIR/werkator/.git/werkator/.werkator.yml'")"
[ -n "$remote_port" ] || { echo "ERROR: no server.port in the machine config — run 'tools/remote werkator start' first" >&2; exit 1; }
case "$COMMAND" in
start)
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "==> Port-forward already running (pid $(cat "$PID_FILE")) — http://localhost:$WERKATOR_LOCAL_PORT"
exit 0
fi
nohup ssh -N -L "$WERKATOR_LOCAL_PORT:127.0.0.1:$remote_port" "$HOST" \
>"$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
sleep 1
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "==> Forwarding http://localhost:$WERKATOR_LOCAL_PORT -> $HOST:127.0.0.1:$remote_port (pid $(cat "$PID_FILE"))"
else
echo "ERROR: port-forward failed to start — see $LOG_FILE" >&2
rm -f "$PID_FILE"
exit 1
fi
;;
stop)
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
kill "$(cat "$PID_FILE")"
rm -f "$PID_FILE"
echo "==> Port-forward stopped"
else
rm -f "$PID_FILE"
echo "==> Port-forward is not running"
fi
;;
*)
echo "ERROR: unknown port-forward command: $COMMAND (use start or stop)" >&2
exit 2
;;
esac
}
# Print the control token guarding the mutating build endpoints. If the server
# has not created it yet (it does so on first use), generate one in place — the
# server reads the file lazily, so a pre-created token is equivalent.
control_token() {
ensure_ssh
local token_file="$TARGET_DIR/werkator/.git/werkator/control-token"
ssh "$HOST" "if [ -f '$token_file' ]; then cat '$token_file'; else \
umask 077 && head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \\n' > '$token_file' && cat '$token_file'; fi"
}
case "$REPO" in
port-forward)
port_forward
;;
werkator)
case "$COMMAND" in
check-prerequisites)
ensure_ssh
check_prerequisites
;;
install)
install
;;
build)
build
;;
start)
start
;;
control-token)
control_token
;;
*)
echo "ERROR: unknown command: $COMMAND" >&2
usage
;;
esac
;;
*)
echo "ERROR: unknown repo selector: $REPO" >&2
usage
;;
esac
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
#
# Verify the bwrap (bubblewrap) build precondition on a target host before
# running Werkator's bwrap build runtime there (step 17 / ADR 0007).
#
# The whole "build Werkator inside bubblewrap on a Managed Webspace" approach
# hinges on one hard precondition: unprivileged user namespaces with a uid-0
# mapping and read-only root binds must work. This script runs the exact
# command line recorded in docs/plan/17-bwrap-build-runtime.md, checks the
# expected signals, and additionally verifies the disk/quota situation:
# a bwrap build unpacks the rootfs (a zstd archive expands to several GiB)
# plus a Gradle distribution and per-branch caches, so the host needs both
# raw free space and enough group-quota headroom.
#
# Run this ON the target host (the webspace), no root needed.
#
# Optional: the rootfs archive to size the disk/quota check against, e.g.
# werkator-build-prerequisites.sh /path/to/werkator-buildenv-trixie.tar.zst
# When omitted, the check runs against a conservative default footprint.
#
# Usage: werkator-build-prerequisites.sh [TARGET_DIR] [ROOTFS_ARCHIVE]
#
# TARGET_DIR is the directory the build workspace will live in (default: $HOME).
# The check verifies it sits on the home filesystem and has enough free space.
# ROOTFS_ARCHIVE, when given, is the rootfs archive that will be used there.
#
# Output is one PASS/FAIL line per check plus a final RESULT line, e.g.:
# PASS: bwrap version: bubblewrap 0.8.0
# PASS: build runs as root inside the namespace (uid 0)
# PASS: uid_map maps root back to the unprivileged user (uid 120957)
# PASS: read-only root bind is enforced
# PASS: at least 5 GiB free space on the build working filesystem
# FAIL: group quota headroom below the 5 GiB build footprint ...
# RESULT: FAIL (4/5) — Werkator bubblewrap builds are not usable on this host.
#
set -euo pipefail
target_dir_arg="${1:-}"
rootfs_arg="${2:-}"
die() { echo "ERROR: $*" >&2; exit 1; }
# Disk footprint a bwrap build needs headroom for, in 1K blocks: unpacked
# rootfs (zstd expands roughly 3-4x), Gradle distribution + per-branch cache,
# build output and artifacts. ~5 GiB.
MIN_FREE_BLOCKS=$((5 * 1024 * 1024))
# Reference filesystem: the one the invoking user's home directory lives on.
# Builds (repo clone, buildenv, caches) must run there — other mounts, such as
# a slow mass-storage volume, are rejected.
HOME_FS="$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {print $1}')"
pass=0
fail=0
result() { # result PASS|FAIL "message"
echo "$1: $2"
if [ "$1" = "PASS" ]; then pass=$((pass+1)); else fail=$((fail+1)); fi
}
command -v bwrap >/dev/null 2>&1 || die "bwrap is not installed on this host"
output="$(bwrap --unshare-user --unshare-pid --die-with-parent --uid 0 --gid 0 \
--ro-bind / / --dev /dev --proc /proc --tmpfs /tmp \
sh -c 'id -u && cat /proc/self/uid_map && (touch /usr/ro-test 2>&1 || true)' 2>&1)" ||
die "bwrap invocation failed (no user namespace support?): $output"
# Signal 0: bwrap itself is usable (version as a visible marker).
result PASS "bwrap version: $(bwrap --version 2>&1)"
# Signal 1: runs as root (uid 0) inside the namespace.
first="$(printf '%s\n' "$output" | sed -n '1p')"
if [ "$first" = "0" ]; then
result PASS "build runs as root inside the namespace (uid 0)"
else
result FAIL "expected uid 0 inside the namespace, got: $first"
fi
# Signal 2: uid_map maps root to the invoking unprivileged user.
uid_line="$(printf '%s\n' "$output" | sed -n '2p')"
self_uid="$(id -u)"
if printf '%s\n' "$uid_line" | grep -E "^[[:space:]]*0[[:space:]]+${self_uid}[[:space:]]+1" >/dev/null; then
result PASS "uid_map maps root back to the unprivileged user (uid $self_uid)"
else
result FAIL "expected uid_map '0 $self_uid 1', got: $uid_line"
fi
# Signal 3: the read-only root bind is enforced (a write to /usr fails).
if printf '%s\n' "$output" | grep -qi "read-only file system"; then
result PASS "read-only root bind is enforced"
else
result FAIL "the read-only root bind did not reject a write to /usr"
fi
# --- Disk / quota checks ------------------------------------------------
target_dir="${target_dir_arg:-$HOME}"
target_dir="$(realpath -m "$target_dir")"
min_gib=$((MIN_FREE_BLOCKS / 1024 / 1024))
if [ -n "$rootfs_arg" ] && [ ! -f "$rootfs_arg" ]; then
echo "WARNING: rootfs archive not found: $rootfs_arg (continuing without it)"
fi
target_fs="$(df -Pk "$target_dir" 2>/dev/null | awk 'NR==2 {print $1}')"
df_output="$(df -Pk "$target_dir" 2>/dev/null | awk 'NR==2 {print int($4) " " $6}')"
if [ -n "$df_output" ]; then
avail_k="${df_output%% *}"
mount="${df_output##* }"
if [ -n "$HOME_FS" ] && [ "$target_fs" != "$HOME_FS" ]; then
# An explicitly chosen foreign filesystem is allowed (e.g. for testing)
# but flagged: builds there will be slow.
echo "WARNING: target dir is on $target_fs (mounted at $mount), not the home filesystem ($HOME_FS) — builds will run on slower storage"
fi
if [ "${avail_k:-0}" -lt "$MIN_FREE_BLOCKS" ]; then
result FAIL "less than ${min_gib} GiB free space on the build working filesystem ($mount)"
else
result PASS "at least ${min_gib} GiB free space on the build working filesystem ($mount, device $target_fs)"
fi
else
echo "WARNING: could not measure free space on $target_dir — only the quota check below applies"
fi
if quota_output="$(quota -g 2>/dev/null)" && [ -n "$quota_output" ]; then
quota_ok=1
quota_seen=0
detail=""
while read -r fs blocks quota_limit; do
quota_seen=1
# Only the quota of the target filesystem counts — other volumes may
# legitimately be full or unquota'd without affecting the build.
if [ -n "$target_fs" ] && [ "$(basename "$fs")" != "$(basename "$target_fs")" ] && [ "$fs" != "$target_fs" ]; then
continue
fi
headroom=$((quota_limit - blocks))
if [ "$headroom" -lt "$MIN_FREE_BLOCKS" ]; then
quota_ok=0
detail+=" $(basename "$fs"): $(awk -v b="$headroom" 'BEGIN{printf "%.1f", b/1024/1024}') GiB free of quota;"
fi
done < <(printf '%s\n' "$quota_output" | awk '
NF==1 && $1 ~ /^\// { pending_fs=$1; next }
$1 ~ /^\// && $2 ~ /^[0-9]+$/ { print $1, $2, $4; pending_fs=""; next }
$1 ~ /^[0-9]+[*]?/ && pending_fs != "" { gsub(/\*/, "", $1); print pending_fs, $1, $3; pending_fs="" }')
if [ "$quota_seen" -eq 0 ]; then
echo "WARNING: quota tooling present but no group quota lines could be parsed — only free space was checked"
elif [ "$quota_ok" -eq 1 ]; then
result PASS "group quota headroom covers the ${min_gib} GiB build footprint"
else
result FAIL "group quota headroom below the ${min_gib} GiB build footprint (rootfs + Gradle cache); raise the quota before building.$detail"
fi
else
echo "WARNING: no readable group quota tooling on this host — only free space was checked"
fi
total=$((pass + fail))
echo
if [ "$fail" -eq 0 ]; then
echo "RESULT: PASS ($pass/$total) — Werkator bubblewrap builds are usable on this host."
echo "Next: install the Werkator instance with: tools/remote werkator install ${WERKATOR_SSH_TARGET:-<user>@<host>} '$target_dir'"
exit 0
else
echo "RESULT: FAIL ($pass/$total) — Werkator bubblewrap builds are not usable on this host."
exit 1
fi