Merge pull request #7 from mhoennig/werkator-consumes-werkdock
BwrapBuildRunner delegates to werkdock; webspace install path replaces the self-build prototype
This commit is contained in:
@@ -39,9 +39,9 @@ All production code lives under `de.hoennig.werkator`, with sub-packages `comman
|
|||||||
- Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build.
|
- Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build.
|
||||||
- When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`.
|
- When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`.
|
||||||
- Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config.
|
- Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config.
|
||||||
- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
|
- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
|
||||||
- A build definition carries the complete description of its build, split in two: the `trigger` block (`onPush`, `atTimes`, `branches`, `activeWithin`) says when and for which branches it runs, everything else what it does. `builds.default` is the base every other definition inherits its settings — never its `trigger` — from. The split is structural so that a selector added to `TriggerConfig` later is non-inheritable by construction; writing a trigger key flat is refused, never ignored, because ignoring it leaves a build that silently stops running. A `!` prefix in `trigger.branches` excludes and always wins.
|
- A build definition carries the complete description of its build, split in two: the `trigger` block (`onPush`, `atTimes`, `branches`, `activeWithin`) says when and for which branches it runs, everything else what it does. `builds.default` is the base every other definition inherits its settings — never its `trigger` — from. The split is structural so that a selector added to `TriggerConfig` later is non-inheritable by construction; writing a trigger key flat is refused, never ignored, because ignoring it leaves a build that silently stops running. A `!` prefix in `trigger.branches` excludes and always wins.
|
||||||
- The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, and `bwrap.rootfs`. Docker and bwrap are mutually exclusive per branch — enabling both is rejected at start.
|
- The inheritance is applied after all layers are merged: that order is what makes a build a branch invents inherit the host's sandbox policy instead of the data-class default, so the pinning also holds for a build the host has never heard of. Pinned are `requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, `bwrap.rootfs`, and `bwrap.werkdock`. Docker and bwrap are mutually exclusive per branch — enabling both is rejected at start.
|
||||||
- `builds` or the legacy `branches`, never both: `branches` is read only while the merged config defines no build at all (`builds.maxConcurrent` is not one), and ignored with a warning as soon as one exists. The section is deprecated and goes away once the repositories have migrated; then `ConfigVersions.FORMAT_BROKE_IN` gets set and a leftover `branches:` key must be rejected by name — the version check alone cannot catch a file that declares no version.
|
- `builds` or the legacy `branches`, never both: `branches` is read only while the merged config defines no build at all (`builds.maxConcurrent` is not one), and ignored with a warning as soon as one exists. The section is deprecated and goes away once the repositories have migrated; then `ConfigVersions.FORMAT_BROKE_IN` gets set and a leftover `branches:` key must be rejected by name — the version check alone cannot catch a file that declares no version.
|
||||||
- Web UI: server-rendered Thymeleaf plus one hand-written `static/werkator.js` — no SPA framework, no frontend build pipeline; every fetch has a timeout and an explicit error badge; `UiFormats` and `werkator.js` must produce identical display formats.
|
- Web UI: server-rendered Thymeleaf plus one hand-written `static/werkator.js` — no SPA framework, no frontend build pipeline; every fetch has a timeout and an explicit error badge; `UiFormats` and `werkator.js` must produce identical display formats.
|
||||||
- Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK.
|
- Git and Docker access shells out to the CLIs (`GitCommandRunner`, `docker`) — no JGit, no Docker SDK.
|
||||||
|
|||||||
+11
-10
@@ -91,7 +91,7 @@ single branch may decide it:
|
|||||||
- the repository-side settings: the whole `gitea`, `executor`, and `watcher` sections;
|
- the repository-side settings: the whole `gitea`, `executor`, and `watcher` sections;
|
||||||
- the trust gate: `requirePullRequest`, and the Gitea status context: `statusContext`;
|
- the trust gate: `requirePullRequest`, and the Gitea status context: `statusContext`;
|
||||||
- the container sandbox policy: `docker.enabled`/`docker.network` and
|
- the container sandbox policy: `docker.enabled`/`docker.network` and
|
||||||
`bwrap.enabled`/`bwrap.rootfs` — host-pinned as
|
`bwrap.enabled`/`bwrap.rootfs`/`bwrap.werkdock` — host-pinned as
|
||||||
long as only the host's configuration sets them, master-pinned once the committed
|
long as only the host's configuration sets them, master-pinned once the committed
|
||||||
configuration does.
|
configuration does.
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@ Both parts combine as an intersection.
|
|||||||
|
|
||||||
Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `bwrap` with all their keys.
|
Settings: `buildCommand`, `cleanCommand`, `artifactDirs`, `stdoutLog`/`stderrLog`, `requirePullRequest`, `statusContext`, and `docker` and `bwrap` with all their keys.
|
||||||
A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to Werkator's own defaults.
|
A definition carries the complete description of its build; unset keys fall back to `builds.default` and then to Werkator's own defaults.
|
||||||
`requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, and `bwrap.rootfs` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config.
|
`requirePullRequest`, `statusContext`, `docker.enabled`, `docker.network`, `bwrap.enabled`, `bwrap.rootfs`, and `bwrap.werkdock` are pinned (master-pinned, see [the branch layer](#the-branch-layer-a-branch-describes-its-own-ci)): they are read from the repo install/project config even when a branch sets them in its own committed config.
|
||||||
Inheritance from `builds.default` covers the settings only — the `trigger` block says when and where *this* build runs and is never inherited.
|
Inheritance from `builds.default` covers the settings only — the `trigger` block says when and where *this* build runs and is never inherited.
|
||||||
Definitions are part of the branch layer: a branch may add its own and override those from the project config, for its own builds only.
|
Definitions are part of the branch layer: a branch may add its own and override those from the project config, for its own builds only.
|
||||||
Because the inheritance is applied after all layers are merged, a build a branch invents still inherits the host's `builds.default` — its sandbox policy included, which is what keeps the pinning effective for a build the host has never heard of.
|
Because the inheritance is applied after all layers are merged, a build a branch invents still inherits the host's `builds.default` — its sandbox policy included, which is what keeps the pinning effective for a build the host has never heard of.
|
||||||
@@ -411,16 +411,17 @@ All Werkator containers carry `org.hoennig.werkator` labels; stale build contain
|
|||||||
|
|
||||||
### Notes on `builds.<name>.bwrap`
|
### Notes on `builds.<name>.bwrap`
|
||||||
|
|
||||||
With `bwrap.enabled`, Werkator shells out to the `bwrap` CLI (bubblewrap) instead of native execution.
|
With `bwrap.enabled`, Werkator runs the build in a bubblewrap sandbox instead of native execution.
|
||||||
This is the third runtime, for hosts without root and without a Docker daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md` and ADR 0007.
|
This is the third runtime, for hosts without root and without a Docker daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md` and ADR 0008.
|
||||||
`bwrap` must be on the `PATH`.
|
Since step 21 session C the sandbox is executed by the `werkdock` CLI (`bwrap.werkdock`, default: resolved via `PATH`) — Werkator no longer invokes `bwrap` itself; `bwrap` must be installed for werkdock.
|
||||||
|
`werkdock doctor` checks the host's capability, superseding `tools/werkator-build-prerequisites.sh`.
|
||||||
|
|
||||||
`bwrap.rootfs` names the prepared root filesystem archive — a Debian-base rootfs with the build tools (JDK, git, locales, project-specific tooling) built elsewhere, since `debootstrap` is unavailable on the target.
|
`bwrap.rootfs` names the prepared root filesystem archive — a Debian-base rootfs with the build tools (JDK, git, locales, project-specific tooling) built elsewhere, since `debootstrap` is unavailable on the target.
|
||||||
It is a local path or an `http(s)` URL; a URL is downloaded once.
|
It is a local path or an `http(s)` URL; a URL is downloaded once into `.git/werkator/buildenv/`.
|
||||||
Build the archive with `tools/build-bwrap-rootfs.sh` on any machine with Docker; verify the host's user-namespace capability first with `tools/werkator-build-prerequisites.sh`.
|
Build the archive with `tools/build-bwrap-rootfs.sh` on any machine with Docker.
|
||||||
The archive is unpacked on demand (`tar --no-same-owner`) into `.git/werkator/buildenv/<envKey>/rootfs`, shared across all branch worktrees like the Docker Gradle cache volume; `<envKey>` derives from a hash of the source, so a changed `rootfs` unpacks a fresh environment and stale ones can be pruned.
|
The archive is loaded once per source as the werkdock image `werkator-buildenv-<hash>` into werkdock's store (`$WERKDOCK_HOME`, default `~/.werkdock`) — shared by every repository of this OS user; the hash derives from the source string, so a changed `rootfs` loads a fresh image and stale ones can be removed from the store.
|
||||||
Per-branch Gradle caches persist in `.git/werkator/buildenv/home`, bound as `/root`.
|
Per-repo Gradle caches persist in `.git/werkator/buildenv/home`, bound as `/root`.
|
||||||
`bwrap.env` adds environment variables inside the sandbox.
|
`bwrap.env` adds environment variables inside the sandbox; the environment is otherwise cleared (docker semantics) — the server's environment does not leak in.
|
||||||
Files created inside the sandbox are owned by the host user, because uid 0 maps back to the unprivileged webspace user.
|
Files created inside the sandbox are owned by the host user, because uid 0 maps back to the unprivileged webspace user.
|
||||||
|
|
||||||
`docker` and `bwrap` are mutually exclusive per branch: enabling both is rejected at start, not silently picked.
|
`docker` and `bwrap` are mutually exclusive per branch: enabling both is rejected at start, not silently picked.
|
||||||
|
|||||||
@@ -261,3 +261,31 @@ All nginx and certificate failures are non-fatal warnings — the plain HTTP ser
|
|||||||
The nginx container cannot reach `localhost` of the host, so the proxy upstream defaults to `serverName`; set `server.nginx.upstreamHost` if the host is reachable under a different name from inside containers.
|
The nginx container cannot reach `localhost` of the host, so the proxy upstream defaults to `serverName`; set `server.nginx.upstreamHost` if the host is reachable under a different name from inside containers.
|
||||||
With the managed nginx, set `server.bindAddress: 0.0.0.0` explicitly (or an address reachable from the Docker network) — the default `127.0.0.1` makes Werkator unreachable for the proxy container.
|
With the managed nginx, set `server.bindAddress: 0.0.0.0` explicitly (or an address reachable from the Docker network) — the default `127.0.0.1` makes Werkator unreachable for the proxy container.
|
||||||
See [configuration.md](configuration.md) for all `server.nginx.*` keys.
|
See [configuration.md](configuration.md) for all `server.nginx.*` keys.
|
||||||
|
|
||||||
|
## Hostsharing Managed Webspace
|
||||||
|
|
||||||
|
The third deployment variant (plan step 21, verified live on a real webspace): no root, no Docker daemon, no own reverse proxy.
|
||||||
|
Werkator runs as a systemd *user* service on the assigned localhost port ("eigener Serverdienst"), the platform's managed Apache terminates TLS and proxies via `.htaccess`, and builds run in the bubblewrap sandbox executed by the [werkdock](../werkdock/README.md) CLI (ADR 0008, step 21 session C).
|
||||||
|
|
||||||
|
Werkator is never built on the webspace: the runtime bundle and the werkdock binary are built locally and uploaded (ADR 0006).
|
||||||
|
All steps are driven by `tools/remote`, configured through the `.env` file in the repository root; commands name their role — `instance-*` manages the installed Werkator, `repo-*` the repository it watches.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/remote werkator check-prerequisites # bwrap capability, disk and quota headroom
|
||||||
|
tools/remote werkator instance-install # upload + unpack the runtime bundle and werkdock
|
||||||
|
tools/remote werkator repo-init # clone the watched repo, init, rootfs archive, bwrap config
|
||||||
|
tools/remote werkator instance-start # server config, Apache proxy, systemd user unit
|
||||||
|
tools/remote port-forward start # browser tunnel while no public domain is set up
|
||||||
|
```
|
||||||
|
|
||||||
|
Layout on the host: the watched repository at `$WERKATOR_PATH/werkator/`, the unpacked runtime at `$WERKATOR_PATH/.werkator/werkator/`, the werkdock binary at `$WERKATOR_PATH/.werkator/bin/werkdock`.
|
||||||
|
The rootfs archive is loaded once per source into werkdock's image store (`~/.werkdock`), shared by every repository of the user.
|
||||||
|
Fill `git.account`/`git.token` in the machine config when the origin is private, and make the user's services survive logout with `loginctl enable-linger`.
|
||||||
|
|
||||||
|
Updates are one command, refused while a build runs (`FORCE=1` overrides):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/remote werkator instance-update
|
||||||
|
```
|
||||||
|
|
||||||
|
The previous runtime stays as `.werkator/werkator.prev` for one deployment as the rollback asset.
|
||||||
|
|||||||
@@ -64,13 +64,16 @@ A docker-like CLI over `bwrap`, filesystem isolation only.
|
|||||||
- Own docs, plan, and ADRs under `werkdock/` from the start, so the later repository split is a directory move; the Werkator side only keeps what is Werkator-specific (the git-metadata mounts of step 16 and the config pinning).
|
- Own docs, plan, and ADRs under `werkdock/` from the start, so the later repository split is a directory move; the Werkator side only keeps what is Werkator-specific (the git-metadata mounts of step 16 and the config pinning).
|
||||||
- Keep `werkdock/` self-contained: no imports from Werkator code, no Gradle coupling to the Werkator build — it must build and test on its own.
|
- Keep `werkdock/` self-contained: no imports from Werkator code, no Gradle coupling to the Werkator build — it must build and test on its own.
|
||||||
|
|
||||||
### C — Werkator consumes Werkdock (this repo, after B)
|
### C — Werkator consumes Werkdock (this repo, after B; implemented 2026-09-01 on branch `werkator-consumes-werkdock`)
|
||||||
|
|
||||||
- `BwrapBuildRunner` shells out to `werkdock run` instead of assembling the raw `bwrap` argv — same pattern as git and docker: CLI, no library.
|
- `BwrapBuildRunner` shells out to `werkdock run` instead of assembling the raw `bwrap` argv — same pattern as git and docker: CLI, no library.
|
||||||
- Config keys (`bwrap.enabled`, `bwrap.rootfs`) and their pinning stay as they are; only the executor behind them changes.
|
- Config keys (`bwrap.enabled`, `bwrap.rootfs`) and their pinning stay as they are; only the executor behind them changes.
|
||||||
- Decide in the step: whether the git-metadata mounts stay Werkator-side (passed as extra `--bind`/`--tmpfs` options to `werkdock run`) or become a Werkdock feature; the secrets-masking of `.git/werkator/` must hold either way.
|
One key was added: `bwrap.werkdock` (the executing binary, default via PATH) — pinned like the rest of the sandbox policy, since a branch must not substitute the executing binary.
|
||||||
|
- Decided: the git-metadata mounts stay Werkator-side, passed as `-v …:ro` / `--tmpfs` / `-v` options whose flag order werkdock preserves (it grew `--tmpfs` and an ordered mount list for exactly this); the secrets-masking of `.git/werkator/` holds unchanged.
|
||||||
|
- Decided: the rootfs archive becomes a werkdock *image* (`werkator-buildenv-<source-hash>`, checked via `werkdock images`, loaded via `werkdock load`) in werkdock's own store — shared across every repository of the OS user, which resolves step 22's buildenv-sharing question; only the URL download cache and the persistent `/root` toolchain home stay under `.git/werkator/buildenv/`.
|
||||||
|
- Consequence of werkdock's `--clearenv`: the server environment no longer leaks into builds, and the runner's TMPDIR workaround is gone.
|
||||||
|
|
||||||
### D — The Managed-Webspace install path (this repo, independent of B/C)
|
### D — The Managed-Webspace install path (this repo, independent of B/C; implemented 2026-09-01 on branch `werkator-consumes-werkdock`)
|
||||||
|
|
||||||
Bring intent 1 to the webspace: build locally, install the bundle — Werkator never builds itself on the target.
|
Bring intent 1 to the webspace: build locally, install the bundle — Werkator never builds itself on the target.
|
||||||
|
|
||||||
@@ -90,6 +93,9 @@ Bring intent 1 to the webspace: build locally, install the bundle — Werkator n
|
|||||||
Three defects found and fixed on the way: unanchored tar excludes dropped the Go stdlib's `sys` directory from the archive, pam_tmpdir's `TMPDIR` leaked into the sandbox (Werkator-side fix; Werkdock is immune via `--clearenv`), and non-report artifacts were stored below `reports/` and invisible in the UI.
|
Three defects found and fixed on the way: unanchored tar excludes dropped the Go stdlib's `sys` directory from the archive, pam_tmpdir's `TMPDIR` leaked into the sandbox (Werkator-side fix; Werkdock is immune via `--clearenv`), and non-report artifacts were stored below `reports/` and invisible in the UI.
|
||||||
The image was then trimmed (headless JDK, en/de locales only, no man/doc/apt-lists): 351 MB compressed — smaller than the original JDK-only archive despite carrying Go and Node.
|
The image was then trimmed (headless JDK, en/de locales only, no man/doc/apt-lists): 351 MB compressed — smaller than the original JDK-only archive despite carrying Go and Node.
|
||||||
All rollback assets on mih34 are removed; the PR for this branch is prepared (PR-doc with `PR#000` placeholder) and will be opened later.
|
All rollback assets on mih34 are removed; the PR for this branch is prepared (PR-doc with `PR#000` placeholder) and will be opened later.
|
||||||
|
- 2026-09-01, session C deployed to mih34: the werkdock binary sits at `.werkator/bin/werkdock`, the machine config names it in `bwrap.werkdock`, the runtime bundle carries the delegating runner, and the TMPDIR workaround left the machine config (obsolete under werkdock's clearenv).
|
||||||
|
- 2026-09-01, session D done and live-verified on mih34: `tools/remote` reworked to role-named commands (`instance-install`/`instance-update`/`instance-start` for the builder, `repo-init` for the built; the retired `install`/`build`/`start` fail loudly naming their successors); the self-build, the repo clone for it, and the GitHub-key step are gone — the instance installs from locally built artifacts (bundle + werkdock), the watched repo clones anonymously via https.
|
||||||
|
`instance-update` refuses to swap under a running build, `repo-init` is idempotent (checksum-skipped rootfs upload; the machine-config guard whose indentation mismatch once appended nine duplicate bwrap blocks is fixed); `docs/deployment.md` gained the Managed Webspace as the third variant, written from the verified setup.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
> **WARNING:** This document describes only the change applied in this PR.
|
||||||
|
> It may already be outdated once the next PR is merged.
|
||||||
|
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
PR #6 grew Werkdock as a standalone sandbox CLI, but `BwrapBuildRunner` still assembled its own raw `bwrap` invocation — the extraction was only half done, and the two implementations could drift.
|
||||||
|
Separately, the webspace deployment path in `tools/remote` still followed the original self-build prototype: clone Werkator's own repository onto the target and build it there, which is exactly the pattern ADR 0006 rejected ("build locally, install the bundle") and step 21 set out to correct.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- `tools/remote`'s configuration-writing duplication (heredocs/`sed` into the machine config) — that is step 23, PR #8/#9.
|
||||||
|
- Multi-repository support for one Werkator instance (step 22, PR #10).
|
||||||
|
- RFC 0002 levels 2/3 and RFC 0003 (composable toolchain mounts) stay deferred/candidate.
|
||||||
|
|
||||||
|
## The Scenarios
|
||||||
|
|
||||||
|
### Feature: `BwrapBuildRunner` delegates to Werkdock
|
||||||
|
|
||||||
|
#### Scenario#7.01: A build runs through `werkdock run` instead of a raw `bwrap` invocation
|
||||||
|
|
||||||
|
So that Werkator and Werkdock never carry two implementations of the same sandbox invocation.
|
||||||
|
|
||||||
|
- **Given** `bwrap.enabled` and a configured rootfs archive
|
||||||
|
- **When** a build needs the sandbox
|
||||||
|
- **Then** `BwrapBuildRunner` loads the image via `werkdock images`/`werkdock load` (once, keyed by `imageName(rootfs)` = `werkator-buildenv-<hash-of-source>`) and runs the build via `werkdock run --rm`
|
||||||
|
- **and** the configured `bwrap.werkdock` binary (default: `werkdock` via `PATH`) is what gets invoked.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [assembles the exact werkdock run command for a loaded image](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
- [loads the image once when werkdock does not know it yet](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
- [does not load an image werkdock already has](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
- [uses the configured werkdock binary path](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
|
||||||
|
#### Scenario#7.02: The git-metadata mask survives the move to ordered `-v`/`--tmpfs` flags
|
||||||
|
|
||||||
|
So that secrets stay outside the sandbox exactly as before, now expressed as flag order instead of an internal mount list.
|
||||||
|
|
||||||
|
- **Given** a worktree build
|
||||||
|
- **When** the invocation is assembled
|
||||||
|
- **Then** `.git` is bound read-only, then `.git/werkator/` is masked with `--tmpfs`, then the worktree's admin dir is bound read-write, in that exact order
|
||||||
|
- **and** Werkdock's `Mount` list (replacing the earlier unordered `Bind` list) preserves the order flags were given in.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [exposes git metadata read-only with the werkator dir masked, in mount order](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
- [mounts no git metadata when the workspace is not a worktree](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
- [TestParseRunKeepsMountFlagOrderAcrossVolumeAndTmpfs](../../werkdock/internal/cli/run_test.go)
|
||||||
|
|
||||||
|
#### Scenario#7.03: Werkdock gained what the delegation needed
|
||||||
|
|
||||||
|
So that `images`/`:rw` were built because Werkator's runner needed them, not speculatively.
|
||||||
|
|
||||||
|
- **Given** the new `werkdock images` verb and `:rw` volume option
|
||||||
|
- **When** the runner checks whether an image is already loaded, or mounts the admin dir read-write
|
||||||
|
- **Then** `images` lists loaded image names (one per line, `docker images --format` shaped) and `-v src:dst:rw` is accepted alongside the existing `:ro`.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [TestListNamesLoadedImagesAndIgnoresTmpLeftovers](../../werkdock/internal/store/store_test.go)
|
||||||
|
- [TestParseRunAcceptsTheExplicitRwVolumeOption](../../werkdock/internal/cli/run_test.go)
|
||||||
|
- [TestImageNameFromArchive](../../werkdock/internal/store/store_test.go)
|
||||||
|
|
||||||
|
#### Scenario#7.04: The TMPDIR workaround is gone because it is now structurally impossible
|
||||||
|
|
||||||
|
So that the fix and its own workaround do not both linger in the codebase.
|
||||||
|
|
||||||
|
- **Given** Werkdock's `--clearenv`
|
||||||
|
- **When** a build runs in the sandbox
|
||||||
|
- **Then** no host `TMPDIR`/`TMP` reaches the sandboxed process at all, so `BwrapBuildRunner`'s earlier explicit `--setenv TMPDIR /tmp` workaround (PR #4) is removed as dead code, not merely redundant.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- [adds bwrap env after the branch environment](../../src/test/kotlin/de/hoennig/werkator/build/BwrapBuildRunnerTest.kt)
|
||||||
|
- [TestArgvAssemblesTheHardenedInvocation](../../werkdock/internal/engine/bwrap_test.go)
|
||||||
|
|
||||||
|
### Feature: the webspace install path replaces the self-build prototype
|
||||||
|
|
||||||
|
#### Scenario#7.05: `tools/remote` separates the builder role from the built (watched) repository role
|
||||||
|
|
||||||
|
So that "build Werkator on the webspace" and "Werkator watches a repository on the webspace" are never conflated again.
|
||||||
|
|
||||||
|
- **Given** a Managed Webspace target
|
||||||
|
- **When** the wrapper manages the Werkator runtime versus a repository Werkator watches
|
||||||
|
- **Then** `instance-install`/`instance-update`/`instance-start` install and run the Werkator **builder** binary+bundle
|
||||||
|
- **and** `repo-init` prepares a repository to be **built by** that instance
|
||||||
|
- **and** the retired `install`/`build`/`start` commands fail loudly, naming their successors, instead of silently doing the old thing.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- manual invocation of the retired commands on mih34 (shell script; no automated test harness for `tools/remote`)
|
||||||
|
|
||||||
|
#### Scenario#7.06: The self-build prototype is gone
|
||||||
|
|
||||||
|
So that Werkator is never again built by checking out its own source onto the target and compiling there.
|
||||||
|
|
||||||
|
- **Given** the old prototype cloned werkator's own repository onto the webspace and built it in place
|
||||||
|
- **When** an instance is installed or updated now
|
||||||
|
- **Then** the runtime bundle is built locally and transported (`instance-install`/`instance-update`), never cloned-and-built on the target.
|
||||||
|
|
||||||
|
##### Verified by
|
||||||
|
|
||||||
|
- live run on mih34: `instance-update` against a runtime bundle built locally
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
`BwrapBuildRunner.invocation()` no longer builds a `bwrap` argv; it shells out to the `werkdock` binary named by `bwrap.werkdock` (a new pinned config key, alongside `bwrap.enabled`/`bwrap.rootfs`) for `images`, `load`, and `run --rm`.
|
||||||
|
`werkdock/internal/engine/engine.go` was rewritten from an unordered `Bind` list to an ordered `Mount` list (`MountBind`/`MountRoBind`/`MountTmpfs`) specifically so the CLI's `-v`/`--tmpfs` flag order — which the git-metadata mask depends on — survives into the sandbox invocation unchanged.
|
||||||
|
`werkdock/internal/cli/images.go` is new; `run.go`'s volume parsing gained the `:rw` option.
|
||||||
|
`tools/remote` was reorganized around two roles instead of one flat command list: builder lifecycle (`instance-install`/`instance-update`/`instance-start`) versus watched-repository lifecycle (`repo-init`); the old `install`/`build`/`start` now `die` with the successor's name.
|
||||||
|
`require_idle()`/`FORCE=1` guards a runtime swap against a build in progress.
|
||||||
|
|
||||||
|
## Additional Changes
|
||||||
|
|
||||||
|
- `docs/deployment.md`: the webspace section now describes the role-separated commands.
|
||||||
|
- `docs/configuration.md` and `AGENTS.md`: `bwrap.werkdock` documented as a fourth pinned bwrap key.
|
||||||
|
- Step 21 plan: sessions C and D marked done with live-verification notes.
|
||||||
|
|
||||||
|
## Prerequisite PRs
|
||||||
|
|
||||||
|
- PR #6 (Werkdock bootstrap) — this PR is the consumer of the CLI it built.
|
||||||
|
|
||||||
|
## Follow-up PRs
|
||||||
|
|
||||||
|
- PR #8/#9: `tools/remote`'s remaining configuration-writing duplication with `werkator init` is resolved next (step 23).
|
||||||
|
- PR #10: multi-repository support for one Werkator instance (step 22).
|
||||||
@@ -10,20 +10,30 @@ import java.nio.file.Path
|
|||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0007),
|
* Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0008),
|
||||||
* for hosts without root and without a Docker daemon (e.g. Hostsharing managed
|
* for hosts without root and without a Docker daemon (e.g. Hostsharing managed
|
||||||
* webspaces). Shells out to the `bwrap` CLI via the generic [GitCommandRunner] process
|
* webspaces). Since step 21 session C it no longer assembles the raw `bwrap` argv:
|
||||||
* wrapper — no library, consistent with git and docker.
|
* it shells out to the `werkdock` CLI (`bwrap.werkdock`, default via PATH) — the same
|
||||||
|
* pattern as git and docker, CLI, no library.
|
||||||
*
|
*
|
||||||
* The prepared rootfs (a Debian-base archive built elsewhere, since `debootstrap` is not
|
* The rootfs archive becomes a werkdock *image*, loaded once per source
|
||||||
* available on the target) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs`,
|
* (`werkator-buildenv-<hash>`, the hash over the source string, so a changed source
|
||||||
* shared across all branch worktrees like the Docker gradle cache volume; `<envKey>` derives
|
* loads a fresh image) into werkdock's own store (`$WERKDOCK_HOME`, default
|
||||||
* from a hash of the archive source, so a changed source unpacks a fresh rootfs and stale
|
* `~/.werkdock`) — shared by every repository of this OS user, unlike the old
|
||||||
* ones can be pruned. The returned [Process] is the attached `bwrap` process, so log
|
* per-repo unpack. Only the download cache for URL sources and the persistent
|
||||||
* streaming and cancellation work exactly like native builds (`--die-with-parent` plus
|
* toolchain home (bound to `/root` for Gradle/Go caches) stay under
|
||||||
* `--unshare-pid` tear down the whole tree on cancel). Git works inside the sandbox with
|
* `.git/werkator/buildenv/`.
|
||||||
* the same layered mounts as the Docker runner: the primary `.git` read-only with
|
*
|
||||||
* `.git/werkator/` masked, see [gitMetadataMounts].
|
* Werkdock clears the environment inside the sandbox (docker semantics), so the
|
||||||
|
* server's environment no longer leaks in — only the explicit `-e` variables below
|
||||||
|
* plus werkdock's own `HOME`/`PATH` exist inside; the pam_tmpdir TMPDIR class of
|
||||||
|
* bugs is gone by construction. Git works inside the sandbox with the same layered
|
||||||
|
* mounts as the Docker runner, expressed as werkdock flags whose order is
|
||||||
|
* significant and preserved: read-only `.git`, tmpfs mask over `.git/werkator`,
|
||||||
|
* read-write worktree admin dir — see [gitMetadataMounts]. The returned [Process]
|
||||||
|
* is the attached `werkdock run`, whose `bwrap` child dies with it
|
||||||
|
* (`--die-with-parent`), so log streaming and cancellation work exactly like
|
||||||
|
* native builds.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class BwrapBuildRunner(
|
class BwrapBuildRunner(
|
||||||
@@ -31,7 +41,7 @@ class BwrapBuildRunner(
|
|||||||
) : BuildRunner {
|
) : BuildRunner {
|
||||||
private val log = LoggerFactory.getLogger(BwrapBuildRunner::class.java)
|
private val log = LoggerFactory.getLogger(BwrapBuildRunner::class.java)
|
||||||
|
|
||||||
/** Replaceable process launcher so unit tests can capture the assembled `bwrap` argv. */
|
/** Replaceable process launcher so unit tests can capture the assembled `werkdock` argv. */
|
||||||
internal var processStarter: (List<String>, Path) -> Process = { command, dir ->
|
internal var processStarter: (List<String>, Path) -> Process = { command, dir ->
|
||||||
ProcessBuilder(command).directory(dir.toFile()).start()
|
ProcessBuilder(command).directory(dir.toFile()).start()
|
||||||
}
|
}
|
||||||
@@ -46,76 +56,37 @@ class BwrapBuildRunner(
|
|||||||
): Process {
|
): Process {
|
||||||
val bwrap = branchConfig.bwrap
|
val bwrap = branchConfig.bwrap
|
||||||
require(bwrap.rootfs.isNotBlank()) { "branches.<name>.bwrap.rootfs must be set when bwrap.enabled is true" }
|
require(bwrap.rootfs.isNotBlank()) { "branches.<name>.bwrap.rootfs must be set when bwrap.enabled is true" }
|
||||||
val buildEnvRoot = buildEnvRoot(repoDir)
|
val werkdock = bwrap.werkdock.ifBlank { "werkdock" }
|
||||||
val envKey = envKey(bwrap.rootfs)
|
val image = imageName(bwrap.rootfs)
|
||||||
val rootfsDir = buildEnvRoot.resolve(envKey).resolve(ROOTFS_DIR)
|
ensureImage(werkdock, image, bwrap, repoDir, onAuxProcess)
|
||||||
ensureRootfs(bwrap, rootfsDir, repoDir, onAuxProcess)
|
val homeDir = repoDir.resolve(BUILDENV_DIR).resolve(HOME_DIR)
|
||||||
val homeDir = buildEnvRoot.resolve(HOME_DIR)
|
|
||||||
Files.createDirectories(homeDir)
|
Files.createDirectories(homeDir)
|
||||||
val args =
|
val args = invocation(command, workingDir, environment, repoDir, bwrap, werkdock, image, homeDir)
|
||||||
invocation(command, workingDir, environment, repoDir, bwrap, rootfsDir, homeDir)
|
|
||||||
ensureMountpoints(rootfsDir, args)
|
|
||||||
return processStarter(args, repoDir)
|
return processStarter(args, repoDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* bwrap creates mountpoint directories for bind destinations inside the sandbox —
|
* Loads the rootfs archive into the werkdock image store once per source.
|
||||||
* against the read-only rootfs bind that fails with "Can't mkdir parents ...
|
* `werkdock images` answers existence through the CLI, like `docker image
|
||||||
* Read-only file system" for every destination that does not exist in the rootfs
|
* inspect` does for the Docker runner.
|
||||||
* (the workspace under the repo, for example). The rootfs directory itself is a
|
|
||||||
* plain host directory, so we pre-create the mountpoints there; bwrap then finds
|
|
||||||
* them and has nothing left to mkdir.
|
|
||||||
*/
|
*/
|
||||||
private fun ensureMountpoints(
|
private fun ensureImage(
|
||||||
rootfsDir: Path,
|
werkdock: String,
|
||||||
args: List<String>,
|
image: String,
|
||||||
) {
|
|
||||||
var i = 0
|
|
||||||
while (i < args.size) {
|
|
||||||
val arg = args[i]
|
|
||||||
if (arg == "--bind" || arg == "--ro-bind") {
|
|
||||||
val dest = args[i + 2]
|
|
||||||
val mountpoint = rootfsDir.resolve(dest.substring(1))
|
|
||||||
// Skip anything that already exists in the rootfs (e.g. /etc/resolv.conf
|
|
||||||
// is a file the rootfs ships); only missing dirs are created.
|
|
||||||
if (dest.startsWith("/") && !Files.exists(mountpoint)) {
|
|
||||||
Files.createDirectories(mountpoint)
|
|
||||||
}
|
|
||||||
i += 3
|
|
||||||
} else if (arg == "--proc" || arg == "--dev" || arg == "--tmpfs") {
|
|
||||||
// The rootfs archive ships no /proc, /dev (excluded when packed), so
|
|
||||||
// these mountpoints must exist too.
|
|
||||||
val dest = args[i + 1]
|
|
||||||
if (dest.startsWith("/") && !Files.exists(rootfsDir.resolve(dest.substring(1)))) {
|
|
||||||
Files.createDirectories(rootfsDir.resolve(dest.substring(1)))
|
|
||||||
}
|
|
||||||
i += 2
|
|
||||||
} else {
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unpacks the configured archive into [rootfsDir] once per environment version
|
|
||||||
* (identified by [envKey]). Missing means "not yet unpacked"; the environment is a
|
|
||||||
* cache like the Docker image and the Gradle volume, and stale ones are pruned with
|
|
||||||
* the rest of `.git/werkator`.
|
|
||||||
*/
|
|
||||||
private fun ensureRootfs(
|
|
||||||
bwrap: BwrapConfig,
|
bwrap: BwrapConfig,
|
||||||
rootfsDir: Path,
|
|
||||||
repoDir: Path,
|
repoDir: Path,
|
||||||
onAuxProcess: (Process) -> Unit,
|
onAuxProcess: (Process) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (Files.isDirectory(rootfsDir)) {
|
val loaded = commandRunner.runOrThrow(listOf(werkdock, "images"), repoDir, onProcess = onAuxProcess).lines()
|
||||||
|
if (image in loaded) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Files.createDirectories(rootfsDir)
|
val envDir = repoDir.resolve(BUILDENV_DIR).resolve(sourceKey(bwrap.rootfs))
|
||||||
val archive = localArchive(bwrap.rootfs, rootfsDir.parent, repoDir, onAuxProcess)
|
Files.createDirectories(envDir)
|
||||||
log.info("unpacking build environment {} into {}", bwrap.rootfs, rootfsDir)
|
val archive = localArchive(bwrap.rootfs, envDir, repoDir, onAuxProcess)
|
||||||
|
log.info("loading build environment {} as werkdock image {}", bwrap.rootfs, image)
|
||||||
commandRunner.runOrThrow(
|
commandRunner.runOrThrow(
|
||||||
listOf("tar", "--no-same-owner", "-xf", archive, "-C", rootfsDir.toString()),
|
listOf(werkdock, "load", "-i", archive, "--name", image),
|
||||||
repoDir,
|
repoDir,
|
||||||
onProcess = onAuxProcess,
|
onProcess = onAuxProcess,
|
||||||
)
|
)
|
||||||
@@ -123,9 +94,7 @@ class BwrapBuildRunner(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves [BwrapConfig.rootfs] to a local archive path: a bare or `file:` path is
|
* Resolves [BwrapConfig.rootfs] to a local archive path: a bare or `file:` path is
|
||||||
* used as-is; an `http(s)` URL is downloaded once into the buildenv root. GNU tar
|
* used as-is; an `http(s)` URL is downloaded once into the buildenv cache.
|
||||||
* auto-detects the compression from the archive magic, so a `.tar.gz` or `.tar.zst`
|
|
||||||
* needs no extra flag.
|
|
||||||
*/
|
*/
|
||||||
private fun localArchive(
|
private fun localArchive(
|
||||||
rootfs: String,
|
rootfs: String,
|
||||||
@@ -155,77 +124,48 @@ class BwrapBuildRunner(
|
|||||||
environment: Map<String, String>,
|
environment: Map<String, String>,
|
||||||
repoDir: Path,
|
repoDir: Path,
|
||||||
bwrap: BwrapConfig,
|
bwrap: BwrapConfig,
|
||||||
rootfsDir: Path,
|
werkdock: String,
|
||||||
|
image: String,
|
||||||
homeDir: Path,
|
homeDir: Path,
|
||||||
): List<String> {
|
): List<String> {
|
||||||
// bwrap creates mountpoints for bind destinations inside the sandbox; a
|
// Mounts at absolute host paths — same contract as the Docker runner.
|
||||||
// relative workspace path would resolve there into the read-only rootfs
|
// Relative paths come from the CLI relative to the repo, so resolve them
|
||||||
// ("Can't mkdir parents ...: Read-only file system"). Bind at absolute
|
// against repoDir, not against the process working directory.
|
||||||
// host paths instead — same contract as the Docker runner. Relative
|
|
||||||
// paths come from the CLI relative to the repo, so resolve them against
|
|
||||||
// repoDir, not against the process working directory.
|
|
||||||
val repoDirAbs = repoDir.toAbsolutePath().normalize()
|
val repoDirAbs = repoDir.toAbsolutePath().normalize()
|
||||||
val workspaceAbs =
|
val workspaceAbs =
|
||||||
if (workspace.isAbsolute) workspace.normalize() else repoDirAbs.resolve(workspace).normalize()
|
if (workspace.isAbsolute) workspace.normalize() else repoDirAbs.resolve(workspace).normalize()
|
||||||
val homeDirAbs =
|
val homeDirAbs =
|
||||||
if (homeDir.isAbsolute) homeDir.normalize() else repoDirAbs.resolve(homeDir).normalize()
|
if (homeDir.isAbsolute) homeDir.normalize() else repoDirAbs.resolve(homeDir).normalize()
|
||||||
val args =
|
val args = mutableListOf(werkdock, "run", "--rm")
|
||||||
mutableListOf(
|
// The repo read-write FIRST, as the base the later mountpoints (workspace,
|
||||||
"bwrap",
|
// worktree admin dir) are created in; the git metadata mounts then layer
|
||||||
"--unshare-user",
|
// the isolation on top, and the workspace bind last shadows the tmpfs mask
|
||||||
"--unshare-pid",
|
// at exactly its own path (it lives under .git/werkator/worktrees).
|
||||||
"--die-with-parent",
|
args += listOf("-v", "$repoDirAbs:$repoDirAbs")
|
||||||
"--uid",
|
|
||||||
"0",
|
|
||||||
"--gid",
|
|
||||||
"0",
|
|
||||||
"--ro-bind",
|
|
||||||
rootfsDir.toString(),
|
|
||||||
"/",
|
|
||||||
)
|
|
||||||
// Bind the repo read-write FIRST so bwrap can create the mountpoints of
|
|
||||||
// the later binds (workspace, worktree admin dir) inside it — creating
|
|
||||||
// them against the read-only rootfs fails with "Can't mkdir parents ...
|
|
||||||
// Read-only file system". The git metadata mounts below then layer the
|
|
||||||
// usual isolation on top: read-only .git, tmpfs mask over .git/werkator,
|
|
||||||
// read-write worktree admin dir.
|
|
||||||
args += listOf("--bind", "$repoDirAbs", "$repoDirAbs")
|
|
||||||
// Git metadata mounts BEFORE the workspace bind: the tmpfs mask over
|
|
||||||
// .git/werkator must not shadow the workspace, which lives under
|
|
||||||
// .git/werkator/worktrees — the later workspace bind shadows the mask
|
|
||||||
// at exactly its own path and nothing else.
|
|
||||||
args += gitMetadataMounts(workspaceAbs, repoDir)
|
args += gitMetadataMounts(workspaceAbs, repoDir)
|
||||||
args += listOf("--bind", "$workspaceAbs", "$workspaceAbs")
|
args += listOf("-v", "$workspaceAbs:$workspaceAbs")
|
||||||
args += listOf("--bind", "$homeDirAbs", "/root")
|
args += listOf("-v", "$homeDirAbs:/root")
|
||||||
args += listOf("--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf")
|
|
||||||
args += listOf("--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp")
|
|
||||||
args += listOf("--setenv", "HOME", "/root")
|
|
||||||
// The sandbox /tmp is a fresh tmpfs, but bwrap inherits the server's
|
|
||||||
// environment — on hosts with pam_tmpdir that includes
|
|
||||||
// TMPDIR=/tmp/user/<uid>, which does not exist inside and breaks every
|
|
||||||
// tool honoring it (go: "creating work dir: stat ...: no such file or
|
|
||||||
// directory"; the JVM ignores TMPDIR, so Gradle never noticed). Set
|
|
||||||
// both back to /tmp; explicit env below can still override.
|
|
||||||
args += listOf("--setenv", "TMPDIR", "/tmp")
|
|
||||||
args += listOf("--setenv", "TMP", "/tmp")
|
|
||||||
for ((key, value) in environment) {
|
for ((key, value) in environment) {
|
||||||
args += listOf("--setenv", key, value)
|
args += listOf("-e", "$key=$value")
|
||||||
}
|
}
|
||||||
for ((key, value) in bwrap.env) {
|
for ((key, value) in bwrap.env) {
|
||||||
args += listOf("--setenv", key, value)
|
args += listOf("-e", "$key=$value")
|
||||||
}
|
}
|
||||||
args += listOf("--chdir", "$workspaceAbs", "/bin/sh", "-c", command)
|
args += listOf("-w", "$workspaceAbs")
|
||||||
|
args += image
|
||||||
|
args += listOf("/bin/sh", "-c", command)
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes git work inside the sandbox without exposing Werkator's secrets — the same
|
* Makes git work inside the sandbox without exposing Werkator's secrets — the same
|
||||||
* three layered mounts as the Docker runner, expressed in `bwrap` flags (bwrap nests
|
* three layered mounts as the Docker runner, expressed as werkdock flags (werkdock
|
||||||
* mounts by target path like Docker): the primary `.git` read-only, an empty tmpfs
|
* preserves the -v/--tmpfs flag order, and bwrap nests mounts by target path): the
|
||||||
* masking `.git/werkator/` (machine config with `git.token`, control token, build
|
* primary `.git` read-only, an empty tmpfs masking `.git/werkator/` (machine config
|
||||||
* state), and this worktree's admin directory read-write so index-refreshing commands
|
* with `git.token`, control token, build state), and this worktree's admin directory
|
||||||
* keep working. Object and ref writes stay blocked by the read-only `.git` mount.
|
* read-write so index-refreshing commands keep working. Object and ref writes stay
|
||||||
* No mounts are added when the workspace is not a worktree of [repoDir].
|
* blocked by the read-only `.git` mount. No mounts are added when the workspace is
|
||||||
|
* not a worktree of [repoDir].
|
||||||
*/
|
*/
|
||||||
private fun gitMetadataMounts(
|
private fun gitMetadataMounts(
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
@@ -247,28 +187,27 @@ class BwrapBuildRunner(
|
|||||||
if (!adminDir.startsWith(gitDir) || !Files.isDirectory(adminDir)) {
|
if (!adminDir.startsWith(gitDir) || !Files.isDirectory(adminDir)) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
val args = mutableListOf("--ro-bind", "$gitDir", "$gitDir")
|
val args = mutableListOf("-v", "$gitDir:$gitDir:ro")
|
||||||
val werkatorDir = gitDir.resolve("werkator")
|
val werkatorDir = gitDir.resolve("werkator")
|
||||||
if (Files.isDirectory(werkatorDir)) {
|
if (Files.isDirectory(werkatorDir)) {
|
||||||
args += listOf("--tmpfs", "$werkatorDir")
|
args += listOf("--tmpfs", "$werkatorDir")
|
||||||
}
|
}
|
||||||
args += listOf("--bind", "$adminDir", "$adminDir")
|
args += listOf("-v", "$adminDir:$adminDir")
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildEnvRoot(repoDir: Path): Path = repoDir.resolve(BUILDENV_DIR)
|
/** A short hash of the archive source, so a changed source loads a fresh image. */
|
||||||
|
private fun sourceKey(rootfs: String): String =
|
||||||
/** A short hash of the archive source, so a changed source unpacks a fresh rootfs. */
|
|
||||||
private fun envKey(rootfs: String): String =
|
|
||||||
MessageDigest
|
MessageDigest
|
||||||
.getInstance("SHA-256")
|
.getInstance("SHA-256")
|
||||||
.digest(rootfs.toByteArray())
|
.digest(rootfs.toByteArray())
|
||||||
.joinToString("") { "%02x".format(it) }
|
.joinToString("") { "%02x".format(it) }
|
||||||
.take(12)
|
.take(12)
|
||||||
|
|
||||||
|
private fun imageName(rootfs: String): String = "werkator-buildenv-${sourceKey(rootfs)}"
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val BUILDENV_DIR = ".git/werkator/buildenv"
|
const val BUILDENV_DIR = ".git/werkator/buildenv"
|
||||||
const val ROOTFS_DIR = "rootfs"
|
|
||||||
const val HOME_DIR = "home"
|
const val HOME_DIR = "home"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ class InitCommand(
|
|||||||
bwrap:
|
bwrap:
|
||||||
enabled: false # run clean/build in a bwrap sandbox instead of natively (pinned)
|
enabled: false # run clean/build in a bwrap sandbox instead of natively (pinned)
|
||||||
rootfs: "" # prepared rootfs archive (path or URL); required when enabled (pinned)
|
rootfs: "" # prepared rootfs archive (path or URL); required when enabled (pinned)
|
||||||
|
werkdock: werkdock # the werkdock CLI executing the sandbox; default resolves via PATH (pinned)
|
||||||
env: {} # additional environment variables set inside the sandbox
|
env: {} # additional environment variables set inside the sandbox
|
||||||
# Gitea check this build reports as; empty uses gitea.statusContext.
|
# Gitea check this build reports as; empty uses gitea.statusContext.
|
||||||
# Two builds of one commit under the same context overwrite each other.
|
# Two builds of one commit under the same context overwrite each other.
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ data class BuildDefinition(
|
|||||||
branchConfig.bwrap.copy(
|
branchConfig.bwrap.copy(
|
||||||
enabled = bwrap?.enabled ?: branchConfig.bwrap.enabled,
|
enabled = bwrap?.enabled ?: branchConfig.bwrap.enabled,
|
||||||
rootfs = bwrap?.rootfs ?: branchConfig.bwrap.rootfs,
|
rootfs = bwrap?.rootfs ?: branchConfig.bwrap.rootfs,
|
||||||
|
werkdock = bwrap?.werkdock ?: branchConfig.bwrap.werkdock,
|
||||||
env = bwrap?.env ?: branchConfig.bwrap.env,
|
env = bwrap?.env ?: branchConfig.bwrap.env,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -174,5 +175,7 @@ data class BwrapOverrides(
|
|||||||
val enabled: Boolean? = null,
|
val enabled: Boolean? = null,
|
||||||
/** Rootfs archive source. Pinned — a branch must not substitute a foreign rootfs. */
|
/** Rootfs archive source. Pinned — a branch must not substitute a foreign rootfs. */
|
||||||
val rootfs: String? = null,
|
val rootfs: String? = null,
|
||||||
|
/** The werkdock CLI executing the sandbox. Pinned — a branch must not substitute the executing binary. */
|
||||||
|
val werkdock: String? = null,
|
||||||
val env: Map<String, String>? = null,
|
val env: Map<String, String>? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -403,8 +403,8 @@ class ConfigLoader(
|
|||||||
/** `docker` keys a branch must never override: the sandbox policy. */
|
/** `docker` keys a branch must never override: the sandbox policy. */
|
||||||
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
|
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
|
||||||
|
|
||||||
/** `bwrap` keys a branch must never override: the sandbox policy (Step 17). */
|
/** `bwrap` keys a branch must never override: the sandbox policy (Step 17) and its executing binary. */
|
||||||
private val PINNED_BWRAP_KEYS = setOf("enabled", "rootfs")
|
private val PINNED_BWRAP_KEYS = setOf("enabled", "rootfs", "werkdock")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one key of a build definition that says *when* and *for which branches* it
|
* The one key of a build definition that says *when* and *for which branches* it
|
||||||
|
|||||||
@@ -197,6 +197,11 @@ data class BwrapConfig(
|
|||||||
* Pinned — a branch must not substitute a foreign rootfs via its committed config.
|
* Pinned — a branch must not substitute a foreign rootfs via its committed config.
|
||||||
*/
|
*/
|
||||||
val rootfs: String = "",
|
val rootfs: String = "",
|
||||||
|
/**
|
||||||
|
* The werkdock CLI executing the sandbox (step 21 session C); empty or the default
|
||||||
|
* resolves via PATH. Pinned — a branch must not substitute the executing binary.
|
||||||
|
*/
|
||||||
|
val werkdock: String = "werkdock",
|
||||||
/** Additional environment variables set inside the sandbox. */
|
/** Additional environment variables set inside the sandbox. */
|
||||||
val env: Map<String, String> = emptyMap(),
|
val env: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,11 +35,18 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun rootfsUnpacked(rootfs: String = "/srv/buildenv.tar.zst"): Path =
|
private fun imageName(rootfs: String = "/srv/buildenv.tar.zst"): String = "werkator-buildenv-${rootfs.sha12()}"
|
||||||
repoDir
|
|
||||||
.resolve(BwrapBuildRunner.BUILDENV_DIR)
|
/** The image is already loaded: `werkdock images` lists it, so no load runs. */
|
||||||
.resolve(rootfs.sha12())
|
private fun givenImageLoaded(rootfs: String = "/srv/buildenv.tar.zst") {
|
||||||
.resolve(BwrapBuildRunner.ROOTFS_DIR)
|
every { commandRunner.runOrThrow(listOf("werkdock", "images"), repoDir, any(), any()) } returns
|
||||||
|
GitCommandResult(0, imageName(rootfs) + "\n", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun givenImageMissing() {
|
||||||
|
every { commandRunner.runOrThrow(listOf("werkdock", "images"), repoDir, any(), any()) } returns
|
||||||
|
GitCommandResult(0, "some-other-image\n", "")
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach {
|
beforeEach {
|
||||||
@@ -54,110 +61,92 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("unpacks the rootfs on demand and assembles the exact bwrap command") {
|
test("assembles the exact werkdock run command for a loaded image") {
|
||||||
every {
|
givenImageLoaded()
|
||||||
commandRunner.runOrThrow(
|
|
||||||
listOf("tar", "--no-same-owner", "-xf", "/srv/buildenv.tar.zst", "-C", rootfsUnpacked().toString()),
|
|
||||||
repoDir,
|
|
||||||
any(),
|
|
||||||
any(),
|
|
||||||
)
|
|
||||||
} returns
|
|
||||||
GitCommandResult(0, "", "")
|
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
||||||
|
|
||||||
val args = captured.single()
|
captured.single() shouldBe
|
||||||
val rootfsDir = args[args.indexOf("--ro-bind") + 1]
|
|
||||||
args shouldBe
|
|
||||||
listOf(
|
listOf(
|
||||||
"bwrap",
|
"werkdock",
|
||||||
"--unshare-user",
|
"run",
|
||||||
"--unshare-pid",
|
"--rm",
|
||||||
"--die-with-parent",
|
"-v",
|
||||||
"--uid",
|
"$repoDir:$repoDir",
|
||||||
"0",
|
"-v",
|
||||||
"--gid",
|
"$workspace:$workspace",
|
||||||
"0",
|
"-v",
|
||||||
"--ro-bind",
|
"${repoDir.resolve(".git/werkator/buildenv/home")}:/root",
|
||||||
rootfsUnpacked().toString(),
|
"-e",
|
||||||
"/",
|
"branch=main",
|
||||||
"--bind",
|
"-w",
|
||||||
repoDir.toString(),
|
|
||||||
repoDir.toString(),
|
|
||||||
"--bind",
|
|
||||||
workspace.toString(),
|
|
||||||
workspace.toString(),
|
|
||||||
"--bind",
|
|
||||||
repoDir.resolve(".git/werkator/buildenv/home").toString(),
|
|
||||||
"/root",
|
|
||||||
"--ro-bind",
|
|
||||||
"/etc/resolv.conf",
|
|
||||||
"/etc/resolv.conf",
|
|
||||||
"--proc",
|
|
||||||
"/proc",
|
|
||||||
"--dev",
|
|
||||||
"/dev",
|
|
||||||
"--tmpfs",
|
|
||||||
"/tmp",
|
|
||||||
"--setenv",
|
|
||||||
"HOME",
|
|
||||||
"/root",
|
|
||||||
"--setenv",
|
|
||||||
"TMPDIR",
|
|
||||||
"/tmp",
|
|
||||||
"--setenv",
|
|
||||||
"TMP",
|
|
||||||
"/tmp",
|
|
||||||
"--setenv",
|
|
||||||
"branch",
|
|
||||||
"main",
|
|
||||||
"--chdir",
|
|
||||||
workspace.toString(),
|
workspace.toString(),
|
||||||
|
imageName(),
|
||||||
"/bin/sh",
|
"/bin/sh",
|
||||||
"-c",
|
"-c",
|
||||||
"./gradlew test",
|
"./gradlew test",
|
||||||
)
|
)
|
||||||
Files.isDirectory(rootfsUnpacked()) shouldBe true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("binds a relative workspace path at its absolute location") {
|
test("loads the image once when werkdock does not know it yet") {
|
||||||
// bwrap creates mountpoints for bind destinations inside the sandbox;
|
givenImageMissing()
|
||||||
// a relative path would land in the read-only rootfs and fail with
|
|
||||||
// "Can't mkdir parents ...: Read-only file system" (seen on the webspace).
|
|
||||||
every {
|
every {
|
||||||
commandRunner.runOrThrow(
|
commandRunner.runOrThrow(
|
||||||
listOf("tar", "--no-same-owner", "-xf", "/srv/buildenv.tar.zst", "-C", rootfsUnpacked().toString()),
|
listOf("werkdock", "load", "-i", "/srv/buildenv.tar.zst", "--name", imageName()),
|
||||||
repoDir,
|
repoDir,
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
)
|
)
|
||||||
} returns
|
} returns GitCommandResult(0, "", "")
|
||||||
GitCommandResult(0, "", "")
|
|
||||||
|
|
||||||
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
||||||
|
|
||||||
|
verify {
|
||||||
|
commandRunner.runOrThrow(
|
||||||
|
listOf("werkdock", "load", "-i", "/srv/buildenv.tar.zst", "--name", imageName()),
|
||||||
|
repoDir,
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("does not load an image werkdock already has") {
|
||||||
|
givenImageLoaded()
|
||||||
|
|
||||||
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
||||||
|
|
||||||
|
verify(exactly = 0) { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("uses the configured werkdock binary path") {
|
||||||
|
every { commandRunner.runOrThrow(listOf("/opt/bin/werkdock", "images"), repoDir, any(), any()) } returns
|
||||||
|
GitCommandResult(0, imageName() + "\n", "")
|
||||||
|
val branchConfig =
|
||||||
|
BranchConfig(
|
||||||
|
bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst", werkdock = "/opt/bin/werkdock"),
|
||||||
|
)
|
||||||
|
|
||||||
|
runner.start("./gradlew test", workspace, emptyMap(), repoDir, branchConfig)
|
||||||
|
|
||||||
|
captured.single().first() shouldBe "/opt/bin/werkdock"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mounts a relative workspace path at its absolute location") {
|
||||||
|
givenImageLoaded()
|
||||||
val relativeWorkspace = repoDir.relativize(workspace)
|
val relativeWorkspace = repoDir.relativize(workspace)
|
||||||
|
|
||||||
runner.start("./gradlew test", relativeWorkspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
runner.start("./gradlew test", relativeWorkspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
val absolute = workspace.toAbsolutePath().normalize().toString()
|
val absolute = workspace.toAbsolutePath().normalize().toString()
|
||||||
val bindIdx = args.withIndex().filter { it.value == "--bind" }.map { it.index }
|
args shouldContainElement "-v"
|
||||||
// first bind is the repo dir (mountpoint base), second is the workspace
|
args[args.indexOf("-w") + 1] shouldBe absolute
|
||||||
args[bindIdx[1] + 1] shouldBe absolute
|
args.count { it == "$absolute:$absolute" } shouldBe 1
|
||||||
args[bindIdx[1] + 2] shouldBe absolute
|
|
||||||
args[args.indexOf("--chdir") + 1] shouldBe absolute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("does not re-unpack an already prepared rootfs") {
|
test("adds bwrap env after the branch environment") {
|
||||||
Files.createDirectories(rootfsUnpacked())
|
givenImageLoaded()
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
|
||||||
|
|
||||||
verify(exactly = 0) { commandRunner.runOrThrow(match { it.first() == "tar" }, any(), any(), any()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
test("adds bwrap env and passes the branch environment through") {
|
|
||||||
Files.createDirectories(rootfsUnpacked())
|
|
||||||
|
|
||||||
runner.start(
|
runner.start(
|
||||||
"./gradlew test",
|
"./gradlew test",
|
||||||
@@ -168,39 +157,43 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
args[args.indexOf("branch") - 1] shouldBe "--setenv"
|
args[args.indexOf("branch=main") - 1] shouldBe "-e"
|
||||||
args[args.indexOf("branch") + 1] shouldBe "main"
|
args[args.indexOf("FOO=bar") - 1] shouldBe "-e"
|
||||||
args[args.indexOf("FOO") - 1] shouldBe "--setenv"
|
args.indexOf("branch=main") shouldBe args.indexOf("FOO=bar") - 2
|
||||||
args[args.indexOf("FOO") + 1] shouldBe "bar"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("exposes git metadata read-only with the werkator dir masked for a worktree workspace") {
|
test("exposes git metadata read-only with the werkator dir masked, in mount order") {
|
||||||
val gitDir = repoDir.resolve(".git")
|
val gitDir = repoDir.resolve(".git")
|
||||||
val adminDir = gitDir.resolve("worktrees/workspace")
|
val adminDir = gitDir.resolve("worktrees/workspace")
|
||||||
Files.createDirectories(adminDir)
|
Files.createDirectories(adminDir)
|
||||||
Files.createDirectories(gitDir.resolve("werkator"))
|
Files.createDirectories(gitDir.resolve("werkator"))
|
||||||
Files.createDirectories(workspace)
|
Files.createDirectories(workspace)
|
||||||
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
|
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
|
||||||
Files.createDirectories(rootfsUnpacked())
|
givenImageLoaded()
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
args[args.indexOf(gitDir.toString()) - 1] shouldBe "--ro-bind"
|
args[args.indexOf("$gitDir:$gitDir:ro") - 1] shouldBe "-v"
|
||||||
args[args.indexOf("$gitDir/werkator") - 1] shouldBe "--tmpfs"
|
args[args.indexOf("$gitDir/werkator") - 1] shouldBe "--tmpfs"
|
||||||
args[args.indexOf(adminDir.toString()) - 1] shouldBe "--bind"
|
args[args.indexOf("$adminDir:$adminDir") - 1] shouldBe "-v"
|
||||||
|
// order: ro .git, tmpfs mask, admin dir, then the workspace bind that
|
||||||
|
// shadows the mask at its own path
|
||||||
|
val roGit = args.indexOf("$gitDir:$gitDir:ro")
|
||||||
|
val mask = args.indexOf("$gitDir/werkator")
|
||||||
|
val admin = args.indexOf("$adminDir:$adminDir")
|
||||||
|
val workspaceBind = args.indexOf("$workspace:$workspace")
|
||||||
|
(roGit < mask && mask < admin && admin < workspaceBind) shouldBe true
|
||||||
}
|
}
|
||||||
|
|
||||||
test("mounts no git metadata when the workspace is not a worktree") {
|
test("mounts no git metadata when the workspace is not a worktree") {
|
||||||
Files.createDirectories(rootfsUnpacked())
|
givenImageLoaded()
|
||||||
Files.createDirectories(workspace)
|
Files.createDirectories(workspace)
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
|
||||||
|
|
||||||
val args = captured.single()
|
val args = captured.single()
|
||||||
val gitDir = repoDir.resolve(".git")
|
val gitDir = repoDir.resolve(".git")
|
||||||
// the sandbox's own /tmp tmpfs is always present; the point is that no tmpfs
|
|
||||||
// masks .git/werkator and no worktree admin dir is bound
|
|
||||||
args.none { it == "$gitDir/werkator" } shouldBe true
|
args.none { it == "$gitDir/werkator" } shouldBe true
|
||||||
args.none { it.contains("worktrees/") } shouldBe true
|
args.none { it.contains("worktrees/") } shouldBe true
|
||||||
}
|
}
|
||||||
@@ -216,16 +209,17 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
exception.message shouldContain "bwrap.rootfs"
|
exception.message shouldContain "bwrap.rootfs"
|
||||||
}
|
}
|
||||||
|
|
||||||
test("downloads a URL rootfs once before unpacking") {
|
test("downloads a URL rootfs once before loading it") {
|
||||||
val url = "https://example.test/buildenv.tar.zst"
|
val url = "https://example.test/buildenv.tar.zst"
|
||||||
val downloadTarget =
|
val downloadTarget =
|
||||||
repoDir
|
repoDir
|
||||||
.resolve(BwrapBuildRunner.BUILDENV_DIR)
|
.resolve(BwrapBuildRunner.BUILDENV_DIR)
|
||||||
.resolve(url.sha12())
|
.resolve(url.sha12())
|
||||||
.resolve("buildenv.tar.zst")
|
.resolve("buildenv.tar.zst")
|
||||||
|
givenImageMissing()
|
||||||
every { commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any()) } returns
|
every { commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any()) } returns
|
||||||
GitCommandResult(0, "", "")
|
GitCommandResult(0, "", "")
|
||||||
every { commandRunner.runOrThrow(match { it.first() == "tar" }, any(), any(), any()) } returns
|
every { commandRunner.runOrThrow(match { "load" in it }, any(), any(), any()) } returns
|
||||||
GitCommandResult(0, "", "")
|
GitCommandResult(0, "", "")
|
||||||
|
|
||||||
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig(rootfs = url))
|
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig(rootfs = url))
|
||||||
@@ -233,10 +227,19 @@ class BwrapBuildRunnerTest : FunSpec() {
|
|||||||
verify {
|
verify {
|
||||||
commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any())
|
commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any())
|
||||||
}
|
}
|
||||||
verify { commandRunner.runOrThrow(match { it.first() == "tar" }, repoDir, any(), any()) }
|
verify {
|
||||||
|
commandRunner.runOrThrow(
|
||||||
|
listOf("werkdock", "load", "-i", downloadTarget.toString(), "--name", "werkator-buildenv-${url.sha12()}"),
|
||||||
|
repoDir,
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private infix fun List<String>.shouldContainElement(element: String) = (element in this) shouldBe true
|
||||||
|
|
||||||
private fun String.sha12(): String =
|
private fun String.sha12(): String =
|
||||||
java.security.MessageDigest
|
java.security.MessageDigest
|
||||||
.getInstance("SHA-256")
|
.getInstance("SHA-256")
|
||||||
|
|||||||
+177
-108
@@ -5,63 +5,65 @@
|
|||||||
# the second the command. All connection and deployment values come from the
|
# the second the command. All connection and deployment values come from the
|
||||||
# `.env` file in the repository root — never as command line parameters.
|
# `.env` file in the repository root — never as command line parameters.
|
||||||
#
|
#
|
||||||
# NOTE: `install` (its clone step) and `build` are a prototype of the webspace
|
# Commands name their role (step 21 session D): `instance-*` manages the
|
||||||
# self-build workflow. They proved the bwrap sandbox, but as a deployment path
|
# BUILDER — the installed Werkator instance and its werkdock sandbox tool —
|
||||||
# they invert ADR 0006 (build locally, install the bundle) and will be replaced
|
# while `repo-*` acts on the BUILT, the repository the instance watches.
|
||||||
# by session D of docs/plan/21-werkdock-extraction-and-webspace-install.md.
|
# 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:
|
# Usage:
|
||||||
# tools/remote werkator check-prerequisites
|
# tools/remote werkator check-prerequisites bwrap capability + disk/quota on the host
|
||||||
# tools/remote werkator install
|
# tools/remote werkator instance-install first-time: upload + unpack bundle and werkdock
|
||||||
# tools/remote werkator build # WERKATOR_BRANCH to override, default main
|
# tools/remote werkator instance-update redeploy bundle + werkdock, restart the service
|
||||||
# tools/remote werkator start
|
# tools/remote werkator instance-start server config, Apache proxy, systemd unit
|
||||||
# tools/remote port-forward start # background tunnel to the Werkator UI
|
# tools/remote werkator repo-init clone the watched repo, init, rootfs, bwrap config
|
||||||
# tools/remote port-forward stop
|
|
||||||
# tools/remote werkator control-token
|
# tools/remote werkator control-token
|
||||||
|
# tools/remote port-forward start background tunnel to the Werkator UI
|
||||||
|
# tools/remote port-forward stop
|
||||||
#
|
#
|
||||||
# Required in .env:
|
# Required in .env:
|
||||||
# WERKATOR_REMOTE user@host to operate on, e.g. mih34-werkator@mih34.hostsharing.net
|
# 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
|
# 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_PORT the localhost port assigned by Hostsharing (eigener Serverdienst)
|
||||||
# WERKATOR_DOMAIN the domain served by the managed Apache, e.g. ci.example.de
|
# WERKATOR_DOMAIN the domain served by the managed Apache, e.g. ci.example.de
|
||||||
#
|
#
|
||||||
# Required for `port-forward`:
|
# Required for `port-forward`:
|
||||||
# WERKATOR_LOCAL_PORT the local port the browser uses
|
# WERKATOR_LOCAL_PORT the local port the browser uses
|
||||||
# Optional in .env:
|
# Optional in .env:
|
||||||
# WERKATOR_BRANCH branch for `build` (default: main)
|
# WERKATOR_REPO_URL https clone URL of the watched repository
|
||||||
# WERKATOR_MEMORY_MAX systemd MemoryMax for the unit, e.g. 1G (start)
|
# (default: https://github.com/mhoennig/werkator.git)
|
||||||
# WERKATOR_TASKS_MAX systemd TasksMax for the unit, e.g. 512 (start)
|
# WERKATOR_MEMORY_MAX systemd MemoryMax for the unit, e.g. 1G (instance-start)
|
||||||
# WERKATOR_ROOTFS rootfs archive path
|
# WERKATOR_TASKS_MAX systemd TasksMax for the unit, e.g. 512 (instance-start)
|
||||||
# (default: <repo>/build/werkator-buildenv-trixie.tar.zst)
|
# WERKATOR_ROOTFS rootfs archive path for repo-init
|
||||||
|
# (default: <repo>/build/werkator-buildenv-trixie-java-go-node.tar.zst)
|
||||||
#
|
#
|
||||||
# Install layout on the host:
|
# Install layout on the host:
|
||||||
# $WERKATOR_PATH/werkator/ the repository clone
|
# $WERKATOR_PATH/werkator/ the watched repository (clone)
|
||||||
# $WERKATOR_PATH/.werkator/ runtime bundle + rootfs archive
|
# $WERKATOR_PATH/.werkator/werkator/ the unpacked runtime bundle
|
||||||
#
|
# $WERKATOR_PATH/.werkator/bin/ the werkdock binary
|
||||||
# `install` performs, in order:
|
# $WERKATOR_PATH/.werkator/*.tar.* uploaded bundle and rootfs archives
|
||||||
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
REPO="${1:-}"
|
REPO="${1:-}"
|
||||||
COMMAND="${2:-}"
|
COMMAND="${2:-}"
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
PREREQ_SCRIPT="$REPO_ROOT/tools/werkator-build-prerequisites.sh"
|
PREREQ_SCRIPT="$REPO_ROOT/tools/werkator-build-prerequisites.sh"
|
||||||
RUNTIME_BUNDLE="$REPO_ROOT/build/distributions/werkator-runtime-linux-x64.tar.gz"
|
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"
|
PID_FILE="/tmp/werkator-port-forward-$(id -u).pid"
|
||||||
LOG_FILE="/tmp/werkator-port-forward-$(id -u).log"
|
LOG_FILE="/tmp/werkator-port-forward-$(id -u).log"
|
||||||
|
|
||||||
usage() {
|
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
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +89,11 @@ set +a
|
|||||||
require_env WERKATOR_REMOTE WERKATOR_PATH
|
require_env WERKATOR_REMOTE WERKATOR_PATH
|
||||||
HOST="$WERKATOR_REMOTE"
|
HOST="$WERKATOR_REMOTE"
|
||||||
TARGET_DIR="$WERKATOR_PATH"
|
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_present() {
|
||||||
ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" true 2>/dev/null
|
ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" true 2>/dev/null
|
||||||
@@ -99,7 +105,7 @@ ensure_ssh() {
|
|||||||
else
|
else
|
||||||
echo "==> No key-based SSH access yet; running ssh-copy-id (password prompt expected)"
|
echo "==> No key-based SSH access yet; running ssh-copy-id (password prompt expected)"
|
||||||
ssh-copy-id "$HOST"
|
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
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,118 +115,167 @@ check_prerequisites() {
|
|||||||
echo "==> Checking prerequisites on $HOST (target dir: $TARGET_DIR)"
|
echo "==> Checking prerequisites on $HOST (target dir: $TARGET_DIR)"
|
||||||
local rootfs_remote="$TARGET_DIR/.werkator/$(basename "$ROOTFS")"
|
local rootfs_remote="$TARGET_DIR/.werkator/$(basename "$ROOTFS")"
|
||||||
if ! ssh "$HOST" "WERKATOR_SSH_TARGET='$HOST' bash -s -- '$TARGET_DIR' '$rootfs_remote'" < "$PREREQ_SCRIPT"; then
|
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
|
die "prerequisites failed on $HOST — install aborted"
|
||||||
exit 1
|
|
||||||
fi
|
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
|
if [ ! -f "$RUNTIME_BUNDLE" ]; then
|
||||||
echo "==> Runtime bundle not found; building it locally (./gradlew runtimeBundle)"
|
echo "==> Runtime bundle not found; building it locally (./gradlew runtimeBundle)"
|
||||||
(cd "$REPO_ROOT" && ./gradlew runtimeBundle --console=plain -q)
|
(cd "$REPO_ROOT" && ./gradlew runtimeBundle --console=plain -q)
|
||||||
fi
|
fi
|
||||||
[ -f "$RUNTIME_BUNDLE" ] || { echo "ERROR: runtime bundle missing: $RUNTIME_BUNDLE" >&2; exit 1; }
|
[ -f "$RUNTIME_BUNDLE" ] || die "runtime bundle missing: $RUNTIME_BUNDLE"
|
||||||
[ -f "$ROOTFS" ] || {
|
if [ ! -f "$WERKDOCK_BINARY" ]; then
|
||||||
echo "ERROR: rootfs archive missing: $ROOTFS" >&2
|
echo "==> werkdock binary not found; building it locally (go build)"
|
||||||
echo " build it with tools/build-bwrap-rootfs.sh or set WERKATOR_ROOTFS" >&2
|
(cd "$REPO_ROOT/werkdock" && CGO_ENABLED=0 go build -o dist/werkdock .)
|
||||||
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
|
fi
|
||||||
echo
|
[ -f "$WERKDOCK_BINARY" ] || die "werkdock binary missing: $WERKDOCK_BINARY"
|
||||||
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() {
|
# 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
|
ensure_ssh
|
||||||
check_prerequisites
|
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"
|
# Refuse to swap the runtime under a running build; FORCE=1 overrides.
|
||||||
ssh "$HOST" "mkdir -p '$TARGET_DIR/.werkator'"
|
require_idle() {
|
||||||
scp -q "$RUNTIME_BUNDLE" "$HOST:$TARGET_DIR/.werkator/"
|
local port
|
||||||
scp -q "$ROOTFS" "$HOST:$TARGET_DIR/.werkator/"
|
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"
|
instance_update() {
|
||||||
ssh "$HOST" "tar xzf '$TARGET_DIR/.werkator/$(basename "$RUNTIME_BUNDLE")' -C '$TARGET_DIR/.werkator'"
|
ensure_ssh
|
||||||
ssh "$HOST" "'$TARGET_DIR/.werkator/werkator/bin/werkator' --version"
|
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
|
if ssh "$HOST" "test -d '$TARGET_DIR/werkator/.git'"; then
|
||||||
echo " (already cloned, skipping)"
|
echo " (already cloned, skipping)"
|
||||||
else
|
else
|
||||||
ssh "$HOST" "git clone git@github.com:mhoennig/werkator.git '$TARGET_DIR/werkator'"
|
ssh "$HOST" "git clone '$REPO_URL' '$TARGET_DIR/werkator'"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Running werkator init"
|
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"
|
echo "==> Uploading the rootfs archive (skipped when unchanged)"
|
||||||
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'
|
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).
|
echo "==> Writing the machine-local bwrap configuration"
|
||||||
# Both keys are pinned: read from this machine config even if a branch sets
|
# NOTE: the guard must match the block's real indentation — a mismatch here
|
||||||
# its own values in a committed .werkator.yml.
|
# 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:
|
builds:
|
||||||
default:
|
default:
|
||||||
bwrap:
|
bwrap:
|
||||||
enabled: true
|
enabled: true
|
||||||
rootfs: $TARGET_DIR/.werkator/$(basename "$ROOTFS")
|
rootfs: $rootfs_remote
|
||||||
|
werkdock: $TARGET_DIR/.werkator/bin/werkdock
|
||||||
CFG"
|
CFG"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Verifying the effective configuration"
|
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
|
||||||
echo "==> Install complete."
|
echo "==> Repository ready."
|
||||||
echo " Repo: $TARGET_DIR/werkator"
|
echo " Repo: $TARGET_DIR/werkator"
|
||||||
echo " Runtime: $TARGET_DIR/.werkator/werkator/bin/werkator"
|
echo " Next: fill git.account/git.token in $MACHINE_CONFIG if the origin is private,"
|
||||||
echo " Next: tools/remote werkator build"
|
echo " then tools/remote werkator instance-start"
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
# Start the server as a systemd user unit behind the managed Apache.
|
||||||
# WERKATOR_MEMORY_MAX / WERKATOR_TASKS_MAX (optional) are written into the
|
# WERKATOR_MEMORY_MAX / WERKATOR_TASKS_MAX (optional) are written into the
|
||||||
# machine config so `init --systemd` bakes them into the unit.
|
# machine config so `init --systemd` bakes them into the unit.
|
||||||
start() {
|
instance_start() {
|
||||||
ensure_ssh
|
ensure_ssh
|
||||||
require_env WERKATOR_PORT WERKATOR_DOMAIN
|
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"
|
local htaccess="$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www/.htaccess"
|
||||||
|
|
||||||
echo "==> Writing server settings to the machine config"
|
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)
|
# 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
|
else
|
||||||
ssh "$HOST" "cat >> '$machine' <<'CFG'
|
ssh "$HOST" "cat >> '$MACHINE_CONFIG' <<'CFG'
|
||||||
|
|
||||||
# Web access: the managed Apache terminates TLS and proxies to the localhost
|
# Web access: the managed Apache terminates TLS and proxies to the localhost
|
||||||
# port assigned by Hostsharing (eigener Serverdienst); TLS is the domain's
|
# 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"
|
HT"
|
||||||
|
|
||||||
echo "==> Generating the systemd user unit (init --systemd)"
|
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"
|
echo "==> Linking the units into ~/.config/systemd/user and enabling the service"
|
||||||
ssh "$HOST" "mkdir -p ~/.config/systemd/user && \
|
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.service' ~/.config/systemd/user/ && \
|
||||||
ln -sf '$TARGET_DIR/werkator/.git/werkator/werkator-docker-prune.timer' ~/.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
|
||||||
echo "==> Server started. Verify: https://$WERKATOR_DOMAIN/"
|
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
|
# Background SSH tunnel to the Werkator server, so the browser reaches the UI
|
||||||
@@ -266,8 +323,8 @@ HT"
|
|||||||
port_forward() {
|
port_forward() {
|
||||||
require_env WERKATOR_LOCAL_PORT
|
require_env WERKATOR_LOCAL_PORT
|
||||||
local remote_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'")"
|
remote_port="$(ssh "$HOST" "awk '/^server:/{f=1;next} f && /^ port:/{print \$2; exit}' '$MACHINE_CONFIG'")"
|
||||||
[ -n "$remote_port" ] || { echo "ERROR: no server.port in the machine config — run 'tools/remote werkator start' first" >&2; exit 1; }
|
[ -n "$remote_port" ] || die "no server.port in the machine config — run 'tools/remote werkator instance-start' first"
|
||||||
|
|
||||||
case "$COMMAND" in
|
case "$COMMAND" in
|
||||||
start)
|
start)
|
||||||
@@ -324,18 +381,30 @@ case "$REPO" in
|
|||||||
ensure_ssh
|
ensure_ssh
|
||||||
check_prerequisites
|
check_prerequisites
|
||||||
;;
|
;;
|
||||||
install)
|
instance-install)
|
||||||
install
|
instance_install
|
||||||
;;
|
;;
|
||||||
build)
|
instance-update)
|
||||||
build
|
instance_update
|
||||||
;;
|
;;
|
||||||
start)
|
instance-start)
|
||||||
start
|
instance_start
|
||||||
|
;;
|
||||||
|
repo-init)
|
||||||
|
repo_init
|
||||||
;;
|
;;
|
||||||
control-token)
|
control-token)
|
||||||
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
|
echo "ERROR: unknown command: $COMMAND" >&2
|
||||||
usage
|
usage
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ func Main(args []string) int {
|
|||||||
return runCmd(args[1:])
|
return runCmd(args[1:])
|
||||||
case "load":
|
case "load":
|
||||||
return loadCmd(args[1:])
|
return loadCmd(args[1:])
|
||||||
|
case "images":
|
||||||
|
return imagesCmd(args[1:])
|
||||||
case "doctor":
|
case "doctor":
|
||||||
return doctorCmd(args[1:])
|
return doctorCmd(args[1:])
|
||||||
case "version", "--version":
|
case "version", "--version":
|
||||||
@@ -49,11 +51,13 @@ Network, uid, /proc, /dev, and /tmp come from the host by contract.
|
|||||||
Usage:
|
Usage:
|
||||||
werkdock run [flags] IMAGE COMMAND [ARG...] run a command in a sandbox
|
werkdock run [flags] IMAGE COMMAND [ARG...] run a command in a sandbox
|
||||||
werkdock load -i ARCHIVE [--name NAME] import a rootfs archive as an image
|
werkdock load -i ARCHIVE [--name NAME] import a rootfs archive as an image
|
||||||
|
werkdock images list loaded images, one name per line
|
||||||
werkdock doctor [TARGET_DIR] check whether this host can run sandboxes
|
werkdock doctor [TARGET_DIR] check whether this host can run sandboxes
|
||||||
werkdock version print the version
|
werkdock version print the version
|
||||||
|
|
||||||
Run flags:
|
Run flags:
|
||||||
-v, --volume SRC:DEST[:ro] bind mount (repeatable, applied in order)
|
-v, --volume SRC:DEST[:ro] bind mount (repeatable; -v and --tmpfs apply in flag order)
|
||||||
|
--tmpfs DEST empty tmpfs at DEST (repeatable)
|
||||||
-e, --env KEY=VALUE set an environment variable (KEY alone copies it from the host)
|
-e, --env KEY=VALUE set an environment variable (KEY alone copies it from the host)
|
||||||
-w, --workdir DIR working directory inside the sandbox (default /)
|
-w, --workdir DIR working directory inside the sandbox (default /)
|
||||||
--rm remove the instance afterwards (currently required)
|
--rm remove the instance afterwards (currently required)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"werkdock/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// imagesCmd prints the loaded image names, one per line — machine-usable
|
||||||
|
// (Werkator checks image existence through it) and close enough to
|
||||||
|
// `docker images --format '{{.Repository}}'`.
|
||||||
|
func imagesCmd(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("images", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(io.Discard)
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
if len(fs.Args()) != 0 {
|
||||||
|
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[0]))
|
||||||
|
}
|
||||||
|
st, err := store.Default()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
names, err := st.List()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
fmt.Println(name)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// runOptions is the parsed form of `werkdock run` flags, separated from
|
// runOptions is the parsed form of `werkdock run` flags, separated from
|
||||||
// execution so the parsing is testable and a later daemon can reuse it.
|
// execution so the parsing is testable and a later daemon can reuse it.
|
||||||
type runOptions struct {
|
type runOptions struct {
|
||||||
Volumes []engine.Bind
|
Mounts []engine.Mount
|
||||||
Env []engine.EnvVar
|
Env []engine.EnvVar
|
||||||
Workdir string
|
Workdir string
|
||||||
Remove bool
|
Remove bool
|
||||||
@@ -39,7 +39,7 @@ func runCmd(args []string) int {
|
|||||||
}
|
}
|
||||||
spec := engine.RunSpec{
|
spec := engine.RunSpec{
|
||||||
RootFS: rootfs,
|
RootFS: rootfs,
|
||||||
Binds: hostBinds(opts.Volumes),
|
Mounts: hostMounts(opts.Mounts),
|
||||||
Env: opts.Env,
|
Env: opts.Env,
|
||||||
Workdir: opts.Workdir,
|
Workdir: opts.Workdir,
|
||||||
Command: opts.Command,
|
Command: opts.Command,
|
||||||
@@ -52,15 +52,15 @@ func runCmd(args []string) int {
|
|||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostBinds prepends the host mounts the contract prescribes: DNS comes
|
// hostMounts prepends the host mounts the contract prescribes: DNS comes
|
||||||
// from the host, so /etc/resolv.conf is bound read-only when it exists —
|
// from the host, so /etc/resolv.conf is bound read-only when it exists —
|
||||||
// before the user binds, so an explicit bind over /etc wins.
|
// before the user mounts, so an explicit mount over /etc wins.
|
||||||
func hostBinds(volumes []engine.Bind) []engine.Bind {
|
func hostMounts(mounts []engine.Mount) []engine.Mount {
|
||||||
var binds []engine.Bind
|
var all []engine.Mount
|
||||||
if fi, err := os.Stat("/etc/resolv.conf"); err == nil && fi.Mode().IsRegular() {
|
if fi, err := os.Stat("/etc/resolv.conf"); err == nil && fi.Mode().IsRegular() {
|
||||||
binds = append(binds, engine.Bind{Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf", ReadOnly: true})
|
all = append(all, engine.Mount{Mode: engine.MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"})
|
||||||
}
|
}
|
||||||
return append(binds, volumes...)
|
return append(all, mounts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseRun parses the docker-shaped run flags. Docker flags whose
|
// parseRun parses the docker-shaped run flags. Docker flags whose
|
||||||
@@ -69,10 +69,16 @@ func hostBinds(volumes []engine.Bind) []engine.Bind {
|
|||||||
func parseRun(args []string, getenv func(string) string) (*runOptions, error) {
|
func parseRun(args []string, getenv func(string) string) (*runOptions, error) {
|
||||||
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
var volumes, envs stringList
|
var envs stringList
|
||||||
opts := &runOptions{}
|
opts := &runOptions{}
|
||||||
fs.Var(&volumes, "v", "bind mount SRC:DEST[:ro]")
|
// -v and --tmpfs collect into ONE ordered list: bwrap layers mounts in
|
||||||
fs.Var(&volumes, "volume", "bind mount SRC:DEST[:ro]")
|
// order, so a tmpfs between two binds (the git-metadata mask) must stay
|
||||||
|
// between them.
|
||||||
|
volumes := &mountFlag{mounts: &opts.Mounts}
|
||||||
|
tmpfs := &mountFlag{mounts: &opts.Mounts, tmpfs: true}
|
||||||
|
fs.Var(volumes, "v", "bind mount SRC:DEST[:ro]")
|
||||||
|
fs.Var(volumes, "volume", "bind mount SRC:DEST[:ro]")
|
||||||
|
fs.Var(tmpfs, "tmpfs", "empty tmpfs at DEST")
|
||||||
fs.Var(&envs, "e", "environment variable KEY=VALUE")
|
fs.Var(&envs, "e", "environment variable KEY=VALUE")
|
||||||
fs.Var(&envs, "env", "environment variable KEY=VALUE")
|
fs.Var(&envs, "env", "environment variable KEY=VALUE")
|
||||||
fs.StringVar(&opts.Workdir, "w", "", "working directory inside the sandbox")
|
fs.StringVar(&opts.Workdir, "w", "", "working directory inside the sandbox")
|
||||||
@@ -101,13 +107,6 @@ func parseRun(args []string, getenv func(string) string) (*runOptions, error) {
|
|||||||
}
|
}
|
||||||
opts.Image = rest[0]
|
opts.Image = rest[0]
|
||||||
opts.Command = rest[1:]
|
opts.Command = rest[1:]
|
||||||
for _, v := range volumes {
|
|
||||||
bind, err := parseVolume(v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
opts.Volumes = append(opts.Volumes, bind)
|
|
||||||
}
|
|
||||||
for _, e := range envs {
|
for _, e := range envs {
|
||||||
opts.Env = append(opts.Env, parseEnv(e, getenv))
|
opts.Env = append(opts.Env, parseEnv(e, getenv))
|
||||||
}
|
}
|
||||||
@@ -117,25 +116,54 @@ func parseRun(args []string, getenv func(string) string) (*runOptions, error) {
|
|||||||
return opts, nil
|
return opts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseVolume(v string) (engine.Bind, error) {
|
func parseVolume(v string) (engine.Mount, error) {
|
||||||
parts := strings.Split(v, ":")
|
parts := strings.Split(v, ":")
|
||||||
if len(parts) < 2 || len(parts) > 3 {
|
if len(parts) < 2 || len(parts) > 3 {
|
||||||
return engine.Bind{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v)
|
return engine.Mount{}, fmt.Errorf("invalid volume %q, expected SRC:DEST[:ro]", v)
|
||||||
}
|
}
|
||||||
bind := engine.Bind{Source: parts[0], Dest: parts[1]}
|
mount := engine.Mount{Mode: engine.MountBind, Source: parts[0], Dest: parts[1]}
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
if parts[2] != "ro" {
|
switch parts[2] {
|
||||||
return engine.Bind{}, fmt.Errorf("invalid volume option %q in %q, only 'ro' is supported", parts[2], v)
|
case "ro":
|
||||||
|
mount.Mode = engine.MountRoBind
|
||||||
|
case "rw":
|
||||||
|
// docker accepts :rw as the explicit default; so do we
|
||||||
|
default:
|
||||||
|
return engine.Mount{}, fmt.Errorf("invalid volume option %q in %q, only 'ro' and 'rw' are supported", parts[2], v)
|
||||||
}
|
}
|
||||||
bind.ReadOnly = true
|
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(bind.Source) {
|
if !filepath.IsAbs(mount.Source) {
|
||||||
return engine.Bind{}, fmt.Errorf("volume source must be an absolute path: %s", bind.Source)
|
return engine.Mount{}, fmt.Errorf("volume source must be an absolute path: %s", mount.Source)
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(bind.Dest) {
|
if !filepath.IsAbs(mount.Dest) {
|
||||||
return engine.Bind{}, fmt.Errorf("volume destination must be an absolute path: %s", bind.Dest)
|
return engine.Mount{}, fmt.Errorf("volume destination must be an absolute path: %s", mount.Dest)
|
||||||
}
|
}
|
||||||
return bind, nil
|
return mount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountFlag appends -v/--volume and --tmpfs values to one shared,
|
||||||
|
// ordered mount list.
|
||||||
|
type mountFlag struct {
|
||||||
|
mounts *[]engine.Mount
|
||||||
|
tmpfs bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *mountFlag) String() string { return "" }
|
||||||
|
|
||||||
|
func (f *mountFlag) Set(v string) error {
|
||||||
|
if f.tmpfs {
|
||||||
|
if !filepath.IsAbs(v) {
|
||||||
|
return fmt.Errorf("tmpfs destination must be an absolute path: %s", v)
|
||||||
|
}
|
||||||
|
*f.mounts = append(*f.mounts, engine.Mount{Mode: engine.MountTmpfs, Dest: v})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mount, err := parseVolume(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*f.mounts = append(*f.mounts, mount)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseEnv(e string, getenv func(string) string) engine.EnvVar {
|
func parseEnv(e string, getenv func(string) string) engine.EnvVar {
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ func TestParseRunSupportedFlags(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(opts.Command, []string{"sh", "-c", "./gradlew build"}) {
|
if !reflect.DeepEqual(opts.Command, []string{"sh", "-c", "./gradlew build"}) {
|
||||||
t.Errorf("command: got %q", opts.Command)
|
t.Errorf("command: got %q", opts.Command)
|
||||||
}
|
}
|
||||||
wantVolumes := []engine.Bind{
|
wantMounts := []engine.Mount{
|
||||||
{Source: "/repo", Dest: "/repo"},
|
{Mode: engine.MountBind, Source: "/repo", Dest: "/repo"},
|
||||||
{Source: "/cache", Dest: "/root/.gradle", ReadOnly: true},
|
{Mode: engine.MountRoBind, Source: "/cache", Dest: "/root/.gradle"},
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(opts.Volumes, wantVolumes) {
|
if !reflect.DeepEqual(opts.Mounts, wantMounts) {
|
||||||
t.Errorf("volumes: got %+v", opts.Volumes)
|
t.Errorf("mounts: got %+v", opts.Mounts)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "CI", Value: "true"}}) {
|
if !reflect.DeepEqual(opts.Env, []engine.EnvVar{{Key: "CI", Value: "true"}}) {
|
||||||
t.Errorf("env: got %+v", opts.Env)
|
t.Errorf("env: got %+v", opts.Env)
|
||||||
@@ -96,7 +96,8 @@ func TestParseRunValidation(t *testing.T) {
|
|||||||
{"no image", []string{"--rm"}, "no image specified"},
|
{"no image", []string{"--rm"}, "no image specified"},
|
||||||
{"no command", []string{"--rm", "img"}, "no command specified"},
|
{"no command", []string{"--rm", "img"}, "no command specified"},
|
||||||
{"volume without dest", []string{"--rm", "-v", "/only-src", "img", "true"}, "expected SRC:DEST"},
|
{"volume without dest", []string{"--rm", "-v", "/only-src", "img", "true"}, "expected SRC:DEST"},
|
||||||
{"volume with bad option", []string{"--rm", "-v", "/a:/b:rw", "img", "true"}, "only 'ro' is supported"},
|
{"volume with bad option", []string{"--rm", "-v", "/a:/b:cached", "img", "true"}, "only 'ro' and 'rw' are supported"},
|
||||||
|
{"relative tmpfs dest", []string{"--rm", "--tmpfs", "rel", "img", "true"}, "absolute"},
|
||||||
{"relative volume source", []string{"--rm", "-v", "rel:/b", "img", "true"}, "absolute"},
|
{"relative volume source", []string{"--rm", "-v", "rel:/b", "img", "true"}, "absolute"},
|
||||||
{"relative volume dest", []string{"--rm", "-v", "/a:rel", "img", "true"}, "absolute"},
|
{"relative volume dest", []string{"--rm", "-v", "/a:rel", "img", "true"}, "absolute"},
|
||||||
{"relative workdir", []string{"--rm", "-w", "rel", "img", "true"}, "absolute"},
|
{"relative workdir", []string{"--rm", "-w", "rel", "img", "true"}, "absolute"},
|
||||||
@@ -111,6 +112,39 @@ func TestParseRunValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseRunKeepsMountFlagOrderAcrossVolumeAndTmpfs(t *testing.T) {
|
||||||
|
// The git-metadata mask depends on it: ro-bind .git, tmpfs over
|
||||||
|
// .git/werkator, then the workspace bind — in exactly this order.
|
||||||
|
opts, err := parseRun([]string{
|
||||||
|
"--rm",
|
||||||
|
"-v", "/r/.git:/r/.git:ro",
|
||||||
|
"--tmpfs", "/r/.git/werkator",
|
||||||
|
"-v", "/r/ws:/r/ws",
|
||||||
|
"img", "true",
|
||||||
|
}, noEnv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []engine.Mount{
|
||||||
|
{Mode: engine.MountRoBind, Source: "/r/.git", Dest: "/r/.git"},
|
||||||
|
{Mode: engine.MountTmpfs, Dest: "/r/.git/werkator"},
|
||||||
|
{Mode: engine.MountBind, Source: "/r/ws", Dest: "/r/ws"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(opts.Mounts, want) {
|
||||||
|
t.Errorf("mounts: got %+v", opts.Mounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRunAcceptsTheExplicitRwVolumeOption(t *testing.T) {
|
||||||
|
opts, err := parseRun([]string{"--rm", "-v", "/a:/b:rw", "img", "true"}, noEnv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(opts.Mounts, []engine.Mount{{Mode: engine.MountBind, Source: "/a", Dest: "/b"}}) {
|
||||||
|
t.Errorf("mounts: got %+v", opts.Mounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseRunStopsFlagParsingAtTheImage(t *testing.T) {
|
func TestParseRunStopsFlagParsingAtTheImage(t *testing.T) {
|
||||||
// Docker semantics: everything after the image belongs to the
|
// Docker semantics: everything after the image belongs to the
|
||||||
// command, even if it looks like a flag.
|
// command, even if it looks like a flag.
|
||||||
|
|||||||
@@ -65,15 +65,20 @@ func (b *Bwrap) Argv(spec RunSpec) ([]string, error) {
|
|||||||
"--tmpfs", "/tmp",
|
"--tmpfs", "/tmp",
|
||||||
"--tmpfs", "/root",
|
"--tmpfs", "/root",
|
||||||
}
|
}
|
||||||
for _, bd := range spec.Binds {
|
for _, m := range spec.Mounts {
|
||||||
if !filepath.IsAbs(bd.Dest) {
|
if !filepath.IsAbs(m.Dest) {
|
||||||
return nil, fmt.Errorf("bind destination must be an absolute path: %s", bd.Dest)
|
return nil, fmt.Errorf("mount destination must be an absolute path: %s", m.Dest)
|
||||||
}
|
}
|
||||||
flag := "--bind"
|
switch m.Mode {
|
||||||
if bd.ReadOnly {
|
case MountBind:
|
||||||
flag = "--ro-bind"
|
args = append(args, "--bind", m.Source, m.Dest)
|
||||||
|
case MountRoBind:
|
||||||
|
args = append(args, "--ro-bind", m.Source, m.Dest)
|
||||||
|
case MountTmpfs:
|
||||||
|
args = append(args, "--tmpfs", m.Dest)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown mount mode %d for %s", m.Mode, m.Dest)
|
||||||
}
|
}
|
||||||
args = append(args, flag, bd.Source, bd.Dest)
|
|
||||||
}
|
}
|
||||||
args = append(args,
|
args = append(args,
|
||||||
"--clearenv",
|
"--clearenv",
|
||||||
@@ -108,17 +113,23 @@ func EnsureMountpoints(spec RunSpec) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, bd := range spec.Binds {
|
for _, m := range spec.Mounts {
|
||||||
target, err := rootfsPath(spec.RootFS, bd.Dest)
|
target, err := rootfsPath(spec.RootFS, m.Dest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := os.Lstat(target); err == nil {
|
if _, err := os.Lstat(target); err == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
src, err := os.Stat(bd.Source)
|
if m.Mode == MountTmpfs {
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src, err := os.Stat(m.Source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bind source %s: %w", bd.Source, err)
|
return fmt.Errorf("bind source %s: %w", m.Source, err)
|
||||||
}
|
}
|
||||||
if src.Mode().IsRegular() {
|
if src.Mode().IsRegular() {
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ func TestArgvAssemblesTheHardenedInvocation(t *testing.T) {
|
|||||||
b := &Bwrap{}
|
b := &Bwrap{}
|
||||||
spec := RunSpec{
|
spec := RunSpec{
|
||||||
RootFS: "/store/images/buildenv/rootfs",
|
RootFS: "/store/images/buildenv/rootfs",
|
||||||
Binds: []Bind{
|
Mounts: []Mount{
|
||||||
{Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf", ReadOnly: true},
|
{Mode: MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"},
|
||||||
{Source: "/repo", Dest: "/repo"},
|
{Mode: MountRoBind, Source: "/repo/.git", Dest: "/repo/.git"},
|
||||||
{Source: "/cache", Dest: "/root/.gradle"},
|
{Mode: MountTmpfs, Dest: "/repo/.git/werkator"},
|
||||||
|
{Mode: MountBind, Source: "/repo", Dest: "/repo"},
|
||||||
|
{Mode: MountBind, Source: "/cache", Dest: "/root/.gradle"},
|
||||||
},
|
},
|
||||||
Env: []EnvVar{{Key: "CI", Value: "true"}, {Key: "TERM", Value: "dumb"}},
|
Env: []EnvVar{{Key: "CI", Value: "true"}, {Key: "TERM", Value: "dumb"}},
|
||||||
Workdir: "/repo",
|
Workdir: "/repo",
|
||||||
@@ -34,6 +36,8 @@ func TestArgvAssemblesTheHardenedInvocation(t *testing.T) {
|
|||||||
"--ro-bind", "/store/images/buildenv/rootfs", "/",
|
"--ro-bind", "/store/images/buildenv/rootfs", "/",
|
||||||
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", "/root",
|
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", "/root",
|
||||||
"--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf",
|
"--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf",
|
||||||
|
"--ro-bind", "/repo/.git", "/repo/.git",
|
||||||
|
"--tmpfs", "/repo/.git/werkator",
|
||||||
"--bind", "/repo", "/repo",
|
"--bind", "/repo", "/repo",
|
||||||
"--bind", "/cache", "/root/.gradle",
|
"--bind", "/cache", "/root/.gradle",
|
||||||
"--clearenv",
|
"--clearenv",
|
||||||
@@ -59,8 +63,8 @@ func TestArgvValidation(t *testing.T) {
|
|||||||
{"relative rootfs", RunSpec{RootFS: "rootfs", Command: []string{"true"}}, "absolute"},
|
{"relative rootfs", RunSpec{RootFS: "rootfs", Command: []string{"true"}}, "absolute"},
|
||||||
{"missing command", RunSpec{RootFS: "/r"}, "no command specified"},
|
{"missing command", RunSpec{RootFS: "/r"}, "no command specified"},
|
||||||
{
|
{
|
||||||
"relative bind dest",
|
"relative mount dest",
|
||||||
RunSpec{RootFS: "/r", Binds: []Bind{{Source: "/s", Dest: "work"}}, Command: []string{"true"}},
|
RunSpec{RootFS: "/r", Mounts: []Mount{{Mode: MountBind, Source: "/s", Dest: "work"}}, Command: []string{"true"}},
|
||||||
"absolute",
|
"absolute",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -103,17 +107,18 @@ func TestEnsureMountpointsCreatesMissingAndSkipsExisting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
spec := RunSpec{
|
spec := RunSpec{
|
||||||
RootFS: rootfs,
|
RootFS: rootfs,
|
||||||
Binds: []Bind{
|
Mounts: []Mount{
|
||||||
{Source: "/etc", Dest: "/etc/resolv.conf", ReadOnly: true}, // exists: skipped (source type irrelevant)
|
{Mode: MountRoBind, Source: "/etc", Dest: "/etc/resolv.conf"}, // exists: skipped (source type irrelevant)
|
||||||
{Source: srcDir, Dest: "/repo/workspace"}, // missing dir mountpoint
|
{Mode: MountBind, Source: srcDir, Dest: "/repo/workspace"}, // missing dir mountpoint
|
||||||
{Source: srcFile, Dest: "/etc/hosts.werkdock"}, // missing file mountpoint
|
{Mode: MountBind, Source: srcFile, Dest: "/etc/hosts.werkdock"}, // missing file mountpoint
|
||||||
|
{Mode: MountTmpfs, Dest: "/repo/.git/werkator"}, // tmpfs mountpoint, no source
|
||||||
},
|
},
|
||||||
Command: []string{"true"},
|
Command: []string{"true"},
|
||||||
}
|
}
|
||||||
if err := EnsureMountpoints(spec); err != nil {
|
if err := EnsureMountpoints(spec); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, dir := range []string{"proc", "dev", "tmp", "root", "repo/workspace"} {
|
for _, dir := range []string{"proc", "dev", "tmp", "root", "repo/workspace", "repo/.git/werkator"} {
|
||||||
fi, err := os.Stat(filepath.Join(rootfs, dir))
|
fi, err := os.Stat(filepath.Join(rootfs, dir))
|
||||||
if err != nil || !fi.IsDir() {
|
if err != nil || !fi.IsDir() {
|
||||||
t.Errorf("expected directory mountpoint %s in the rootfs: %v", dir, err)
|
t.Errorf("expected directory mountpoint %s in the rootfs: %v", dir, err)
|
||||||
@@ -132,7 +137,7 @@ func TestEnsureMountpointsCreatesMissingAndSkipsExisting(t *testing.T) {
|
|||||||
func TestEnsureMountpointsRefusesEscapingDestinations(t *testing.T) {
|
func TestEnsureMountpointsRefusesEscapingDestinations(t *testing.T) {
|
||||||
spec := RunSpec{
|
spec := RunSpec{
|
||||||
RootFS: t.TempDir(),
|
RootFS: t.TempDir(),
|
||||||
Binds: []Bind{{Source: "/tmp", Dest: "/../outside"}},
|
Mounts: []Mount{{Mode: MountBind, Source: "/tmp", Dest: "/../outside"}},
|
||||||
Command: []string{"true"},
|
Command: []string{"true"},
|
||||||
}
|
}
|
||||||
err := EnsureMountpoints(spec)
|
err := EnsureMountpoints(spec)
|
||||||
|
|||||||
@@ -3,12 +3,25 @@
|
|||||||
// logic without duplicating it (RFC 0002).
|
// logic without duplicating it (RFC 0002).
|
||||||
package engine
|
package engine
|
||||||
|
|
||||||
// Bind is one bind mount, applied in order; later mounts shadow earlier
|
// MountMode distinguishes the mount kinds a RunSpec can carry.
|
||||||
// ones at their own path, exactly as bwrap layers them.
|
type MountMode int
|
||||||
type Bind struct {
|
|
||||||
Source string
|
const (
|
||||||
Dest string
|
// MountBind is a read-write bind mount.
|
||||||
ReadOnly bool
|
MountBind MountMode = iota
|
||||||
|
// MountRoBind is a read-only bind mount.
|
||||||
|
MountRoBind
|
||||||
|
// MountTmpfs is an empty tmpfs at Dest; Source is unused.
|
||||||
|
MountTmpfs
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mount is one mount, applied in order; later mounts shadow earlier
|
||||||
|
// ones at their own path, exactly as bwrap layers them — the order of
|
||||||
|
// -v and --tmpfs flags is therefore significant and preserved.
|
||||||
|
type Mount struct {
|
||||||
|
Mode MountMode
|
||||||
|
Source string
|
||||||
|
Dest string
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnvVar is one environment variable; order is preserved.
|
// EnvVar is one environment variable; order is preserved.
|
||||||
@@ -23,7 +36,7 @@ type RunSpec struct {
|
|||||||
// RootFS is the absolute path to the unpacked image rootfs,
|
// RootFS is the absolute path to the unpacked image rootfs,
|
||||||
// bound read-only at /.
|
// bound read-only at /.
|
||||||
RootFS string
|
RootFS string
|
||||||
Binds []Bind
|
Mounts []Mount
|
||||||
Env []EnvVar
|
Env []EnvVar
|
||||||
Workdir string
|
Workdir string
|
||||||
Command []string
|
Command []string
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -57,6 +58,26 @@ func (s Store) RootFS(name string) (string, error) {
|
|||||||
return rootfs, nil
|
return rootfs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List returns the names of all loaded images, sorted; half-written
|
||||||
|
// `.tmp` directories from an interrupted load are not images.
|
||||||
|
func (s Store) List() ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(filepath.Join(s.Root, "images"))
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() && nameRe.MatchString(e.Name()) && !strings.HasSuffix(e.Name(), ".tmp") {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Load imports a rootfs archive as an image. The archive is unpacked
|
// Load imports a rootfs archive as an image. The archive is unpacked
|
||||||
// with the tar CLI (compression auto-detected; .tar.zst needs the zstd
|
// with the tar CLI (compression auto-detected; .tar.zst needs the zstd
|
||||||
// binary, which doctor checks) into a temporary directory and renamed
|
// binary, which doctor checks) into a temporary directory and renamed
|
||||||
@@ -65,6 +86,11 @@ func (s Store) Load(archive, name string) error {
|
|||||||
if !nameRe.MatchString(name) {
|
if !nameRe.MatchString(name) {
|
||||||
return fmt.Errorf("invalid image name: %q (allowed: lowercase letters, digits, '.', '_', '-')", name)
|
return fmt.Errorf("invalid image name: %q (allowed: lowercase letters, digits, '.', '_', '-')", name)
|
||||||
}
|
}
|
||||||
|
// ".tmp" is the staging suffix of this very function — a legal-looking
|
||||||
|
// image name ending in it would collide with interrupted loads.
|
||||||
|
if strings.HasSuffix(name, ".tmp") {
|
||||||
|
return fmt.Errorf("invalid image name: %q (the .tmp suffix is reserved for staging)", name)
|
||||||
|
}
|
||||||
archiveAbs, err := filepath.Abs(archive)
|
archiveAbs, err := filepath.Abs(archive)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -115,6 +115,25 @@ func TestRootFSValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListNamesLoadedImagesAndIgnoresTmpLeftovers(t *testing.T) {
|
||||||
|
st := Store{Root: t.TempDir()}
|
||||||
|
if names, err := st.List(); err != nil || names != nil {
|
||||||
|
t.Fatalf("empty store: got %v, %v", names, err)
|
||||||
|
}
|
||||||
|
for _, dir := range []string{"beta", "alpha", "broken.tmp"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(st.Root, "images", dir), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names, err := st.List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(names) != 2 || names[0] != "alpha" || names[1] != "beta" {
|
||||||
|
t.Errorf("got %v, want [alpha beta]", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestImageNameFromArchive(t *testing.T) {
|
func TestImageNameFromArchive(t *testing.T) {
|
||||||
tests := []struct{ in, want string }{
|
tests := []struct{ in, want string }{
|
||||||
{"werkator-buildenv-trixie.tar.zst", "werkator-buildenv-trixie"},
|
{"werkator-buildenv-trixie.tar.zst", "werkator-buildenv-trixie"},
|
||||||
|
|||||||
Reference in New Issue
Block a user