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:
co-authored by
Claude Fable 5
parent
74bc4c2d73
commit
e1f3f5f384
+1
-1
@@ -11,7 +11,7 @@ plugins {
|
|||||||
group = "de.hoennig"
|
group = "de.hoennig"
|
||||||
// bump at least the patch version for every deployment, so the UI footer
|
// bump at least the patch version for every deployment, so the UI footer
|
||||||
// (BuildProperties) and --version identify what is actually running
|
// (BuildProperties) and --version identify what is actually running
|
||||||
version = "0.9.6"
|
version = "0.9.7"
|
||||||
|
|
||||||
java {
|
java {
|
||||||
toolchain {
|
toolchain {
|
||||||
|
|||||||
+13
-1
@@ -49,17 +49,29 @@ Generate the systemd user unit:
|
|||||||
java -jar ~/bin/gittally.jar init --systemd
|
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
|
```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-<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 daemon-reload
|
||||||
systemctl --user enable --now gittally-<repo-name>.service
|
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 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.
|
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`.
|
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.
|
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("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 $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 daemon-reload")
|
||||||
println(" systemctl --user enable --now $unitName")
|
println(" systemctl --user enable --now $unitName")
|
||||||
|
println(" systemctl --user enable --now ${SystemdServiceFiles.PRUNE_TIMER_NAME}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class DetectedValues(
|
private data class DetectedValues(
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import java.nio.file.Path
|
|||||||
object SystemdServiceFiles {
|
object SystemdServiceFiles {
|
||||||
const val ENV_FILE_NAME = "gittally.env"
|
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. */
|
/** Per-repository unit name, because one GitTally instance serves exactly one repository. */
|
||||||
fun unitName(repoRoot: Path): String = "gittally-${sanitize(repoRoot.fileName.toString())}.service"
|
fun unitName(repoRoot: Path): String = "gittally-${sanitize(repoRoot.fileName.toString())}.service"
|
||||||
|
|
||||||
@@ -37,6 +41,37 @@ object SystemdServiceFiles {
|
|||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
""".trimIndent() + "\n"
|
""".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 =
|
fun envFileContent(): String =
|
||||||
"""
|
"""
|
||||||
# EnvironmentFile for the GitTally systemd service.
|
# EnvironmentFile for the GitTally systemd service.
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
<div th:replace="~{fragments :: nav(${view})}"></div>
|
<div th:replace="~{fragments :: nav(${view})}"></div>
|
||||||
<div class="panel release-notes">
|
<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>
|
<h2>v0.9.6 <span class="muted">— 2026-08-10</span></h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>This release-notes page, linked from the version in the footer.</li>
|
<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()
|
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") {
|
test("--systemd keeps an existing environment file") {
|
||||||
val tempDir = Files.createTempDirectory("gittally-init-test")
|
val tempDir = Files.createTempDirectory("gittally-init-test")
|
||||||
initCommand.workingDir = tempDir
|
initCommand.workingDir = tempDir
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.hoennig.gittally.commands
|
|||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.kotest.matchers.string.shouldContain
|
import io.kotest.matchers.string.shouldContain
|
||||||
|
import io.kotest.matchers.string.shouldNotContain
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
|
|
||||||
class SystemdServiceFilesTest : FunSpec() {
|
class SystemdServiceFilesTest : FunSpec() {
|
||||||
@@ -46,6 +47,24 @@ class SystemdServiceFilesTest : FunSpec() {
|
|||||||
content shouldContain """-jar "/srv/100%%-repo/gittally.jar" server"""
|
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") {
|
test("environment file template only tunes the JVM") {
|
||||||
val content = SystemdServiceFiles.envFileContent()
|
val content = SystemdServiceFiles.envFileContent()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user