* 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)
186 lines
15 KiB
Markdown
186 lines
15 KiB
Markdown
# Step 17: Running Werkator on a Managed Webspace (bubblewrap builds + web access)
|
|
|
|
Prerequisites: steps 11, 15, 16.
|
|
Read `README.md` first.
|
|
Motivated by running Werkator on Hostsharing **Managed Webspaces**: no root, no Docker daemon, but `bwrap` (bubblewrap) is available and unprivileged user namespaces are allowed.
|
|
Target use case: Werkator builds Werkator itself on a Managed Webspace; builds needing special dependencies get them from a prepared root filesystem instead of the host.
|
|
Projects that need Docker for their own tests (hs.hsadmin.ng with Testcontainers) stay on a container host like vm4006 — the webspace is for Docker-free builds only.
|
|
|
|
The step covers two halves of the same deployment and is deliberately not split:
|
|
the build sandbox (most of this document) and the web access under a domain (last section).
|
|
Without the second half the first one only proves that sandboxed builds work somewhere; without the first one Werkator on a webspace would run builds unsandboxed on the host.
|
|
|
|
## Precondition Check (run on the target webspace first)
|
|
|
|
The whole step hinges on one hard precondition: unprivileged user namespaces with uid-0 mapping and read-only root binds must work.
|
|
Verify it with a single command line on the target webspace **before** starting implementation:
|
|
|
|
```bash
|
|
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)'
|
|
```
|
|
|
|
Expected output:
|
|
|
|
- `0` — the build runs as root inside the namespace.
|
|
- a uid_map like `0 <webspace-uid> 1` — root maps back to the unprivileged webspace user.
|
|
- `touch: cannot touch '/usr/ro-test': Read-only file system` — the read-only root bind is enforced.
|
|
|
|
If this fails (`bwrap` missing, "setting up uid map: Permission denied", or no user namespace support), the approach is dead on that host — record the result in this file either way.
|
|
|
|
**Result: passed** on 2026-08-11, Hostsharing Managed Webspace `h68`, user `mih00`:
|
|
|
|
```
|
|
0
|
|
0 102147 1
|
|
touch: cannot touch '/usr/ro-test': Read-only file system
|
|
```
|
|
|
|
All three signals as expected — root inside the namespace, mapped back to the unprivileged webspace uid, and the read-only root bind enforced.
|
|
So unprivileged user namespaces are available on Hostsharing Managed Webspaces and the step can proceed.
|
|
The host runs `bubblewrap 0.8.0` on kernel `6.1.0-52-amd64`.
|
|
Every option of the invocation below exists in 0.8.0 (`--die-with-parent` since 0.4.0, the rest is older), so the design stands as written.
|
|
What 0.8.0 lacks is overlayfs (`--overlay`, added in 0.9.0): a future "throwaway writable rootfs per build" cannot be built from an overlay here, only from tmpfs mounts over the writable spots.
|
|
|
|
**The runtime bundle runs there — checked, not assumed.** The webspace has glibc 2.36 (Debian 12), below the dev machine's 2.39, which by ADR 0006's original wording would have ruled the bundle out.
|
|
That wording was wrong and has been corrected: the bundle's highest required symbol version is `GLIBC_2.15`, because `jlink` copies Temurin's prebuilt binaries rather than compiling anything.
|
|
So no container build and no second build machine are needed for this platform.
|
|
The bundle's `java.desktop` module does carry X11, ALSA and freetype dependencies, but only in the AWT libraries, which a headless Werkator never loads — as on vm4006.
|
|
|
|
## Goal
|
|
|
|
A third build runtime behind the `BuildRunner` interface: `BwrapBuildRunner`, selected per branch via config, sandboxing the build in an unprivileged user namespace with a prepared Debian root filesystem.
|
|
No root on the host, no Docker daemon, no changes to the native and Docker runtimes.
|
|
|
|
## Design
|
|
|
|
### Prepared root filesystem
|
|
|
|
`debootstrap`/`mmdebstrap` are not available on the webspace, so the rootfs is **not created on the target system**.
|
|
It is built once elsewhere (any machine with Docker or root, e.g. a container VM) and distributed as an archive, e.g. `werkator-buildenv-trixie-java21.tar.zst`, containing Debian plus all build dependencies (JDK 21, git, locales, project-specific tools).
|
|
Werkator unpacks it on demand (`tar --no-same-owner`) into `.git/werkator/buildenv/<envKey>/rootfs` — **not** into the working tree.
|
|
Like the Docker image and the Gradle cache volume, the environment is shared across all branch worktrees and survives worktree pruning; `<envKey>` derives from a hash of the configured archive source, so an environment-version change unpacks a fresh rootfs and stale ones can be pruned.
|
|
|
|
### Configuration
|
|
|
|
New `branches.<name>.bwrap` section: `enabled`, `rootfs` (path or URL of the archive), `env` (like `docker.env`).
|
|
`bwrap.enabled` and `bwrap.rootfs` join the **pinned sandbox-policy set** (like `docker.enabled`/`docker.network`): a branch must not be able to switch off its sandbox or substitute a foreign rootfs via its committed config.
|
|
`docker.enabled` and `bwrap.enabled` are mutually exclusive per branch — reject the config, do not pick silently.
|
|
Keep the three config places in sync: `WerkatorConfig`, the `InitCommand` templates, `docs/configuration.md`.
|
|
|
|
### Invocation
|
|
|
|
`BwrapBuildRunner` shells out to the `bwrap` CLI (no library, like git and docker):
|
|
|
|
```
|
|
bwrap --unshare-user --unshare-pid --die-with-parent --uid 0 --gid 0 \
|
|
--ro-bind <buildenv>/rootfs / \
|
|
--bind <workspace> <workspace> \
|
|
--bind <buildenv>/home /root \
|
|
--ro-bind /etc/resolv.conf /etc/resolv.conf \
|
|
--proc /proc --dev /dev --tmpfs /tmp \
|
|
--chdir <workspace> \
|
|
/bin/sh -c '<buildCommand>'
|
|
```
|
|
|
|
- The workspace is bound at its **host path**, not at `/workspace`: the worktree's `.git` pointer file contains absolute host paths, and the step-16 git metadata mounts (`--ro-bind` of the primary `.git`, `--tmpfs` over `.git/werkator`, `--bind` of `.git/worktrees/<key>`) port 1:1 — reuse that logic, do not duplicate it.
|
|
- `<buildenv>/home` bound as `/root` gives Gradle a persistent `$HOME` (wrapper dists, `.gradle` caches) — the bwrap sibling of the Docker runner's Gradle cache volume.
|
|
- `--die-with-parent` plus `--unshare-pid`: cancellation kills the returned `bwrap` process tree and nothing survives — same semantics as the other runtimes.
|
|
- No ownership repair is needed: files created as uid 0 inside the namespace are owned by the webspace user on the host.
|
|
|
|
### Known limitations (document, do not solve here)
|
|
|
|
- Network stays shared with the host (Gradle needs it); isolation is weaker than Docker's per-container network.
|
|
- No Docker inside the sandbox, so no Testcontainers-based tests; build commands must select a Docker-free test subset.
|
|
For Werkator's own build this means `TestcontainersSmokeTest` must become conditional (`enabledIf` docker present) — that change is part of this step.
|
|
|
|
**Done (branch `bwrap-build-runtime`):** `TestcontainersSmokeTest` is now gated with `enabledIf docker available` — it is reported as skipped (never failed) when no Docker daemon is reachable, and runs as before when one is.
|
|
The two manual steps of the workflow have scripts in `tools/`:
|
|
`tools/build-bwrap-rootfs.sh` builds the rootfs archive (debootstrap-minbase Debian + JDK 21 + git + locales) on any Docker machine — the rootfs is *not* built on the target; `tools/werkator-build-prerequisites.sh` re-runs the precondition command line above on the target webspace and checks all three signals.
|
|
It also checks the disk/quota situation: free space via `df` plus group-quota headroom (`quota -g` limit minus usage) against the ~4 GiB build footprint (unpacked rootfs + Gradle cache + artifacts) — an undersized quota fails the check, since a build would otherwise be blocked mid-flight (experienced on h68: 1 GiB group quota).
|
|
|
|
**Planned: a central `tools/remote` control script** (same pattern as the user's other repos; first argument is the repo, here `werkator`, then a command):
|
|
|
|
- `tools/remote werkator check-prerequisites <user>@<host>` — runs `werkator-build-prerequisites.sh` on the target host (uploaded if missing).
|
|
- `tools/remote werkator install <user>@<host>` — full setup, idempotent: ensure SSH access (`ssh-copy-id`, first login asks for the password), run the prerequisites check, upload runtime bundle + rootfs archive to `~/.werkator/`, unpack, clone the repo, run `werkator init`, write the machine-local bwrap config.
|
|
The one manual dependency remains: the host's public SSH key must be added to GitHub once; the script prints the key and waits.
|
|
|
|
## Web Access under a Domain (no Docker, no managed nginx)
|
|
|
|
The managed nginx/TLS container from ADR 0005 is for container hosts without a reverse proxy.
|
|
A Managed Webspace does not need it: the platform provides Apache plus Let's Encrypt, and documents the reverse proxy to a self-hosted service.
|
|
Three platform-side prerequisites, none of them code:
|
|
|
|
1. **Book the "eigener Serverdienst" option** — a service user plus one reserved localhost port, requested from `service@hostsharing.net` stating the service user and the number of ports.
|
|
Surcharged on Managed Webspaces (RAM contingent in 128 MB steps), included on Managed Servers.
|
|
The port number is **assigned by Hostsharing** (wiki examples use 34567, 38005/38006), so it goes into `server.port` — Werkator's 18080 is not available by choice.
|
|
Sources: [Individuelle Serverdienste](https://www.hostsharing.net/features/individuelle-serverdienste/), [Apache](https://www.hostsharing.net/features/apache/).
|
|
2. **Run the service as a systemd user unit** — mandatory on Managed Webspaces (no `nohup`, no supervisord); lingering needs a valid login shell configured in HSAdmin, and the account's RAM is capped by a slice (`systemctl status pacs-<account>.slice`).
|
|
`werkator init --systemd` already generates the unit and the `werkator.env`, whose `JAVA_OPTS=-Xmx…` is what keeps the JVM inside the slice.
|
|
Source: [Prozessmanagement mit systemd im Userspace](https://wiki.hostsharing.net/index.php/Prozessmanagement_mit_systemd_im_Userspace).
|
|
3. **Let's Encrypt** is a domain option ticked in HSAdmin (free, automatic, includes the wildcard subdomain; requires the domain's nameservers to be delegated to Hostsharing), so TLS terminates in the managed Apache.
|
|
Source: [TLS](https://www.hostsharing.net/doc/managed-operations-platform/tls/).
|
|
|
|
### User model: a dedicated unix user, not the package admin
|
|
|
|
Werkator runs as its own unix user, e.g. `xyz00-werkator`, with the domain assigned to that same user (`domain.add({set:{name:'…',user:'xyz00-werkator'}})`), so the service, its repository checkout and `~/doms/<domain>/htdocs-ssl/` share one home directory.
|
|
That is what every Hostsharing service guide does (`xyz00-chat` for Mattermost, `xyz00-tomcat`, `xyz00-cloud` for Nextcloud) and what their user documentation recommends: a domain *can* run under the package admin, but "aus Sicherheitsgründen empfiehlt es sich aber Domains auf separate Domain-Admins aufzuschalten", so a compromise stays inside one home instead of reaching the whole package.
|
|
Here the argument is stronger than usual, because Werkator checks out foreign commits and executes their build scripts — running that as the package admin would undo the sandbox rationale of this very step.
|
|
The service user is named when ordering the daemon port anyway.
|
|
Sources: [Benutzer](https://www.hostsharing.net/doc/managed-operations-platform/benutzer/), [HSAdmin domain](https://www.hostsharing.net/doc/managed-operations-platform/hsadmin/domain/).
|
|
|
|
Consequences:
|
|
|
|
- The package admin is needed **once**: create the user, set its login shell to `/bin/bash` (this is what enables systemd lingering — `loginctl enable-linger` is not called by hand), assign the domain, order port and RAM. Day-to-day operation needs no admin rights.
|
|
- The RAM contingent is a **package** slice (`pacs-<account>.slice`), not a per-user quota, so a dedicated user gets no extra memory: a runaway Gradle build can starve everything else in the webspace.
|
|
The unit generated by `init --systemd` therefore needs `MemoryMax` and `TasksMax` on this platform — `SystemdServiceFiles.unitFileContent` currently sets neither, so add them (configurable, empty = unset) as part of this step.
|
|
- Unverified, check on the target system: whether the port reservation is technically bound to that uid or merely organisational, and whether a non-admin user may read `systemctl status pacs-<account>.slice`.
|
|
|
|
The proxy itself is one `.htaccess` in `~/doms/<domain>/htdocs-ssl/`, following Hostsharing's own Mattermost and Tomcat guides:
|
|
|
|
```apache
|
|
DirectoryIndex disabled
|
|
RewriteEngine On
|
|
RewriteBase /
|
|
RewriteRule .* http://127.0.0.1:<assigned-port>%{REQUEST_URI} [proxy]
|
|
```
|
|
|
|
Sources: [Mattermost Installieren](https://wiki.hostsharing.net/index.php/Mattermost_Installieren), [Tomcat Installieren](https://wiki.hostsharing.net/index.php?title=Tomcat_Installieren).
|
|
|
|
The matching Werkator configuration:
|
|
|
|
```yaml
|
|
server:
|
|
port: <assigned-port>
|
|
bindAddress: 127.0.0.1 # the default since v0.9.9 — exactly right here
|
|
publicBaseUrl: "https://ci.example.de/" # used for every link posted to Gitea
|
|
nginx:
|
|
enabled: false # the managed nginx container is not used on a webspace
|
|
```
|
|
|
|
**This half needs no code change.** Werkator never reconstructs absolute URLs from the request — everything external comes from `server.publicBaseUrl` and the UI links relatively — so the usual reverse-proxy fix `server.forward-headers-strategy` is not needed.
|
|
|
|
Two claims could **not** be verified from a Hostsharing primary source; check them on the target webspace rather than relying on them:
|
|
|
|
- the effective `AllowOverride` value — that `RewriteRule [P]`, `DirectoryIndex` and `RequestHeader` work in user `.htaccess` is evidenced by the wiki guides, but the literal token is undocumented;
|
|
- whether an unassigned high port would bind at all — the documented contract is to use the assigned ones.
|
|
|
|
## ADR
|
|
|
|
Write ADR 0007: bubblewrap user-namespace sandbox as the third build runtime (options considered: bwrap (chosen), proot/fakechroot (slow, fragile), plain native with hand-installed toolchains (no isolation, host pollution)).
|
|
|
|
## Tests
|
|
|
|
- `BwrapBuildRunnerTest`: exact argv assertions (mount set, uid mapping, chdir, env), rootfs unpack-on-demand and env-key change, mocked process runner — mirror `DockerBuildRunnerTest`.
|
|
- `DispatchingBuildRunnerTest`: routing for `bwrap.enabled`, config rejection when both runtimes are enabled.
|
|
- `ConfigLoader` tests: the pinned set strips `bwrap.enabled`/`bwrap.rootfs` from the worktree layer.
|
|
|
|
## Acceptance Criteria
|
|
|
|
- The precondition command line above passes on the target webspace; its output is recorded in this file.
|
|
- `./gradlew ktlintFormat` then `./gradlew build` is green — also on a machine without Docker (Testcontainers smoke test skipped, not failed).
|
|
- On a Managed Webspace: Werkator (from the runtime bundle) builds a real branch of a repo inside the bwrap sandbox; git commands work in the worktree; `.git/werkator/` is not readable from the build; a write to `/usr` fails.
|
|
- On the same webspace: the UI answers over HTTPS under the domain through the Apache `.htaccess` proxy, the service survives a logout and a reboot (systemd lingering), and Gitea statuses carry `publicBaseUrl` links that resolve.
|
|
- Docs updated: `docs/configuration.md` (bwrap section), architecture skill (third runtime), ADR 0007, and `docs/deployment.md` gains "Hostsharing Managed Webspace" as a third deployment variant — written only once the setup above is verified on a real webspace, not from this plan.
|