diff --git a/docs/configuration.md b/docs/configuration.md index f36df46..38a66bd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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- + containerName: "" + # directory for nginx config, certificates, and logs; + # empty = $XDG_STATE_HOME (or ~/.local/state) plus /gittally/nginx/ + 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:///`. +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..requirePullRequest` The gate applies to all watcher-triggered builds (push-triggered and scheduled auto builds). diff --git a/docs/deployment.md b/docs/deployment.md index 2490db9..a32331c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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. diff --git a/docs/migration-from-legacy.md b/docs/migration-from-legacy.md index ac51bc1..c44b76d 100644 --- a/docs/migration-from-legacy.md +++ b/docs/migration-from-legacy.md @@ -37,13 +37,18 @@ Branch-level keys below live under `branches.`; 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..docker.env` if needed. - `HSADMIN_NG_*` environment-variable fallbacks. diff --git a/docs/plan/13-nginx-tls.md b/docs/plan/13-nginx-tls.md index cb58917..6dda211 100644 --- a/docs/plan/13-nginx-tls.md +++ b/docs/plan/13-nginx-tls.md @@ -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 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:///...`; `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`). diff --git a/docs/plan/README.md b/docs/plan/README.md index 04bf399..6d6e84f 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -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 01–03 are independent of each other. Steps 04–06 depend on 01–03. diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index 6e918fd..1e79452 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -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- + stateDir: "" # empty = XDG_STATE_HOME (or ~/.local/state) + /gittally/nginx/ + letsencryptEmail: "" # e-mail for the Let's Encrypt account; empty registers without one # Gitea integration for fetching commits and posting build statuses. gitea: diff --git a/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt b/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt index ca8de2b..5866bb1 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/ConfigLoader.kt @@ -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:///`. */ + 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 { diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt index 125041c..6298d3d 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt @@ -11,12 +11,44 @@ data class GitTallyConfig( ) data class ServerConfig( + /** + * Public base URL of this installation; empty defaults to `https:///` + * 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-`. */ + val containerName: String = "", + /** + * Directory for nginx config, certificates, and logs; empty means the platform + * default `XDG_STATE_HOME` (or `~/.local/state`) + `/gittally/nginx/`. + */ + val stateDir: String = "", + /** E-mail for the Let's Encrypt account; empty registers without one. */ + val letsencryptEmail: String = "", ) data class GitConfig( diff --git a/src/main/kotlin/de/hoennig/gittally/server/NginxConfigFiles.kt b/src/main/kotlin/de/hoennig/gittally/server/NginxConfigFiles.kt new file mode 100644 index 0000000..88ca544 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/NginxConfigFiles.kt @@ -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" +} diff --git a/src/main/kotlin/de/hoennig/gittally/server/NginxProxyManager.kt b/src/main/kotlin/de/hoennig/gittally/server/NginxProxyManager.kt new file mode 100644 index 0000000..f106b95 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/NginxProxyManager.kt @@ -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 { + 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 = + 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 { + 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 = + 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-` 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()) + } + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/server/ServerNginxLifecycle.kt b/src/main/kotlin/de/hoennig/gittally/server/ServerNginxLifecycle.kt new file mode 100644 index 0000000..c032fc0 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/ServerNginxLifecycle.kt @@ -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 + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt index 070bf87..fd18af3 100644 --- a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt @@ -120,6 +120,36 @@ class ConfigLoaderTest : FunSpec() { config.branches["release"]!!.buildCommand shouldBe "./mvnw -P release test" } + test("empty publicBaseUrl defaults to https:/// 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:" diff --git a/src/test/kotlin/de/hoennig/gittally/server/NginxConfigFilesTest.kt b/src/test/kotlin/de/hoennig/gittally/server/NginxConfigFilesTest.kt new file mode 100644 index 0000000..e7f5e55 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/server/NginxConfigFilesTest.kt @@ -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;" + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/server/NginxProxyManagerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/NginxProxyManagerTest.kt new file mode 100644 index 0000000..256df7d --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/server/NginxProxyManagerTest.kt @@ -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() + private val configLoader = mockk() + private lateinit var manager: NginxProxyManager + private lateinit var repoDir: Path + private lateinit var stateDir: Path + private val captured = mutableListOf>() + private val configsAtContainerRun = mutableListOf() + 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 = + 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 = + 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() + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/server/ServerNginxLifecycleTest.kt b/src/test/kotlin/de/hoennig/gittally/server/ServerNginxLifecycleTest.kt new file mode 100644 index 0000000..994fcc8 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/server/ServerNginxLifecycleTest.kt @@ -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(relaxUnitFun = true) + private val scheduler = mockk(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().run() } + val renewalTask = slot() + 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() } + } + } +}