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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user