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