implemented 11-docker-build-runtime.md: added optional Docker-based build execution with configuration, per-branch runtime selection, image rebuild on input changes, Gradle cache volume, and ownership repair; updated docs and configuration

This commit is contained in:
Michael Hoennig
2026-07-07 13:49:08 +02:00
parent 73221a8b8b
commit e869b46cbf
14 changed files with 836 additions and 5 deletions
@@ -0,0 +1,248 @@
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 the host user without group-add") {
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 "1000"
args shouldNotContain "--group-add"
}
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"
}
}
}