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
@@ -44,13 +44,16 @@ class ProcessBuildRunner : BuildRunner {
/**
* Selects the runtime per branch: Docker when `branches.<name>.docker.enabled`,
* native shell execution otherwise (the unchanged default).
* bubblewrap when `branches.<name>.bwrap.enabled`, native shell execution otherwise
* (the unchanged default). Docker and bwrap are mutually exclusive per branch and are
* rejected together at config load, so the branch order here never has to "pick".
*/
@Primary
@Component
class DispatchingBuildRunner(
private val processBuildRunner: ProcessBuildRunner,
private val dockerBuildRunner: DockerBuildRunner,
private val bwrapBuildRunner: BwrapBuildRunner,
) : BuildRunner {
override fun start(
command: String,
@@ -60,7 +63,12 @@ class DispatchingBuildRunner(
branchConfig: BranchConfig,
onAuxProcess: (Process) -> Unit,
): Process {
val runner = if (branchConfig.docker.enabled) dockerBuildRunner else processBuildRunner
val runner =
when {
branchConfig.docker.enabled -> dockerBuildRunner
branchConfig.bwrap.enabled -> bwrapBuildRunner
else -> processBuildRunner
}
return runner.start(command, workingDir, environment, repoDir, branchConfig, onAuxProcess)
}
}
@@ -0,0 +1,266 @@
package de.hoennig.werkator.build
import de.hoennig.werkator.config.BranchConfig
import de.hoennig.werkator.config.BwrapConfig
import de.hoennig.werkator.git.GitCommandRunner
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Path
import java.security.MessageDigest
/**
* Runs build commands inside a bubblewrap user-namespace sandbox (Step 17 / ADR 0007),
* 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
* wrapper — no library, consistent with git and docker.
*
* The prepared rootfs (a Debian-base archive built elsewhere, since `debootstrap` is not
* available on the target) is unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs`,
* shared across all branch worktrees like the Docker gradle cache volume; `<envKey>` derives
* from a hash of the archive source, so a changed source unpacks a fresh rootfs and stale
* ones can be pruned. The returned [Process] is the attached `bwrap` process, so log
* streaming and cancellation work exactly like native builds (`--die-with-parent` plus
* `--unshare-pid` tear down the whole tree on cancel). Git works inside the sandbox with
* the same layered mounts as the Docker runner: the primary `.git` read-only with
* `.git/werkator/` masked, see [gitMetadataMounts].
*/
@Component
class BwrapBuildRunner(
private val commandRunner: GitCommandRunner,
) : BuildRunner {
private val log = LoggerFactory.getLogger(BwrapBuildRunner::class.java)
/** Replaceable process launcher so unit tests can capture the assembled `bwrap` argv. */
internal var processStarter: (List<String>, Path) -> Process = { command, dir ->
ProcessBuilder(command).directory(dir.toFile()).start()
}
override fun start(
command: String,
workingDir: Path,
environment: Map<String, String>,
repoDir: Path,
branchConfig: BranchConfig,
onAuxProcess: (Process) -> Unit,
): Process {
val bwrap = branchConfig.bwrap
require(bwrap.rootfs.isNotBlank()) { "branches.<name>.bwrap.rootfs must be set when bwrap.enabled is true" }
val buildEnvRoot = buildEnvRoot(repoDir)
val envKey = envKey(bwrap.rootfs)
val rootfsDir = buildEnvRoot.resolve(envKey).resolve(ROOTFS_DIR)
ensureRootfs(bwrap, rootfsDir, repoDir, onAuxProcess)
val homeDir = buildEnvRoot.resolve(HOME_DIR)
Files.createDirectories(homeDir)
val args =
invocation(command, workingDir, environment, repoDir, bwrap, rootfsDir, homeDir)
ensureMountpoints(rootfsDir, args)
return processStarter(args, repoDir)
}
/**
* bwrap creates mountpoint directories for bind destinations inside the sandbox —
* against the read-only rootfs bind that fails with "Can't mkdir parents ...
* Read-only file system" for every destination that does not exist in the rootfs
* (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(
rootfsDir: Path,
args: List<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,
rootfsDir: Path,
repoDir: Path,
onAuxProcess: (Process) -> Unit,
) {
if (Files.isDirectory(rootfsDir)) {
return
}
Files.createDirectories(rootfsDir)
val archive = localArchive(bwrap.rootfs, rootfsDir.parent, repoDir, onAuxProcess)
log.info("unpacking build environment {} into {}", bwrap.rootfs, rootfsDir)
commandRunner.runOrThrow(
listOf("tar", "--no-same-owner", "-xf", archive, "-C", rootfsDir.toString()),
repoDir,
onProcess = onAuxProcess,
)
}
/**
* 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
* auto-detects the compression from the archive magic, so a `.tar.gz` or `.tar.zst`
* needs no extra flag.
*/
private fun localArchive(
rootfs: String,
envDir: Path,
repoDir: Path,
onAuxProcess: (Process) -> Unit,
): String {
if (!rootfs.startsWith("http://") && !rootfs.startsWith("https://")) {
return rootfs.removePrefix("file://")
}
val fileName = rootfs.substringAfterLast('/').ifBlank { "buildenv" }
val target = envDir.resolve(fileName)
if (!Files.exists(target)) {
log.info("downloading build environment {} from {}", fileName, rootfs)
commandRunner.runOrThrow(
listOf("curl", "-fsSL", "-o", target.toString(), rootfs),
repoDir,
onProcess = onAuxProcess,
)
}
return target.toString()
}
private fun invocation(
command: String,
workspace: Path,
environment: Map<String, String>,
repoDir: Path,
bwrap: BwrapConfig,
rootfsDir: Path,
homeDir: Path,
): List<String> {
// bwrap creates mountpoints for bind destinations inside the sandbox; a
// relative workspace path would resolve there into the read-only rootfs
// ("Can't mkdir parents ...: Read-only file system"). Bind at absolute
// 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 workspaceAbs =
if (workspace.isAbsolute) workspace.normalize() else repoDirAbs.resolve(workspace).normalize()
val homeDirAbs =
if (homeDir.isAbsolute) homeDir.normalize() else repoDirAbs.resolve(homeDir).normalize()
val args =
mutableListOf(
"bwrap",
"--unshare-user",
"--unshare-pid",
"--die-with-parent",
"--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 += listOf("--bind", "$workspaceAbs", "$workspaceAbs")
args += listOf("--bind", "$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")
for ((key, value) in environment) {
args += listOf("--setenv", key, value)
}
for ((key, value) in bwrap.env) {
args += listOf("--setenv", key, value)
}
args += listOf("--chdir", "$workspaceAbs", "/bin/sh", "-c", command)
return args
}
/**
* 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
* mounts by target path like Docker): the primary `.git` read-only, an empty tmpfs
* masking `.git/werkator/` (machine config with `git.token`, control token, build
* state), and this worktree's admin directory read-write so index-refreshing commands
* keep working. Object and ref writes stay blocked by the read-only `.git` mount.
* No mounts are added when the workspace is not a worktree of [repoDir].
*/
private fun gitMetadataMounts(
workspace: Path,
repoDir: Path,
): List<String> {
val gitDir = repoDir.toAbsolutePath().normalize().resolve(".git")
val workspaceGitFile = workspace.resolve(".git")
if (!Files.isDirectory(gitDir) || !Files.isRegularFile(workspaceGitFile)) {
return emptyList()
}
val adminDir =
Files
.readString(workspaceGitFile)
.substringAfter("gitdir:", "")
.trim()
.takeIf { it.isNotEmpty() }
?.let { workspace.resolve(it).normalize() }
?: return emptyList()
if (!adminDir.startsWith(gitDir) || !Files.isDirectory(adminDir)) {
return emptyList()
}
val args = mutableListOf("--ro-bind", "$gitDir", "$gitDir")
val werkatorDir = gitDir.resolve("werkator")
if (Files.isDirectory(werkatorDir)) {
args += listOf("--tmpfs", "$werkatorDir")
}
args += listOf("--bind", "$adminDir", "$adminDir")
return args
}
private fun buildEnvRoot(repoDir: Path): Path = repoDir.resolve(BUILDENV_DIR)
/** A short hash of the archive source, so a changed source unpacks a fresh rootfs. */
private fun envKey(rootfs: String): String =
MessageDigest
.getInstance("SHA-256")
.digest(rootfs.toByteArray())
.joinToString("") { "%02x".format(it) }
.take(12)
companion object {
const val BUILDENV_DIR = ".git/werkator/buildenv"
const val ROOTFS_DIR = "rootfs"
const val HOME_DIR = "home"
}
}
@@ -19,6 +19,7 @@ import java.nio.file.Paths
)
class InitCommand(
private val gitService: GitService,
private val configLoader: de.hoennig.werkator.config.ConfigLoader,
/** The version written into the generated config as `werkator.version.since`. */
private val buildProperties: ObjectProvider<BuildProperties>? = null,
) : Runnable {
@@ -157,6 +158,11 @@ class InitCommand(
bindAddress: 127.0.0.1
# optional Impressum (legal disclosure) link in the web UI footer; empty hides the link
impressumUrl: ""
# Resource limits of the systemd user unit (init --systemd); empty = directive omitted.
# Needed where the service shares a memory slice, e.g. Hostsharing Managed Webspaces.
systemd:
memoryMax: "" # e.g. 1G — a runaway Gradle build must not starve the package
tasksMax: "" # e.g. 512
# Opt-in managed nginx+certbot Docker container for HTTPS, for hosts without
# a usable reverse proxy (see docs/deployment.md). Off by default.
nginx:
@@ -215,6 +221,12 @@ class InitCommand(
context: "." # Docker build context used with dockerfile
network: "" # Docker network mode for the build container; empty = Docker default (pinned)
env: {} # additional environment variables set inside the build container
# bubblewrap user-namespace sandbox — for hosts without root and without a
# Docker daemon (e.g. Hostsharing managed webspaces). Mutually exclusive with docker.
bwrap:
enabled: false # run clean/build in a bwrap sandbox instead of natively (pinned)
rootfs: "" # prepared rootfs archive (path or URL); required when enabled (pinned)
env: {} # additional environment variables set inside the sandbox
# Gitea check this build reports as; empty uses gitea.statusContext.
# Two builds of one commit under the same context overwrite each other.
statusContext: ""
@@ -256,6 +268,19 @@ class InitCommand(
println("created ${file.toFile().relativeTo(normalizedWorkingDir.toFile())}")
}
/**
* Resource limits for the unit come from the effective configuration when one is
* already loadable (re-running `init --systemd` on an installed instance); during
* the very first bootstrap they stay unset and the defaults (no directives) apply.
*/
private fun loadedSystemdConfig(): de.hoennig.werkator.config.SystemdConfig =
try {
configLoader.load(Paths.get(".")).server.systemd
} catch (_: Exception) {
de.hoennig.werkator.config
.SystemdConfig()
}
private fun createSystemdFiles(
root: Path,
normalizedWorkingDir: Path,
@@ -277,6 +302,8 @@ class InitCommand(
javaExecutable = javaExecutableResolver(),
jarPath = jarPath,
envFile = envFile,
memoryMax = loadedSystemdConfig().memoryMax,
tasksMax = loadedSystemdConfig().tasksMax,
),
)
println("created ${unitFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
@@ -22,24 +22,33 @@ object SystemdServiceFiles {
javaExecutable: Path,
jarPath: Path,
envFile: Path,
): String =
"""
[Unit]
Description=Werkator CI for ${repoRoot.fileName}
Wants=network-online.target
After=network-online.target docker.service
memoryMax: String = "",
tasksMax: String = "",
): String {
val limits =
listOfNotNull(
"MemoryMax=$memoryMax".takeIf { memoryMax.isNotBlank() },
"TasksMax=$tasksMax".takeIf { tasksMax.isNotBlank() },
).joinToString("\n")
val limitsLine = if (limits.isEmpty()) "" else "\n$limits"
return """
[Unit]
Description=Werkator CI for ${repoRoot.fileName}
Wants=network-online.target
After=network-online.target docker.service
[Service]
Type=simple
WorkingDirectory=${systemdPath("$repoRoot")}
EnvironmentFile=-${systemdPath("$envFile")}
ExecStart=${systemdQuote("$javaExecutable")} ${'$'}JAVA_OPTS -jar ${systemdQuote("$jarPath")} server
Restart=always
RestartSec=30
[Service]
Type=simple
WorkingDirectory=${systemdPath("$repoRoot")}
EnvironmentFile=-${systemdPath("$envFile")}
ExecStart=${systemdQuote("$javaExecutable")} ${'$'}JAVA_OPTS -jar ${systemdQuote("$jarPath")} server
Restart=always
RestartSec=30
[Install]
WantedBy=default.target
""".trimIndent() + "\n"
[Install]
WantedBy=default.target
""".trimIndent().replace("\n\n[Install]", "$limitsLine\n\n[Install]") + "\n"
}
/**
* Nightly Docker cleanup like the legacy `docker-prune.service`, but without `--volumes`:
@@ -42,6 +42,8 @@ data class BuildDefinition(
val statusContext: String? = null,
/** Overrides of the docker settings; null inherits them. */
val docker: DockerOverrides? = null,
/** Overrides of the bwrap settings; null inherits them. */
val bwrap: BwrapOverrides? = null,
) {
/** The settings this build runs with: [branchConfig] with this definition applied; unset values fall through. */
fun applyTo(branchConfig: BranchConfig): BranchConfig =
@@ -62,6 +64,12 @@ data class BuildDefinition(
network = docker?.network ?: branchConfig.docker.network,
env = docker?.env ?: branchConfig.docker.env,
),
bwrap =
branchConfig.bwrap.copy(
enabled = bwrap?.enabled ?: branchConfig.bwrap.enabled,
rootfs = bwrap?.rootfs ?: branchConfig.bwrap.rootfs,
env = bwrap?.env ?: branchConfig.bwrap.env,
),
)
companion object {
@@ -159,3 +167,12 @@ data class DockerOverrides(
val context: String? = null,
val env: Map<String, String>? = null,
)
/** Nullable bubblewrap overrides of a [BuildDefinition]; null values inherit the branch's setting. */
data class BwrapOverrides(
/** Run the build in the bwrap sandbox instead of natively. Pinned — a branch must not escape its sandbox. */
val enabled: Boolean? = null,
/** Rootfs archive source. Pinned — a branch must not substitute a foreign rootfs. */
val rootfs: String? = null,
val env: Map<String, String>? = null,
)
@@ -159,6 +159,11 @@ class ConfigLoader(
val strippedDocker = docker.toMutableMap().apply { PINNED_DOCKER_KEYS.forEach { remove(it) } }
if (strippedDocker.isEmpty()) result.remove("docker") else result["docker"] = strippedDocker
}
val bwrap = entry["bwrap"] as? Map<String, Any?>
if (bwrap != null) {
val strippedBwrap = bwrap.toMutableMap().apply { PINNED_BWRAP_KEYS.forEach { remove(it) } }
if (strippedBwrap.isEmpty()) result.remove("bwrap") else result["bwrap"] = strippedBwrap
}
return result
}
@@ -398,6 +403,9 @@ class ConfigLoader(
/** `docker` keys a branch must never override: the sandbox policy. */
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
/** `bwrap` keys a branch must never override: the sandbox policy (Step 17). */
private val PINNED_BWRAP_KEYS = setOf("enabled", "rootfs")
/**
* The one key of a build definition that says *when* and *for which branches* it
* runs; never inherited from `builds.default`. A single key on purpose: a selector
@@ -37,7 +37,14 @@ data class WerkatorConfig(
build: String,
): BranchConfig {
val branchConfig = branches[branch] ?: branches["default"] ?: BranchConfig()
return effectiveBuildDefinitions()[build]?.applyTo(branchConfig) ?: branchConfig
val settings = effectiveBuildDefinitions()[build]?.applyTo(branchConfig) ?: branchConfig
if (settings.docker.enabled && settings.bwrap.enabled) {
throw IllegalArgumentException(
"builds.$build on '$branch' enables both docker and bwrap; a build runs in exactly one sandbox. " +
"Disable one of them.",
)
}
return settings
}
}
@@ -57,9 +64,24 @@ data class ServerConfig(
val bindAddress: String = "127.0.0.1",
/** Optional Impressum (legal disclosure) link shown in the web UI footer; empty hides the link. */
val impressumUrl: String = "",
/**
* Resource limits for the generated systemd user unit (`init --systemd`); empty
* means the directive is not written. Needed on platforms where the service runs
* inside a shared memory slice, e.g. Hostsharing Managed Webspaces, where a
* runaway Gradle build would starve everything else in the package.
*/
val systemd: SystemdConfig = SystemdConfig(),
val nginx: NginxConfig = NginxConfig(),
)
/** Resource-limit directives of the systemd user unit (`server.systemd`, see [ServerConfig.systemd]). */
data class SystemdConfig(
/** `MemoryMax=` of the unit, e.g. `1G`; empty omits the directive. */
val memoryMax: String = "",
/** `TasksMax=` of the unit, e.g. `512`; empty omits the directive. */
val tasksMax: String = "",
)
/**
* Opt-in managed nginx+certbot Docker container serving Werkator over HTTPS,
* for hosts without a usable reverse proxy (ADR 0005). Off by default; the
@@ -157,6 +179,26 @@ data class BranchConfig(
val statusContext: String = "",
val autoBuild: AutoBuildConfig = AutoBuildConfig(),
val docker: DockerConfig = DockerConfig(),
/** bubblewrap user-namespace sandbox; mutually exclusive with [docker]. */
val bwrap: BwrapConfig = BwrapConfig(),
)
/**
* bubblewrap build sandbox (Step 17): runs the build in an unprivileged user namespace
* with a prepared Debian root filesystem. For hosts without root and without a Docker
* daemon (e.g. Hostsharing managed webspaces); see `docs/plan/17-bwrap-build-runtime.md`.
*/
data class BwrapConfig(
/** Run the clean and build commands in a bwrap sandbox instead of natively. */
val enabled: Boolean = false,
/**
* Path or URL of the prepared rootfs archive (e.g. `werkator-buildenv-trixie-java21.tar.zst`),
* unpacked on demand into `.git/werkator/buildenv/<envKey>/rootfs`; required when [enabled].
* Pinned — a branch must not substitute a foreign rootfs via its committed config.
*/
val rootfs: String = "",
/** Additional environment variables set inside the sandbox. */
val env: Map<String, String> = emptyMap(),
)
data class DockerConfig(