Files
werkator/src/test/kotlin/de/hoennig/gittally/build/DockerBuildRunnerTest.kt
T
mhoennigandClaude Fable 5 8b71d0db8e Fix Docker builds under rootless daemons and expose git metadata to build containers
Two fixes from the vm4006 rollout (docs/plan/16-git-in-docker-builds.md):

Rootless daemons map the host user to container root, so running the build
container as --user <host-uid> put it into the subuid range and it could not
even create .gradle in a fresh worktree (legacy only worked because its
ownership-repair chown had accidentally moved build/ and .gradle/ into subuid
ownership in its reused primary checkout). The container now always runs as
--user 0: the unprivileged host user under rootless, real root under rootful
where the ownership repair still applies; under rootless it degenerates to 0:0.

Git now works inside build containers: the primary .git is mounted read-only
with .git/gittally/ masked by an empty tmpfs (git.token and the control token
stay unreachable, the workspace bind resurfaces only the build's own worktree)
and the worktree admin dir mounted read-write for index-refreshing commands.
Verified on vm4006: git log/status succeed, the machine config is invisible,
ref writes fail on the read-only mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:34:39 +02:00

283 lines
12 KiB
Kotlin

package de.hoennig.gittally.build
import de.hoennig.gittally.config.BranchConfig
import de.hoennig.gittally.config.DockerConfig
import de.hoennig.gittally.git.GitCommandResult
import de.hoennig.gittally.git.GitCommandRunner
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.collections.shouldNotContain
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
import java.nio.file.Paths
class DockerBuildRunnerTest : FunSpec() {
private val commandRunner = mockk<GitCommandRunner>()
private val socketLocator = mockk<DockerSocketLocator>()
private lateinit var runner: DockerBuildRunner
private lateinit var repoDir: Path
private lateinit var workspace: Path
private val captured = mutableListOf<List<String>>()
private fun dockerBranchConfig(
image: String = "build-env:latest",
dockerfile: String = "",
network: String = "",
env: Map<String, String> = emptyMap(),
): BranchConfig =
BranchConfig(
docker =
DockerConfig(
enabled = true,
image = image,
dockerfile = dockerfile,
network = network,
env = env,
),
)
init {
beforeEach {
clearMocks(commandRunner, socketLocator)
captured.clear()
repoDir = Files.createTempDirectory("gittally-docker-runner")
workspace = repoDir.resolve("workspace")
every { commandRunner.run(any(), any(), any()) } returns GitCommandResult(0, "", "")
every { commandRunner.runOrThrow(any(), any(), any()) } returns GitCommandResult(0, "", "")
every { commandRunner.runOrThrow(listOf("id", "-u"), any(), any()) } returns GitCommandResult(0, "1000\n", "")
every { commandRunner.runOrThrow(listOf("id", "-g"), any(), any()) } returns GitCommandResult(0, "1001\n", "")
every { socketLocator.locate("1000") } returns
DockerSocket(Paths.get("/var/run/docker.sock"), rootless = false, gid = 999L)
runner = DockerBuildRunner(commandRunner, socketLocator)
runner.processStarter = { command, _ ->
captured += command
ProcessBuilder("true").start()
}
}
test("assembles the exact docker run command (rootful socket, default network)") {
val branchConfig = dockerBranchConfig(env = mapOf("FOO" to "bar"))
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, branchConfig)
val repoKey = ArtifactKeys.repoKey(repoDir)
val args = captured.single()
val script = args[args.size - 5]
args shouldBe
listOf(
"docker",
"run",
"--rm",
"--init",
"--name",
"gittally-build-$repoKey-${ArtifactKeys.branchKey("main")}",
"--label",
"org.hoennig.gittally=true",
"--label",
"org.hoennig.gittally.repository=$repoKey",
"--label",
"org.hoennig.gittally.role=build",
"--workdir",
"$workspace",
"--volume",
"$workspace:$workspace",
"--volume",
"gittally-gradle-$repoKey:/gradle-user-home",
"--env",
"HOME=/tmp/docker-home",
"--env",
"GRADLE_USER_HOME=/gradle-user-home",
"--env",
"branch=main",
"--env",
"FOO=bar",
"--volume",
"/var/run/docker.sock:/var/run/docker.sock",
"--env",
"TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock",
"--env",
"DOCKER_HOST=unix:///var/run/docker.sock",
"--group-add",
"999",
"--user",
"0",
"--env",
"TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal",
"--add-host",
"host.docker.internal:host-gateway",
"build-env:latest",
"sh",
"-c",
script,
"sh",
"1000",
"1001",
"./gradlew test",
)
script shouldContain "bash -c \"\$3\""
script shouldContain "chown -R \"\$1:\$2\""
script shouldContain "exit \$build_exit"
}
test("a rootless socket runs the container as root (the host user) without group-add or host-id chown") {
every { socketLocator.locate("1000") } returns
DockerSocket(Paths.get("/run/user/1000/docker.sock"), rootless = true, gid = 998L)
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, dockerBranchConfig())
val args = captured.single()
args shouldContain "/run/user/1000/docker.sock:/var/run/docker.sock"
args[args.indexOf("--user") + 1] shouldBe "0"
args shouldNotContain "--group-add"
// container root already is the host user — the repair chown must not target the host ids,
// which would push the worktree files into the subuid range
args[args.size - 3] shouldBe "0"
args[args.size - 2] shouldBe "0"
verify {
commandRunner.runOrThrow(
match { it.take(2) == listOf("docker", "run") && it.takeLast(2) == listOf("0", "0") },
repoDir,
)
}
}
test("exposes git metadata read-only with the gittally dir masked for a worktree workspace") {
val gitDir = repoDir.resolve(".git")
val adminDir = gitDir.resolve("worktrees/workspace")
Files.createDirectories(adminDir)
Files.createDirectories(gitDir.resolve("gittally"))
Files.createDirectories(workspace)
Files.writeString(workspace.resolve(".git"), "gitdir: $adminDir\n")
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, dockerBranchConfig())
val args = captured.single()
args shouldContain "$gitDir:$gitDir:ro"
args[args.indexOf("--tmpfs") + 1] shouldBe "${gitDir.resolve("gittally")}"
args shouldContain "$adminDir:$adminDir"
}
test("mounts no git metadata when the workspace is not a worktree") {
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, dockerBranchConfig())
val args = captured.single()
args shouldNotContain "--tmpfs"
args.none { it.endsWith(":ro") } shouldBe true
}
test("host network keeps Testcontainers on localhost without add-host") {
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, dockerBranchConfig(network = "host"))
val args = captured.single()
args[args.indexOf("--network") + 1] shouldBe "host"
args shouldContain "TESTCONTAINERS_HOST_OVERRIDE=localhost"
args shouldNotContain "--add-host"
}
test("without a docker socket the container runs without socket mount as root") {
every { socketLocator.locate("1000") } returns null
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, dockerBranchConfig())
val args = captured.single()
args shouldNotContain "/var/run/docker.sock:/var/run/docker.sock"
args shouldNotContain "DOCKER_HOST=unix:///var/run/docker.sock"
args[args.indexOf("--user") + 1] shouldBe "0"
}
test("builds a missing image from the Dockerfile with the input labels") {
Files.writeString(repoDir.resolve("Dockerfile"), "FROM eclipse-temurin:21\n")
every { commandRunner.run(match { it.take(3) == listOf("docker", "image", "inspect") }, any(), any()) } returns
GitCommandResult(1, "", "no such image")
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, dockerBranchConfig(dockerfile = "Dockerfile"))
val dockerfileHash = DockerImageInputs.dockerfileSha256(repoDir.resolve("Dockerfile"))
val inputsHash = DockerImageInputs.inputsSha256(dockerfileHash, "Dockerfile", ".")
verify {
commandRunner.runOrThrow(
listOf(
"docker",
"build",
"--label",
"org.gittally.dockerfile=Dockerfile",
"--label",
"org.gittally.dockerfile-sha256=$dockerfileHash",
"--label",
"org.gittally.build-context=.",
"--label",
"org.gittally.build-inputs-sha256=$inputsHash",
"-t",
"build-env:latest",
"-f",
"Dockerfile",
".",
),
repoDir,
)
}
}
test("skips the image build when the build-inputs label still matches") {
Files.writeString(repoDir.resolve("Dockerfile"), "FROM eclipse-temurin:21\n")
val dockerfileHash = DockerImageInputs.dockerfileSha256(repoDir.resolve("Dockerfile"))
val inputsHash = DockerImageInputs.inputsSha256(dockerfileHash, "Dockerfile", ".")
every { commandRunner.run(match { it.take(3) == listOf("docker", "image", "inspect") }, any(), any()) } returns
GitCommandResult(0, "$inputsHash\n", "")
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, dockerBranchConfig(dockerfile = "Dockerfile"))
verify(exactly = 0) {
commandRunner.runOrThrow(match { it.take(2) == listOf("docker", "build") }, any(), any())
}
}
test("prepares the gradle cache volume once but removes the container before every command") {
val branchConfig = dockerBranchConfig()
runner.start("./gradlew clean", workspace, mapOf("branch" to "main"), repoDir, branchConfig)
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, branchConfig)
val repoKey = ArtifactKeys.repoKey(repoDir)
verify(exactly = 1) {
commandRunner.runOrThrow(listOf("docker", "volume", "create", "gittally-gradle-$repoKey"), repoDir)
}
verify(exactly = 2) {
commandRunner.run(
listOf("docker", "rm", "-f", "gittally-build-$repoKey-${ArtifactKeys.branchKey("main")}"),
repoDir,
)
}
}
test("removes stale labelled build containers once, before the first docker build") {
every { commandRunner.run(match { it.take(3) == listOf("docker", "ps", "-aq") }, any(), any()) } returns
GitCommandResult(0, "abc\ndef\n", "")
runner.start("./gradlew clean", workspace, mapOf("branch" to "main"), repoDir, dockerBranchConfig())
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, dockerBranchConfig())
verify(exactly = 1) { commandRunner.run(match { it.take(3) == listOf("docker", "ps", "-aq") }, any(), any()) }
verify { commandRunner.run(listOf("docker", "rm", "-f", "abc", "def"), repoDir) }
}
test("fails without a configured image") {
val branchConfig = BranchConfig(docker = DockerConfig(enabled = true))
val exception =
shouldThrow<IllegalArgumentException> {
runner.start("./gradlew test", workspace, mapOf("branch" to "main"), repoDir, branchConfig)
}
exception.message shouldContain "docker.image"
}
}
}