Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,14 @@ So release to the local mirror and run the live check **before** opening a pull
```sh
set -a; . secrets/.env; set +a
deploy/make-release.sh
docker restart "$CADDY_CONTAINER" # required: see below
checks/check-live-urls.sh "$HUGO_BASEURL"
```

**The restart is not optional, and omitting it produces a false pass.** Caddy expands `import` at config-parse time, both for the site config and for the `map` blocks that read `maps/*.map`. It does not watch those files. Swapping the `current` symlink therefore changes what a *static file* request resolves to, per request, but the redirect rules and map tables stay exactly as they were when Caddy last loaded. Verified against the running mirror: a new map entry present in the live release on disk returned 404 until the container was restarted, then 301.

That is the failure this whole loop exists to catch, so it is worth being blunt about. Change a redirect, release, and check without reloading, and the check exercises the **previous** rules. A broken redirect reports `PASS` while the shipped artifact is broken.

Sourcing `secrets/.env` first puts the deploy root and the base URL in the environment, so no literal value is typed. `make-release.sh` then takes no arguments, and it refuses to install a release that fails the build gate. `check-live-urls.sh` does take a base URL, which is where the sourced `$HUGO_BASEURL` goes. It follows all 1,245 URLs against the running mirror, checking each redirect's destination rather than trusting its status code.

Expect `PASS - 1245 URLs honored`. Anything less is a finding, and the output names each URL that failed and what it answered.
Expand Down Expand Up @@ -81,9 +86,12 @@ Point `current` at the previous release. The swap is a single rename, so a reque
```sh
ln -sfn "releases/<previous>" "<deploy-root>/.current.tmp"
mv -Tf "<deploy-root>/.current.tmp" "<deploy-root>/current"
docker restart "$CADDY_CONTAINER"
```

No restart and no reload. The container mounts the parent directory, so the kernel resolves `current` per request and the change is visible immediately.
The content reverts on the rename alone, because the container mounts the parent directory and the kernel resolves `current` per request. **The rules do not.** Caddy holds the Caddyfile and the maps as parsed config, so without the restart a rollback serves the previous release's content under the current release's redirects, which is precisely the mismatch that shipping the config inside the bundle exists to prevent.

The restart is therefore part of the rollback, not an optional follow-up. It costs a few seconds of downtime on a static site, which is the cheaper half of the trade.

Verify with `checks/check-live-urls.sh` against the environment before considering the rollback finished.

Expand All @@ -101,6 +109,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 "$CADDY_CONTAINER"
```

A restart is needed whenever **any** Caddy config changes, not only this file. That includes `deploy/Caddyfile` and anything under `deploy/maps/`, because both are expanded at config-parse time and Caddy does not watch them. Only static file requests follow the `current` symlink per request. See "Local Verification Before a Pull Request" above, where skipping the restart is the difference between a real check and a false pass.

`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.
Expand Down
1 change: 0 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 27 additions & 18 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,26 +88,35 @@ Everything the site does not render is the web server's job, and the workload co
- **The query string must be matchable.** 110 `?p=<id>` 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 Caddyfile carries **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 |
Directives and URL classes are not one to one, in both directions. `@mapped` is a single directive serving three classes, because their key spaces are disjoint and merging them keeps one lookup on the hot path. `@uploads` is one directive covering a URL set that is gated separately.

Each row below is a **URL class**, named by the matcher that serves it, so the table can be checked against [`Caddyfile`](./Caddyfile) by grep rather than by trust.

