From 772dccab444f95d5171288878ec67f4f48fa9c89 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 1 Aug 2026 11:18:56 -0700 Subject: [PATCH 1/4] Record the container appdata path and re-derive the redirect table Two gaps, both surfaced by asking why `secrets/.env` carried only two values. **The appdata path was recorded nowhere.** The container reads three host paths and a release writes only one. `$DEPLOY_ROOT` holds the bundle, while the bootstrap Caddyfile and Caddy's persistent state live under a separate appdata root that no script touches and nothing documented. The bootstrap is outside the bundle deliberately, since rules held there would leave a rolled-back site served by the current release's redirects, but that also means no release refreshes it and a rebuild depended on someone remembering where it goes. `CADDY_APPDATA` now records it in `secrets/.env`, and OPERATIONS.md carries the install command and the mount table. The value stays out of committed files, because this repo names no host path. The deployed bootstrap had drifted from the repo's copy, carrying the pre-rewrite comments and a stale rule count. Functionally identical, the `import` line matched exactly, so nothing was broken. Now byte-identical, and the site was re-verified after the restart at 1245 URLs honored. **The redirect table was wrong in more than its count.** It claimed 11 regex rules where the Caddyfile carries 13 `redir` directives reading 5 map files through 3 `map` blocks. Three destinations were also stale: the date archives, their pagination, and the Blogger monthly archives were all documented as redirecting to `/` when the config sends them to `/all/`. `labels.map` and `terms.map` appeared nowhere. The table is rebuilt against the config and keyed on the Caddy matcher names rather than invented R numbers, so a reader can grep the Caddyfile instead of trusting the doc. Every row was checked: the classes sum to 917 exactly, matching `redirect-urls.txt`, with the 778 legacy image URLs called out as gated separately. `@label` is documented as deliberately outside the contract, since that class is a soft 404 rather than a redirect. Clears the backlog item added when #5 dropped the count rather than guessing at it. Co-Authored-By: Claude Opus 5 (1M context) --- OPERATIONS.md | 24 ++++++++++++++++++++++++ TODO.md | 1 - deploy/README.md | 42 ++++++++++++++++++++++++------------------ deploy/env.example | 12 +++++++++++- 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/OPERATIONS.md b/OPERATIONS.md index a2e5c5a..ce83ae4 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -101,6 +101,30 @@ The container mounts the deploy root **read-only**, and mounts the **parent** ra Routing differs by environment and the bundle does not. Traefik on the home host has the Docker provider enabled, so container labels route. Pangolin's Traefik on the VPS does not, so routing there is created in the Pangolin UI and labels are silently ignored. +### The bootstrap, and why it is not in the release + +The container reads three host paths, and only one of them a release ever writes: + +| Host path | Mounted at | Written by | +| --- | --- | --- | +| `$DEPLOY_ROOT` | `/srv/blog`, read-only | every release | +| `$CADDY_APPDATA/config` | `/config` | placed once, by hand | +| `$CADDY_APPDATA/data` | `/data` | Caddy itself, persisting state across a recreate | + +[`deploy/bootstrap.Caddyfile`](./deploy/bootstrap.Caddyfile) goes in the `config` directory and is the **only** Caddy file outside the release bundle. It carries a single `import` and no rules of its own, deliberately: everything describing the site ships inside the release, so a rollback reverts the rules and the content together. Rules held here instead would leave a rolled-back site being served by the current release's redirects. + +Because it sits outside the bundle, no release updates it. Install or refresh it explicitly: + +```sh +set -a; . secrets/.env; set +a +install -m 644 deploy/bootstrap.Caddyfile "$CADDY_APPDATA/config/Caddyfile" +docker restart blog +``` + +The restart is needed only when the bootstrap itself changes. A normal release needs none, because the `current` symlink is resolved per request. + +`CADDY_APPDATA` is recorded in `secrets/.env` for exactly this reason. No script reads it, so a rebuild would otherwise depend on someone remembering where the bootstrap goes. + ## Redirects The site answers roughly a thousand addresses it does not render. They are satisfied by eleven regular-expression rules and five map files, all inside the bundle. diff --git a/TODO.md b/TODO.md index a645812..1836168 100644 --- a/TODO.md +++ b/TODO.md @@ -23,7 +23,6 @@ The site is built and gated in CI. It is on GitHub, and it is not yet serving it ## Next, in dependency order - Dispatch `publish-release.yml` once to prove the release path, which exists but has never run. -- Re-derive the rule counts in `deploy/README.md`. It says "11 regex rules plus 5 map files", while the Caddyfile carries 13 `redir` directives, so the R1 to R11 numbering does not map one-to-one onto what the file actually does. Found by review on #5, where the same count was quoted and has since been dropped rather than guessed at. - Provision the VPS: an unprivileged `blogdeploy` user, the deploy root, and `unattended-upgrades` with automatic reboot. - Restrict the deploy key with `restrict,command=...`, no pty and no forwarding, so it can do nothing but rsync into `releases/` and swap the symlink. Generate per-environment keys so staging cannot reach production. - Choose the staging FQDN, add its DNS record, and expose it through Pangolin as a public resource with **no auth**, since CI's live-URL check has to reach it. Authentication defaults to on for a public resource and has to be turned off deliberately. diff --git a/deploy/README.md b/deploy/README.md index 89430ce..3163778 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -88,26 +88,32 @@ Everything the site does not render is the web server's job, and the workload co - **The query string must be matchable.** 110 `?p=` shortlinks redirect on the query alone. A server that matches on the path only would resolve `/?p=123` as `/`, redirect the homepage, and carry the query through to it. - **There must be a lookup primitive.** 279 of the 917 resolve through map files rather than patterns, since no rule can derive their destination, and the five maps carry 661 entries between them. A linear scan of that many rules per request is the wrong shape. -The 917 redirects are 11 regex rules plus 5 map files. A map is used wherever no pattern can derive the answer from the input. +The 917 redirects are **13 `redir` directives** reading **5 map files** through **3 `map` blocks**. Ten directives match on a pattern and three resolve through a map lookup, which is used wherever no pattern can derive the destination from the input. -| Rule | Covers | Shape | +Each row names the Caddy matcher, so the table can be checked against [`Caddyfile`](./Caddyfile) by grep rather than by trust. + +| Matcher | Covers | Shape | | --- | --- | --- | -| R1 | 216 | `/YYYY/MM/DD/post//` -> `/YYYY/MM/DD/post/`, attachment pages and per-post feeds | -| R2 | 107 | `/YYYY/MM/DD/post//feed/` -> `/YYYY/MM/DD/post/`, ordered **before** R1 | -| R3 | 78 | `/YYYY/` and `/YYYY/MM/` -> `/` | -| R4 | 5 | `/YYYY/page/N/` -> `/` | -| R5 | 11 | `/author//` and its pagination -> `/` | -| R6 | 1 | `/feed/` -> `/feed.xml` | -| R7 | 192 | `/tag//feed/` and `/category//feed/` -> the term archive | -| R8 | 778 | `/wp-content/uploads/(.*)` -> `/media/$1`, preserving every legacy image URL | -| R9 | 2 | `/p/.html` -> `//`, Blogger's static-page shape | -| R10 | 2 | `/feeds/posts/default` -> `/feed.xml`, Blogger's Atom feed | -| R11 | wildcard | `/YYYY_MM_01_archive.html` -> one post, whatever the date | -| `slugs.map` | 107 | bare `//` -> best destination | -| `p-ids.map` | 110 | `/?p=` -> permalink | -| `blogger.map` | 59 | `/YYYY/MM/slug.html` -> current post URL, both full and truncated slug | - -Two orderings are load-bearing. R2 precedes R1 because both match the same shape. No golden URL is 5 segments under a date, so R1 cannot swallow a page that must render, and R8 rewrites under a prefix no rendered page occupies. +| `@post_child` | 216 | `/YYYY/MM/DD/post//` -> the post, attachment pages | +| `@term_feed` | 192 | `/tag//feed/` and `/category//feed/` -> the term archive | +| `@post_id` | 110 | `/?p=` -> the permalink, via `p-ids.map` | +| `@post_child_feed` | 107 | `/YYYY/MM/DD/post//feed/` -> the post, ordered **before** `@post_child` | +| `@mapped` | 107 | bare `//` -> best destination, via `slugs.map` | +| `@date_archive` | 83 | `/YYYY/`, `/YYYY/MM/`, and their pagination -> `/all/` | +| `@mapped` | 59 | `/YYYY/MM/slug.html` -> the current post, via `blogger.map` | +| `@blogger_archive` | 21 | `/YYYY_MM_01_archive.html` -> `/all/`, any date, including ones never covered | +| `@author` | 12 | `/author//`, its pagination and feed -> `/` | +| `@uploads` | 778 | `/wp-content/uploads/(.*)` -> `/media/$1`, preserving every legacy image URL | +| `@site_feed` | 3 | `/feed/`, `/comments/feed/`, `/about/feed/` -> `/feed.xml` | +| `@mapped` | 3 | the three empty term archives, via `terms.map` | +| `@blogger_feed` | 2 | `/feeds/posts/default` -> `/feed.xml`, Blogger's Atom feed | +| `@blogger_page` | 2 | `/p/.html` -> `//`, Blogger's static-page shape | + +`@uploads` covers the 778 legacy image URLs, which are gated by `golden-media-legacy.txt` rather than counted in the 917. The remaining rows sum to 917. + +`@label` is the fourteenth class and is deliberately **not** in the contract. `/search/label/