added opt-in managed nginx/TLS container (ADR 0005, plan step 13): server.nginx.* config serves GitTally over HTTPS on hosts without a reverse proxy — two-phase startup (ACME webroot via certbot container, then full HTTPS config), daily certificate renewal with nginx reload, labelled container removed on shutdown; all failures are non-fatal, the plain HTTP server keeps running

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michael Hoennig
2026-07-08 21:51:33 +02:00
co-authored by Claude Fable 5
parent fdbbd0516a
commit b104eeee05
15 changed files with 1191 additions and 9 deletions
+30
View File
@@ -32,6 +32,26 @@ server:
bindAddress: 0.0.0.0
# optional Impressum (legal disclosure) link in the web UI footer; empty hides the link
impressumUrl: ""
# Opt-in managed nginx+certbot Docker container for HTTPS, for hosts without
# a usable reverse proxy (ADR 0005; see notes below and deployment.md).
nginx:
# manage an nginx Docker container with Let's Encrypt certificates
enabled: false
# public DNS name served by nginx and used for the certificate; required when enabled
serverName: ""
# host port published as nginx port 80 (ACME challenge + HTTPS redirect)
httpPort: 8080
# host port published as nginx port 443
httpsPort: 8443
# host nginx proxies to; empty = serverName (the container cannot reach localhost)
upstreamHost: ""
# name of the managed container; empty = gittally-nginx-<repo-name>
containerName: ""
# directory for nginx config, certificates, and logs;
# empty = $XDG_STATE_HOME (or ~/.local/state) plus /gittally/nginx/<repo-key>
stateDir: ""
# e-mail for the Let's Encrypt account; empty registers without one
letsencryptEmail: ""
# Gitea integration for fetching commits and posting build statuses.
gitea:
@@ -127,6 +147,16 @@ branches:
- "04:00"
```
### Notes on `server.nginx`
With `nginx.enabled`, the `server` subcommand also starts a managed nginx Docker container that serves GitTally over HTTPS (ADR 0005).
This is meant for hosts that provide Docker but no usable reverse proxy (e.g. Hostsharing managed containers); otherwise prefer the reverse-proxy setup in [deployment.md](deployment.md).
Certificates are obtained and renewed via Let's Encrypt (certbot Docker container, webroot mode), so `serverName` must be a public DNS name pointing at the host and `httpPort` must be reachable from the internet as port 80 (or via a port forward).
When `server.publicBaseUrl` is empty and `serverName` is set, it defaults to `https://<serverName>/`.
All nginx/certificate failures are non-fatal warnings; the plain HTTP server keeps running without the proxy.
The container is labelled `org.hoennig.gittally`; stale nginx containers of the repository are removed before each start, and the container is removed on shutdown.
`server.port` must differ from `httpPort` and `httpsPort`.
### Notes on `branches.<name>.requirePullRequest`
The gate applies to all watcher-triggered builds (push-triggered and scheduled auto builds).
+31 -2
View File
@@ -2,7 +2,8 @@
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.
By default GitTally does not manage nginx or TLS certificates itself; it relies on the host's existing web server and certbot.
For hosts without one, an opt-in managed nginx/TLS container is available, see [Hosts Without a Reverse Proxy](#hosts-without-a-reverse-proxy-managed-nginxtls).
## Prerequisites
@@ -122,4 +123,32 @@ sudo certbot --nginx -d ci.example.org
```
This replaces the legacy script's managed nginx/Let's Encrypt Docker container for hosts that have their own web server.
For hosts without a usable reverse proxy (e.g. Hostsharing managed containers), an opt-in managed nginx/TLS container is planned (see `docs/plan/13-nginx-tls.md`, ADR 0005).
## Hosts Without a Reverse Proxy (Managed nginx/TLS)
Some hosts provide Docker but no root access and no host web server, e.g. Hostsharing managed container environments.
For these, GitTally can manage its own nginx+certbot Docker container (ADR 0005).
This is opt-in; where a host web server exists, prefer the reverse-proxy setup above.
Enable it in the server section of the configuration:
```yaml
server:
port: 18080
nginx:
enabled: true
serverName: ci.example.org
httpPort: 8080
httpsPort: 8443
letsencryptEmail: admin@example.org
```
On server start, GitTally writes the nginx configuration, starts a labelled nginx container publishing `httpPort` and `httpsPort`, obtains a Let's Encrypt certificate via a certbot container (webroot mode), and restarts nginx with the full HTTPS configuration.
A renewal check runs daily; certificates and nginx state persist in `server.nginx.stateDir` across restarts.
On shutdown the container is removed.
All nginx and certificate failures are non-fatal warnings — the plain HTTP server keeps running without the proxy.
`serverName` must be a public DNS name pointing at the host, reachable from the internet on port 80/443 (directly or via a port forward to `httpPort`/`httpsPort`), otherwise the ACME challenge fails.
The nginx container cannot reach `localhost` of the host, so the proxy upstream defaults to `serverName`; set `server.nginx.upstreamHost` if the host is reachable under a different name from inside containers.
With the managed nginx, keep `server.bindAddress: 0.0.0.0` (or an address reachable from the Docker network) — binding GitTally to `127.0.0.1` would make it unreachable for the proxy.
See [configuration.md](configuration.md) for all `server.nginx.*` keys.
+7 -2
View File
@@ -37,13 +37,18 @@ Branch-level keys below live under `branches.<name>`; use `branches.default` for
| `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 |
| `GITTALLY_ARTIFACT_NGINX_SERVER_NAME` | `server.nginx.serverName` — also set `server.nginx.enabled: true` (replaces the `--nginx` flag) |
| `GITTALLY_ARTIFACT_NGINX_HTTP_PORT` | `server.nginx.httpPort` |
| `GITTALLY_ARTIFACT_NGINX_HTTPS_PORT` | `server.nginx.httpsPort` |
| `GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST` | `server.nginx.upstreamHost` |
| `GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME` | `server.nginx.containerName` |
| `GITTALLY_ARTIFACT_NGINX_STATE_DIR` | `server.nginx.stateDir` |
| `GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL` | `server.nginx.letsencryptEmail` |
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`) — not ported yet, but planned as an opt-in feature for hosts without a reverse proxy (see `docs/plan/13-nginx-tls.md`).
Until then, 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.
+22
View File
@@ -39,3 +39,25 @@ Port the legacy nginx subsystem (functions `configure_artifact_nginx_defaults` ~
- Manual walkthrough on a Docker host: nginx container starts with the init config and proxies HTTP to GitTally.
Full ACME issuance needs a public DNS name; if none is available, verify the certbot argv and the full-config path against the legacy script and document that in this file.
- `docs/deployment.md` gains a section for hosts without a reverse proxy; `docs/migration-from-legacy.md` maps the `GITTALLY_ARTIFACT_NGINX_*`/`GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL` variables.
## Result (2026-07-08)
Implemented as `NginxConfigFiles` (config generation), `NginxProxyManager` (docker orchestration), and `ServerNginxLifecycle` (server-profile startup, daily renewal, shutdown cleanup).
Deviations from the design above and from legacy:
- Renewal reloads nginx via `docker exec <container> nginx -s reload` after `certbot renew` instead of restarting the container (no dropped connections).
- Legacy auto-moved the artifact server port on a collision with the nginx ports; the rewrite refuses to start the proxy with a warning instead — the Spring port cannot move after startup.
- Legacy derived a missing `serverName` from the public base URL host; the rewrite requires `serverName` explicitly (the config default direction is only publicBaseUrl ← serverName).
- `serverName` and `upstreamHost` are validated against a host-name pattern instead of substituting raw values, so no nginx directives can be injected via config.
- The container label namespace is `org.hoennig.gittally` (like the Docker build runner), not `org.hostsharing.gittally`; port cleanup still also matches legacy-named containers.
- The legacy `--nginx` CLI flag is not ported; enablement is `server.nginx.enabled` only.
- `ssl-dhparams.pem` is downloaded via the Java HTTP client instead of `curl` (replaceable seam for tests).
Manual walkthrough (dev machine, Rancher Desktop Docker, no public DNS name — so no real ACME issuance):
- Fresh state dir: nginx container started with the init config; port 80 served the ACME challenge location and answered `301 https://<serverName>/...`; `certonly` then failed as expected without public DNS and the HTTP server kept running (non-fatal path).
- Pre-seeded certificate (self-signed): manager took the full-config path, `certbot renew` ran (exit 0), and HTTPS end-to-end worked — `/api/branches` JSON served through the TLS proxy with the configured no-cache headers.
- `SIGTERM` removed the container (lifecycle `@PreDestroy`).
- The certbot `certonly`/`renew` argv and both config modes are asserted verbatim against the legacy script in `NginxProxyManagerTest`/`NginxConfigFilesTest`.
- Environment note: the nginx container cannot reach the host's `localhost`; on the walkthrough machine the upstream had to be `host.docker.internal` (documented in `deployment.md` as `upstreamHost`).
+1 -1
View File
@@ -67,7 +67,7 @@ Completion:
Added after the initial plan (ADR 0005):
- [ ] `13-nginx-tls.md` — opt-in managed nginx/TLS container for hosts without a reverse proxy
- [x] `13-nginx-tls.md` — opt-in managed nginx/TLS container for hosts without a reverse proxy
Steps 0103 are independent of each other.
Steps 0406 depend on 0103.
@@ -126,6 +126,17 @@ class InitCommand(
bindAddress: 0.0.0.0
# optional Impressum (legal disclosure) link in the web UI footer; empty hides the link
impressumUrl: ""
# Opt-in managed nginx+certbot Docker container for HTTPS, for hosts without
# a usable reverse proxy (see docs/deployment.md). Off by default.
nginx:
enabled: false # manage an nginx container with Let's Encrypt certificates
serverName: "" # public DNS name served by nginx; required when enabled
httpPort: 8080 # host port published as nginx port 80
httpsPort: 8443 # host port published as nginx port 443
upstreamHost: "" # host nginx proxies to; empty = serverName
containerName: "" # empty = gittally-nginx-<repo-name>
stateDir: "" # empty = XDG_STATE_HOME (or ~/.local/state) + /gittally/nginx/<repo-key>
letsencryptEmail: "" # e-mail for the Let's Encrypt account; empty registers without one
# Gitea integration for fetching commits and posting build statuses.
gitea:
@@ -21,11 +21,24 @@ class ConfigLoader {
fun load(workingDir: Path = Paths.get(".")): GitTallyConfig {
val raw = loadRaw(workingDir)
return if (raw.isEmpty()) {
GitTallyConfig()
} else {
yaml.convertValue(mergeBranchDefaults(raw), GitTallyConfig::class.java)
val config =
if (raw.isEmpty()) {
GitTallyConfig()
} else {
yaml.convertValue(mergeBranchDefaults(raw), GitTallyConfig::class.java)
}
return defaultPublicBaseUrl(config)
}
/** Legacy default: an empty `server.publicBaseUrl` becomes `https://<nginx.serverName>/`. */
private fun defaultPublicBaseUrl(config: GitTallyConfig): GitTallyConfig {
if (config.server.publicBaseUrl.isNotBlank() ||
config.server.nginx.serverName
.isBlank()
) {
return config
}
return config.copy(server = config.server.copy(publicBaseUrl = "https://${config.server.nginx.serverName}/"))
}
fun loadRaw(workingDir: Path = Paths.get(".")): Map<String, Any?> {
@@ -11,12 +11,44 @@ data class GitTallyConfig(
)
data class ServerConfig(
/**
* Public base URL of this installation; empty defaults to `https://<nginx.serverName>/`
* when [NginxConfig.serverName] is set (applied by [ConfigLoader]).
*/
val publicBaseUrl: String = "",
/** HTTP port of the `server` subcommand; 18080 like the legacy artifact server. */
val port: Int = 18080,
val bindAddress: String = "0.0.0.0",
/** Optional Impressum (legal disclosure) link shown in the web UI footer; empty hides the link. */
val impressumUrl: String = "",
val nginx: NginxConfig = NginxConfig(),
)
/**
* Opt-in managed nginx+certbot Docker container serving GitTally over HTTPS,
* for hosts without a usable reverse proxy (ADR 0005). Off by default; the
* reverse-proxy deployment from `docs/deployment.md` stays the recommended setup.
*/
data class NginxConfig(
/** Manage an nginx Docker container with Let's Encrypt certificates. */
val enabled: Boolean = false,
/** Public DNS name served by nginx and used for the certificate; required when [enabled]. */
val serverName: String = "",
/** Host port published as nginx port 80 (ACME challenge + HTTPS redirect). */
val httpPort: Int = 8080,
/** Host port published as nginx port 443. */
val httpsPort: Int = 8443,
/** Host nginx proxies to; empty uses [serverName] (the container cannot reach `localhost`). */
val upstreamHost: String = "",
/** Name of the managed container; empty means `gittally-nginx-<repo-name>`. */
val containerName: String = "",
/**
* Directory for nginx config, certificates, and logs; empty means the platform
* default `XDG_STATE_HOME` (or `~/.local/state`) + `/gittally/nginx/<repo-key>`.
*/
val stateDir: String = "",
/** E-mail for the Let's Encrypt account; empty registers without one. */
val letsencryptEmail: String = "",
)
data class GitConfig(
@@ -0,0 +1,83 @@
package de.hoennig.gittally.server
/**
* Generates the nginx configuration for the managed proxy container, ported from
* the legacy `artifact_nginx_write_config`/`artifact_nginx_write_ssl_options`.
* Values are substituted verbatim — callers must validate the server name and
* upstream host (see [NginxProxyManager.HOST_NAME_PATTERN]) so no nginx directives
* can be injected.
*/
object NginxConfigFiles {
/**
* The `nginx.conf` content. Without [full] it is the init config for the
* two-phase startup: HTTP only, serving the ACME webroot challenge and
* redirecting everything else to HTTPS. With [full] an HTTPS server block
* with the Let's Encrypt certificate and the proxy to GitTally is added.
*/
fun nginxConf(
serverName: String,
upstreamHost: String,
upstreamPort: Int,
full: Boolean,
): String {
val httpServer =
"""
| server {
| listen 80;
| server_name $serverName;
|
| location /.well-known/acme-challenge/ {
| root /var/www/certbot;
| }
|
| location / {
| return 301 https://${'$'}host${'$'}request_uri;
| }
| }
""".trimMargin()
val httpsServer =
"""
|
| server {
| listen 443 ssl;
| server_name $serverName;
|
| ssl_certificate /etc/letsencrypt/live/$serverName/fullchain.pem;
| ssl_certificate_key /etc/letsencrypt/live/$serverName/privkey.pem;
| include /etc/letsencrypt/options-ssl-nginx.conf;
|
| location /.well-known/acme-challenge/ {
| root /var/www/certbot;
| }
|
| location / {
| proxy_pass http://$upstreamHost:$upstreamPort;
| proxy_set_header Host ${'$'}host;
| proxy_set_header X-Real-IP ${'$'}remote_addr;
| proxy_set_header X-Forwarded-For ${'$'}proxy_add_x_forwarded_for;
| proxy_set_header X-Forwarded-Proto ${'$'}scheme;
| add_header Cache-Control "no-store, max-age=0" always;
| add_header Pragma "no-cache" always;
| add_header Expires "0" always;
| }
| }
""".trimMargin()
return "events {}\n\nhttp {\n" + httpServer + (if (full) httpsServer else "") + "\n}\n"
}
/** The certbot-recommended `options-ssl-nginx.conf`, verbatim from legacy. */
const val SSL_OPTIONS: String =
"ssl_session_cache shared:le_nginx_SSL:1m;\n" +
"ssl_session_timeout 1440m;\n" +
"ssl_protocols TLSv1.2 TLSv1.3;\n" +
"ssl_prefer_server_ciphers off;\n" +
"\n" +
"ssl_ciphers \"ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" +
"ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" +
"ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" +
"DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384\";\n" +
"ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;\n"
/** Certbot's pinned DH parameters, downloaded once into the state dir (like legacy). */
const val DH_PARAMS_URL: String = "https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem"
}
@@ -0,0 +1,442 @@
package de.hoennig.gittally.server
import de.hoennig.gittally.build.ArtifactKeys
import de.hoennig.gittally.build.DockerBuildRunner.Companion.GITTALLY_LABEL
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.git.GitCommandRunner
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.Duration
/**
* Manages the opt-in nginx+certbot Docker container that serves GitTally over
* HTTPS on hosts without a reverse proxy (ADR 0005), ported from the legacy
* `start_artifact_nginx` subsystem. Shells out to the `docker` CLI via the
* generic [GitCommandRunner] process wrapper, like [de.hoennig.gittally.build.DockerBuildRunner].
*
* Startup is two-phase: an HTTP-only init config serves the ACME webroot
* challenge, the certificate is obtained via a certbot container, then nginx is
* restarted with the full HTTPS config. All failures are non-fatal warnings —
* the plain HTTP server keeps running without the proxy (legacy behavior).
* Nothing runs unless [start] is called (server profile only, see [ServerNginxLifecycle]).
*/
@Component
class NginxProxyManager(
private val commandRunner: GitCommandRunner,
private val configLoader: ConfigLoader,
) {
private val log = LoggerFactory.getLogger(NginxProxyManager::class.java)
var workingDir: Path = Paths.get(".")
/** Replaceable for tests: fetches certbot's pinned DH parameters into [target]; throws on failure. */
internal var dhParamsDownloader: (target: Path) -> Unit = ::downloadDhParams
/** Replaceable for tests: the wait between port-conflict re-checks. */
internal var sleeper: (millis: Long) -> Unit = Thread::sleep
/** The settings the running container was started with; null while no container is managed. */
private var running: NginxSettings? = null
fun isEnabled(): Boolean =
configLoader
.load(workingDir)
.server.nginx.enabled
/**
* Legacy `start_artifact_nginx`: prepare the state dir, remove stale containers,
* run nginx (init config first when no certificate exists yet), obtain or renew
* the certificate, then restart with the full HTTPS config.
*/
@Synchronized
fun start() {
try {
val settings = resolveSettings() ?: return
prepareStateDirs(settings)
writeSslOptions(settings)
cleanupStaleContainers(settings)
if (!waitForPortsFree(settings)) {
log.warn("managed nginx was not started because a configured port is still in use")
return
}
writeNginxConf(settings, full = Files.exists(settings.certFile))
if (!runContainer(settings)) {
return
}
if (!obtainOrRenewCertificate(settings)) {
log.warn("managed nginx is running, but Let's Encrypt certificate setup failed")
return
}
writeNginxConf(settings, full = true)
if (!runContainer(settings)) {
log.warn("certificate is available, but restarting managed nginx with HTTPS failed")
return
}
log.info(
"managed nginx proxy: https://{}:{}/ -> http://{}:{}/ (state: {})",
settings.serverName,
settings.httpsPort,
settings.upstreamHost,
settings.upstreamPort,
settings.stateDir,
)
} catch (e: Exception) {
log.warn("could not start managed nginx: {}", e.toString())
}
}
/**
* Renews the certificate and reloads nginx; scheduled daily by [ServerNginxLifecycle].
* Improvement over legacy, which renewed only at process start and relied on
* frequent self-update restarts. No-op while no container is managed.
*/
@Synchronized
fun renewCertificateAndReload() {
val settings = running ?: return
try {
val renew = commandRunner.run(certbotArgs(settings) + listOf("renew", "-q"), workingDir)
if (!renew.isSuccess) {
log.warn("certificate renewal failed: {}", renew.stderr.trim())
return
}
val reload = commandRunner.run(listOf("docker", "exec", settings.containerName, "nginx", "-s", "reload"), workingDir)
if (!reload.isSuccess) {
log.warn("certificate renewed, but nginx reload failed: {}", reload.stderr.trim())
}
} catch (e: Exception) {
log.warn("certificate renewal check failed: {}", e.toString())
}
}
/** Removes the managed container (legacy shutdown cleanup); safe to call when none runs. */
@Synchronized
fun stop() {
val settings = running ?: return
running = null
try {
commandRunner.run(listOf("docker", "rm", "-f", settings.containerName), workingDir)
} catch (e: Exception) {
log.warn("could not remove managed nginx container {}: {}", settings.containerName, e.toString())
}
}
/**
* Legacy `configure_artifact_nginx_defaults` plus validation: resolves the
* effective settings or returns null (with a warning) when misconfigured.
* Host names are restricted to [HOST_NAME_PATTERN] so they substitute safely
* into the nginx config.
*/
internal fun resolveSettings(): NginxSettings? {
val config = configLoader.load(workingDir)
val nginx = config.server.nginx
if (!nginx.enabled) {
return null
}
if (nginx.serverName.isBlank()) {
log.warn("cannot start managed nginx because server.nginx.serverName is empty")
return null
}
val upstreamHost = nginx.upstreamHost.ifBlank { nginx.serverName }
if (!HOST_NAME_PATTERN.matches(nginx.serverName) || !HOST_NAME_PATTERN.matches(upstreamHost)) {
log.warn("cannot start managed nginx because serverName or upstreamHost is not a valid host name")
return null
}
if (nginx.httpPort !in 1..65535 || nginx.httpsPort !in 1..65535 || nginx.httpPort == nginx.httpsPort) {
log.warn("cannot start managed nginx because the nginx ports are invalid")
return null
}
if (config.server.port == nginx.httpPort || config.server.port == nginx.httpsPort) {
log.warn("cannot start managed nginx because server.port {} collides with an nginx port", config.server.port)
return null
}
val repoDir = workingDir.toAbsolutePath().normalize()
val stateDir =
if (nginx.stateDir.isNotBlank()) {
repoDir.resolve(expandHome(nginx.stateDir)).normalize()
} else {
defaultStateDir(repoDir)
}
return NginxSettings(
serverName = nginx.serverName,
httpPort = nginx.httpPort,
httpsPort = nginx.httpsPort,
upstreamHost = upstreamHost,
upstreamPort = config.server.port,
containerName = nginx.containerName.ifBlank { defaultContainerName(repoDir) },
stateDir = stateDir,
letsencryptEmail = nginx.letsencryptEmail,
repoKey = ArtifactKeys.repoKey(repoDir),
)
}
private fun prepareStateDirs(settings: NginxSettings) {
Files.createDirectories(settings.certbotConf)
Files.createDirectories(settings.certbotWww)
Files.createDirectories(settings.certbotLog)
Files.createDirectories(settings.nginxLog)
}
/** Legacy `artifact_nginx_write_ssl_options`: the certbot nginx snippet plus its pinned DH parameters. */
private fun writeSslOptions(settings: NginxSettings) {
Files.writeString(settings.certbotConf.resolve("options-ssl-nginx.conf"), NginxConfigFiles.SSL_OPTIONS)
val dhParams = settings.certbotConf.resolve("ssl-dhparams.pem")
if (!Files.exists(dhParams)) {
dhParamsDownloader(dhParams)
}
}
private fun writeNginxConf(
settings: NginxSettings,
full: Boolean,
) {
Files.writeString(
settings.nginxConf,
NginxConfigFiles.nginxConf(settings.serverName, settings.upstreamHost, settings.upstreamPort, full),
)
}
/**
* Legacy `cleanup_stale_artifact_nginx_containers`: remove the container by
* name, all nginx-role containers of this repository by label, and any
* GitTally container still occupying the configured ports.
*/
private fun cleanupStaleContainers(settings: NginxSettings) {
commandRunner.run(listOf("docker", "rm", "-f", settings.containerName), workingDir)
val labelled =
commandRunner.run(
listOf(
"docker",
"ps",
"-aq",
"--filter",
"label=$GITTALLY_LABEL=true",
"--filter",
"label=$GITTALLY_LABEL.repository=${settings.repoKey}",
"--filter",
"label=$GITTALLY_LABEL.role=nginx",
),
workingDir,
)
if (labelled.isSuccess && labelled.lines().isNotEmpty()) {
commandRunner.run(listOf("docker", "rm", "-f") + labelled.lines(), workingDir)
}
for (container in listContainersUsingPorts(settings)) {
if (container.labels.contains("$GITTALLY_LABEL=true") ||
container.name.startsWith("gittally-") ||
container.name.startsWith("git-watch-origin-and-test-nginx-")
) {
log.info("removing stale GitTally container using an nginx port: {}", container.name)
commandRunner.run(listOf("docker", "rm", "-f", container.id), workingDir)
}
}
}
/** Legacy `wait_for_artifact_nginx_ports`: re-check up to four times, warning about foreign owners. */
private fun waitForPortsFree(settings: NginxSettings): Boolean {
repeat(4) {
val owners = listContainersUsingPorts(settings)
if (owners.isEmpty()) {
return true
}
owners.forEach { log.warn("nginx port is already used by Docker container {} ({})", it.name, it.id) }
sleeper(1000)
}
return listContainersUsingPorts(settings).isEmpty()
}
private data class ContainerInfo(
val id: String,
val name: String,
val ports: String,
val labels: String,
)
private fun listContainersUsingPorts(settings: NginxSettings): List<ContainerInfo> {
val listing =
commandRunner.run(
listOf("docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Ports}}\t{{.Labels}}"),
workingDir,
)
if (!listing.isSuccess) {
return emptyList()
}
return listing
.lines()
.mapNotNull { line ->
val fields = line.split('\t')
if (fields.size < 3) null else ContainerInfo(fields[0], fields[1], fields[2], fields.getOrElse(3) { "" })
}.filter { container ->
listOf(settings.httpPort, settings.httpsPort).any { container.ports.contains(":$it->") }
}
}
/** Legacy `artifact_nginx_run_container`; replaces any previous instance of the container. */
private fun runContainer(settings: NginxSettings): Boolean {
commandRunner.run(listOf("docker", "rm", "-f", settings.containerName), workingDir)
val run = commandRunner.run(runContainerArgs(settings), workingDir)
if (!run.isSuccess) {
log.warn("could not start managed nginx container {}: {}", settings.containerName, run.stderr.trim())
return false
}
running = settings
return true
}
internal fun runContainerArgs(settings: NginxSettings): List<String> =
listOf(
"docker",
"run",
"-d",
"--name",
settings.containerName,
"--publish",
"${settings.httpPort}:80",
"--publish",
"${settings.httpsPort}:443",
"--network",
"bridge",
"--volume",
"${settings.certbotConf}:/etc/letsencrypt",
"--volume",
"${settings.certbotWww}:/var/www/certbot",
"--volume",
"${settings.nginxLog}:/var/log/nginx",
"--volume",
"${settings.nginxConf}:/etc/nginx/nginx.conf:ro",
"--label",
"$GITTALLY_LABEL=true",
"--label",
"$GITTALLY_LABEL.repository=${settings.repoKey}",
"--label",
"$GITTALLY_LABEL.role=nginx",
"nginx",
)
/**
* Legacy `artifact_nginx_obtain_or_renew_certificate`: `certonly` in webroot
* mode for a first certificate, plain `renew` when one already exists (nginx
* is already running and serves the challenge directory).
*/
private fun obtainOrRenewCertificate(settings: NginxSettings): Boolean {
val args =
if (Files.exists(settings.certFile)) {
certbotArgs(settings) + listOf("renew", "-q")
} else {
obtainCertificateArgs(settings)
}
val result = commandRunner.run(args, workingDir)
if (!result.isSuccess) {
log.warn("certbot failed: {}", result.stderr.trim())
}
return result.isSuccess
}
internal fun obtainCertificateArgs(settings: NginxSettings): List<String> {
val emailArgs =
if (settings.letsencryptEmail.isNotBlank()) {
listOf("--email", settings.letsencryptEmail)
} else {
listOf("--register-unsafely-without-email")
}
return certbotArgs(settings) +
listOf(
"certonly",
"--webroot",
"--webroot-path",
"/var/www/certbot",
"--cert-name",
settings.serverName,
"-d",
settings.serverName,
"--rsa-key-size",
"4096",
"--non-interactive",
"--agree-tos",
) + emailArgs
}
private fun certbotArgs(settings: NginxSettings): List<String> =
listOf(
"docker",
"run",
"--rm",
"--volume",
"${settings.certbotConf}:/etc/letsencrypt",
"--volume",
"${settings.certbotWww}:/var/www/certbot",
"--volume",
"${settings.certbotLog}:/var/log/letsencrypt",
"certbot/certbot",
)
private fun expandHome(path: String): Path =
if (path == "~" || path.startsWith("~/")) {
Paths.get(System.getProperty("user.home"), path.removePrefix("~"))
} else {
Paths.get(path)
}
/** The effective managed-nginx settings with all defaults resolved. */
internal data class NginxSettings(
val serverName: String,
val httpPort: Int,
val httpsPort: Int,
val upstreamHost: String,
val upstreamPort: Int,
val containerName: String,
val stateDir: Path,
val letsencryptEmail: String,
val repoKey: String,
) {
val certbotConf: Path get() = stateDir.resolve("certbot/conf")
val certbotWww: Path get() = stateDir.resolve("certbot/www")
val certbotLog: Path get() = stateDir.resolve("certbot/log")
val nginxLog: Path get() = stateDir.resolve("nginx/log")
val nginxConf: Path get() = stateDir.resolve("nginx/nginx.conf")
val certFile: Path get() = certbotConf.resolve("live/$serverName/fullchain.pem")
}
companion object {
/** Host names substitute unescaped into the nginx config, so only safe characters are allowed. */
internal val HOST_NAME_PATTERN = Regex("[A-Za-z0-9][A-Za-z0-9.-]*")
/** Legacy `artifact_nginx_default_state_dir`. */
fun defaultStateDir(repoDir: Path): Path {
val stateHome =
System.getenv("XDG_STATE_HOME")?.takeIf { it.isNotBlank() }?.let { Paths.get(it) }
?: Paths.get(System.getProperty("user.home"), ".local", "state")
return stateHome
.resolve("gittally")
.resolve("nginx")
.resolve(ArtifactKeys.repoKey(repoDir))
.toAbsolutePath()
.normalize()
}
/** Legacy default `gittally-nginx-<repo-name>` with unsafe characters replaced. */
fun defaultContainerName(repoDir: Path): String =
"gittally-nginx-" + repoDir.fileName.toString().replace(Regex("[^A-Za-z0-9_.-]"), "-")
private fun downloadDhParams(target: Path) {
val response =
HttpClient
.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build()
.send(
HttpRequest.newBuilder(URI.create(NginxConfigFiles.DH_PARAMS_URL)).GET().build(),
HttpResponse.BodyHandlers.ofString(),
)
check(response.statusCode() == 200) { "downloading ssl-dhparams.pem failed with HTTP ${response.statusCode()}" }
Files.writeString(target, response.body())
}
}
}
@@ -0,0 +1,63 @@
package de.hoennig.gittally.server
import jakarta.annotation.PreDestroy
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.annotation.Profile
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
/**
* Starts the managed nginx container once the web server is up and removes it on
* shutdown, plus a daily certificate-renewal check — an improvement over legacy,
* which renewed only at process start and relied on frequent self-update restarts.
* Only in the `server` profile, like [ServerWatcherLifecycle]; with
* `server.nginx.enabled: false` (the default) nothing is scheduled or touched.
* Startup runs on the scheduler thread, so a slow certificate issuance never
* blocks the web server; the single thread also serializes startup and renewals.
*/
@Component
@Profile("server")
class ServerNginxLifecycle(
private val nginxProxyManager: NginxProxyManager,
) {
/** Replaceable for tests: the scheduler running startup and renewal checks. */
internal var schedulerFactory: () -> ScheduledExecutorService = {
Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "gittally-nginx").apply { isDaemon = true }
}
}
private var scheduler: ScheduledExecutorService? = null
@EventListener(ApplicationReadyEvent::class)
fun onApplicationReady() {
if (!nginxProxyManager.isEnabled()) {
return
}
scheduler =
schedulerFactory().also {
it.execute(nginxProxyManager::start)
it.scheduleWithFixedDelay(
nginxProxyManager::renewCertificateAndReload,
RENEWAL_CHECK_INTERVAL_HOURS,
RENEWAL_CHECK_INTERVAL_HOURS,
TimeUnit.HOURS,
)
}
}
@PreDestroy
fun onShutdown() {
scheduler?.shutdownNow()
scheduler = null
nginxProxyManager.stop()
}
companion object {
/** Daily, like certbot's own systemd timer; `certbot renew` only acts when a certificate is due. */
const val RENEWAL_CHECK_INTERVAL_HOURS = 24L
}
}
@@ -120,6 +120,36 @@ class ConfigLoaderTest : FunSpec() {
config.branches["release"]!!.buildCommand shouldBe "./mvnw -P release test"
}
test("empty publicBaseUrl defaults to https://<nginx.serverName>/ when set") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
server:
nginx:
serverName: ci.example.org
""".trimIndent(),
)
loader.load(dir).server.publicBaseUrl shouldBe "https://ci.example.org/"
}
test("explicit publicBaseUrl wins over the nginx.serverName default") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
"""
server:
publicBaseUrl: https://other.example.org/
nginx:
serverName: ci.example.org
""".trimIndent(),
)
loader.load(dir).server.publicBaseUrl shouldBe "https://other.example.org/"
}
test("publicBaseUrl stays empty without an nginx.serverName") {
val dir = Files.createTempDirectory("gittally-test")
loader.load(dir).server.publicBaseUrl shouldBe ""
}
test("toYaml serializes GitTallyConfig with all sections") {
val yaml = loader.toYaml(GitTallyConfig())
yaml shouldContain "server:"
@@ -0,0 +1,41 @@
package de.hoennig.gittally.server
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
class NginxConfigFilesTest : FunSpec() {
init {
test("init config serves the ACME challenge and redirects to HTTPS, without a TLS server") {
val conf = NginxConfigFiles.nginxConf("ci.example.org", "ci.example.org", 18080, full = false)
conf shouldContain "listen 80;"
conf shouldContain "server_name ci.example.org;"
conf shouldContain "location /.well-known/acme-challenge/"
conf shouldContain "root /var/www/certbot;"
conf shouldContain "return 301 https://\$host\$request_uri;"
conf shouldNotContain "listen 443"
conf shouldNotContain "proxy_pass"
conf shouldNotContain "ssl_certificate"
}
test("full config adds the TLS server with certificate paths and the proxy") {
val conf = NginxConfigFiles.nginxConf("ci.example.org", "upstream.example.org", 18081, full = true)
conf shouldContain "listen 80;"
conf shouldContain "listen 443 ssl;"
conf shouldContain "ssl_certificate /etc/letsencrypt/live/ci.example.org/fullchain.pem;"
conf shouldContain "ssl_certificate_key /etc/letsencrypt/live/ci.example.org/privkey.pem;"
conf shouldContain "include /etc/letsencrypt/options-ssl-nginx.conf;"
conf shouldContain "proxy_pass http://upstream.example.org:18081;"
conf shouldContain "proxy_set_header Host \$host;"
conf shouldContain "proxy_set_header X-Forwarded-Proto \$scheme;"
conf shouldContain "add_header Cache-Control \"no-store, max-age=0\" always;"
}
test("ssl options reference the mounted dhparams and modern protocols") {
NginxConfigFiles.SSL_OPTIONS shouldContain "ssl_protocols TLSv1.2 TLSv1.3;"
NginxConfigFiles.SSL_OPTIONS shouldContain "ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;"
}
}
}
@@ -0,0 +1,312 @@
package de.hoennig.gittally.server
import de.hoennig.gittally.build.ArtifactKeys
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.config.GitTallyConfig
import de.hoennig.gittally.config.NginxConfig
import de.hoennig.gittally.config.ServerConfig
import de.hoennig.gittally.git.GitCommandResult
import de.hoennig.gittally.git.GitCommandRunner
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldEndWith
import io.kotest.matchers.string.shouldNotContain
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import java.nio.file.Files
import java.nio.file.Path
class NginxProxyManagerTest : FunSpec() {
private val commandRunner = mockk<GitCommandRunner>()
private val configLoader = mockk<ConfigLoader>()
private lateinit var manager: NginxProxyManager
private lateinit var repoDir: Path
private lateinit var stateDir: Path
private val captured = mutableListOf<List<String>>()
private val configsAtContainerRun = mutableListOf<String>()
private var sleepCount = 0
private fun nginxConfig(
enabled: Boolean = true,
serverName: String = "ci.example.org",
letsencryptEmail: String = "",
containerName: String = "test-nginx",
httpPort: Int = 8080,
httpsPort: Int = 8443,
explicitStateDir: Boolean = true,
serverPort: Int = 18080,
): GitTallyConfig =
GitTallyConfig(
server =
ServerConfig(
port = serverPort,
nginx =
NginxConfig(
enabled = enabled,
serverName = serverName,
httpPort = httpPort,
httpsPort = httpsPort,
containerName = containerName,
stateDir = if (explicitStateDir) stateDir.toString() else "",
letsencryptEmail = letsencryptEmail,
),
),
)
private fun expectedRunArgs(): List<String> =
listOf(
"docker",
"run",
"-d",
"--name",
"test-nginx",
"--publish",
"8080:80",
"--publish",
"8443:443",
"--network",
"bridge",
"--volume",
"$stateDir/certbot/conf:/etc/letsencrypt",
"--volume",
"$stateDir/certbot/www:/var/www/certbot",
"--volume",
"$stateDir/nginx/log:/var/log/nginx",
"--volume",
"$stateDir/nginx/nginx.conf:/etc/nginx/nginx.conf:ro",
"--label",
"org.hoennig.gittally=true",
"--label",
"org.hoennig.gittally.repository=${ArtifactKeys.repoKey(repoDir)}",
"--label",
"org.hoennig.gittally.role=nginx",
"nginx",
)
private fun expectedCertbotPrefix(): List<String> =
listOf(
"docker",
"run",
"--rm",
"--volume",
"$stateDir/certbot/conf:/etc/letsencrypt",
"--volume",
"$stateDir/certbot/www:/var/www/certbot",
"--volume",
"$stateDir/certbot/log:/var/log/letsencrypt",
"certbot/certbot",
)
private fun preCreateCertificate() {
val certFile = stateDir.resolve("certbot/conf/live/ci.example.org/fullchain.pem")
Files.createDirectories(certFile.parent)
Files.writeString(certFile, "certificate")
}
init {
beforeEach {
clearMocks(commandRunner, configLoader)
captured.clear()
configsAtContainerRun.clear()
sleepCount = 0
repoDir = Files.createTempDirectory("gittally-nginx-repo")
stateDir = Files.createTempDirectory("gittally-nginx-state")
every { commandRunner.run(capture(captured), any(), any()) } answers {
if (captured.last().take(3) == listOf("docker", "run", "-d")) {
configsAtContainerRun += Files.readString(stateDir.resolve("nginx/nginx.conf"))
}
GitCommandResult(0, "", "")
}
manager = NginxProxyManager(commandRunner, configLoader)
manager.workingDir = repoDir
manager.dhParamsDownloader = { Files.writeString(it, "dh-params") }
manager.sleeper = { sleepCount++ }
}
test("start makes no docker calls when disabled") {
every { configLoader.load(repoDir) } returns nginxConfig(enabled = false)
manager.start()
captured.shouldBeEmpty()
}
test("resolves the legacy defaults for upstream host, container name, and state dir") {
every { configLoader.load(repoDir) } returns
nginxConfig(containerName = "", explicitStateDir = false)
val settings = manager.resolveSettings().shouldNotBeNull()
settings.upstreamHost shouldBe "ci.example.org"
settings.upstreamPort shouldBe 18080
settings.containerName shouldBe "gittally-nginx-${repoDir.fileName}"
settings.stateDir.toString() shouldEndWith "gittally/nginx/${ArtifactKeys.repoKey(repoDir)}"
}
test("rejects a server name that could inject nginx directives") {
every { configLoader.load(repoDir) } returns
nginxConfig(serverName = "evil.example.org;\n} inject {")
manager.resolveSettings().shouldBeNull()
manager.start()
captured.shouldBeEmpty()
}
test("rejects a missing server name") {
every { configLoader.load(repoDir) } returns nginxConfig(serverName = "")
manager.resolveSettings().shouldBeNull()
}
test("rejects a server.port collision with the nginx ports") {
every { configLoader.load(repoDir) } returns nginxConfig(serverPort = 8080)
manager.resolveSettings().shouldBeNull()
}
test("first start runs the init config, obtains a certificate, and restarts with the full config") {
every { configLoader.load(repoDir) } returns nginxConfig()
manager.start()
// phase 1 runs on the HTTP-only init config, phase 2 on the full HTTPS config
configsAtContainerRun.size shouldBe 2
configsAtContainerRun[0] shouldNotContain "listen 443"
configsAtContainerRun[1] shouldContain "listen 443 ssl;"
captured shouldContain expectedRunArgs()
captured shouldContain expectedCertbotPrefix() +
listOf(
"certonly",
"--webroot",
"--webroot-path",
"/var/www/certbot",
"--cert-name",
"ci.example.org",
"-d",
"ci.example.org",
"--rsa-key-size",
"4096",
"--non-interactive",
"--agree-tos",
"--register-unsafely-without-email",
)
Files.readString(stateDir.resolve("certbot/conf/options-ssl-nginx.conf")) shouldBe NginxConfigFiles.SSL_OPTIONS
Files.readString(stateDir.resolve("certbot/conf/ssl-dhparams.pem")) shouldBe "dh-params"
}
test("start with an existing certificate uses the full config immediately and renews") {
every { configLoader.load(repoDir) } returns nginxConfig()
preCreateCertificate()
manager.start()
configsAtContainerRun.size shouldBe 2
configsAtContainerRun[0] shouldContain "listen 443 ssl;"
captured shouldContain expectedCertbotPrefix() + listOf("renew", "-q")
}
test("a configured letsencryptEmail registers with --email instead of unsafely") {
every { configLoader.load(repoDir) } returns nginxConfig(letsencryptEmail = "admin@example.org")
val settings = manager.resolveSettings().shouldNotBeNull()
val args = manager.obtainCertificateArgs(settings)
args shouldContain "--email"
args shouldContain "admin@example.org"
(args.contains("--register-unsafely-without-email")) shouldBe false
}
test("stale nginx containers of this repository are removed by label before the start") {
every { configLoader.load(repoDir) } returns nginxConfig()
manager.start()
captured shouldContain
listOf(
"docker",
"ps",
"-aq",
"--filter",
"label=org.hoennig.gittally=true",
"--filter",
"label=org.hoennig.gittally.repository=${ArtifactKeys.repoKey(repoDir)}",
"--filter",
"label=org.hoennig.gittally.role=nginx",
)
captured shouldContain listOf("docker", "rm", "-f", "test-nginx")
}
test("does not start while a foreign container occupies an nginx port") {
every { configLoader.load(repoDir) } returns nginxConfig()
every {
commandRunner.run(match { it.take(2) == listOf("docker", "ps") && it.contains("--format") }, any(), any())
} returns GitCommandResult(0, "abc123\tother-app\t0.0.0.0:8080->80/tcp\tvendor=other", "")
manager.start()
captured.none { it.take(3) == listOf("docker", "run", "-d") }.shouldBeTrue()
sleepCount shouldBe 4
}
test("removes a stale gittally-named container occupying an nginx port") {
every { configLoader.load(repoDir) } returns nginxConfig()
every {
commandRunner.run(match { it.take(2) == listOf("docker", "ps") && it.contains("--format") }, any(), any())
} returnsMany
listOf(
GitCommandResult(0, "abc123\tgittally-nginx-old\t0.0.0.0:8080->80/tcp\t", ""),
GitCommandResult(0, "", ""),
)
manager.start()
captured shouldContain listOf("docker", "rm", "-f", "abc123")
captured shouldContain expectedRunArgs()
}
test("renewCertificateAndReload renews the certificate and reloads nginx") {
every { configLoader.load(repoDir) } returns nginxConfig()
manager.start()
captured.clear()
manager.renewCertificateAndReload()
captured shouldBe
listOf(
expectedCertbotPrefix() + listOf("renew", "-q"),
listOf("docker", "exec", "test-nginx", "nginx", "-s", "reload"),
)
}
test("renewCertificateAndReload is a no-op while no container is managed") {
manager.renewCertificateAndReload()
captured.shouldBeEmpty()
}
test("stop removes the managed container exactly once") {
every { configLoader.load(repoDir) } returns nginxConfig()
manager.start()
captured.clear()
manager.stop()
manager.stop()
captured shouldBe listOf(listOf("docker", "rm", "-f", "test-nginx"))
}
test("a docker failure never throws — the HTTP server keeps running") {
every { configLoader.load(repoDir) } returns nginxConfig()
every { commandRunner.run(any(), any(), any()) } throws RuntimeException("docker: command not found")
manager.start()
}
}
}
@@ -0,0 +1,69 @@
package de.hoennig.gittally.server
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
class ServerNginxLifecycleTest : FunSpec() {
private val manager = mockk<NginxProxyManager>(relaxUnitFun = true)
private val scheduler = mockk<ScheduledExecutorService>(relaxed = true)
private lateinit var lifecycle: ServerNginxLifecycle
private var schedulerCreated = 0
init {
beforeEach {
clearMocks(manager, scheduler)
schedulerCreated = 0
lifecycle = ServerNginxLifecycle(manager)
lifecycle.schedulerFactory = {
schedulerCreated++
scheduler
}
}
test("nothing is scheduled and no container is touched when disabled") {
every { manager.isEnabled() } returns false
lifecycle.onApplicationReady()
schedulerCreated shouldBe 0
verify(exactly = 0) { manager.start() }
}
test("starts nginx on the scheduler thread and schedules the daily renewal check") {
every { manager.isEnabled() } returns true
every { scheduler.execute(any()) } answers { firstArg<Runnable>().run() }
val renewalTask = slot<Runnable>()
every { scheduler.scheduleWithFixedDelay(capture(renewalTask), 24L, 24L, TimeUnit.HOURS) } returns mockk()
lifecycle.onApplicationReady()
verify(exactly = 1) { manager.start() }
renewalTask.captured.run()
verify(exactly = 1) { manager.renewCertificateAndReload() }
}
test("shutdown stops the scheduler and removes the container") {
every { manager.isEnabled() } returns true
lifecycle.onApplicationReady()
lifecycle.onShutdown()
verify(exactly = 1) { scheduler.shutdownNow() }
verify(exactly = 1) { manager.stop() }
}
test("shutdown without a prior start still asks the manager to stop") {
lifecycle.onShutdown()
verify(exactly = 1) { manager.stop() }
verify(exactly = 0) { scheduler.shutdownNow() }
}
}
}