| Matcher | Class size | Shape |
| --- | --- | --- |
| R1 | 216 | `/YYYY/MM/DD/post/<child>/` -> `/YYYY/MM/DD/post/`, attachment pages and per-post feeds |
| R2 | 107 | `/YYYY/MM/DD/post/<child>/feed/` -> `/YYYY/MM/DD/post/`, ordered **before** R1 |
| R3 | 78 | `/YYYY/` and `/YYYY/MM/` -> `/` |
| R4 | 5 | `/YYYY/page/N/` -> `/` |
| R5 | 11 | `/author/<name>/` and its pagination -> `/` |
| R6 | 1 | `/feed/` -> `/feed.xml` |
| R7 | 192 | `/tag/<t>/feed/` and `/category/<c>/feed/` -> the term archive |
| R8 | 778 | `/wp-content/uploads/(.*)` -> `/media/$1`, preserving every legacy image URL |
| R9 | 2 | `/p/<slug>.html` -> `/<slug>/`, 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 `/<attachment-slug>/` -> best destination |
| `p-ids.map` | 110 | `/?p=<id>` -> 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/<child>/` -> the post, attachment pages |
| `@term_feed` | 192 | `/tag/<t>/feed/` and `/category/<c>/feed/` -> the term archive |
| `@post_id` | 110 | `/?p=<id>` -> the permalink, via `p-ids.map` |
| `@post_child_feed` | 107 | `/YYYY/MM/DD/post/<child>/feed/` -> the post, ordered **before** `@post_child` |
| `@mapped` via `slugs.map` | 107 | bare `/<attachment-slug>/` -> best destination |
| `@date_archive` | 83 | `/YYYY/`, `/YYYY/MM/`, and their pagination -> `/all/` |
| `@mapped` via `blogger.map` | 59 | `/YYYY/MM/slug.html` -> the current post |
| `@blogger_archive` | 21 | `/YYYY_MM_01_archive.html` -> `/all/`, any date, including ones never covered |
| `@author` | 12 | `/author/<name>/`, its pagination and feed -> `/` |
| `@site_feed` | 3 | `/feed/`, `/comments/feed/`, `/about/feed/` -> `/feed.xml` |
| `@mapped` via `terms.map` | 3 | the three empty term archives |
| `@blogger_feed` | 2 | `/feeds/posts/default` -> `/feed.xml`, Blogger's Atom feed |
| `@blogger_page` | 2 | `/p/<slug>.html` -> `/<slug>/`, Blogger's static-page shape |

**Those thirteen classes sum to 917**, which is the line count of [`checks/redirect-urls.txt`](../checks/redirect-urls.txt) and the whole redirect contract.

`@uploads` is deliberately absent from that table and from the 917. It rewrites `/wp-content/uploads/(.*)` to `/media/$1`, preserving all 778 legacy image URLs, which are gated by `golden-media-legacy.txt` on their own. Counting them here would double-count a set that has its own list.

`@label` is the fourteenth class and is deliberately **not** in the contract. `/search/label/<Label>` was never a redirect: the old platform answered it with a generic search page that returns 200 for a label that never existed, so it is a soft 404 that looks alive. `labels.map` sends each label to its term archive and defaults anything unmatched to `/all/`, which is a choice rather than a preservation.

Two orderings are load-bearing. `@post_child_feed` precedes `@post_child` because both match the same shape and the broader one would claim both. No golden URL is five segments under a date, so `@post_child` cannot swallow a page that must render, and `@uploads` rewrites under a prefix no rendered page occupies.

`blogger.map` carries 59 entries rather than 48 because Blogger truncated an auto-generated slug at 40 characters on a whole-word boundary, so a long-titled post was served at the truncated URL and that is the form in search indexes. `slugs.map` is generated by `checks/build-redirects.py`, which recovers each attachment's parent from the media inventory, since all 107 have `post_parent = 0` in the export. 85 resolve to a real post and the remaining 22 were never used anywhere, so `/` is correct for them.

Expand Down
32 changes: 27 additions & 5 deletions deploy/env.example
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
# Copy to secrets/.env and set for this host. Every value here names a particular machine rather
# than the project, which is why secrets/ is gitignored as a whole directory and this template
# lives outside it. CI sets the same variables from environment secrets and never reads a file.
# lives outside it. CI sets the deploy values from environment secrets and never reads a file.
#
# DEPLOY_ROOT is the fallback deploy root, which make-release.sh also takes as its first argument.
# HUGO_BASEURL must be set for anything that is not production. The base URL is baked into the
# canonical tag, the feed links, and every absolute permalink, so a mirror built without it serves
# pages that all point back at production while every gate still passes.
# Naming convention: the prefix names whatever owns the value, not whatever reads it.
# HUGO_ is fixed by Hugo, which maps HUGO_<KEY> onto its own config natively.
# DEPLOY_ is the release tooling, which writes the deploy root.
# CADDY_ is the container, which owns state the release never touches.

# Written by every release, and mounted read-only by the container at /srv/blog.
# make-release.sh also takes it as a first argument, which wins over this value.
# CI sets the same name per environment, where the deploy is an rsync rather than a local copy.
DEPLOY_ROOT=/path/to/deploy/root

# Must be set for anything that is not production.
# The base URL is baked into the canonical tag, the feed links, and every absolute permalink,
# so a mirror built without it serves pages that all point back at production while every gate
# still passes.
HUGO_BASEURL=https://blog.example.com/

# The container's persistent state root, deliberately outside DEPLOY_ROOT.
# Two directories hang off it, and a release writes neither:
# <CADDY_APPDATA>/config mounted at /config, holding the bootstrap Caddyfile
# <CADDY_APPDATA>/data mounted at /data, persisting Caddy state across a recreate
# No script reads this. It is recorded so a rebuild does not depend on someone remembering
# where the bootstrap goes, since the release bundle deliberately does not carry it.
CADDY_APPDATA=/path/to/container/appdata

# The container to restart when config changes.
# Caddy expands the site config and the map files at parse time and does not watch them, so a
# release or a rollback that changes either is not live until the container restarts.
CADDY_CONTAINER=blog