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>
283 lines
12 KiB
Kotlin
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"
|
|
}
|
|
}
|
|
}
|