Install a nightly Docker cleanup timer with init --systemd (v0.9.7)

Port of the legacy host's docker-prune.timer: 02:00 host time,
Persistent=true, docker system prune -af — but without --volumes, so
the per-repository Gradle cache volumes survive. The units are
host-global; several GitTally instances share one timer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-10 19:22:44 +02:00
co-authored by Claude Fable 5
parent 74bc4c2d73
commit e1f3f5f384
8 changed files with 176 additions and 3 deletions
@@ -236,10 +236,22 @@ class InitCommand(
println("created ${envFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
}
println("install and start the service with:")
// the nightly Docker cleanup is host-global: every repository generates the same
// units, so with several GitTally instances the symlinks simply coincide
val pruneServiceFile = gittallyDir.resolve(SystemdServiceFiles.PRUNE_SERVICE_NAME)
val pruneTimerFile = gittallyDir.resolve(SystemdServiceFiles.PRUNE_TIMER_NAME)
pruneServiceFile.toFile().writeText(SystemdServiceFiles.pruneServiceContent())
println("created ${pruneServiceFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
pruneTimerFile.toFile().writeText(SystemdServiceFiles.pruneTimerContent())
println("created ${pruneTimerFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
println("install and start the service and the nightly Docker cleanup with:")
println(" ln -sf $unitFile ~/.config/systemd/user/$unitName")
println(" ln -sf $pruneServiceFile ~/.config/systemd/user/${SystemdServiceFiles.PRUNE_SERVICE_NAME}")
println(" ln -sf $pruneTimerFile ~/.config/systemd/user/${SystemdServiceFiles.PRUNE_TIMER_NAME}")
println(" systemctl --user daemon-reload")
println(" systemctl --user enable --now $unitName")
println(" systemctl --user enable --now ${SystemdServiceFiles.PRUNE_TIMER_NAME}")
}
private data class DetectedValues(
@@ -10,6 +10,10 @@ import java.nio.file.Path
object SystemdServiceFiles {
const val ENV_FILE_NAME = "gittally.env"
/** Host-global unit names of the nightly Docker cleanup — shared by all GitTally repositories on the host. */
const val PRUNE_SERVICE_NAME = "gittally-docker-prune.service"
const val PRUNE_TIMER_NAME = "gittally-docker-prune.timer"
/** Per-repository unit name, because one GitTally instance serves exactly one repository. */
fun unitName(repoRoot: Path): String = "gittally-${sanitize(repoRoot.fileName.toString())}.service"
@@ -37,6 +41,37 @@ object SystemdServiceFiles {
WantedBy=default.target
""".trimIndent() + "\n"
/**
* Nightly Docker cleanup like the legacy `docker-prune.service`, but without `--volumes`:
* the per-repository Gradle cache volumes must survive the cleanup.
* Running containers and their images are never pruned, so an in-flight build is safe.
*/
fun pruneServiceContent(): String =
"""
[Unit]
Description=Clean up unused Docker containers and images (GitTally)
[Service]
Type=oneshot
# skipped (not failed) on hosts without a docker CLI
ExecCondition=sh -c 'command -v docker'
ExecStart=docker system prune -af
""".trimIndent() + "\n"
/** Fires before the usual auto-build slots, `Persistent=true` catches missed runs after downtime. */
fun pruneTimerContent(): String =
"""
[Unit]
Description=Nightly Docker cleanup before the auto builds (GitTally)
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
""".trimIndent() + "\n"
fun envFileContent(): String =
"""
# EnvironmentFile for the GitTally systemd service.
@@ -7,6 +7,14 @@
<div th:replace="~{fragments :: nav(${view})}"></div>
<div class="panel release-notes">
<h2>v0.9.7 <span class="muted">— 2026-08-10</span></h2>
<ul>
<li><code>init --systemd</code> also installs a nightly Docker cleanup timer
(<code>gittally-docker-prune.timer</code>, 02:00 host time): stopped containers and unused
images are pruned before the auto builds — like the legacy host, but the per-repository
Gradle cache volumes survive.</li>
</ul>
<h2>v0.9.6 <span class="muted">— 2026-08-10</span></h2>
<ul>
<li>This release-notes page, linked from the version in the footer.</li>
@@ -104,6 +104,26 @@ class InitCommandTest : FunSpec() {
tempDir.resolve(".git/gittally/gittally.env").toFile().shouldExist()
}
test("--systemd also generates the nightly Docker cleanup timer") {
val tempDir = Files.createTempDirectory("gittally-init-test")
initCommand.workingDir = tempDir
initCommand.systemd = true
initCommand.jarPathResolver = { Paths.get("/home/ci/bin/gittally.jar") }
initCommand.javaExecutableResolver = { Paths.get("/usr/bin/java") }
every { gitService.getTopLevel(tempDir) } returns tempDir
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
initCommand.run()
val pruneService = tempDir.resolve(".git/gittally/gittally-docker-prune.service")
pruneService.toFile().shouldExist()
pruneService.toFile().readText() shouldContain "docker system prune -af"
val pruneTimer = tempDir.resolve(".git/gittally/gittally-docker-prune.timer")
pruneTimer.toFile().shouldExist()
pruneTimer.toFile().readText() shouldContain "OnCalendar=*-*-* 02:00:00"
}
test("--systemd keeps an existing environment file") {
val tempDir = Files.createTempDirectory("gittally-init-test")
initCommand.workingDir = tempDir
@@ -3,6 +3,7 @@ package de.hoennig.gittally.commands
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import java.nio.file.Paths
class SystemdServiceFilesTest : FunSpec() {
@@ -46,6 +47,24 @@ class SystemdServiceFilesTest : FunSpec() {
content shouldContain """-jar "/srv/100%%-repo/gittally.jar" server"""
}
test("prune service cleans containers and images but never volumes") {
val content = SystemdServiceFiles.pruneServiceContent()
content shouldContain "Type=oneshot"
content shouldContain "ExecStart=docker system prune -af"
// the per-repository Gradle cache volumes must survive the cleanup
content shouldNotContain "--volumes"
content shouldContain "ExecCondition=sh -c 'command -v docker'"
}
test("prune timer fires nightly at 02:00 and catches up after downtime") {
val content = SystemdServiceFiles.pruneTimerContent()
content shouldContain "OnCalendar=*-*-* 02:00:00"
content shouldContain "Persistent=true"
content shouldContain "WantedBy=timers.target"
}
test("environment file template only tunes the JVM") {
val content = SystemdServiceFiles.envFileContent()