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:
Executable
+164
@@ -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
|
||||
Reference in New Issue
Block a user