diff --git a/docs/bootstrapping.md b/docs/bootstrapping.md index a6f1c59..ace8ad0 100644 --- a/docs/bootstrapping.md +++ b/docs/bootstrapping.md @@ -90,7 +90,7 @@ Then, you have to configure *Werkator* by amending this config file according to `init --apply FILE` installs a YAML fragment in the configuration schema as the applied instance layer — see [configuration.md](configuration.md#the-applied-instance-fragment-init---apply). Deployment tooling hands its parameters over this way instead of patching config files; the fragment is validated strictly and replaced wholesale on re-apply. -It runs before `--systemd`, so an applied `server.port` reaches the generated unit and the Apache `.htaccess` (written beside the units when a `publicBaseUrl` is configured). +It runs before `--systemd`, so an applied `server.port` reaches the generated unit and the Apache `.htaccess` (written beside the units when a `publicBaseUrl` is configured, together with the static `werkator-maintenance.html` its `ErrorDocument`s fall back to while the service restarts). ## Output diff --git a/docs/deployment.md b/docs/deployment.md index f525deb..ba601eb 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -345,5 +345,6 @@ tools/remote --env-file .env.mih34 werkator instance-update ``` The previous runtime stays as `.werkator/werkator.prev` for one deployment as the rollback asset. +For the few seconds `instance-update` restarts the service, nothing listens on its port; the generated `.htaccess` maps that refused connection (`ErrorDocument 502/503/504`) to a static `werkator-maintenance.html` placed next to it by `instance-start`, so the page reads "Werkator is restarting" instead of Apache's default error page or a hung request. The `/system` page's disk metric is quota-aware (PR#16): on a Managed Webspace the binding limit is usually the package's group quota, not the free space of the shared host volume, so `diskTotalGib` there is the quota's soft limit — the info line names it (`group quota , hard limit … GiB`) instead of showing the host's full disk size. diff --git a/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt index 1871685..e16f3e8 100644 --- a/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/werkator/commands/InitCommand.kt @@ -359,6 +359,13 @@ class InitCommand( normalizedWorkingDir.toFile(), )} (Apache reverse proxy; copy it into the domain docroot on a managed webspace)", ) + val maintenancePageFile = werkatorDir.resolve(SystemdServiceFiles.MAINTENANCE_PAGE_NAME) + maintenancePageFile.toFile().writeText(SystemdServiceFiles.maintenancePageContent()) + println( + "created ${maintenancePageFile.toFile().relativeTo( + normalizedWorkingDir.toFile(), + )} (shown by Apache while the service restarts; copy it next to the .htaccess)", + ) } println("install and start the service and the nightly Docker cleanup with:") diff --git a/src/main/kotlin/de/hoennig/werkator/commands/SystemdServiceFiles.kt b/src/main/kotlin/de/hoennig/werkator/commands/SystemdServiceFiles.kt index 2b7e2ba..fdfa9f9 100644 --- a/src/main/kotlin/de/hoennig/werkator/commands/SystemdServiceFiles.kt +++ b/src/main/kotlin/de/hoennig/werkator/commands/SystemdServiceFiles.kt @@ -13,6 +13,9 @@ object SystemdServiceFiles { /** Host-global unit names of the nightly Docker cleanup — shared by all Werkator repositories on the host. */ const val HTACCESS_NAME = "werkator.htaccess" + /** Static page [maintenancePageContent] serves at, referenced by [htaccessContent]'s `ErrorDocument`s. */ + const val MAINTENANCE_PAGE_NAME = "werkator-maintenance.html" + const val PRUNE_SERVICE_NAME = "werkator-docker-prune.service" const val PRUNE_TIMER_NAME = "werkator-docker-prune.timer" @@ -96,15 +99,53 @@ object SystemdServiceFiles { * Apache terminates TLS for the domain and forwards everything to the * localhost port of the "eigener Serverdienst". Generated host integration * like the units — the wrapper copies it into the domain's docroot. + * `ErrorDocument` maps a refused connection (Apache's 502/503/504 while the + * service restarts) to the static [maintenancePageContent] instead of Apache's + * default error page; the `RewriteCond` keeps that one file from being proxied + * itself, since `ErrorDocument` serves it as a sub-request through the same rules. */ fun htaccessContent(port: Int): String = """ DirectoryIndex disabled + ErrorDocument 502 /$MAINTENANCE_PAGE_NAME + ErrorDocument 503 /$MAINTENANCE_PAGE_NAME + ErrorDocument 504 /$MAINTENANCE_PAGE_NAME RewriteEngine On RewriteBase / + RewriteCond %{REQUEST_URI} !^/${MAINTENANCE_PAGE_NAME}${'$'} RewriteRule .* http://127.0.0.1:$port%{REQUEST_URI} [proxy] """.trimIndent() + "\n" + /** + * Static fallback for [htaccessContent]'s `ErrorDocument`s: shown by Apache directly, + * without involving Werkator, during the brief window where the service restarts and + * nothing listens on its port yet (`instance-update`). Self-contained — no external + * assets, since nothing would be there to serve them while Werkator itself is down. + */ + fun maintenancePageContent(): String = + """ + + + + + Werkator — Maintenance + + + +
+

Werkator is restarting

+

A deployment is in progress. Please retry in a few minutes.

+
+ + + """.trimIndent() + "\n" + private fun sanitize(name: String): String = name.replace(Regex("[^A-Za-z0-9_.-]"), "-") /** Escape `%` specifiers in systemd unit values (legacy `systemd_path`). */ diff --git a/src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt b/src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt index feb2053..41c4be4 100644 --- a/src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/commands/SystemdServiceFilesTest.kt @@ -97,5 +97,26 @@ class SystemdServiceFilesTest : FunSpec() { content shouldContain "DirectoryIndex disabled" content shouldContain "RewriteRule .* http://127.0.0.1:18088%{REQUEST_URI} [proxy]" } + + test("the htaccess maps a refused connection to the static maintenance page") { + val content = SystemdServiceFiles.htaccessContent(18088) + + content shouldContain "ErrorDocument 502 /werkator-maintenance.html" + content shouldContain "ErrorDocument 503 /werkator-maintenance.html" + content shouldContain "ErrorDocument 504 /werkator-maintenance.html" + // the maintenance page itself must not be proxied, or ErrorDocument's sub-request loops + content shouldContain "RewriteCond %{REQUEST_URI} !^/werkator-maintenance.html$" + } + + test("the maintenance page is a self-contained, static page naming a retry") { + val content = SystemdServiceFiles.maintenancePageContent() + + content shouldContain " Placing the generated Apache reverse proxy at $htaccess" - ssh "$HOST" "mkdir -p '$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www' && cp '$htaccess_src' '$htaccess'" + ssh "$HOST" "mkdir -p '$TARGET_DIR/doms/$WERKATOR_DOMAIN/subs/www' && cp '$htaccess_src' '$htaccess' && cp '$maintenance_src' '$maintenance'" else echo "==> No werkator.htaccess generated (no server.publicBaseUrl configured) — skipping the Apache proxy" fi