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
+1 -1
View File
@@ -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 {
+13 -1
View File
@@ -49,17 +49,29 @@ Generate the systemd user unit:
java -jar ~/bin/gittally.jar init --systemd
```
This writes `.git/gittally/gittally-<repo-name>.service` and `.git/gittally/gittally.env` and prints the install commands:
This writes `.git/gittally/gittally-<repo-name>.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-<repo-name>.service ~/.config/systemd/user/gittally-<repo-name>.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-<repo-name>.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.
@@ -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.
@@ -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()