Step 21 session D: the managed-webspace install path replaces the self-build

tools/remote reworked to role-named commands (the builder-vs-built
tangle noted 2026-09-01): instance-install/instance-update/
instance-start manage the installed Werkator + werkdock, repo-init the
watched repository. The self-build is retired — no repo clone for
building, no GitHub-key step; the instance installs from locally built
artifacts (ADR 0006), the watched repo clones anonymously via https.

instance-update refuses to swap the runtime under a running build
(FORCE=1 overrides) and keeps the previous runtime as the rollback
asset. repo-init is idempotent: checksum-skipped rootfs upload, and the
machine-config guard is fixed — its indentation mismatch (grep for two
spaces, block written with four) appended the bwrap block on every run,
the source of the nine duplicates found on mih34. The retired
install/build/start commands fail loudly naming their successors.

docs/deployment.md gains the Hostsharing Managed Webspace as the third
deployment variant, written from the live-verified setup: both new
commands ran against mih34 (full update cycle; fully skipping re-init).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-01 17:49:41 +02:00
co-authored by Claude Fable 5
parent fc59c31963
commit 2b538b668e
3 changed files with 208 additions and 109 deletions
+177 -108
View File
@@ -5,63 +5,65 @@
# the second the command. All connection and deployment values come from the
# `.env` file in the repository root — never as command line parameters.
#
# NOTE: `install` (its clone step) and `build` are a prototype of the webspace
# self-build workflow. They proved the bwrap sandbox, but as a deployment path
# they invert ADR 0006 (build locally, install the bundle) and will be replaced
# by session D of docs/plan/21-werkdock-extraction-and-webspace-install.md.
# Commands name their role (step 21 session D): `instance-*` manages the
# BUILDER — the installed Werkator instance and its werkdock sandbox tool —
# while `repo-*` acts on the BUILT, the repository the instance watches.
# Werkator is never built on the target: the instance is installed from the
# locally built runtime bundle (ADR 0006), and builds of the watched
# repository are the running instance's job (or `bin/werkator build` on the
# host — the werkator CLI, not this script).
#
# 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 check-prerequisites bwrap capability + disk/quota on the host
# tools/remote werkator instance-install first-time: upload + unpack bundle and werkdock
# tools/remote werkator instance-update redeploy bundle + werkdock, restart the service
# tools/remote werkator instance-start server config, Apache proxy, systemd unit
# tools/remote werkator repo-init clone the watched repo, init, rootfs, bwrap config
# tools/remote werkator control-token
# tools/remote port-forward start background tunnel to the Werkator UI
# tools/remote port-forward stop
#
# 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`:
# Required for `instance-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)
# WERKATOR_REPO_URL https clone URL of the watched repository
# (default: https://github.com/mhoennig/werkator.git)
# WERKATOR_MEMORY_MAX systemd MemoryMax for the unit, e.g. 1G (instance-start)
# WERKATOR_TASKS_MAX systemd TasksMax for the unit, e.g. 512 (instance-start)
# WERKATOR_ROOTFS rootfs archive path for repo-init
# (default: <repo>/build/werkator-buildenv-trixie-java-go-node.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
# $WERKATOR_PATH/werkator/ the watched repository (clone)
# $WERKATOR_PATH/.werkator/werkator/ the unpacked runtime bundle
# $WERKATOR_PATH/.werkator/bin/ the werkdock binary
# $WERKATOR_PATH/.werkator/*.tar.* uploaded bundle and rootfs archives
#
set -euo pipefail
die() { echo "ERROR: $*" >&2; exit 1; }
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"
WERKDOCK_BINARY="$REPO_ROOT/werkdock/dist/werkdock"
PID_FILE="/tmp/werkator-port-forward-$(id -u).pid"
LOG_FILE="/tmp/werkator-port-forward-$(id -u).log"
usage() {
sed -n '3,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
awk 'NR > 2 && !/^#/ { exit } NR > 2 { sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"
exit 2
}
@@ -87,7 +89,11 @@ 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}"
ROOTFS="${WERKATOR_ROOTFS:-$REPO_ROOT/build/werkator-buildenv-trixie-java-go-node.tar.zst}"
REPO_URL="${WERKATOR_REPO_URL:-https://github.com/mhoennig/werkator.git}"
MACHINE_CONFIG="$TARGET_DIR/werkator/.git/werkator/.werkator.yml"
WERKATOR_BIN="$TARGET_DIR/.werkator/werkator/bin/werkator"
UNIT="werkator-werkator.service"
ssh_present() {
ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" true 2>/dev/null
@@ -99,7 +105,7 @@ ensure_ssh() {
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; }
ssh_present || die "SSH access still not working after ssh-copy-id"
fi
}
@@ -109,118 +115,167 @@ 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
die "prerequisites failed on $HOST — install aborted"
fi
}
ensure_local_artifacts() {
# The instance artifacts are built locally (ADR 0006): the runtime bundle via
# Gradle, the werkdock binary via the Go toolchain. Both are rebuilt when
# missing, never on the target.
ensure_instance_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
[ -f "$RUNTIME_BUNDLE" ] || die "runtime bundle missing: $RUNTIME_BUNDLE"
if [ ! -f "$WERKDOCK_BINARY" ]; then
echo "==> werkdock binary not found; building it locally (go build)"
(cd "$REPO_ROOT/werkdock" && CGO_ENABLED=0 go build -o dist/werkdock .)
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"
[ -f "$WERKDOCK_BINARY" ] || die "werkdock binary missing: $WERKDOCK_BINARY"
}
install() {
# Uploads and unpacks the instance artifacts. The previous runtime stays as
# werkator.prev for one deployment as the rollback asset.
deploy_instance() {
echo "==> Uploading runtime bundle and werkdock binary"
ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator/bin'"
scp -q "$RUNTIME_BUNDLE" "$HOST:$TARGET_DIR/.werkator/"
scp -q "$WERKDOCK_BINARY" "$HOST:$TARGET_DIR/.werkator/bin/werkdock.new"
echo "==> Unpacking"
ssh "$HOST" "set -e
cd '$TARGET_DIR/.werkator'
mv bin/werkdock.new bin/werkdock && chmod 755 bin/werkdock
rm -rf werkator.prev
[ ! -d werkator ] || mv werkator werkator.prev
tar xzf '$(basename "$RUNTIME_BUNDLE")'
'./werkator/bin/werkator' --version
'./bin/werkdock' version"
}
instance_install() {
ensure_ssh
check_prerequisites
ensure_local_artifacts
ensure_instance_artifacts
deploy_instance
echo
echo "==> Instance installed."
echo " Runtime: $WERKATOR_BIN"
echo " werkdock: $TARGET_DIR/.werkator/bin/werkdock"
echo " Next: tools/remote werkator repo-init, then instance-start"
}
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/"
# Refuse to swap the runtime under a running build; FORCE=1 overrides.
require_idle() {
local port
port="$(ssh "$HOST" "awk '/^server:/{f=1;next} f && /^ port:/{print \$2; exit}' '$MACHINE_CONFIG' 2>/dev/null" || true)"
[ -n "$port" ] || return 0
local current
current="$(ssh "$HOST" "curl -s --max-time 5 http://127.0.0.1:$port/api/builds/current" || true)"
if [ -n "$current" ] && [ "$current" != "[]" ]; then
[ "${FORCE:-}" = "1" ] || die "a build is running on $HOST — retry when idle, or FORCE=1 to override"
echo "==> WARNING: deploying although a build is running (FORCE=1)"
fi
}
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"
instance_update() {
ensure_ssh
ensure_instance_artifacts
require_idle
local was_active=0
if ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user is-active --quiet '$UNIT'"; then
was_active=1
fi
if [ "$was_active" = "1" ]; then
echo "==> Stopping $UNIT"
ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user stop '$UNIT'"
fi
deploy_instance
if [ "$was_active" = "1" ]; then
echo "==> Starting $UNIT"
ssh "$HOST" "XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user start '$UNIT' && sleep 3 && systemctl --user is-active '$UNIT'"
else
echo "==> Service was not running; not started (use instance-start for the first start)"
fi
echo "==> Instance updated."
}
ensure_github_access
# Sets up the WATCHED repository: an anonymous https clone (a private origin
# gets its credentials via git.account/git.token in the machine config that
# `werkator init` creates), the werkator init, the rootfs archive for the
# sandbox builds, and the machine-local bwrap configuration.
repo_init() {
ensure_ssh
[ -f "$ROOTFS" ] || die "rootfs archive missing: $ROOTFS — build it with tools/build-bwrap-rootfs.sh or set WERKATOR_ROOTFS"
ssh "$HOST" "test -x '$WERKATOR_BIN'" || die "no instance on $HOST — run instance-install first"
echo "==> Cloning the repository"
echo "==> Cloning the watched 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'"
ssh "$HOST" "git clone '$REPO_URL' '$TARGET_DIR/werkator'"
fi
echo "==> Running werkator init"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$TARGET_DIR/.werkator/werkator/bin/werkator' init"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' 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'
echo "==> Uploading the rootfs archive (skipped when unchanged)"
local rootfs_remote="$TARGET_DIR/.werkator/$(basename "$ROOTFS")"
local local_sha remote_sha
local_sha="$(sha256sum "$ROOTFS" | cut -d' ' -f1)"
remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' 2>/dev/null | cut -d' ' -f1" || true)"
if [ "$local_sha" = "$remote_sha" ]; then
echo " (already on the host, skipping)"
else
scp -q "$ROOTFS" "$HOST:$rootfs_remote"
remote_sha="$(ssh "$HOST" "sha256sum '$rootfs_remote' | cut -d' ' -f1")"
[ "$local_sha" = "$remote_sha" ] || die "rootfs upload checksum mismatch"
fi
# 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.
echo "==> Writing the machine-local bwrap configuration"
# NOTE: the guard must match the block's real indentation — a mismatch here
# once appended the block on every run.
if ssh "$HOST" "grep -q '^ bwrap:' '$MACHINE_CONFIG' 2>/dev/null"; then
echo " (bwrap block present, skipping)"
else
ssh "$HOST" "cat >> '$MACHINE_CONFIG' <<'CFG'
# Build in the bubblewrap sandbox instead of natively (ADR 0008), executed by
# the werkdock CLI (step 21 session C). All three keys are pinned: read from
# this machine config even if a branch sets its own values.
builds:
default:
bwrap:
enabled: true
rootfs: $TARGET_DIR/.werkator/$(basename "$ROOTFS")
rootfs: $rootfs_remote
werkdock: $TARGET_DIR/.werkator/bin/werkdock
CFG"
fi
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"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' config:print 2>/dev/null | grep -A4 'bwrap:' | head -5"
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'"
echo "==> Repository ready."
echo " Repo: $TARGET_DIR/werkator"
echo " Next: fill git.account/git.token in $MACHINE_CONFIG if the origin is private,"
echo " then tools/remote werkator instance-start"
}
# 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() {
instance_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
if ssh "$HOST" "grep -q '^server:' '$MACHINE_CONFIG' 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'"
ssh "$HOST" "sed -i 's/^ port: .*/ port: $WERKATOR_PORT/; s|^ publicBaseUrl: .*| publicBaseUrl: \"https://$WERKATOR_DOMAIN/\"|' '$MACHINE_CONFIG'"
else
ssh "$HOST" "cat >> '$machine' <<'CFG'
ssh "$HOST" "cat >> '$MACHINE_CONFIG' <<'CFG'
# Web access: the managed Apache terminates TLS and proxies to the localhost
# port assigned by Hostsharing (eigener Serverdienst); TLS is the domain's
@@ -246,18 +301,20 @@ 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"
ssh "$HOST" "cd '$TARGET_DIR/werkator' && '$WERKATOR_BIN' 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/$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"
XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user daemon-reload && \
XDG_RUNTIME_DIR=/run/user/\$(id -u) systemctl --user restart '$UNIT' && \
XDG_RUNTIME_DIR=/run/user/\$(id -u) 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'"
echo " Logs: ssh $HOST -- systemctl --user status '$UNIT'"
}
# Background SSH tunnel to the Werkator server, so the browser reaches the UI
@@ -266,8 +323,8 @@ HT"
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; }
remote_port="$(ssh "$HOST" "awk '/^server:/{f=1;next} f && /^ port:/{print \$2; exit}' '$MACHINE_CONFIG'")"
[ -n "$remote_port" ] || die "no server.port in the machine config — run 'tools/remote werkator instance-start' first"
case "$COMMAND" in
start)
@@ -324,18 +381,30 @@ case "$REPO" in
ensure_ssh
check_prerequisites
;;
install)
install
instance-install)
instance_install
;;
build)
build
instance-update)
instance_update
;;
start)
start
instance-start)
instance_start
;;
repo-init)
repo_init
;;
control-token)
control_token
;;
install)
die "'install' was the self-build prototype; use instance-install + repo-init (step 21 session D)"
;;
build)
die "'build' (the self-build) is retired; the instance builds pushes itself, or run '$WERKATOR_BIN build <branch>' on the host"
;;
start)
die "'start' is now 'instance-start' — commands name their role (builder vs built)"
;;
*)
echo "ERROR: unknown command: $COMMAND" >&2
usage