From e1f3f5f3843b41e0c8b39834396d7c00272b8aeb Mon Sep 17 00:00:00 2001 From: mhoennig Date: Mon, 10 Aug 2026 19:22:44 +0200 Subject: [PATCH] Install a nightly Docker cleanup timer with init --systemd (v0.9.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- build.gradle.kts | 2 +- docs/deployment.md | 14 +++- ...08-10-PR#000-nightly-docker-prune-timer.md | 67 +++++++++++++++++++ .../hoennig/gittally/commands/InitCommand.kt | 14 +++- .../gittally/commands/SystemdServiceFiles.kt | 35 ++++++++++ src/main/resources/templates/releases.html | 8 +++ .../gittally/commands/InitCommandTest.kt | 20 ++++++ .../commands/SystemdServiceFilesTest.kt | 19 ++++++ 8 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 docs/prs/2026-08-10-PR#000-nightly-docker-prune-timer.md diff --git a/build.gradle.kts b/build.gradle.kts index 62c95d3..ba44e86 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ plugins { group = "de.hoennig" // bump at least the patch version for every deployment, so the UI footer // (BuildProperties) and --version identify what is actually running -version = "0.9.6" +version = "0.9.7" java { toolchain { diff --git a/docs/deployment.md b/docs/deployment.md index b9a5689..4dafa10 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -49,17 +49,29 @@ Generate the systemd user unit: java -jar ~/bin/gittally.jar init --systemd ``` -This writes `.git/gittally/gittally-.service` and `.git/gittally/gittally.env` and prints the install commands: +This writes `.git/gittally/gittally-.service`, `.git/gittally/gittally.env`, and the nightly Docker cleanup units (`gittally-docker-prune.service`/`.timer`), and prints the install commands: ```bash ln -sf /path/to/repo/.git/gittally/gittally-.service ~/.config/systemd/user/gittally-.service +ln -sf /path/to/repo/.git/gittally/gittally-docker-prune.service ~/.config/systemd/user/gittally-docker-prune.service +ln -sf /path/to/repo/.git/gittally/gittally-docker-prune.timer ~/.config/systemd/user/gittally-docker-prune.timer systemctl --user daemon-reload systemctl --user enable --now gittally-.service +systemctl --user enable --now gittally-docker-prune.timer ``` The unit runs `java -jar ~/bin/gittally.jar server` with the repository as working directory and `Restart=always`. The unit name contains the repository name, so several repositories can be served by one host, each with its own service and port. +### Nightly Docker Cleanup + +The `gittally-docker-prune.timer` runs `docker system prune -af` every night at 02:00 (host time), before the usual auto-build slots. +It removes stopped containers, unused images, unused networks, and dangling build cache, so nightly builds start from freshly built images. +Unlike the legacy cleanup it does **not** prune volumes — the per-repository Gradle cache volumes survive. +The units are host-global (no repository name): with several GitTally instances on one host, every `init --systemd` generates the same files and the symlinks coincide. +On hosts without a `docker` CLI the service is skipped, not failed (`ExecCondition`). +`Persistent=true` catches up a missed run after downtime. + Expect a burst of builds right after the very first start: in a fresh clone every origin branch counts as new, so each branch with commits younger than `watcher.newBranchMaxAge` (default 5d) is built once — one build per branch, executed serially up to `builds.maxConcurrent`. Lower `watcher.newBranchMaxAge` before the first start, or enable `requirePullRequest`, to limit the initial backlog. diff --git a/docs/prs/2026-08-10-PR#000-nightly-docker-prune-timer.md b/docs/prs/2026-08-10-PR#000-nightly-docker-prune-timer.md new file mode 100644 index 0000000..3e02b4f --- /dev/null +++ b/docs/prs/2026-08-10-PR#000-nightly-docker-prune-timer.md @@ -0,0 +1,67 @@ +> **WARNING:** This document describes only the change applied in this PR. +> It may already be outdated once the next PR is merged. +> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation. + +## The Problem + +The legacy host (vm2176) had a `docker-prune.timer` that cleaned up Docker every night at 02:00, before the nightly runs. +Without it, unused images and stopped containers accumulate on the build host until the disk fills up. +The rewrite did not port this timer, and it had to be installed by hand. + +## Non-Goals + +- No volume pruning: the legacy `docker system prune -af --volumes` would delete GitTally's per-repository Gradle cache volumes every night; the new cleanup keeps volumes. +- No configurable schedule; 02:00 host time is hardcoded like on the legacy host. +- No pruning from within the GitTally server process — the cleanup stays a plain systemd timer. + +## The Scenarios + +### Feature: nightly Docker cleanup installed with the service + +#### Background + +- The legacy unit (vm2176, Hostsharing-internal) ran `docker system prune -af --volumes` at 02:00 with `Persistent=true`. +- GitTally's Docker builds maintain a per-repository Gradle cache volume that must survive a cleanup. + +#### Scenario#000.01: `init --systemd` generates the cleanup units alongside the service unit + +So that the cleanup is part of every GitTally installation instead of a manual step. + +- **Given** a repository initialized with `init --systemd` +- **When** the generation finishes +- **Then** `.git/gittally/gittally-docker-prune.service` and `.git/gittally/gittally-docker-prune.timer` exist + - **and** the printed install commands link and enable the timer together with the service + +##### Verified by + +- [InitCommandTest — "--systemd also generates the nightly Docker cleanup timer"](../../src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt) + +#### Scenario#000.02: The cleanup prunes containers and images but never volumes + +So that nightly builds start from fresh images while the Gradle caches survive. + +- **Given** the generated `gittally-docker-prune.service` +- **When** the timer fires at 02:00 host time +- **Then** it runs `docker system prune -af` (stopped containers, unused images, networks, dangling build cache) + - **and** it passes no `--volumes` flag + - **and** on hosts without a `docker` CLI the run is skipped, not failed + +##### Verified by + +- [SystemdServiceFilesTest — "prune service cleans containers and images but never volumes"](../../src/test/kotlin/de/hoennig/gittally/commands/SystemdServiceFilesTest.kt) +- [SystemdServiceFilesTest — "prune timer fires nightly at 02:00 and catches up after downtime"](../../src/test/kotlin/de/hoennig/gittally/commands/SystemdServiceFilesTest.kt) + +## The Solution + +`SystemdServiceFiles` gets `pruneServiceContent()`/`pruneTimerContent()` next to the existing unit generation, and `createSystemdFiles` writes both files and extends the printed install commands. +The unit names are host-global (`gittally-docker-prune.*`, no repository name): Docker is a host-wide resource, so several GitTally instances on one host share one timer — every `init --systemd` regenerates the same content and the symlinks coincide. +Running containers and their images are never pruned by Docker, so an in-flight build is safe even if it overlaps 02:00. +Documented in `docs/deployment.md` (section "Nightly Docker Cleanup"). + +## Additional Changes + +- None. + +## Follow-up PRs + +- None planned. diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index 94dacfb..c99b256 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -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( diff --git a/src/main/kotlin/de/hoennig/gittally/commands/SystemdServiceFiles.kt b/src/main/kotlin/de/hoennig/gittally/commands/SystemdServiceFiles.kt index 04e3cdd..6e0b826 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/SystemdServiceFiles.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/SystemdServiceFiles.kt @@ -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. diff --git a/src/main/resources/templates/releases.html b/src/main/resources/templates/releases.html index 420136d..1d925f5 100644 --- a/src/main/resources/templates/releases.html +++ b/src/main/resources/templates/releases.html @@ -7,6 +7,14 @@
+

v0.9.7 — 2026-08-10

+
    +
  • init --systemd also installs a nightly Docker cleanup timer + (gittally-docker-prune.timer, 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.
  • +
+

v0.9.6 — 2026-08-10

  • This release-notes page, linked from the version in the footer.
  • diff --git a/src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt b/src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt index f880ec1..293d54b 100644 --- a/src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt @@ -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 diff --git a/src/test/kotlin/de/hoennig/gittally/commands/SystemdServiceFilesTest.kt b/src/test/kotlin/de/hoennig/gittally/commands/SystemdServiceFilesTest.kt index 5d4ecb1..53bcfae 100644 --- a/src/test/kotlin/de/hoennig/gittally/commands/SystemdServiceFilesTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/commands/SystemdServiceFilesTest.kt @@ -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()