Bwrap build runtime und Installation in Hostsharing Managed Webspace (#4)

* Add the bubblewrap build runtime (step 17, ADR 0007)

BwrapBuildRunner: third runtime behind BuildRunner for hosts without root
and without Docker (e.g. Hostsharing managed webspaces). Shells out to the
bwrap CLI, unpacks a prepared rootfs on demand into
.git/werkator/buildenv/<envKey>/rootfs, reuses the Docker runner's git
metadata mounts, and returns the attached bwrap process for streaming and
cancellation.

Config: bwrap.enabled/rootfs/env on BranchConfig and BwrapOverrides on
BuildDefinition; enabled/rootfs are pinned like the docker sandbox policy.
Docker and bwrap are mutually exclusive per build, rejected in
buildSettings instead of picked silently. DispatchingBuildRunner routes
bwrap; InitCommand template, docs/configuration.md and AGENTS.md in sync.

* bwrap rollout tooling: remote script, prerequisites disk/quota check, absolute workspace binds

- tools/remote: central remote control script with check-prerequisites, install and build commands
- tools/werkator-build-prerequisites.sh: compact PASS/FAIL output, target-dir parameter, free-space and
  group-quota headroom checks against the ~5 GiB build footprint, home-filesystem reference
- BwrapBuildRunner: bind workspace and home at absolute paths resolved against repoDir — a relative
  path made bwrap create mountpoints inside the read-only rootfs (seen on the webspace); regression test
- TestcontainersSmokeTest: gated with enabledIf docker available (skip, never fail, without a daemon)
- docs: configuration reference, step-17 plan notes, PR-doc

* bwrap: bind the repo read-write before the workspace so mountpoints are creatable

bwrap creates mountpoints for bind destinations inside the sandbox; with only a
read-only rootfs bound at /, creating them for the workspace under .git/werkator/
worktrees failed with 'Read-only file system' (seen on the webspace). Binding the
repo dir read-write first provides the base; the git metadata mounts then layer
the usual isolation on top (read-only .git, tmpfs mask over .git/werkator,
read-write worktree admin dir).

* bwrap: pre-create bind mountpoints inside the unpacked rootfs

bwrap mkdirs mountpoints for bind destinations against the sandbox view; with the
rootfs ro-bound at / every destination missing from the rootfs (the repo dir under
/home/storage/... on the webspace) fails with 'Read-only file system'. The rootfs
directory is a plain host dir, so create the mountpoints there before launching
bwrap; it then finds them and has nothing left to create.

* bwrap: skip existing rootfs files when pre-creating bind mountpoints

/etc/resolv.conf is a file the rootfs already ships; createDirectories threw on it.
Only missing directories are created now.

* bwrap: pre-create proc/dev/tmpfs mountpoints in the rootfs too

The rootfs archive ships no /proc or /dev (excluded when packed), so bwrap failed
mkdir'ing their mountpoints against the read-only root.

* bwrap: bind the workspace after the git metadata mounts

The tmpfs mask over .git/werkator shadowed the earlier workspace bind, because the
worktree lives under .git/werkator/worktrees — chdir then failed with ENOENT. The
workspace bind now comes last and shadows the mask at exactly its own path.

* systemd resource limits and webspace start command (step 17, web access)

- server.systemd.memoryMax/tasksMax (empty = directive omitted): on platforms where
  the service runs in a shared memory slice (Hostsharing Managed Webspaces) a
  runaway Gradle build must not starve the whole package; init --systemd reads the
  effective config and bakes the values into the generated unit
- tools/remote werkator start: writes server settings (assigned port, loopback
  bind, publicBaseUrl, nginx off) plus the Apache reverse-proxy .htaccess into
  ~/doms/<domain>/subs/www, runs init --systemd and enables the user unit
- docs/configuration.md documents the new keys

* tools/remote: env-based configuration and background port-forward

All connection and deployment values come from .env in the repository root
(WERKATOR_REMOTE, WERKATOR_PATH, WERKATOR_PORT, WERKATOR_DOMAIN,
WERKATOR_LOCAL_PORT, optional WERKATOR_BRANCH/MEMORY_MAX/TASKS_MAX/ROOTFS);
missing values fail with a pointing error instead of positional parameters.

- port-forward is now 'tools/remote port-forward start|stop' with a detached
  ssh tunnel, pid file under /tmp, and idempotent start
- start restarts the systemd unit after updating the machine config
- control-token generates the token in place when the server has not yet
- the rootfs archive default moves to build/ (already gitignored)
This commit is contained in:
Michael Hönnig
2026-08-31 19:56:04 +02:00
committed by GitHub
parent 516765a717
commit 71f1fc62c6
21 changed files with 1564 additions and 37 deletions
@@ -0,0 +1,240 @@
package de.hoennig.werkator.build
import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.BwrapConfig
import de.hoennig.werkator.git.GitCommandResult
import de.hoennig.werkator.git.GitCommandRunner
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import java.nio.file.Files
import java.nio.file.Path
class BwrapBuildRunnerTest : FunSpec() {
private val commandRunner = mockk<GitCommandRunner>()
private lateinit var runner: BwrapBuildRunner
private lateinit var repoDir: Path
private lateinit var workspace: Path
private val captured = mutableListOf<List<String>>()
private fun bwrapBranchConfig(
rootfs: String = "/srv/buildenv.tar.zst",
env: Map<String, String> = emptyMap(),
): BranchConfig =
BranchConfig(
bwrap =
BwrapConfig(
enabled = true,
rootfs = rootfs,
env = env,
),
)
private fun rootfsUnpacked(rootfs: String = "/srv/buildenv.tar.zst"): Path =
repoDir
.resolve(BwrapBuildRunner.BUILDENV_DIR)
.resolve(rootfs.sha12())
.resolve(BwrapBuildRunner.ROOTFS_DIR)
init {
beforeEach {
clearMocks(commandRunner)
captured.clear()
repoDir = Files.createTempDirectory("werkator-bwrap-runner")
workspace = repoDir.resolve("workspace")
runner = BwrapBuildRunner(commandRunner)
runner.processStarter = { command, _ ->
captured += command
ProcessBuilder("true").start()
}
}
test("unpacks the rootfs on demand and assembles the exact bwrap command") {
every {
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())
val args = captured.single()
val rootfsDir = args[args.indexOf("--ro-bind") + 1]
args shouldBe
listOf(
"bwrap",
"--unshare-user",
"--unshare-pid",
"--die-with-parent",
"--uid",
"0",
"--gid",
"0",
"--ro-bind",
rootfsUnpacked().toString(),
"/",
"--bind",
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",
"branch",
"main",
"--chdir",
workspace.toString(),
"/bin/sh",
"-c",
"./gradlew test",
)
Files.isDirectory(rootfsUnpacked()) shouldBe true
}
test("binds a relative workspace path at its absolute location") {
// bwrap creates mountpoints for bind destinations inside the sandbox;
// 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 {
commandRunner.runOrThrow(
listOf("tar", "--no-same-owner", "-xf", "/srv/buildenv.tar.zst", "-C", rootfsUnpacked().toString()),
repoDir,
any(),
any(),
)
} returns
GitCommandResult(0, "", "")
val relativeWorkspace = repoDir.relativize(workspace)
runner.start("./gradlew test", relativeWorkspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
val args = captured.single()
val absolute = workspace.toAbsolutePath().normalize().toString()
val bindIdx = args.withIndex().filter { it.value == "--bind" }.map { it.index }
// first bind is the repo dir (mountpoint base), second is the workspace
args[bindIdx[1] + 1] shouldBe absolute
args[bindIdx[1] + 2] shouldBe absolute
args[args.indexOf("--chdir") + 1] shouldBe absolute
}
test("does not re-unpack an already prepared rootfs") {
Files.createDirectories(rootfsUnpacked())
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(
"./gradlew test",
workspace,
mapOf("branch" to "main"),
repoDir,
bwrapBranchConfig(env = mapOf("FOO" to "bar")),
)
val args = captured.single()
args[args.indexOf("branch") - 1] shouldBe "--setenv"
args[args.indexOf("branch") + 1] shouldBe "main"
args[args.indexOf("FOO") - 1] shouldBe "--setenv"
args[args.indexOf("FOO") + 1] shouldBe "bar"
}
test("exposes git metadata read-only with the werkator dir masked for a worktree workspace") {
val gitDir = repoDir.resolve(".git")
val adminDir = gitDir.resolve("worktrees/workspace")
Files.createDirectories(adminDir)
Files.createDirectories(gitDir.resolve("werkator"))
Files.createDirectories(workspace)
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
Files.createDirectories(rootfsUnpacked())
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
val args = captured.single()
args[args.indexOf(gitDir.toString()) - 1] shouldBe "--ro-bind"
args[args.indexOf("$gitDir/werkator") - 1] shouldBe "--tmpfs"
args[args.indexOf(adminDir.toString()) - 1] shouldBe "--bind"
}
test("mounts no git metadata when the workspace is not a worktree") {
Files.createDirectories(rootfsUnpacked())
Files.createDirectories(workspace)
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig())
val args = captured.single()
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.contains("worktrees/") } shouldBe true
}
test("fails without a configured rootfs") {
val branchConfig = BranchConfig(bwrap = BwrapConfig(enabled = true))
val exception =
shouldThrow<IllegalArgumentException> {
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, branchConfig)
}
exception.message shouldContain "bwrap.rootfs"
}
test("downloads a URL rootfs once before unpacking") {
val url = "https://example.test/buildenv.tar.zst"
val downloadTarget =
repoDir
.resolve(BwrapBuildRunner.BUILDENV_DIR)
.resolve(url.sha12())
.resolve("buildenv.tar.zst")
every { commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any()) } returns
GitCommandResult(0, "", "")
every { commandRunner.runOrThrow(match { it.first() == "tar" }, any(), any(), any()) } returns
GitCommandResult(0, "", "")
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, bwrapBranchConfig(rootfs = url))
verify {
commandRunner.runOrThrow(listOf("curl", "-fsSL", "-o", downloadTarget.toString(), url), repoDir, any(), any())
}
verify { commandRunner.runOrThrow(match { it.first() == "tar" }, repoDir, any(), any()) }
}
}
private fun String.sha12(): String =
java.security.MessageDigest
.getInstance("SHA-256")
.digest(toByteArray())
.joinToString("") { "%02x".format(it) }
.take(12)
}
@@ -1,6 +1,7 @@
package de.hoennig.werkator.build
import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.BwrapConfig
import de.hoennig.werkator.config.DockerConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
@@ -14,12 +15,13 @@ import java.nio.file.Paths
class DispatchingBuildRunnerTest : FunSpec() {
private val processBuildRunner = mockk<ProcessBuildRunner>()
private val dockerBuildRunner = mockk<DockerBuildRunner>()
private val dispatcher = DispatchingBuildRunner(processBuildRunner, dockerBuildRunner)
private val bwrapBuildRunner = mockk<BwrapBuildRunner>()
private val dispatcher = DispatchingBuildRunner(processBuildRunner, dockerBuildRunner, bwrapBuildRunner)
private val process = mockk<Process>()
private val dir = Paths.get(".")
init {
beforeEach { clearMocks(processBuildRunner, dockerBuildRunner) }
beforeEach { clearMocks(processBuildRunner, dockerBuildRunner, bwrapBuildRunner) }
test("runs natively by default") {
val branchConfig = BranchConfig()
@@ -28,6 +30,7 @@ class DispatchingBuildRunnerTest : FunSpec() {
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
verify { dockerBuildRunner wasNot Called }
verify { bwrapBuildRunner wasNot Called }
}
test("runs in Docker when the branch enables it") {
@@ -37,6 +40,17 @@ class DispatchingBuildRunnerTest : FunSpec() {
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
verify { processBuildRunner wasNot Called }
verify { bwrapBuildRunner wasNot Called }
}
test("runs in bwrap when the branch enables it (and not Docker)") {
val branchConfig = BranchConfig(bwrap = BwrapConfig(enabled = true, rootfs = "/srv/buildenv.tar.zst"))
every { bwrapBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
verify { processBuildRunner wasNot Called }
verify { dockerBuildRunner wasNot Called }
}
}
}
@@ -14,7 +14,12 @@ import java.nio.file.attribute.PosixFilePermissions
class InitCommandTest : FunSpec() {
private val gitService = mockk<GitService>()
private val initCommand = InitCommand(gitService)
private val initCommand =
InitCommand(
gitService,
de.hoennig.werkator.config
.ConfigLoader(mockk(relaxed = true)),
)
init {
test("creates config files with auto-detected values") {
@@ -33,6 +33,26 @@ class SystemdServiceFilesTest : FunSpec() {
content shouldContain "WantedBy=default.target"
}
test("resource limits are written when configured and omitted when unset") {
fun unit(
memoryMax: String,
tasksMax: String,
) = SystemdServiceFiles.unitFileContent(
repoRoot = Paths.get("/srv/repos/my-repo"),
javaExecutable = Paths.get("/usr/bin/java"),
jarPath = Paths.get("/srv/repos/my-repo/werkator.jar"),
envFile = Paths.get("/srv/repos/my-repo/werkator.env"),
memoryMax = memoryMax,
tasksMax = tasksMax,
)
val with = unit(memoryMax = "1G", tasksMax = "512")
with shouldContain "MemoryMax=1G"
with shouldContain "TasksMax=512"
val without = unit(memoryMax = "", tasksMax = "")
without shouldNotContain "MemoryMax"
without shouldNotContain "TasksMax"
}
test("percent signs in paths are escaped for systemd") {
val content =
SystemdServiceFiles.unitFileContent(
@@ -350,6 +350,39 @@ class ConfigLoaderTest : FunSpec() {
settings.docker.image shouldBe "attacker-image"
}
test("a branch cannot disable its bwrap sandbox or substitute a foreign rootfs through a build definition") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
"""
builds:
default:
bwrap:
enabled: true
rootfs: /host/rootfs.tar.zst
""".trimIndent(),
)
val worktree = Files.createTempDirectory("werkator-test-worktree")
worktree.resolve(".werkator.yml").toFile().writeText(
"""
builds:
default:
bwrap:
enabled: false
rootfs: /attacker/rootfs.tar.zst
env:
FOO: from-branch
""".trimIndent(),
)
val settings = loader.loadForWorktree(dir, worktree).buildSettings("any-branch", "default")
// pinned: the sandbox can neither be switched off nor pointed at a foreign rootfs
settings.bwrap.enabled shouldBe true
settings.bwrap.rootfs shouldBe "/host/rootfs.tar.zst"
// everything that describes the build itself stays the branch's own business
settings.bwrap.env shouldBe mapOf("FOO" to "from-branch")
}
test("a build the branch invents inherits the host's sandbox policy") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
@@ -386,6 +419,31 @@ class ConfigLoaderTest : FunSpec() {
settings.requirePullRequest shouldBe true
}
test("enabling both docker and bwrap on a build is rejected, not picked silently") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
"""
builds:
default:
docker:
enabled: true
image: build-env
bwrap:
enabled: true
rootfs: /srv/rootfs.tar.zst
""".trimIndent(),
)
val config = loader.load(dir)
val exception =
shouldThrow<IllegalArgumentException> {
config.buildSettings("any-branch", "default")
}
exception.message shouldContain "both docker and bwrap"
exception.message shouldContain "builds.default"
}
test("an exclusion pattern takes a branch out of a build that would otherwise select it") {
val dir = Files.createTempDirectory("werkator-test")
dir.resolve(".werkator.yml").toFile().writeText(
@@ -2,18 +2,35 @@ package de.hoennig.werkator.framework
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.GenericContainer
import org.testcontainers.utility.DockerImageName
/**
* Probes that Testcontainers can actually start a container on this host.
* Skipped, not failed, when no Docker is present — that is what lets Werkator
* build itself on a Docker-less host (e.g. a Hostsharing webspace, where its own
* build runs in the bubblewrap sandbox); see `tools/werkator-build-prerequisites.sh`.
*/
class TestcontainersSmokeTest :
FunSpec({
test("Testcontainers starts a container") {
val container =
GenericContainer(DockerImageName.parse("alpine:3"))
.withCommand("sh", "-c", "sleep 30")
container.start()
container.isRunning shouldBe true
container.stop()
}
})
test("Testcontainers starts a container")
.config(enabledIf = { dockerAvailable() }) {
val container =
GenericContainer(DockerImageName.parse("alpine:3"))
.withCommand("sh", "-c", "sleep 30")
container.start()
container.isRunning shouldBe true
container.stop()
}
}) {
companion object {
private fun dockerAvailable(): Boolean =
try {
DockerClientFactory.instance().isDockerAvailable()
} catch (_: Throwable) {
false
}
}
}