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,42 @@
package de.hoennig.gittally.build
import de.hoennig.gittally.config.BranchConfig
import de.hoennig.gittally.config.DockerConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.Called
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
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 process = mockk<Process>()
private val dir = Paths.get(".")
init {
beforeEach { clearMocks(processBuildRunner, dockerBuildRunner) }
test("runs natively by default") {
val branchConfig = BranchConfig()
every { processBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
verify { dockerBuildRunner wasNot Called }
}
test("runs in Docker when the branch enables it") {
val branchConfig = BranchConfig(docker = DockerConfig(enabled = true, image = "build-env:latest"))
every { dockerBuildRunner.start("cmd", dir, emptyMap(), dir, branchConfig) } returns process
dispatcher.start("cmd", dir, emptyMap(), dir, branchConfig) shouldBe process
verify { processBuildRunner wasNot Called }
}
}
}
@@ -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"
}
}
}
@@ -0,0 +1,51 @@
package de.hoennig.gittally.build
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldMatch
import java.nio.file.Files
class DockerImageInputsTest : FunSpec() {
init {
test("dockerfileSha256 is a stable hex checksum of the file contents") {
val dir = Files.createTempDirectory("gittally-docker-inputs")
val dockerfile = dir.resolve("Dockerfile")
Files.writeString(dockerfile, "FROM eclipse-temurin:21\n")
val hash = DockerImageInputs.dockerfileSha256(dockerfile)
hash shouldMatch Regex("[0-9a-f]{64}")
DockerImageInputs.dockerfileSha256(dockerfile) shouldBe hash
}
test("inputs checksum changes when the Dockerfile contents change") {
val dir = Files.createTempDirectory("gittally-docker-inputs")
val dockerfile = dir.resolve("Dockerfile")
Files.writeString(dockerfile, "FROM eclipse-temurin:21\n")
val before =
DockerImageInputs.inputsSha256(
DockerImageInputs.dockerfileSha256(dockerfile),
"Dockerfile",
".",
)
Files.writeString(dockerfile, "FROM eclipse-temurin:22\n")
DockerImageInputs.inputsSha256(
DockerImageInputs.dockerfileSha256(dockerfile),
"Dockerfile",
".",
) shouldNotBe before
}
test("inputs checksum changes when the Dockerfile path or the context change") {
val dockerfileHash = "0".repeat(64)
val base = DockerImageInputs.inputsSha256(dockerfileHash, "ci/Dockerfile", "ci")
DockerImageInputs.inputsSha256(dockerfileHash, "other/Dockerfile", "ci") shouldNotBe base
DockerImageInputs.inputsSha256(dockerfileHash, "ci/Dockerfile", "other") shouldNotBe base
DockerImageInputs.inputsSha256(dockerfileHash, "ci/Dockerfile", "ci") shouldBe base
}
}
}