154 lines
6.7 KiB
Markdown
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.
|