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:
@@ -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 [<branch>]"
|
||||
@@ -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)
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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-<repo-name>.service` and `.git/gittally/gittally.env` and prints the install commands:
|
||||
|
||||
```bash
|
||||
ln -sf /path/to/repo/.git/gittally/gittally-<repo-name>.service ~/.config/systemd/user/gittally-<repo-name>.service
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now gittally-<repo-name>.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-<repo-name>.service # state and last log lines
|
||||
journalctl --user -u gittally-<repo-name>.service -f # follow the log
|
||||
systemctl --user restart gittally-<repo-name>.service # restart (e.g. after config changes)
|
||||
systemctl --user stop gittally-<repo-name>.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)).
|
||||
@@ -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.<name>`; use `branches.default` for what used to be the global value.
|
||||
|
||||
| Legacy environment variable | New YAML key |
|
||||
|---|---|
|
||||
| `GITTALLY_BUILD_COMMAND` | `branches.<name>.buildCommand` |
|
||||
| `GITTALLY_BUILD_CLEAN_COMMAND` | `branches.<name>.cleanCommand` |
|
||||
| `GITTALLY_BUILD_ARTEFACT_DIRS` | `branches.<name>.artifactDirs` — YAML list instead of `;`-separated |
|
||||
| `GITTALLY_BUILD_STDOUT_LOG` | `branches.<name>.stdoutLog` |
|
||||
| `GITTALLY_BUILD_STDERR_LOG` | `branches.<name>.stderrLog` |
|
||||
| `GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE` | `watcher.newBranchMaxAge` |
|
||||
| `GITTALLY_BUILD_DOCKER_IMAGE` | `branches.<name>.docker.image` — also set `docker.enabled: true` (replaces the `--docker` flag) |
|
||||
| `GITTALLY_BUILD_DOCKERFILE` | `branches.<name>.docker.dockerfile` |
|
||||
| `GITTALLY_BUILD_DOCKER_CONTEXT` | `branches.<name>.docker.context` |
|
||||
| `GITTALLY_BUILD_DOCKER_NETWORK` | `branches.<name>.docker.network` — default is now Docker's default network, not `host` |
|
||||
| `GITTALLY_BUILD_DOCKER_ENV` | `branches.<name>.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.<name>.autoBuild.enabled: true` per branch instead of a branch list |
|
||||
| `GITTALLY_AUTO_BUILD_TIMES` | `branches.<name>.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.<name>.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.
|
||||
@@ -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-<repo-name>.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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
#
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user