From 60ff595a9d110f537f98361c693b5f1744368986 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 7 Jul 2026 14:18:41 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 5 +- README.md | 12 ++ docs/GitTally-Konzept.md | 8 +- .../0004-2026-07-07.rewrite-architecture.md | 77 +++++++++++ docs/bootstrapping.md | 4 +- docs/deployment.md | 124 ++++++++++++++++++ docs/migration-from-legacy.md | 74 +++++++++++ docs/plan/12-deployment.md | 25 ++++ docs/plan/README.md | 2 +- legacy/gitTally | 3 + .../hoennig/gittally/commands/InitCommand.kt | 76 +++++++++++ .../gittally/commands/SystemdServiceFiles.kt | 61 +++++++++ .../gittally/commands/InitCommandTest.kt | 56 ++++++++ .../commands/SystemdServiceFilesTest.kt | 56 ++++++++ 14 files changed, 578 insertions(+), 5 deletions(-) create mode 100644 docs/adrs/0004-2026-07-07.rewrite-architecture.md create mode 100644 docs/deployment.md create mode 100644 docs/migration-from-legacy.md create mode 100644 src/main/kotlin/de/hoennig/gittally/commands/SystemdServiceFiles.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/commands/SystemdServiceFilesTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 7249a1c..d9ed38f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ GitTallyApplication ← @SpringBootApplication CliRunner ← CommandLineRunner + ExitCodeGenerator GitTallyCommand ← root @Command, delegates to subcommands commands/ - InitCommand ← "init" + InitCommand ← "init [--systemd]" ServerCommand ← "server" StatusCommand ← "status [--history]" BuildCommand ← "build []" @@ -150,6 +150,8 @@ Keep sentences short. - `docs/GitTally-Konzept.md` — product concept and target architecture (in German): git-centric CI, builds in Docker, one instance per repository, status reported back to Gitea. - `docs/configuration.md` — configuration reference; keep in sync with `GitTallyConfig` and the `init` templates. - `docs/bootstrapping.md` — how `init` prepares a repository. +- `docs/deployment.md` — running GitTally as a systemd user service behind an existing reverse proxy (`init --systemd` generates the unit). +- `docs/migration-from-legacy.md` — legacy env vars → YAML keys mapping and the manual migration steps; `legacy/gitTally` is deprecated. - `docs/plan/` — the step-by-step rewrite plan; `docs/plan/README.md` explains how to execute a step, `docs/plan/00-legacy-analysis.md` summarizes the legacy bash script. ## Key Architectural Decisions @@ -159,3 +161,4 @@ All major decisions are in `docs/adrs/`. Run `adr-status` (after `source .envrc` - **Test framework**: Kotest + MockK + WireMock + Testcontainers (ADR 0001) - **Gradle**: 8.14.5 (ADR 0002) - **Spring Boot**: 4.0.6 (ADR 0003) +- **Rewrite architecture**: JSON-file persistence behind a repository interface, server-rendered UI with JSON polling, no managed nginx — systemd unit behind the host's reverse proxy (ADR 0004) diff --git a/README.md b/README.md index 21a5ef9..036e238 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,18 @@ Lightweight, declarative and highly opinionated software build system (CI/CD). +## Documentation + +- [docs/configuration.md](docs/configuration.md) — configuration reference +- [docs/bootstrapping.md](docs/bootstrapping.md) — initializing a repository with `init` +- [docs/deployment.md](docs/deployment.md) — running GitTally as a systemd service behind a reverse proxy +- [docs/migration-from-legacy.md](docs/migration-from-legacy.md) — migrating from the legacy bash script + +## Legacy Script + +`legacy/gitTally` (bash) is **deprecated** and kept only as a behavioral reference for the rewrite. +Do not use it for new installations; see [docs/migration-from-legacy.md](docs/migration-from-legacy.md). + ## Developer Setup Source `.envrc` to add `tools/` to your `PATH`, or install [direnv](#direnv) to have this done automatically on `cd`: diff --git a/docs/GitTally-Konzept.md b/docs/GitTally-Konzept.md index 9aac13e..a083e57 100644 --- a/docs/GitTally-Konzept.md +++ b/docs/GitTally-Konzept.md @@ -9,7 +9,7 @@ Ziel ist es, die Komplexität klassischer CI-Systeme wie Jenkins erheblich zu re ## Grundprinzipien - Git wird immer verwendet. -- Builds laufen in Docker. +- Builds laufen nativ oder optional in Docker (pro Branch konfigurierbar). - Die Konfiguration erfolgt primär über YAML-Dateien. - Eine Instanz verwaltet zunächst genau ein Repository. - Build-Status werden an das Git-System zurückgemeldet. @@ -20,6 +20,7 @@ Ziel ist es, die Komplexität klassischer CI-Systeme wie Jenkins erheblich zu re - Interaktive Nutzung - Status anzeigen +- Builds starten und wiederholen - Konfiguration anzeigen - Initialisierung durchführen @@ -110,6 +111,7 @@ flowchart LR ### CLIFrontend - Status anzeigen +- Builds starten und wiederholen - Konfiguration anzeigen - Initialisierung durchführen @@ -168,6 +170,8 @@ java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar init java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar server ``` +Für den Dauerbetrieb als systemd-User-Service siehe [deployment.md](deployment.md) (`init --systemd`). + ### Konfigurationsanzeige ```bash @@ -177,7 +181,7 @@ java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar config:print --full ## Erweiterungen -- Deployment / CD +- Continuous Delivery (CD; das Deployment von GitTally selbst ist in [deployment.md](deployment.md) beschrieben) - SQLite statt Dateisystem - Mehrere BuildWorker - Multi-Repository-Verwaltung diff --git a/docs/adrs/0004-2026-07-07.rewrite-architecture.md b/docs/adrs/0004-2026-07-07.rewrite-architecture.md new file mode 100644 index 0000000..c357803 --- /dev/null +++ b/docs/adrs/0004-2026-07-07.rewrite-architecture.md @@ -0,0 +1,77 @@ +# Rewrite Architecture Decisions + +**Status:** +- proposed: 2026-07-07 +- accepted: 2026-07-07 +- rejected: - +- superseded: - + +**Decision [accepted]:** JSON-file persistence behind a repository interface, server-rendered UI with JSON polling, no managed nginx/TLS — deployment via systemd user unit behind the host's reverse proxy. + +## Context and Problem Statement + +The rewrite of `legacy/gitTally` (bash) as a Kotlin/Spring application (see `docs/plan/`) required several cross-cutting architecture decisions. +They were proposed in `docs/plan/README.md`, validated step by step during implementation, and are summarized here as one record. + +### Technical Background + +The legacy script kept all state in TSV/HTML files, patched its web UI with regex rewrites, and managed its own nginx+certbot Docker container. +Its two structural defects — build status not observable during a build, and a web UI that could get stuck loading forever — had to be fixed by design, not by patching. + +## Considered Options + +* Database persistence (SQLite) vs. JSON files behind a repository interface +* SPA frontend or server push (WebSocket/SSE) vs. server-rendered HTML with JSON polling +* Managed nginx/Let's Encrypt container vs. documented deployment behind an existing reverse proxy + +### Persistence: JSON files behind `BuildResultRepository` + +Build results are persisted as a JSON file under `.git/gittally/`, accessed only through the `BuildResultRepository` interface. + +#### Advantages + +- No database dependency, no schema migrations; state stays inspectable with a text editor, like legacy. +- The volume is tiny (retention prunes per branch), so file rewrites are cheap. +- The interface keeps a later switch to SQLite possible without touching callers. + +#### Disadvantages + +- No queries or transactions; concurrent writers must be serialized in-process. + +### Web UI: server-rendered Thymeleaf plus JSON polling + +Pages render the full state server-side; one hand-written JavaScript file polls JSON endpoints and re-renders table bodies. + +#### Advantages + +- No SPA framework and no frontend build pipeline. +- Fixes the legacy stuck-spinner defect by design: every fetch has a timeout and an explicit error badge, and status transitions are event-driven and observable while a build runs. +- Pages stay useful without JavaScript (initial render is complete). + +#### Disadvantages + +- Updates are only as fresh as the polling interval; no server push. + +### Deployment: no managed nginx, systemd user unit instead + +nginx/Let's Encrypt container management was not ported; `init --systemd` generates a user unit running `java -jar gittally.jar server`, and `docs/deployment.md` documents the reverse-proxy setup with the host's certbot. + +#### Advantages + +- Removes the largest and most brittle legacy subsystem (container lifecycle, certificate renewal, config templating). +- Hosts usually already run a web server with TLS; one `server` block suffices. +- The generated unit replaces the legacy self-copy/self-update machinery with plain jar deployment. + +#### Disadvantages + +- HTTPS setup is a manual, host-specific step outside GitTally's control. + +## Decision Outcome + +All three proposals from the plan were confirmed during implementation and are in force: + +- JSON-file persistence behind `BuildResultRepository` (steps 01, 05, 06). +- Server-rendered HTML with polling JSON endpoints and explicit error states (steps 07–09). +- No nginx management; systemd user unit plus reverse-proxy documentation (step 12). + +Related, previously decided in the same spirit: external systems are accessed by shelling out to the `git` and `docker` CLIs instead of SDK dependencies (steps 02, 11). diff --git a/docs/bootstrapping.md b/docs/bootstrapping.md index 105a0ac..01a7f93 100644 --- a/docs/bootstrapping.md +++ b/docs/bootstrapping.md @@ -1,7 +1,8 @@ # GitTally Bootstrapping Bootstrapping prepares a git repository for use with GitTally. -It creates the config files described in [configuration.md](configuration.md) and optionally installs GitTally as a system service. +It creates the config files described in [configuration.md](configuration.md). +With `init --systemd` it also generates a systemd user unit for running the server permanently, see [deployment.md](deployment.md). Run `init` once per repository, from within a checked-out working tree. @@ -125,6 +126,7 @@ Until then, a Java runtime must be available on the host. ```bash java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar server ``` +5. For permanent operation, install the systemd user service described in [deployment.md](deployment.md). ## Example: Self-Hosting GitTally diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..93c663c --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,124 @@ +# GitTally Deployment + +This document describes how to run GitTally as a permanent service. +The recommended setup is a systemd user service behind an existing reverse proxy. +GitTally does not manage nginx or TLS certificates itself (unlike the legacy script); it relies on the host's existing web server and certbot. + +## Prerequisites + +- Linux with systemd. +- Java runtime (JRE 21). +- `git` CLI on the `PATH`. +- `docker` CLI on the `PATH`, only if any branch uses `docker.enabled` (see [configuration.md](configuration.md)). +- A checked-out working tree of the repository to watch, with a remote named `origin`. + +## Jar Location Convention + +Build the executable jar once: + +```bash +./gradlew build +ls build/libs/gittally-*-SNAPSHOT.jar +``` + +Copy the jar to a stable path outside any watched repository, by convention `~/bin/gittally.jar`: + +```bash +mkdir -p ~/bin +cp build/libs/gittally-0.1.0-SNAPSHOT.jar ~/bin/gittally.jar +``` + +The systemd unit generated below points at the jar that was used to run `init --systemd`. +So always run it via the stable path, not via `build/libs/`. + +## Install the Service + +Initialize GitTally in the repository to watch (see [bootstrapping.md](bootstrapping.md) for details): + +```bash +cd /path/to/repo +java -jar ~/bin/gittally.jar init +# fill in git.account and git.token in .git/gittally/.gittally.yml +# review .gittally.yml +``` + +Generate the systemd user unit: + +```bash +java -jar ~/bin/gittally.jar init --systemd +``` + +This writes `.git/gittally/gittally-.service` and `.git/gittally/gittally.env` and prints the install commands: + +```bash +ln -sf /path/to/repo/.git/gittally/gittally-.service ~/.config/systemd/user/gittally-.service +systemctl --user daemon-reload +systemctl --user enable --now gittally-.service +``` + +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. + +User services stop at logout unless lingering is enabled once per user: + +```bash +loginctl enable-linger "$USER" +``` + +## Operating the Service + +```bash +systemctl --user status gittally-.service # state and last log lines +journalctl --user -u gittally-.service -f # follow the log +systemctl --user restart gittally-.service # restart (e.g. after config changes) +systemctl --user stop gittally-.service # stop +``` + +To update GitTally, replace `~/bin/gittally.jar` and restart the service. + +## Environment File + +`.git/gittally/gittally.env` is loaded by the unit as `EnvironmentFile`. +It only tunes the JVM process, e.g. `JAVA_OPTS=-Xmx256m`. +All GitTally configuration lives in the YAML files described in [configuration.md](configuration.md), not in environment variables. +`init --systemd` never overwrites an existing environment file. + +## Reverse Proxy (nginx) + +Bind GitTally to localhost and set the public URL in `.gittally.yml`: + +```yaml +server: + bindAddress: 127.0.0.1 + port: 18080 + publicBaseUrl: "https://ci.example.org/" +``` + +`publicBaseUrl` is used for all links posted to Gitea, so it must be the externally reachable URL. + +Add a `server` block to the host's nginx: + +```nginx +server { + listen 443 ssl; + server_name ci.example.org; + + ssl_certificate /etc/letsencrypt/live/ci.example.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/ci.example.org/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:18080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +Obtain and renew the certificate with the host's existing certbot, e.g.: + +```bash +sudo certbot --nginx -d ci.example.org +``` + +This replaces the legacy script's managed nginx/Let's Encrypt Docker container, which was intentionally not ported (see [migration-from-legacy.md](migration-from-legacy.md)). diff --git a/docs/migration-from-legacy.md b/docs/migration-from-legacy.md new file mode 100644 index 0000000..f59bbad --- /dev/null +++ b/docs/migration-from-legacy.md @@ -0,0 +1,74 @@ +# Migration from the Legacy Script + +The bash script `legacy/gitTally` is deprecated and replaced by this application. +This document maps the legacy environment-variable configuration to the YAML configuration and lists the manual migration steps. +See [configuration.md](configuration.md) for the full configuration reference and [deployment.md](deployment.md) for the new service setup. + +## Configuration Mapping + +Legacy configuration came from environment variables (`gitTally --env` template, sourced env files). +The new configuration lives in two YAML files: `.gittally.yml` (committed) and `.git/gittally/.gittally.yml` (machine-specific, secrets). + +Branch-level keys below live under `branches.`; use `branches.default` for what used to be the global value. + +| Legacy environment variable | New YAML key | +|---|---| +| `GITTALLY_BUILD_COMMAND` | `branches..buildCommand` | +| `GITTALLY_BUILD_CLEAN_COMMAND` | `branches..cleanCommand` | +| `GITTALLY_BUILD_ARTEFACT_DIRS` | `branches..artifactDirs` — YAML list instead of `;`-separated | +| `GITTALLY_BUILD_STDOUT_LOG` | `branches..stdoutLog` | +| `GITTALLY_BUILD_STDERR_LOG` | `branches..stderrLog` | +| `GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE` | `watcher.newBranchMaxAge` | +| `GITTALLY_BUILD_DOCKER_IMAGE` | `branches..docker.image` — also set `docker.enabled: true` (replaces the `--docker` flag) | +| `GITTALLY_BUILD_DOCKERFILE` | `branches..docker.dockerfile` | +| `GITTALLY_BUILD_DOCKER_CONTEXT` | `branches..docker.context` | +| `GITTALLY_BUILD_DOCKER_NETWORK` | `branches..docker.network` — default is now Docker's default network, not `host` | +| `GITTALLY_BUILD_DOCKER_ENV` | `branches..docker.env` — YAML map instead of space-separated assignments | +| `GITTALLY_ARTIFACT_SERVER_PORT` | `server.port` | +| `GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS` | `server.bindAddress` | +| `GITTALLY_ARTIFACT_PUBLIC_BASE_URL` | `server.publicBaseUrl` | +| `GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH` | `artifacts.retentionPerBranch` — build count only; the legacy age suffix (`h`/`d`) is not supported | +| `GITTALLY_IMPRESSUM_URL` | `server.impressumUrl` | +| `GITTALLY_AUTO_BUILD_BRANCHES` | `branches..autoBuild.enabled: true` per branch instead of a branch list | +| `GITTALLY_AUTO_BUILD_TIMES` | `branches..autoBuild.times` — YAML list, per branch | +| `GITTALLY_GITEA_BASE_URL` | `gitea.baseUrl` | +| `GITTALLY_GITEA_OWNER` | `gitea.owner` | +| `GITTALLY_GITEA_REPO` | `gitea.repo` | +| `GITTALLY_GITEA_STATUS_CONTEXT` | `gitea.statusContext` | +| `GITTALLY_GITEA_GIT_USERNAME` | `git.account` — in `.git/gittally/.gittally.yml` | +| `GITTALLY_GITEA_TOKEN` | `git.token` — in `.git/gittally/.gittally.yml`, never committed | + +New keys without a legacy counterpart: `builds.maxConcurrent`, `artifacts.rootDir`, and `watcher.pollInterval`. + +## Intentionally Not Ported + +- Managed nginx/Let's Encrypt container (`GITTALLY_ARTIFACT_NGINX_*`, `GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL`) — use the host's reverse proxy, see [deployment.md](deployment.md). +- Self-install and self-update (`--install`, `--pull`, `GITTALLY_INSTALL_DIR`) — replaced by jar deployment plus `init --systemd`. +- `GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND` and `GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS` — hsadmin-ng-specific; use `branches..docker.env` if needed. +- `HSADMIN_NG_*` environment-variable fallbacks. +- Env-file configuration itself — the systemd `EnvironmentFile` now only tunes the JVM (`JAVA_OPTS`). +- `GITTALLY_GITEA_DELETED_STATUS_DESCRIPTION`, `GITTALLY_BIN_FORWARD`, `GITTALLY_CONFIG_*` — internal legacy mechanics without a counterpart. + +## Build History + +Legacy build history (`.git/git-watch-origin-and-test/build-results.tsv`) is **not** imported; history starts fresh. +The formats differ substantially (TSV vs. JSON with commit metadata and artifact keys), and retention would prune imported rows quickly anyway. +Old artifacts under the legacy artifact root remain readable on disk until you delete them. + +## Manual Migration Steps + +1. Stop and remove the legacy service: + + ```bash + systemctl --user disable --now gitTally.service + rm -f ~/.config/systemd/user/gitTally.service + systemctl --user daemon-reload + ``` + +2. Build and place the jar as described in [deployment.md](deployment.md). +3. In the repository, run `java -jar ~/bin/gittally.jar init`. +4. Transfer your settings from the legacy env file into `.gittally.yml` using the table above. +5. Put `git.account` and `git.token` into `.git/gittally/.gittally.yml`. +6. Verify the effective configuration: `java -jar ~/bin/gittally.jar config:print --full`. +7. Install and start the new service: `init --systemd` plus the printed commands, see [deployment.md](deployment.md). +8. Optionally clean up legacy state: `.git/git-watch-origin-and-test/` and the legacy artifact root. diff --git a/docs/plan/12-deployment.md b/docs/plan/12-deployment.md index 4a687e6..3dd9d99 100644 --- a/docs/plan/12-deployment.md +++ b/docs/plan/12-deployment.md @@ -34,3 +34,28 @@ Housekeeping: - `./gradlew ktlintFormat` then `./gradlew build` is green. - A fresh clone can follow `docs/deployment.md` to a running service (manual walkthrough; document the result in this file). + +## Implementation Notes (2026-07-07) + +Implemented as designed: `init --systemd` (an option on `init`, not a separate subcommand) generates the unit and its `EnvironmentFile` under `.git/gittally/`, prints the install commands, and never touches `~/.config/systemd` itself (no self-install). +`SystemdServiceFiles` builds the file contents and is unit-tested by content assertions, including the legacy `%` escaping and `ExecStart` quoting. +`docs/deployment.md` and `docs/migration-from-legacy.md` were written; `README.md`, `docs/bootstrapping.md`, `docs/GitTally-Konzept.md`, and `CLAUDE.md` were updated to reference them. + +Deviations and decisions: + +- The unit is named per repository (`gittally-.service`) instead of the global legacy `gitTally.service`, because one instance serves one repository and several repositories can share a host. +- `ExecStart` uses the `java` binary and the jar path of the JVM that ran `init --systemd`, so the unit points at the jar in place (legacy copied the script to an install dir); systemd expands `$JAVA_OPTS` from the `EnvironmentFile` into the command line. + When not started via `java -jar` (e.g. from Gradle), `init --systemd` prints an error instead of generating a broken unit. +- The `EnvironmentFile` only tunes the JVM (`JAVA_OPTS`); the legacy env file carried username/token, which now live in `.git/gittally/.gittally.yml`. + An existing `gittally.env` is kept; the unit file is regenerated on every run (same as legacy). +- The legacy `--nginx --docker` `ExecStart` flags were dropped (runtime selection is per-branch config now); the `After=… docker.service` ordering was kept. +- Legacy build history (`build-results.tsv`) is not imported — decided and documented in `docs/migration-from-legacy.md` (formats differ substantially; retention would prune imported rows quickly). +- ADR 0004 records the rewrite architecture decisions (JSON-file persistence behind a repository interface, polling UI, no managed nginx). +- `docs/GitTally-Konzept.md` review: only one real deviation found — "Builds laufen in Docker" became "nativ oder optional in Docker (pro Branch konfigurierbar)"; CLI capability lists gained build/retry; deployment links added. + +Manual walkthrough (2026-07-07, fresh clone under `~/.cache`): + +- Followed `docs/deployment.md` end to end: built the jar, copied it to a stable path, cloned the repository freshly, ran `init` and `init --systemd`, linked the generated unit, `daemon-reload`, started the service. +- The clone already contained the committed `.gittally.yml`, so only the machine config was created; `server.port` was overridden to a free port via `.git/gittally/.gittally.yml` to avoid clashing with a locally running instance. +- Result: unit `active (running)`, `GET /` returned 200, `/api/watcher` showed a successful poll, `journalctl --user -u …` showed the startup log, `restart` and `stop` worked; the unit link and the scratch clone were removed afterwards. +- Not machine-verified: `systemctl --user enable` and `loginctl enable-linger` (the walkthrough used a transient `start` to leave no persistent service behind) and the nginx/certbot section (no public host available); those commands were reviewed against the systemd/certbot documentation instead. diff --git a/docs/plan/README.md b/docs/plan/README.md index 242d363..4306f43 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -62,7 +62,7 @@ Completion: - [x] `10-cli-commands.md` — CLI build/status commands - [x] `11-docker-build-runtime.md` — optional Docker build execution -- [ ] `12-deployment.md` — systemd service, migration from legacy, docs +- [x] `12-deployment.md` — systemd service, migration from legacy, docs Steps 01–03 are independent of each other. Steps 04–06 depend on 01–03. diff --git a/legacy/gitTally b/legacy/gitTally index 02e1e5c..b155c80 100755 --- a/legacy/gitTally +++ b/legacy/gitTally @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# DEPRECATED: This script has been replaced by the Kotlin/Spring application in this repository. +# See docs/migration-from-legacy.md for the migration guide; the script is kept only as a behavioral reference. +# # A small and opinionated continuous integration tool for projects that do not have the hardware budget or operational staff for large CI/CD systems. # Made to run locally or in Designed for Hostsharing Container Server environments with Docker (Podman not tested yet). # diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index c11bab3..d838429 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -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 /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]) } + } + } } diff --git a/src/main/kotlin/de/hoennig/gittally/commands/SystemdServiceFiles.kt b/src/main/kotlin/de/hoennig/gittally/commands/SystemdServiceFiles.kt new file mode 100644 index 0000000..04e3cdd --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/commands/SystemdServiceFiles.kt @@ -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("%", "%%") + + "\"" +} diff --git a/src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt b/src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt index 65bff1b..f880ec1 100644 --- a/src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/commands/InitCommandTest.kt @@ -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 diff --git a/src/test/kotlin/de/hoennig/gittally/commands/SystemdServiceFilesTest.kt b/src/test/kotlin/de/hoennig/gittally/commands/SystemdServiceFilesTest.kt new file mode 100644 index 0000000..5d4ecb1 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/commands/SystemdServiceFilesTest.kt @@ -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" + } + } +}