Files
2026-08-15 07:13:00 +00:00

154 lines
6.7 KiB
Markdown

# simplepages
A static-site deployment target for CI. Your build job pushes a directory; the
site switches over to it in one step.
The problem it exists to solve is the one `rsync` has: rsync writes the new
build into the live directory file by file, and for the length of that transfer
visitors get a **half-updated site** — new HTML asking for JS that is not there
yet, hashed asset names that do not resolve, images 404ing. simplepages closes
that window. A deployment is uploaded and verified in full first, and only then
does one atomic pointer store make it live.
**The guarantee:** every request sees the whole old deployment or the whole new
one, never a mixture. That includes requests already in flight — a download that
started before the switch keeps reading the deployment it started on, all the
way to its last byte, even if that deployment is deleted meanwhile.
Rolling back is the same operation in reverse, so it costs one pointer store
too: the old deployment's files are still on disk.
## How it fits together
```
CI runner pages-server visitor
───────── ──────────── ───────
pages deploy ./dist ──manifest──▶ "I am missing 11 of these 142"
──blobs─────▶ content-addressed store
──finalize──▶ assemble tree, fsync, rename
──activate──▶ ┌──────────────────────────┐
│ atomic.Pointer[Deployment]│◀── GET /~demo/
└──────────────────────────┘
$WEBROOT/~demo -> …/dpl_a1b2…
```
Uploads are **content-addressed and incremental**: the CLI sends a manifest of
paths, SHA-256 digests and sizes, and the server answers with the digests it
does not already have. A rebuild that changes one file uploads one file, no
matter how big the site is, and identical files are stored once across every
deployment and every project.
`$WEBROOT/~PROJECT` is kept pointing at the live deployment's directory so nginx
or any other external consumer can serve it directly, but the server serves the
site itself and does not depend on that symlink being correct.
## Quick start
```sh
make build # -> bin/pages-server and bin/pages
export PAGES_DATA=$(mktemp -d) PAGES_WEBROOT=$(mktemp -d)
bin/pages-server --data-dir "$PAGES_DATA" --webroot "$PAGES_WEBROOT" &
```
On first start the server mints an admin key and writes the token to
`$DATA_DIR/bootstrap-token`, mode 0600. That is the only place it ever writes a
token to disk; delete the file once you have taken the token.
```sh
export PAGES_SERVER=http://127.0.0.1:8081
export PAGES_TOKEN=$(cat "$PAGES_DATA/bootstrap-token")
bin/pages project create demo
export PAGES_TOKEN=$(bin/pages key create --project demo -o json | jq -r .token)
mkdir -p dist/assets
echo '<h1>v1</h1><script src="assets/app.js"></script>' > dist/index.html
echo 'console.log(1)' > dist/assets/app.js
bin/pages deploy ./dist --project demo
curl -s localhost:8080/~demo/ # <h1>v1</h1>…
```
Change one file and deploy again — the CLI reports how much the manifest
negotiation saved:
```
142 files, 3.1 MiB; 1 new blob, 402 KiB to upload
```
Give CI a **project-scoped** key, never the admin one. A project key can create,
upload, finalize, activate and delete deployments in its own project and read
that project's settings, and nothing else anywhere.
## Two binaries
| | |
|---|---|
| `pages-server` | The server: management API on `--api-listen` (default `127.0.0.1:8081`), static content on `--listen` (default `:8080`). Storage is SQLite plus a content-addressed blob store under `--data-dir`. |
| `pages` | The CLI. Pure HTTP client — no SQLite, no cgo, no server packages linked in. It is downloaded by CI runners, so keeping it that way is a feature, and `cmd/pages/deps_test.go` fails the build if a server dependency creeps in. |
## CLI
```
pages deploy <dir> upload a directory and switch to it
pages project create|list|show|update|delete
pages deployment list|show|activate|delete # activate an older one = rollback
pages key create|list|revoke
pages whoami which key am I actually using
pages system info|gc|fsck admin only
pages config show|set|path
pages version
```
Settings resolve flag → `PAGES_*` environment variable → config file →
built-in default. In CI, pass the token as `PAGES_TOKEN` or `--token-file`;
`--token` puts the secret in argv, which on a shared runner is world-readable
through `/proc/<pid>/cmdline`.
Every `pages deployment` command works with a project key, so a CI job can roll
its own project back without an admin credential.
## Building and testing
```sh
make build # both binaries, -trimpath -ldflags '-s -w'
make test # go test ./...
make race # go test -race ./...
make atomicity # the switch-is-atomic proof, -race -count=20
make vet fmt tidy
```
`make atomicity` is the test that guards the one thing this project sells: 64
readers hammer three files plus a 512 KiB body while a writer activates 50
deployments back to back, and any reader that observes two versions at once
fails the run.
## Security
Read [`docs/operations.md`](docs/operations.md) §7 before hosting anything you
care about. The headlines:
- **Path routing is not a security boundary.** `/~a/` and `/~b/` share one
origin, so project A's JavaScript can read project B's files, cookies and
`localStorage`, and can register a service worker that intercepts *every*
project on the host. This is fine for mutually trusting projects, which is
what v1 targets. Hosting untrusted sites needs one domain per project.
- Client-supplied hashes are never trusted: the server rehashes every uploaded
byte and rejects a mismatch, which is what makes cross-project dedup safe.
- Tokens appear once, at creation. They are never logged, never accepted in a
query string, and never included in an error message.
- Blobs and assembled files are `0444`, directories `0755`; upload permissions
are ignored entirely, so a setuid bit cannot reach disk.
- A project key can tell whether a digest it already knows exists on the server
(a blob existence oracle). Known, accepted, and documented in §7.
## Documentation
[`docs/operations.md`](docs/operations.md) — installation, configuration,
deploying, garbage collection and retention, the security model and its limits,
scaling limits, troubleshooting and backups.
[`AGENTS.md`](AGENTS.md) — repository map, the invariants that hold the
guarantee up, and what to run before calling a change done.