implemented 12-deployment.md: added systemd service generation (init --systemd) and migration guide from legacy script; introduced JSON-file persistence, server-rendered UI with polling, and reverse-proxy-based deployment; updated documentation

This commit is contained in:
Michael Hoennig
2026-07-07 14:18:41 +02:00
parent e869b46cbf
commit 60ff595a9d
14 changed files with 578 additions and 5 deletions
@@ -3,6 +3,7 @@ package de.hoennig.gittally.commands
import de.hoennig.gittally.git.GitService
import org.springframework.stereotype.Component
import picocli.CommandLine.Command
import picocli.CommandLine.Option
import java.nio.file.Path
import java.nio.file.Paths
@@ -17,6 +18,18 @@ class InitCommand(
) : Runnable {
var workingDir: Path = Paths.get(".")
@Option(
names = ["--systemd"],
description = ["also generate a systemd user unit that runs `gittally server` for this repository"],
)
var systemd: Boolean = false
/** Replaceable for tests: the jar this JVM was started from, or null when not run via `java -jar`. */
internal var jarPathResolver: () -> Path? = { runningJarPath() }
/** Replaceable for tests: the `java` binary of the current JVM. */
internal var javaExecutableResolver: () -> Path = { Paths.get(System.getProperty("java.home"), "bin", "java") }
override fun run() {
val normalizedWorkingDir = workingDir.toAbsolutePath().normalize()
val root =
@@ -32,6 +45,9 @@ class InitCommand(
createRepoInstallConfig(root, detected, normalizedWorkingDir)
createProjectConfig(root, detected, normalizedWorkingDir)
if (systemd) {
createSystemdFiles(root, normalizedWorkingDir)
}
}
private fun detectFromUrl(url: String?): DetectedValues {
@@ -165,10 +181,70 @@ class InitCommand(
println("created ${file.toFile().relativeTo(normalizedWorkingDir.toFile())}")
}
private fun createSystemdFiles(
root: Path,
normalizedWorkingDir: Path,
) {
val jarPath = jarPathResolver()
if (jarPath == null) {
println("Error: cannot determine the GitTally jar path — run `init --systemd` via `java -jar <path-to>/gittally.jar`")
return
}
val gittallyDir = root.resolve(".git/gittally")
gittallyDir.toFile().mkdirs()
val unitName = SystemdServiceFiles.unitName(root)
val unitFile = gittallyDir.resolve(unitName)
val envFile = gittallyDir.resolve(SystemdServiceFiles.ENV_FILE_NAME)
unitFile.toFile().writeText(
SystemdServiceFiles.unitFileContent(
repoRoot = root,
javaExecutable = javaExecutableResolver(),
jarPath = jarPath,
envFile = envFile,
),
)
println("created ${unitFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
if (envFile.toFile().exists()) {
println("${envFile.toFile().relativeTo(normalizedWorkingDir.toFile())} already exists — not overwritten")
} else {
envFile.toFile().writeText(SystemdServiceFiles.envFileContent())
println("created ${envFile.toFile().relativeTo(normalizedWorkingDir.toFile())}")
}
println("install and start the service with:")
println(" ln -sf $unitFile ~/.config/systemd/user/$unitName")
println(" systemctl --user daemon-reload")
println(" systemctl --user enable --now $unitName")
}
private data class DetectedValues(
val baseUrl: String = "",
val owner: String = "",
val repo: String = "",
val account: String = "",
)
companion object {
/**
* The jar this JVM was started from. With `java -jar` the launch command starts with the
* jar path; as a fallback (e.g. custom launchers) the Spring Boot loader's nested code
* source URL contains it. Null when running from classes (IDE, Gradle, tests).
*/
private fun runningJarPath(): Path? {
val launchCommand = System.getProperty("sun.java.command").orEmpty().substringBefore(' ')
if (launchCommand.endsWith(".jar")) {
return Paths.get(launchCommand).toAbsolutePath().normalize()
}
val codeSource =
InitCommand::class.java.protectionDomain.codeSource
?.location
?.toString()
.orEmpty()
return Regex("""(/[^!]*?\.jar)""")
.find(codeSource)
?.let { Paths.get(it.groupValues[1]) }
}
}
}
@@ -0,0 +1,61 @@
package de.hoennig.gittally.commands
import java.nio.file.Path
/**
* Generates the content of the systemd user unit and its `EnvironmentFile` for running
* `gittally server` as a service — the shape of the legacy `generate_systemd_config`,
* without the self-copy/self-update machinery (the unit points at the jar in place).
*/
object SystemdServiceFiles {
const val ENV_FILE_NAME = "gittally.env"
/** Per-repository unit name, because one GitTally instance serves exactly one repository. */
fun unitName(repoRoot: Path): String = "gittally-${sanitize(repoRoot.fileName.toString())}.service"
fun unitFileContent(
repoRoot: Path,
javaExecutable: Path,
jarPath: Path,
envFile: Path,
): String =
"""
[Unit]
Description=GitTally CI for ${repoRoot.fileName}
Wants=network-online.target
After=network-online.target docker.service
[Service]
Type=simple
WorkingDirectory=${systemdPath("$repoRoot")}
EnvironmentFile=-${systemdPath("$envFile")}
ExecStart=${systemdQuote("$javaExecutable")} ${'$'}JAVA_OPTS -jar ${systemdQuote("$jarPath")} server
Restart=always
RestartSec=30
[Install]
WantedBy=default.target
""".trimIndent() + "\n"
fun envFileContent(): String =
"""
# EnvironmentFile for the GitTally systemd service.
# GitTally itself is configured via .gittally.yml and .git/gittally/.gittally.yml,
# not via environment variables; this file only tunes the JVM process.
#JAVA_OPTS=-Xmx256m
""".trimIndent() + "\n"
private fun sanitize(name: String): String = name.replace(Regex("[^A-Za-z0-9_.-]"), "-")
/** Escape `%` specifiers in systemd unit values (legacy `systemd_path`). */
private fun systemdPath(value: String): String = value.replace("%", "%%")
/** Quote one `ExecStart` word (legacy `systemd_quote`). */
private fun systemdQuote(value: String): String =
"\"" +
value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("%", "%%") +
"\""
}
@@ -82,6 +82,62 @@ class InitCommandTest : FunSpec() {
projectConfig.toFile().readText() shouldBe "existing: content"
}
test("--systemd generates unit and environment file with install instructions") {
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 unitName = SystemdServiceFiles.unitName(tempDir)
val unitFile = tempDir.resolve(".git/gittally/$unitName")
unitFile.toFile().shouldExist()
val unitContent = unitFile.toFile().readText()
unitContent shouldContain "WorkingDirectory=$tempDir"
unitContent shouldContain """ExecStart="/usr/bin/java" ${'$'}JAVA_OPTS -jar "/home/ci/bin/gittally.jar" server"""
tempDir.resolve(".git/gittally/gittally.env").toFile().shouldExist()
}
test("--systemd keeps an existing environment file") {
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") }
val envFile = tempDir.resolve(".git/gittally/gittally.env")
Files.createDirectories(envFile.parent)
envFile.toFile().writeText("JAVA_OPTS=-Xmx1g\n")
every { gitService.getTopLevel(tempDir) } returns tempDir
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
initCommand.run()
envFile.toFile().readText() shouldBe "JAVA_OPTS=-Xmx1g\n"
}
test("--systemd without a resolvable jar path generates no unit file") {
val tempDir = Files.createTempDirectory("gittally-init-test")
initCommand.workingDir = tempDir
initCommand.systemd = true
initCommand.jarPathResolver = { null }
every { gitService.getTopLevel(tempDir) } returns tempDir
every { gitService.getOriginUrl(tempDir) } returns "https://git.example.org/my-org/my-repo.git"
initCommand.run()
val unitFile = tempDir.resolve(".git/gittally/${SystemdServiceFiles.unitName(tempDir)}")
unitFile.toFile().exists() shouldBe false
}
test("reproduces path root mismatch issue") {
val tempDir = Files.createTempDirectory("gittally-init-test").toAbsolutePath().normalize()
initCommand.workingDir = Paths.get(".") // Set to relative path as in real app
@@ -0,0 +1,56 @@
package de.hoennig.gittally.commands
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import java.nio.file.Paths
class SystemdServiceFilesTest : FunSpec() {
init {
test("unit name is derived from the sanitized repository directory name") {
SystemdServiceFiles.unitName(Paths.get("/srv/repos/my-repo")) shouldBe "gittally-my-repo.service"
SystemdServiceFiles.unitName(Paths.get("/srv/repos/my repo!")) shouldBe "gittally-my-repo-.service"
}
test("unit file runs the server jar in the repository with restart and environment file") {
val content =
SystemdServiceFiles.unitFileContent(
repoRoot = Paths.get("/srv/repos/my-repo"),
javaExecutable = Paths.get("/usr/lib/jvm/java-21/bin/java"),
jarPath = Paths.get("/home/ci/bin/gittally.jar"),
envFile = Paths.get("/srv/repos/my-repo/.git/gittally/gittally.env"),
)
content shouldContain "Description=GitTally CI for my-repo"
content shouldContain "After=network-online.target docker.service"
content shouldContain "WorkingDirectory=/srv/repos/my-repo"
content shouldContain "EnvironmentFile=-/srv/repos/my-repo/.git/gittally/gittally.env"
content shouldContain
"""ExecStart="/usr/lib/jvm/java-21/bin/java" ${'$'}JAVA_OPTS -jar "/home/ci/bin/gittally.jar" server"""
content shouldContain "Restart=always"
content shouldContain "RestartSec=30"
content shouldContain "WantedBy=default.target"
}
test("percent signs in paths are escaped for systemd") {
val content =
SystemdServiceFiles.unitFileContent(
repoRoot = Paths.get("/srv/100%-repo"),
javaExecutable = Paths.get("/usr/bin/java"),
jarPath = Paths.get("/srv/100%-repo/gittally.jar"),
envFile = Paths.get("/srv/100%-repo/gittally.env"),
)
content shouldContain "WorkingDirectory=/srv/100%%-repo"
content shouldContain "EnvironmentFile=-/srv/100%%-repo/gittally.env"
content shouldContain """-jar "/srv/100%%-repo/gittally.jar" server"""
}
test("environment file template only tunes the JVM") {
val content = SystemdServiceFiles.envFileContent()
content shouldContain "#JAVA_OPTS="
content shouldContain ".gittally.yml"
}
}
}