init
This commit is contained in:
commit
dd50674fdc
114 files changed
+26865
No files matched your search
@@ -0,0 +1,166 @@
|
||||
# Working on simplepages
|
||||
|
||||
Read [`README.md`](README.md) first for what this is, and
|
||||
[`docs/operations.md`](docs/operations.md) for how it is run. This file is for
|
||||
whoever — human or agent — is changing the code.
|
||||
|
||||
Everything here exists to protect one property: **a request sees the whole old
|
||||
deployment or the whole new one, never a mixture.** If a change makes that
|
||||
property harder to reason about, it is the wrong change however much code it
|
||||
saves.
|
||||
|
||||
## Before you call a change done
|
||||
|
||||
```sh
|
||||
make fmt vet test
|
||||
make race
|
||||
make atomicity # -race -count=20, takes ~80s
|
||||
go test ./cmd/pages -run TestCLIImportGraph
|
||||
```
|
||||
|
||||
Run the last two whenever you touch `internal/site`, `internal/deploy`, or
|
||||
anything the CLI imports. `make race` is not optional for changes in those
|
||||
packages — the interesting failures here are all interleavings.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | |
|
||||
|---|---|
|
||||
| `api/` | Wire types, error codes, path builders. **Stdlib only** — it is shared with the CLI, and that is the whole reason the CLI stays dependency-free. |
|
||||
| `cmd/pages-server/` | Assembly: config, storage, recovery, listeners, background workers, signals. Also holds the end-to-end tests. |
|
||||
| `cmd/pages/` | CLI entry point plus `deps_test.go`, which enforces the import graph. |
|
||||
| `internal/store/` | SQLite: dual pool, migrations, queries, fsck. |
|
||||
| `internal/cas/` | Content-addressed blob store. |
|
||||
| `internal/site/` | ★ The read path: registry, immutable deployment snapshots, URL resolution, the HTTP handler. |
|
||||
| `internal/deploy/` | Deployment lifecycle, assembly, startup recovery, reconciler, GC. |
|
||||
| `internal/webroot/` | `$WEBROOT/~name` symlink maintenance. |
|
||||
| `internal/auth/` | Token mint/parse/verify, middleware, failed-auth rate limiting. |
|
||||
| `internal/adminapi/` | Management API routes and handlers. |
|
||||
| `internal/config/`, `internal/httpx/`, `internal/cache/`, `internal/pathutil/`, `internal/version/` | Support. |
|
||||
| `internal/client/`, `internal/clicmd/`, `internal/cliutil/` | CLI: HTTP client and deploy flow, commands, command tree and rendering. |
|
||||
|
||||
## Invariants
|
||||
|
||||
Each of these is load-bearing. Changing one is a design decision, not a
|
||||
refactor.
|
||||
|
||||
**1. Load `p.Active()` exactly once per request.** `internal/site/serve.go`
|
||||
takes the snapshot at the top and every later access uses that value. A second
|
||||
`Active()` call in the same request is the bug this whole design prevents: the
|
||||
two loads could straddle an activation and serve a mixed page. `Deployment` is
|
||||
immutable after construction, so holding the pointer is free and correct.
|
||||
|
||||
**2. Activation order is fixed.** Project lock → require `ready` → build the
|
||||
snapshot → **commit to SQLite** → `atomic.Pointer.Store` → best-effort
|
||||
`webroot.Point`. Database before memory, so a crash between the two restarts
|
||||
into the state the database already recorded. The reverse order leaves a
|
||||
process serving A while the database says B. Building the snapshot before the
|
||||
commit means a failure there changes nothing at all.
|
||||
|
||||
**3. Never trust a client-supplied hash.** `cas.Store.Put` rehashes the received
|
||||
stream and compares against the claimed digest before linking the blob into
|
||||
place; a mismatch is a 400 and the temp file is discarded. Without this a client
|
||||
could claim another project's digest, upload arbitrary bytes, and poison that
|
||||
blob for every project referencing it. This check is what makes cross-project
|
||||
dedup safe — do not move it, skip it for "already known" digests, or trust a
|
||||
digest because the transport was TLS.
|
||||
|
||||
**4. Tokens never appear in logs, query strings, or error messages.** There is a
|
||||
test that runs a request through the middleware into a buffer and greps for the
|
||||
token; keep it passing. The CLI prefers `PAGES_TOKEN` and `--token-file` over
|
||||
`--token` because argv is world-readable via `/proc/<pid>/cmdline` on a shared
|
||||
runner, and the help text says so. The bootstrap token file and the CLI config
|
||||
file are 0600.
|
||||
|
||||
**5. All writes go through `db.Tx`.** `store.DB` exposes `Reader()` for queries
|
||||
and `Tx(ctx, fn)` for everything else. There is deliberately no `Writer()` — the
|
||||
single write connection plus `BEGIN IMMEDIATE` plus busy retries is the only
|
||||
thing keeping `SQLITE_BUSY` off the hot path. Tests write through `db.Tx` too.
|
||||
|
||||
**6. Upload paths pass both `fs.ValidPath` and `filepath.Localize`,** and are
|
||||
additionally rejected for NUL or control bytes, a segment over 255 bytes, a
|
||||
total over 4096, duplicates, and case-insensitive collisions (the assembled tree
|
||||
may land on a case-insensitive filesystem, where a collision silently
|
||||
overwrites). One check is not enough: `fs.ValidPath` allows backslashes and
|
||||
Windows reserved names, `Localize` allows things `ValidPath` rejects.
|
||||
|
||||
**7. The read path serves from the CAS by digest.** The only filesystem path
|
||||
constructed while serving is `cas/ab/cd/<64 hex>`, derived from a `[32]byte`
|
||||
that came out of a map lookup. No user-controlled string reaches the filesystem,
|
||||
so read-path traversal is not defended against — it is structurally impossible.
|
||||
Serving from the assembled directory instead would give that back.
|
||||
|
||||
**8. No archive extraction, ever.** Files arrive one at a time; the server never
|
||||
unpacks or decompresses anything. That is what makes zip bombs and tar-slip
|
||||
inapplicable rather than mitigated. "Just accept a tarball, it's fewer round
|
||||
trips" reopens both — it needs a fresh security review, not a patch.
|
||||
|
||||
**9. `RequireProject` compares resolved project IDs,** never the name string
|
||||
from the URL. Names can be reused after a delete; IDs cannot. Same rule for
|
||||
deployments: confirm `{id}` really belongs to `{name}` before changing anything.
|
||||
|
||||
**10. `webroot.Reconcile` only removes entries that are symlinks pointing inside
|
||||
`$DATA_DIR/deployments`.** `$WEBROOT` belongs to the operator and will contain
|
||||
files that are none of our business.
|
||||
|
||||
**11. Assembled files and blobs are 0444, directories 0755, and client-supplied
|
||||
modes are ignored.** There is no `mode` column in the schema on purpose, so a
|
||||
setuid bit has nowhere to be stored even if someone tries to send one. On a
|
||||
hardlinking filesystem the assembled file *is* the blob's inode — writing to a
|
||||
file under `$WEBROOT` corrupts it for every project that references it.
|
||||
|
||||
**12. The CLI's dependency graph is enforced, not documented.**
|
||||
`cmd/pages/deps_test.go` fails if `go list -deps ./cmd/pages` reaches
|
||||
`modernc.org/sqlite`, `modernc.org/libc`, `database/sql`, `github.com/BurntSushi/toml`,
|
||||
`net/http/httptest`, or `internal/{store,site,deploy,cas,auth,adminapi}`.
|
||||
`database/sql` is a probe: it can only appear if server code leaked in. If you
|
||||
need a type in both the CLI and the server, it belongs in `api/` (stdlib only)
|
||||
or `internal/pathutil` (stdlib only).
|
||||
|
||||
## Conventions
|
||||
|
||||
**Comments explain why, not what.** The code already says what it does. A
|
||||
comment earns its place by recording the reasoning that is not recoverable from
|
||||
the code — why this order, why not the obvious alternative, what breaks if
|
||||
someone changes it. Match the density of the surrounding file; several of the
|
||||
sharper decisions are commented in place precisely so a later reader does not
|
||||
"optimize" them away (`internal/cache`'s note about not caching file handles is
|
||||
the canonical example).
|
||||
|
||||
**Do not over-design.** No metrics nobody reads, no abstraction with one
|
||||
implementation, no configuration knob without a caller who needs it. If a
|
||||
simpler thing works, ship the simpler thing.
|
||||
|
||||
**Dependencies.** Three direct ones, on purpose: `modernc.org/sqlite` (pure Go,
|
||||
so `CGO_ENABLED=0` cross-compiles), `github.com/BurntSushi/toml` (server config
|
||||
only), `golang.org/x/sync/errgroup` (bounded concurrency, both sides). No web
|
||||
framework, router, ORM, migration tool, CLI framework, logging library
|
||||
(`log/slog`), or UUID library (`crypto/rand` + hex). Adding a fourth needs a
|
||||
reason that survives being written down.
|
||||
|
||||
**Tests** are table-driven with names that read as sentences
|
||||
(`TestAnInFlightRequestSurvivesTheContentBeingCollected`). Two gotchas worth
|
||||
knowing before you debug an unexpected pass: retention tests must patch
|
||||
`retention_grace_s = 0`, because a deployment that was never activated is aged
|
||||
from `created_at` and is otherwise protected for the full grace period; and blob
|
||||
collection tests need a negative `Service.BlobGrace`, since zero means "use the
|
||||
one-hour default", not "collect now".
|
||||
|
||||
**CLI rendering:** `cliutil.Bool` prints `yes`/`no`, and `cliutil.Truncate(s, n)`
|
||||
returns `n-1` characters plus an ellipsis. Both have caught tests out.
|
||||
|
||||
## Scope
|
||||
|
||||
v1 (M0–M5) is complete: deploy, atomic switch, rollback, crash recovery,
|
||||
retention and GC, and the atomicity proof tests.
|
||||
|
||||
Deferred, deliberately — do not build these speculatively: precompressed
|
||||
`br`/`gzip` variants (the `deployment_files.encoding` column is already there
|
||||
for it), per-project header rules, host routing (one domain per project, which
|
||||
is also the fix for the same-origin limit), autoindex, a metrics endpoint, a
|
||||
resident-manifest cap, and packaging.
|
||||
|
||||
**Known limit:** one server process per database. The in-memory registry is
|
||||
updated by the process that wrote the change, so a second process on the same
|
||||
SQLite file serves stale content until it restarts. Horizontal scaling needs a
|
||||
change-notification mechanism first.
|
||||
@@ -0,0 +1,40 @@
|
||||
BIN ?= bin
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
PKG := github.com/iceBear67/simplepages
|
||||
LDFLAGS := -s -w -X $(PKG)/internal/version.Version=$(VERSION)
|
||||
GOFLAGS := -trimpath
|
||||
|
||||
.PHONY: all build server cli test race atomicity vet fmt tidy clean
|
||||
|
||||
all: build
|
||||
|
||||
build: server cli
|
||||
|
||||
server:
|
||||
go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BIN)/pages-server ./cmd/pages-server
|
||||
|
||||
# The CLI is downloaded by CI runners, so its size is a feature: keep it small
|
||||
# and keep server-side dependencies out of it (enforced by cmd/pages/deps_test.go).
|
||||
cli:
|
||||
CGO_ENABLED=0 go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BIN)/pages ./cmd/pages
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
race:
|
||||
go test -race ./...
|
||||
|
||||
atomicity:
|
||||
go test -race -count=20 ./internal/deploy -run TestActivationAtomicity
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
gofmt -l -w .
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
clean:
|
||||
rm -rf $(BIN)
|
||||
@@ -0,0 +1,153 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,84 @@
|
||||
// Package api holds the wire format shared by pages-server and the pages CLI.
|
||||
//
|
||||
// It must depend on the standard library only. cmd/pages imports this package,
|
||||
// and cmd/pages/deps_test.go fails the build if any server-side dependency
|
||||
// (SQLite driver, database/sql, internal/store, ...) reaches the CLI through it.
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Code is a machine-readable error code. Clients switch on these; the
|
||||
// accompanying message is for humans and may change.
|
||||
type Code string
|
||||
|
||||
const (
|
||||
CodeBadRequest Code = "bad_request"
|
||||
CodeUnauthorized Code = "unauthorized"
|
||||
CodeForbidden Code = "forbidden"
|
||||
CodeNotFound Code = "not_found"
|
||||
CodeMethodNotAllowed Code = "method_not_allowed"
|
||||
CodeConflict Code = "conflict"
|
||||
CodePayloadTooLarge Code = "payload_too_large"
|
||||
CodeRateLimited Code = "rate_limited"
|
||||
CodeInternal Code = "internal"
|
||||
CodeUnavailable Code = "unavailable"
|
||||
|
||||
// Domain-specific codes.
|
||||
CodeProjectExists Code = "project_exists"
|
||||
CodeInvalidProjectName Code = "invalid_project_name"
|
||||
CodeInvalidPath Code = "invalid_path"
|
||||
CodeDigestMismatch Code = "digest_mismatch"
|
||||
CodeSizeMismatch Code = "size_mismatch"
|
||||
CodeDeploymentNotReady Code = "deployment_not_ready"
|
||||
CodeDeploymentActive Code = "deployment_active"
|
||||
CodeBlobsMissing Code = "blobs_missing"
|
||||
CodeLimitExceeded Code = "limit_exceeded"
|
||||
)
|
||||
|
||||
// Error is the body of every non-2xx management API response:
|
||||
//
|
||||
// {"error":{"code":"deployment_not_ready","message":"...","details":{...}}}
|
||||
type Error struct {
|
||||
Code Code `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorEnvelope wraps Error so the JSON has a single top-level "error" key.
|
||||
type ErrorEnvelope struct {
|
||||
Error Error `json:"error"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Message == "" {
|
||||
return string(e.Code)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Errorf builds an *Error with a formatted message.
|
||||
func Errorf(code Code, format string, args ...any) *Error {
|
||||
return &Error{Code: code, Message: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// WithDetail attaches a detail key and returns the receiver for chaining.
|
||||
func (e *Error) WithDetail(key string, val any) *Error {
|
||||
if e.Details == nil {
|
||||
e.Details = make(map[string]any, 1)
|
||||
}
|
||||
e.Details[key] = val
|
||||
return e
|
||||
}
|
||||
|
||||
// CodeOf reports the Code carried by err, or CodeInternal when err is not an
|
||||
// *Error. It unwraps, so a wrapped *Error is still recognised.
|
||||
func CodeOf(err error) Code {
|
||||
var apiErr *Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.Code
|
||||
}
|
||||
return CodeInternal
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package api
|
||||
|
||||
import "net/url"
|
||||
|
||||
// Version is the API path prefix. It is a constant rather than a client option
|
||||
// because the CLI and the server are released together; a mismatch is a bug,
|
||||
// not a configuration.
|
||||
const Version = "/api/v1"
|
||||
|
||||
// Path builders, shared so the client and the server's route table cannot drift
|
||||
// apart. Every segment that comes from user input is escaped: a project named
|
||||
// with a slash could otherwise rewrite the request into a different endpoint.
|
||||
// (Project names are pattern-checked on creation, so this is defence in depth
|
||||
// against a name that predates a stricter pattern.)
|
||||
|
||||
func PathProjects() string { return Version + "/projects" }
|
||||
|
||||
func PathProject(name string) string {
|
||||
return Version + "/projects/" + url.PathEscape(name)
|
||||
}
|
||||
|
||||
func PathProjectKeys(name string) string {
|
||||
return PathProject(name) + "/keys"
|
||||
}
|
||||
|
||||
func PathKeys() string { return Version + "/keys" }
|
||||
|
||||
func PathKey(id string) string {
|
||||
return Version + "/keys/" + url.PathEscape(id)
|
||||
}
|
||||
|
||||
func PathWhoAmI() string { return Version + "/whoami" }
|
||||
|
||||
func PathSystemInfo() string { return Version + "/system/info" }
|
||||
|
||||
func PathGC() string { return Version + "/gc" }
|
||||
|
||||
func PathFsck() string { return Version + "/fsck" }
|
||||
|
||||
func PathDeployments(project string) string {
|
||||
return PathProject(project) + "/deployments"
|
||||
}
|
||||
|
||||
func PathDeployment(project, id string) string {
|
||||
return PathDeployments(project) + "/" + url.PathEscape(id)
|
||||
}
|
||||
|
||||
func PathManifest(project, id string) string {
|
||||
return PathDeployment(project, id) + "/manifest"
|
||||
}
|
||||
|
||||
func PathFinalize(project, id string) string {
|
||||
return PathDeployment(project, id) + "/finalize"
|
||||
}
|
||||
|
||||
func PathActivate(project, id string) string {
|
||||
return PathDeployment(project, id) + "/activate"
|
||||
}
|
||||
|
||||
// PathBlob addresses a blob by lowercase hex digest. Blobs are global rather
|
||||
// than per-project because the content-addressed store deduplicates across
|
||||
// projects; see the security notes on the blob existence oracle.
|
||||
func PathBlob(hexDigest string) string {
|
||||
return Version + "/blobs/" + url.PathEscape(hexDigest)
|
||||
}
|
||||
|
||||
// SiteURL returns where a project is served under path routing.
|
||||
func SiteURL(base, project string) string {
|
||||
return base + "/~" + url.PathEscape(project) + "/"
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
package api
|
||||
|
||||
import "time"
|
||||
|
||||
// Deployment states, as they appear on the wire.
|
||||
const (
|
||||
StatePending = "pending"
|
||||
StateUploading = "uploading"
|
||||
StateReady = "ready"
|
||||
StateFailed = "failed"
|
||||
StateDeleting = "deleting"
|
||||
)
|
||||
|
||||
// Key scopes, as they appear on the wire.
|
||||
const (
|
||||
ScopeAdmin = "admin"
|
||||
ScopeProject = "project"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- projects
|
||||
|
||||
// Project is the server's view of a project.
|
||||
//
|
||||
// Every mutable setting also appears in ProjectPatch. Adding a field here that
|
||||
// cannot be changed afterwards is a deliberate choice, not an oversight: Name
|
||||
// is immutable because renaming would invalidate every deployed URL and every
|
||||
// webroot symlink pointing at it.
|
||||
type Project struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
IndexFile string `json:"index_file"`
|
||||
NotFoundFile string `json:"not_found_file,omitempty"`
|
||||
SPAFallback bool `json:"spa_fallback"`
|
||||
CacheControl string `json:"cache_control"`
|
||||
|
||||
RetentionCount int `json:"retention_count"`
|
||||
RetentionGrace int `json:"retention_grace_s"`
|
||||
MaxFiles int `json:"max_files"`
|
||||
MaxFileBytes int64 `json:"max_file_bytes"`
|
||||
MaxTotalBytes int64 `json:"max_total_bytes"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// ActiveDeployment is nil when the project has never been deployed, which
|
||||
// is why it is a pointer rather than a zero-valued struct.
|
||||
ActiveDeployment *Deployment `json:"active_deployment,omitempty"`
|
||||
// URL is where the active deployment is served, when the server knows its
|
||||
// public base URL.
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// CreateProjectRequest creates a project. Everything except Name is optional
|
||||
// and falls back to the server's defaults.
|
||||
type CreateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Patch *ProjectPatch `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectPatch is a partial update.
|
||||
//
|
||||
// Every field is a pointer so the server can tell "leave this alone" from "set
|
||||
// this to the zero value" — without that, PATCH could never clear a custom 404
|
||||
// document or turn the SPA fallback off.
|
||||
type ProjectPatch struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
IndexFile *string `json:"index_file,omitempty"`
|
||||
NotFoundFile *string `json:"not_found_file,omitempty"`
|
||||
SPAFallback *bool `json:"spa_fallback,omitempty"`
|
||||
CacheControl *string `json:"cache_control,omitempty"`
|
||||
RetentionCount *int `json:"retention_count,omitempty"`
|
||||
RetentionGrace *int `json:"retention_grace_s,omitempty"`
|
||||
MaxFiles *int `json:"max_files,omitempty"`
|
||||
MaxFileBytes *int64 `json:"max_file_bytes,omitempty"`
|
||||
MaxTotalBytes *int64 `json:"max_total_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectList is the paged response for GET /api/v1/projects.
|
||||
type ProjectList struct {
|
||||
Projects []Project `json:"projects"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- keys
|
||||
|
||||
// Key describes an API key. It never carries the secret: the full token exists
|
||||
// on the wire exactly once, in CreateKeyResponse.
|
||||
type Key struct {
|
||||
ID string `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
LastUsed *time.Time `json:"last_used_at,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
// Revoked reports whether the key has been revoked.
|
||||
func (k Key) Revoked() bool { return k.RevokedAt != nil }
|
||||
|
||||
// CreateKeyRequest mints a key. Project is set by the URL for the
|
||||
// project-scoped endpoint and must be empty otherwise.
|
||||
type CreateKeyRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
// ExpiresAt is absolute, not a duration: the CLI parses "90d" locally so a
|
||||
// clock skew between client and server cannot silently shift expiry.
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// CreateKeyResponse is the only place a full token ever appears.
|
||||
type CreateKeyResponse struct {
|
||||
Key Key `json:"key"`
|
||||
// Token is shown once and never retrievable again. Clients must not log it.
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// KeyList is the response for the key listing endpoints.
|
||||
type KeyList struct {
|
||||
Keys []Key `json:"keys"`
|
||||
}
|
||||
|
||||
// WhoAmI describes the caller's own credential.
|
||||
type WhoAmI struct {
|
||||
KeyID string `json:"key_id"`
|
||||
Scope string `json:"scope"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- deployments
|
||||
|
||||
// Deployment is the server's view of one upload.
|
||||
type Deployment struct {
|
||||
ID string `json:"id"`
|
||||
Project string `json:"project"`
|
||||
State string `json:"state"`
|
||||
Active bool `json:"active"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
FinalizedAt *time.Time `json:"finalized_at,omitempty"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty"`
|
||||
|
||||
// URL is where this deployment is served, set on the response to an
|
||||
// activation when the server knows its public base URL. It is the project's
|
||||
// URL: only the active deployment has one, since there are no per-version
|
||||
// preview addresses.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// Files is populated only by GET .../deployments/{id}?files=true.
|
||||
Files []FileEntry `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
// FileEntry is one line of a manifest. Digest is lowercase hex; the server
|
||||
// stores the raw 32 bytes, and hex exists only at this boundary.
|
||||
type FileEntry struct {
|
||||
Path string `json:"path"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// CreateDeploymentRequest starts a deployment.
|
||||
type CreateDeploymentRequest struct {
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// ManifestRequest declares the complete file list of a deployment.
|
||||
type ManifestRequest struct {
|
||||
Files []FileEntry `json:"files"`
|
||||
}
|
||||
|
||||
// ManifestResponse tells the client which blobs the server does not have yet.
|
||||
//
|
||||
// Missing is the number that makes content-addressed upload worth having, so
|
||||
// the CLI prints it: "142 files, 3.1 MiB; 11 new blobs, 402 KiB to upload".
|
||||
type ManifestResponse struct {
|
||||
Missing []string `json:"missing"`
|
||||
MissingBytes int64 `json:"missing_bytes"`
|
||||
Have int `json:"have"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
}
|
||||
|
||||
// BlobResponse acknowledges an uploaded blob.
|
||||
type BlobResponse struct {
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// DeploymentList is the paged response for the deployment listing endpoint.
|
||||
type DeploymentList struct {
|
||||
Deployments []Deployment `json:"deployments"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ system
|
||||
|
||||
// SystemInfo is the response for GET /api/v1/system/info.
|
||||
type SystemInfo struct {
|
||||
Version string `json:"version"`
|
||||
UptimeS int64 `json:"uptime_s"`
|
||||
Projects int64 `json:"projects"`
|
||||
Deployments int64 `json:"deployments"`
|
||||
Blobs int64 `json:"blobs"`
|
||||
CASBytes int64 `json:"cas_bytes"`
|
||||
LinkMode string `json:"link_mode"`
|
||||
SchemaVer int `json:"schema_version"`
|
||||
}
|
||||
|
||||
// GCRequest asks for a garbage collection pass.
|
||||
type GCRequest struct {
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// GCStats reports what a collection pass did, or would have done.
|
||||
//
|
||||
// On a dry run the blob numbers count what is collectable right now, not what
|
||||
// deleting the listed deployments would additionally free: nothing was deleted,
|
||||
// so those blobs are still referenced. The figures are a floor.
|
||||
type GCStats struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
DeploymentsDeleted int `json:"deployments_deleted"`
|
||||
BlobsDeleted int `json:"blobs_deleted"`
|
||||
BytesFreed int64 `json:"bytes_freed"`
|
||||
}
|
||||
|
||||
// FsckRequest asks for a consistency check, optionally correcting what it
|
||||
// finds.
|
||||
type FsckRequest struct {
|
||||
Repair bool `json:"repair,omitempty"`
|
||||
}
|
||||
|
||||
// FsckReport is the result of a consistency check.
|
||||
type FsckReport struct {
|
||||
// Blobs is how many were examined, DriftCount how many disagreed with the
|
||||
// manifests that reference them. Drift lists the first hundred of them,
|
||||
// because the list is for a person to read.
|
||||
Blobs int64 `json:"blobs"`
|
||||
DriftCount int `json:"drift_count"`
|
||||
Drift []BlobDrift `json:"drift,omitempty"`
|
||||
Repaired int `json:"repaired"`
|
||||
}
|
||||
|
||||
// BlobDrift is one blob whose stored reference count is not the number of
|
||||
// manifest entries that name it.
|
||||
type BlobDrift struct {
|
||||
Digest string `json:"digest"`
|
||||
// Stored above Actual only wastes disk. Stored below Actual is the
|
||||
// dangerous direction: the collector may remove content a deployment still
|
||||
// needs.
|
||||
Stored int64 `json:"stored"`
|
||||
Actual int64 `json:"actual"`
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/client"
|
||||
"github.com/iceBear67/simplepages/internal/config"
|
||||
)
|
||||
|
||||
// testenv is the whole server: the real app assembly, both handlers, and the
|
||||
// pages CLI's own client library driving them. Nothing between the CI job and
|
||||
// the bytes on the wire is stubbed, which is the point — the milestone's claim
|
||||
// is that `pages deploy` followed by a GET returns the site, and only a test at
|
||||
// this level can make that claim.
|
||||
type testenv struct {
|
||||
app *app
|
||||
cfg config.Config
|
||||
site *httptest.Server
|
||||
api *httptest.Server
|
||||
admin *client.Client
|
||||
adminToken string
|
||||
logBuf *bytes.Buffer
|
||||
}
|
||||
|
||||
func newTestenv(t *testing.T) *testenv {
|
||||
t.Helper()
|
||||
|
||||
base := t.TempDir()
|
||||
cfg := config.Default()
|
||||
cfg.DataDir = filepath.Join(base, "data")
|
||||
cfg.Webroot = filepath.Join(base, "www")
|
||||
cfg.LogFormat = "text"
|
||||
cfg.LogLevel = "debug"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
if err := cfg.EnsureDirs(); err != nil {
|
||||
t.Fatalf("ensure dirs: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
a, err := newApp(t.Context(), cfg, log)
|
||||
if err != nil {
|
||||
t.Fatalf("newApp: %v", err)
|
||||
}
|
||||
t.Cleanup(a.close)
|
||||
a.ready.Store(true)
|
||||
|
||||
e := &testenv{app: a, cfg: cfg, logBuf: &buf}
|
||||
e.site = httptest.NewServer(a.siteHandler())
|
||||
t.Cleanup(e.site.Close)
|
||||
e.api = httptest.NewServer(a.apiHandler())
|
||||
t.Cleanup(e.api.Close)
|
||||
|
||||
// The bootstrap token is how an operator gets their first credential, and
|
||||
// the only place the server ever writes one to disk.
|
||||
raw, err := os.ReadFile(cfg.BootstrapTokenPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read bootstrap token: %v", err)
|
||||
}
|
||||
fi, err := os.Stat(cfg.BootstrapTokenPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("bootstrap-token mode = %v, want 0600", fi.Mode().Perm())
|
||||
}
|
||||
e.adminToken = strings.TrimSpace(string(raw))
|
||||
e.admin = e.client(t, e.adminToken)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *testenv) client(t *testing.T, token string) *client.Client {
|
||||
t.Helper()
|
||||
c, err := client.New(client.Config{BaseURL: e.api.URL, Token: token, HTTP: e.api.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// project creates a project and returns a client holding a key scoped to it,
|
||||
// which is what a CI job would be given.
|
||||
func (e *testenv) project(t *testing.T, name string) *client.Client {
|
||||
t.Helper()
|
||||
if _, err := e.admin.CreateProject(t.Context(), api.CreateProjectRequest{Name: name}); err != nil {
|
||||
t.Fatalf("create project %s: %v", name, err)
|
||||
}
|
||||
key, err := e.admin.CreateProjectKey(t.Context(), name, api.CreateKeyRequest{Name: "ci"})
|
||||
if err != nil {
|
||||
t.Fatalf("create key for %s: %v", name, err)
|
||||
}
|
||||
return e.client(t, key.Token)
|
||||
}
|
||||
|
||||
// deploy scans a directory of literal file contents and pushes it, exactly as
|
||||
// `pages deploy` does.
|
||||
func (e *testenv) deploy(t *testing.T, c *client.Client, project string, files map[string]string) *client.DeployResult {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, content := range files {
|
||||
p := filepath.Join(dir, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
src, err := client.Scan(t.Context(), dir, client.ScanOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
res, err := c.Deploy(t.Context(), client.DeployOptions{
|
||||
Project: project, Source: src, Activate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// get fetches a site URL.
|
||||
func (e *testenv) get(t *testing.T, path string, header http.Header) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, e.site.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header = header
|
||||
if req.Header == nil {
|
||||
req.Header = http.Header{}
|
||||
}
|
||||
// Redirects are part of what is under test, so they are never followed.
|
||||
c := *e.site.Client()
|
||||
c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
return resp
|
||||
}
|
||||
|
||||
func (e *testenv) body(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestDeployThenServe is the milestone's acceptance test, in the order the
|
||||
// manual walkthrough in the plan does it.
|
||||
func TestDeployThenServe(t *testing.T) {
|
||||
e := newTestenv(t)
|
||||
ci := e.project(t, "demo")
|
||||
|
||||
v1 := map[string]string{
|
||||
"index.html": "<h1>v1</h1><script src=\"assets/app.js\"></script>",
|
||||
"assets/app.js": "console.log(1)",
|
||||
"docs/index.html": "<h1>docs</h1>",
|
||||
}
|
||||
res := e.deploy(t, ci, "demo", v1)
|
||||
if !res.Activated || res.Deployment.State != api.StateReady {
|
||||
t.Fatalf("deploy result = %+v", res)
|
||||
}
|
||||
if res.FileCount != 3 || res.Uploaded != 3 || res.Deduplicated != 0 {
|
||||
t.Fatalf("first deploy uploaded %d of %d files, deduplicated %d",
|
||||
res.Uploaded, res.FileCount, res.Deduplicated)
|
||||
}
|
||||
|
||||
// --- the site is being served
|
||||
resp := e.get(t, "/~demo/", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /~demo/ = %d", resp.StatusCode)
|
||||
}
|
||||
if got := e.body(t, resp); got != v1["index.html"] {
|
||||
t.Errorf("body = %q, want %q", got, v1["index.html"])
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q", ct)
|
||||
}
|
||||
if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Error("the nosniff header is missing")
|
||||
}
|
||||
|
||||
resp = e.get(t, "/~demo/assets/app.js", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET the script = %d", resp.StatusCode)
|
||||
}
|
||||
etag := resp.Header.Get("ETag")
|
||||
if !strings.HasPrefix(etag, `"sha256:`) {
|
||||
t.Errorf("ETag = %q, want a content digest", etag)
|
||||
}
|
||||
if got := e.body(t, resp); got != v1["assets/app.js"] {
|
||||
t.Errorf("script = %q", got)
|
||||
}
|
||||
|
||||
// A repeat request with the validator is the cheap 304 the default
|
||||
// Cache-Control is chosen to produce.
|
||||
resp = e.get(t, "/~demo/assets/app.js", http.Header{"If-None-Match": {etag}})
|
||||
if resp.StatusCode != http.StatusNotModified {
|
||||
t.Errorf("conditional GET = %d, want 304", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Range and conditional handling come from http.ServeContent, which is the
|
||||
// reason the handler hands it an *os.File rather than writing bytes itself.
|
||||
resp = e.get(t, "/~demo/assets/app.js", http.Header{"Range": {"bytes=8-12"}})
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
t.Errorf("ranged GET = %d, want 206", resp.StatusCode)
|
||||
}
|
||||
if got, want := e.body(t, resp), v1["assets/app.js"][8:13]; got != want {
|
||||
t.Errorf("range bytes=8-12 gave %q, want %q", got, want)
|
||||
}
|
||||
if cr := resp.Header.Get("Content-Range"); cr != "bytes 8-12/14" {
|
||||
t.Errorf("Content-Range = %q", cr)
|
||||
}
|
||||
// If-Range with a matching validator serves the range; a stale one serves
|
||||
// the whole file, which is what keeps a mid-deploy resume correct.
|
||||
resp = e.get(t, "/~demo/assets/app.js", http.Header{
|
||||
"Range": {"bytes=0-3"}, "If-Range": {`"sha256:` + strings.Repeat("0", 64) + `"`},
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("If-Range with a stale validator = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Directory handling.
|
||||
resp = e.get(t, "/~demo/docs", nil)
|
||||
if resp.StatusCode != http.StatusMovedPermanently {
|
||||
t.Errorf("GET /~demo/docs = %d, want 301", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "/~demo/docs/" {
|
||||
t.Errorf("Location = %q", loc)
|
||||
}
|
||||
resp = e.get(t, "/~demo/docs/", nil)
|
||||
if got := e.body(t, resp); got != v1["docs/index.html"] {
|
||||
t.Errorf("GET /~demo/docs/ = %q", got)
|
||||
}
|
||||
resp = e.get(t, "/~demo/nope.html", nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a missing file = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
// --- the symlink is live
|
||||
dir, err := os.Readlink(filepath.Join(e.cfg.Webroot, "~demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("readlink ~demo: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(dir, res.Deployment.ID) {
|
||||
t.Errorf("~demo -> %q, want the active deployment", dir)
|
||||
}
|
||||
onDisk, err := os.ReadFile(filepath.Join(dir, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatalf("read through the symlink: %v", err)
|
||||
}
|
||||
if string(onDisk) != v1["index.html"] {
|
||||
t.Errorf("the assembled tree holds %q", onDisk)
|
||||
}
|
||||
|
||||
// --- a second deploy uploads only what changed
|
||||
v2 := map[string]string{
|
||||
"index.html": "<h1>v2</h1><script src=\"assets/app.js\"></script>",
|
||||
"assets/app.js": v1["assets/app.js"],
|
||||
"docs/index.html": v1["docs/index.html"],
|
||||
}
|
||||
res2 := e.deploy(t, ci, "demo", v2)
|
||||
if res2.Uploaded != 1 || res2.Deduplicated != 2 {
|
||||
t.Errorf("second deploy uploaded %d and reused %d; want 1 and 2",
|
||||
res2.Uploaded, res2.Deduplicated)
|
||||
}
|
||||
if got := e.body(t, e.get(t, "/~demo/", nil)); got != v2["index.html"] {
|
||||
t.Errorf("after the second deploy the site serves %q", got)
|
||||
}
|
||||
|
||||
// --- rollback is one activation of the older deployment
|
||||
if _, err := ci.Activate(t.Context(), "demo", res.Deployment.ID); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
if got := e.body(t, e.get(t, "/~demo/", nil)); got != v1["index.html"] {
|
||||
t.Errorf("after the rollback the site serves %q", got)
|
||||
}
|
||||
dir, err = os.Readlink(filepath.Join(e.cfg.Webroot, "~demo"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(dir, res.Deployment.ID) {
|
||||
t.Errorf("the rollback left ~demo -> %q", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetentionAndRollback is M5's acceptance check, in the order the plan's
|
||||
// manual walkthrough does it: deploy fifteen times, confirm that collection
|
||||
// keeps ten plus the one being served and that the blob count falls, then roll
|
||||
// back to an old deployment and see the old site.
|
||||
//
|
||||
// It drives the endpoints through the CLI's own client, so `pages deployment
|
||||
// list`, `pages deployment activate` and `pages system gc` are covered by the
|
||||
// same run.
|
||||
func TestRetentionAndRollback(t *testing.T) {
|
||||
e := newTestenv(t)
|
||||
ci := e.project(t, "demo")
|
||||
|
||||
// Ten to keep, and no retention grace: the grace exists so that a rollback
|
||||
// target is not collected out from under an operator who is still deciding,
|
||||
// and any real value for it would outlast the test.
|
||||
ten, zero := 10, 0
|
||||
if _, err := e.admin.PatchProject(t.Context(), "demo",
|
||||
api.ProjectPatch{RetentionCount: &ten, RetentionGrace: &zero}); err != nil {
|
||||
t.Fatalf("configure retention: %v", err)
|
||||
}
|
||||
// Likewise the blob grace, which protects content a request has resolved and
|
||||
// is about to open.
|
||||
e.app.deploy.BlobGrace = -time.Minute
|
||||
|
||||
const versions = 15
|
||||
var deps []string
|
||||
for i := 1; i <= versions; i++ {
|
||||
body := fmt.Sprintf("<h1>v%d</h1>", i)
|
||||
res := e.deploy(t, ci, "demo", map[string]string{
|
||||
"index.html": body,
|
||||
// Shared by every version, so the blob count below is a statement
|
||||
// about content that fell out of use, not about content churning.
|
||||
"assets/app.js": "console.log(1)",
|
||||
})
|
||||
deps = append(deps, res.Deployment.ID)
|
||||
}
|
||||
if got := e.body(t, e.get(t, "/~demo/", nil)); got != "<h1>v15</h1>" {
|
||||
t.Fatalf("after %d deploys the site serves %q", versions, got)
|
||||
}
|
||||
|
||||
// --- roll back to the first deployment
|
||||
//
|
||||
// Deliberately the oldest one: it is also the one retention would drop
|
||||
// first, so this proves the active deployment is exempt rather than merely
|
||||
// young enough to survive.
|
||||
if _, err := ci.Activate(t.Context(), "demo", deps[0]); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
if got := e.body(t, e.get(t, "/~demo/", nil)); got != "<h1>v1</h1>" {
|
||||
t.Errorf("after the rollback the site serves %q, want the old version", got)
|
||||
}
|
||||
link, err := os.Readlink(filepath.Join(e.cfg.Webroot, "~demo"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(link, deps[0]) {
|
||||
t.Errorf("~demo -> %q, want the rolled-back deployment", link)
|
||||
}
|
||||
|
||||
before, err := e.admin.SystemInfo(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before.Deployments != versions {
|
||||
t.Fatalf("%d deployments before collection, want %d", before.Deployments, versions)
|
||||
}
|
||||
|
||||
// --- collect
|
||||
stats, err := e.admin.Collect(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatalf("gc: %v", err)
|
||||
}
|
||||
// Fifteen deployments, one of them active and therefore not considered at
|
||||
// all, ten of the remaining fourteen kept by retention: four to delete.
|
||||
if stats.DeploymentsDeleted != 4 {
|
||||
t.Errorf("collected %d deployments, want 4", stats.DeploymentsDeleted)
|
||||
}
|
||||
if stats.BlobsDeleted != 4 || stats.BytesFreed == 0 {
|
||||
t.Errorf("collected %+v, want the four index pages those deployments held", stats)
|
||||
}
|
||||
|
||||
after, err := e.admin.SystemInfo(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Deployments != 11 {
|
||||
t.Errorf("%d deployments after collection, want the 10 kept plus the active one", after.Deployments)
|
||||
}
|
||||
if after.Blobs >= before.Blobs {
|
||||
t.Errorf("blobs went from %d to %d, want the count to fall", before.Blobs, after.Blobs)
|
||||
}
|
||||
if after.Blobs != 12 {
|
||||
// Eleven surviving index pages plus the script every version shares.
|
||||
t.Errorf("%d blobs after collection, want 12", after.Blobs)
|
||||
}
|
||||
|
||||
// The survivors are the active one and the ten newest, and a listing says so.
|
||||
list, err := ci.ListDeployments(t.Context(), "demo", client.DeploymentListOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
live := make(map[string]bool, len(list.Deployments))
|
||||
for _, d := range list.Deployments {
|
||||
live[d.ID] = true
|
||||
if d.Active != (d.ID == deps[0]) {
|
||||
t.Errorf("deployment %s: active = %v", d.ID, d.Active)
|
||||
}
|
||||
}
|
||||
if len(live) != 11 {
|
||||
t.Fatalf("%d deployments listed, want 11", len(live))
|
||||
}
|
||||
for i, id := range deps {
|
||||
want := i == 0 || i >= versions-10
|
||||
if live[id] != want {
|
||||
t.Errorf("v%d (%s): present = %v, want %v", i+1, id, live[id], want)
|
||||
}
|
||||
}
|
||||
|
||||
// The site is still what the rollback made it, and the content of a
|
||||
// collected deployment is what was reclaimed — not the active one's.
|
||||
if got := e.body(t, e.get(t, "/~demo/", nil)); got != "<h1>v1</h1>" {
|
||||
t.Errorf("after collection the site serves %q", got)
|
||||
}
|
||||
if got := e.body(t, e.get(t, "/~demo/assets/app.js", nil)); got != "console.log(1)" {
|
||||
t.Errorf("the shared script is now %q", got)
|
||||
}
|
||||
|
||||
// A collected deployment is gone from the API too, and a second pass has
|
||||
// nothing left to do.
|
||||
if _, err := ci.GetDeployment(t.Context(), "demo", deps[1], false); err == nil {
|
||||
t.Errorf("deployment v2 (%s) still readable after collection", deps[1])
|
||||
}
|
||||
again, err := e.admin.Collect(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.DeploymentsDeleted != 0 || again.BlobsDeleted != 0 {
|
||||
t.Errorf("a second pass collected %+v", again)
|
||||
}
|
||||
|
||||
// --- deleting on request
|
||||
//
|
||||
// The one being served is refused; another one goes immediately.
|
||||
if err := ci.DeleteDeployment(t.Context(), "demo", deps[0]); err == nil {
|
||||
t.Error("the active deployment was deleted on request")
|
||||
} else if code := api.CodeOf(err); code != api.CodeDeploymentActive {
|
||||
t.Errorf("delete active: code = %q, want %q", code, api.CodeDeploymentActive)
|
||||
}
|
||||
spare := deps[versions-2]
|
||||
if err := ci.DeleteDeployment(t.Context(), "demo", spare); err != nil {
|
||||
t.Fatalf("delete %s: %v", spare, err)
|
||||
}
|
||||
if _, err := ci.GetDeployment(t.Context(), "demo", spare, false); err == nil {
|
||||
t.Errorf("deployment %s still readable after being deleted", spare)
|
||||
}
|
||||
if got := e.body(t, e.get(t, "/~demo/", nil)); got != "<h1>v1</h1>" {
|
||||
t.Errorf("deleting a spare disturbed the site: %q", got)
|
||||
}
|
||||
|
||||
// Reference counts and manifests agree throughout: the collector trusts
|
||||
// those counters, and a count that reads low is the one way this design can
|
||||
// lose content a deployment still needs.
|
||||
rep, err := e.admin.Fsck(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatalf("fsck: %v", err)
|
||||
}
|
||||
if rep.DriftCount != 0 {
|
||||
t.Errorf("fsck reports %d drifted blobs: %+v", rep.DriftCount, rep.Drift)
|
||||
}
|
||||
}
|
||||
|
||||
// A project that exists but has never activated anything is a different answer
|
||||
// from one that does not exist: 503 says "come back", 404 says "wrong URL".
|
||||
func TestServeBeforeAnythingIsDeployed(t *testing.T) {
|
||||
e := newTestenv(t)
|
||||
e.project(t, "demo")
|
||||
|
||||
if resp := e.get(t, "/~demo/", nil); resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("GET an undeployed project = %d, want 503", resp.StatusCode)
|
||||
}
|
||||
if resp := e.get(t, "/~ghost/", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("GET an unknown project = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if resp := e.get(t, "/", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("GET the site root = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The management API and the site content are separate listeners on purpose:
|
||||
// neither surface may answer for the other, whatever the request looks like.
|
||||
func TestTheTwoListenersDoNotOverlap(t *testing.T) {
|
||||
e := newTestenv(t)
|
||||
ci := e.project(t, "demo")
|
||||
e.deploy(t, ci, "demo", map[string]string{"index.html": "<h1>hi</h1>"})
|
||||
|
||||
// The site listener knows nothing about the API.
|
||||
if resp := e.get(t, api.PathProjects(), nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("the site listener answered %s with %d", api.PathProjects(), resp.StatusCode)
|
||||
}
|
||||
|
||||
// And the API listener serves no content.
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, e.api.URL+"/~demo/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := e.api.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("the API listener answered /~demo/ with %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Both carry the health probes, so either can be the one a load balancer
|
||||
// targets.
|
||||
for _, base := range []string{e.site.URL, e.api.URL} {
|
||||
for _, path := range []string{"/healthz", "/readyz"} {
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, base+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := e.site.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("GET %s%s = %d", base, path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A project key reaches its own project and nothing else, end to end.
|
||||
func TestProjectKeysAreConfinedToTheirProject(t *testing.T) {
|
||||
e := newTestenv(t)
|
||||
mine := e.project(t, "mine")
|
||||
theirs := e.project(t, "theirs")
|
||||
|
||||
e.deploy(t, theirs, "theirs", map[string]string{"index.html": "<h1>secret</h1>"})
|
||||
|
||||
if _, err := mine.CreateDeployment(t.Context(), "theirs", nil); err == nil {
|
||||
t.Fatal("a project key created a deployment in another project")
|
||||
}
|
||||
if _, err := mine.GetProject(t.Context(), "theirs"); err == nil {
|
||||
t.Fatal("a project key read another project")
|
||||
}
|
||||
|
||||
// Path routing means both sites share an origin, which is exactly the
|
||||
// property documented as the reason not to host mutually untrusting
|
||||
// projects this way. Reading a neighbour's content over HTTP is expected;
|
||||
// reaching its management API is not.
|
||||
if resp := e.get(t, "/~theirs/", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("GET the neighbour's site = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The one assertion that has to hold no matter what else is logged: no part of
|
||||
// a token ever reaches the log, on either listener.
|
||||
func TestTokensNeverReachTheLog(t *testing.T) {
|
||||
e := newTestenv(t)
|
||||
ci := e.project(t, "demo")
|
||||
e.deploy(t, ci, "demo", map[string]string{"index.html": "<h1>hi</h1>"})
|
||||
|
||||
// A bad token in every place a client could put one.
|
||||
bad := "pgs_aaaaaaaaaaaaaaaa_" + strings.Repeat("b", 43)
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
e.api.URL+api.PathProjects()+"?token="+bad, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+bad)
|
||||
resp, err := e.api.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
logged := e.logBuf.String()
|
||||
for _, secret := range []string{bad, strings.Repeat("b", 43), "aaaaaaaaaaaaaaaa"} {
|
||||
if strings.Contains(logged, secret) {
|
||||
t.Fatalf("the log contains %q", secret)
|
||||
}
|
||||
}
|
||||
if strings.Contains(strings.ToLower(logged), "authorization") {
|
||||
t.Fatal("the log mentions the Authorization header")
|
||||
}
|
||||
// The log is not empty, so the assertions above mean something.
|
||||
if !strings.Contains(logged, "deployment activated") {
|
||||
t.Fatal("nothing was logged at all; the assertions above prove nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// Restarting the process must serve exactly what it served before: the registry
|
||||
// is rebuilt from the database, which is the reason activation commits there
|
||||
// before it stores the pointer.
|
||||
func TestRestartServesTheSameDeployment(t *testing.T) {
|
||||
e := newTestenv(t)
|
||||
ci := e.project(t, "demo")
|
||||
res := e.deploy(t, ci, "demo", map[string]string{
|
||||
"index.html": "<h1>v1</h1>",
|
||||
"assets/app.js": "console.log(1)",
|
||||
})
|
||||
if got := e.body(t, e.get(t, "/~demo/", nil)); got != "<h1>v1</h1>" {
|
||||
t.Fatalf("before restart: %q", got)
|
||||
}
|
||||
|
||||
// Tear the process down and build a second one on the same directories.
|
||||
e.site.Close()
|
||||
e.api.Close()
|
||||
e.app.close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
a2, err := newApp(context.Background(), e.cfg, log)
|
||||
if err != nil {
|
||||
t.Fatalf("second newApp: %v", err)
|
||||
}
|
||||
t.Cleanup(a2.close)
|
||||
a2.ready.Store(true)
|
||||
e.app, e.logBuf = a2, &buf
|
||||
e.site = httptest.NewServer(a2.siteHandler())
|
||||
t.Cleanup(e.site.Close)
|
||||
e.api = httptest.NewServer(a2.apiHandler())
|
||||
t.Cleanup(e.api.Close)
|
||||
e.admin = e.client(t, e.adminToken)
|
||||
|
||||
resp := e.get(t, "/~demo/", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("after restart: status = %d", resp.StatusCode)
|
||||
}
|
||||
if got := e.body(t, resp); got != "<h1>v1</h1>" {
|
||||
t.Errorf("after restart the site serves %q", got)
|
||||
}
|
||||
if got := e.body(t, e.get(t, "/~demo/assets/app.js", nil)); got != "console.log(1)" {
|
||||
t.Errorf("after restart the script is %q", got)
|
||||
}
|
||||
|
||||
// And the second process did not mint a second bootstrap admin key.
|
||||
keys, err := e.admin.ListKeys(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admins := 0
|
||||
for _, k := range keys.Keys {
|
||||
if k.Scope == string(api.ScopeAdmin) && k.RevokedAt == nil {
|
||||
admins++
|
||||
}
|
||||
}
|
||||
if admins != 1 {
|
||||
t.Errorf("%d admin keys after a restart, want 1", admins)
|
||||
}
|
||||
|
||||
// The database is the source of truth the registry was rebuilt from, so the
|
||||
// deployment being served is the same row, not merely the same bytes.
|
||||
p, err := e.admin.GetProject(t.Context(), "demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.ActiveDeployment == nil || p.ActiveDeployment.ID != res.Deployment.ID {
|
||||
t.Errorf("active deployment after restart = %+v, want %s", p.ActiveDeployment, res.Deployment.ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
// Command pages-server serves static sites deployed through the pages CLI and
|
||||
// exposes the management API used to drive those deployments.
|
||||
//
|
||||
// It binds two listeners: a public one that only ever serves site content, and a
|
||||
// management one (loopback by default) that only ever serves the API. Keeping
|
||||
// them apart means the management surface never shares an origin with content
|
||||
// that projects control.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/adminapi"
|
||||
"github.com/iceBear67/simplepages/internal/auth"
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/config"
|
||||
"github.com/iceBear67/simplepages/internal/deploy"
|
||||
"github.com/iceBear67/simplepages/internal/httpx"
|
||||
"github.com/iceBear67/simplepages/internal/site"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
"github.com/iceBear67/simplepages/internal/version"
|
||||
"github.com/iceBear67/simplepages/internal/webroot"
|
||||
)
|
||||
|
||||
// Background worker cadences. The auth flusher batches last_used_at updates;
|
||||
// writing one per request would funnel every authenticated read through the
|
||||
// single write connection.
|
||||
const (
|
||||
touchFlushInterval = 60 * time.Second
|
||||
|
||||
// failedAuthBurst is how many failed authentications one client address may
|
||||
// make before it is throttled, and failedAuthPeriod is how long a full
|
||||
// budget takes to refill. Successful requests cost nothing, so a busy CI
|
||||
// fleet never meets these numbers.
|
||||
failedAuthBurst = 10
|
||||
failedAuthPeriod = time.Minute
|
||||
failedAuthClients = 10000
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "pages-server: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) error {
|
||||
opts, err := config.Load(args, stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.ShowVersion {
|
||||
fmt.Fprintf(stdout, "pages-server %s\n", version.String())
|
||||
return nil
|
||||
}
|
||||
if opts.CheckOnly {
|
||||
fmt.Fprintln(stdout, "configuration ok")
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := opts.Config
|
||||
log := cfg.Logger(stderr)
|
||||
log.Info("starting", "version", version.Short(),
|
||||
"data_dir", cfg.DataDir, "webroot", cfg.Webroot,
|
||||
"assemble_mode", string(cfg.AssembleMode))
|
||||
|
||||
if err := cfg.EnsureDirs(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The first signal starts a graceful shutdown; a second one gives up on the
|
||||
// in-flight requests, which is what an operator means by pressing Ctrl-C
|
||||
// twice.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
hard := make(chan os.Signal, 1)
|
||||
signal.Notify(hard, os.Interrupt, syscall.SIGTERM)
|
||||
<-hard
|
||||
fmt.Fprintln(stderr, "pages-server: second signal, exiting immediately")
|
||||
os.Exit(130)
|
||||
}()
|
||||
|
||||
app, err := newApp(ctx, cfg, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer app.close()
|
||||
|
||||
// Background workers get their own cancellable context so they are stopped
|
||||
// *after* the listeners have drained: the auth flusher's final write should
|
||||
// include the last requests the server handled.
|
||||
workerCtx, stopWorkers := context.WithCancel(context.Background())
|
||||
var workers sync.WaitGroup
|
||||
// Registered after the app's own cleanup so it runs before it: the workers
|
||||
// must be finished with the database before anything closes it.
|
||||
defer func() {
|
||||
stopWorkers()
|
||||
workers.Wait()
|
||||
}()
|
||||
for _, worker := range []func(context.Context){
|
||||
func(ctx context.Context) { app.verifier.RunFlusher(ctx, touchFlushInterval) },
|
||||
func(ctx context.Context) { app.deploy.RunReconciler(ctx, cfg.ReconcileInterval.D()) },
|
||||
func(ctx context.Context) { app.deploy.RunCollector(ctx, cfg.GCInterval.D()) },
|
||||
} {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
worker(workerCtx)
|
||||
}()
|
||||
}
|
||||
|
||||
siteSrv, err := httpx.Listen("site", cfg.Listen, app.siteHandler(), httpx.Timeouts{
|
||||
ReadHeader: cfg.ReadHeaderTimeout.D(),
|
||||
Read: cfg.ReadTimeout.D(),
|
||||
Idle: cfg.IdleTimeout.D(),
|
||||
// No Write timeout: see httpx.Timeouts.
|
||||
}, log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %w", cfg.Listen, err)
|
||||
}
|
||||
apiSrv, err := httpx.Listen("api", cfg.APIListen, app.apiHandler(), httpx.Timeouts{
|
||||
ReadHeader: cfg.ReadHeaderTimeout.D(),
|
||||
Read: cfg.ReadTimeout.D(),
|
||||
Idle: cfg.IdleTimeout.D(),
|
||||
}, log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %w", cfg.APIListen, err)
|
||||
}
|
||||
|
||||
app.ready.Store(true)
|
||||
|
||||
g := &httpx.Group{
|
||||
Servers: []*httpx.Server{siteSrv, apiSrv},
|
||||
Grace: cfg.ShutdownGrace.D(),
|
||||
Log: log,
|
||||
}
|
||||
if err := g.Run(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// app holds the process-wide state shared by both listeners.
|
||||
type app struct {
|
||||
cfg config.Config
|
||||
log *slog.Logger
|
||||
started time.Time
|
||||
|
||||
db *store.DB
|
||||
cas *cas.Store
|
||||
sites *site.Registry
|
||||
webroot *webroot.Webroot
|
||||
deploy *deploy.Service
|
||||
verifier *auth.Verifier
|
||||
authmw *auth.Middleware
|
||||
admin *adminapi.Server
|
||||
|
||||
// ready gates /readyz: the process may be accepting connections before it
|
||||
// can actually answer for content, and a load balancer needs to know.
|
||||
ready atomic.Bool
|
||||
}
|
||||
|
||||
func newApp(ctx context.Context, cfg config.Config, log *slog.Logger) (*app, error) {
|
||||
db, err := store.Open(ctx, cfg.DBPath(), log)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
|
||||
if _, err := auth.EnsureAdminKey(ctx, db, cfg.BootstrapTokenPath(), log); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The probe directory must be the one trees are assembled in: hardlinks
|
||||
// cannot cross filesystems, so probing anywhere else answers a different
|
||||
// question. With assemble_mode=none nothing is assembled and the mode is
|
||||
// irrelevant, so ask for copy rather than probe for something unused.
|
||||
casOpts := cas.Options{ProbeDir: cfg.DeploymentsDir(), Log: log}
|
||||
switch cfg.AssembleMode {
|
||||
case config.AssembleHardlink:
|
||||
casOpts.Mode = cas.LinkHard
|
||||
case config.AssembleCopy, config.AssembleNone:
|
||||
casOpts.Mode = cas.LinkCopy
|
||||
}
|
||||
cs, err := cas.Open(cfg.CASDir(), casOpts)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
verifier := auth.NewVerifier(db, log, auth.DefaultCacheTTL)
|
||||
// An empty Dir is how the service is told to skip on-disk assembly.
|
||||
deployDir := cfg.DeploymentsDir()
|
||||
if cfg.AssembleMode == config.AssembleNone {
|
||||
deployDir = ""
|
||||
}
|
||||
|
||||
// With nothing assembled on disk there is nothing for a symlink to point at,
|
||||
// so assemble_mode=none leaves the webroot unmanaged. Failing to open it
|
||||
// otherwise is a permissions or layout problem the operator asked for and
|
||||
// should hear about now, rather than as a warning on every activation.
|
||||
var wr *webroot.Webroot
|
||||
if deployDir != "" && cfg.Webroot != "" {
|
||||
wr, err = webroot.Open(cfg.Webroot, deployDir)
|
||||
if err != nil {
|
||||
cs.Close()
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
a := &app{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
started: time.Now(),
|
||||
db: db,
|
||||
cas: cs,
|
||||
sites: site.NewRegistry(),
|
||||
webroot: wr,
|
||||
verifier: verifier,
|
||||
authmw: &auth.Middleware{
|
||||
V: verifier,
|
||||
Limiter: auth.NewLimiter(failedAuthBurst, failedAuthPeriod, failedAuthClients),
|
||||
Trusted: cfg.TrustedProxies(),
|
||||
Log: log,
|
||||
},
|
||||
}
|
||||
a.deploy = &deploy.Service{
|
||||
DB: db, CAS: cs, Log: log, Dir: deployDir,
|
||||
Sites: a.sites, Webroot: wr,
|
||||
}
|
||||
a.admin = &adminapi.Server{
|
||||
DB: db,
|
||||
Auth: a.authmw,
|
||||
Deploy: a.deploy,
|
||||
Log: log,
|
||||
Limits: cfg.Limits,
|
||||
// The registry answers ownership checks and "what is this project
|
||||
// serving" from memory. Both are only correct once LoadSites below has
|
||||
// finished, which is why it runs before any listener exists.
|
||||
Resolver: a.sites,
|
||||
Sites: a.sites,
|
||||
BaseURL: cfg.SiteURL,
|
||||
LinkMode: string(cs.LinkMode()),
|
||||
Started: a.started,
|
||||
}
|
||||
a.admin.Hooks = adminapi.Hooks{
|
||||
ProjectChanged: func(_ context.Context, p *store.Project) {
|
||||
// A new project starts with nothing activated, so it resolves and
|
||||
// then answers 503 until something is deployed to it. A changed one
|
||||
// keeps serving what it was serving, with new settings.
|
||||
a.sites.Put(p)
|
||||
},
|
||||
ProjectDeleted: func(ctx context.Context, p *store.Project) {
|
||||
a.sites.Delete(p.Name)
|
||||
if wr != nil {
|
||||
if err := wr.Unpoint(p.Name); err != nil {
|
||||
log.WarnContext(ctx, "could not remove the webroot symlink",
|
||||
"project", p.Name, "err", err)
|
||||
}
|
||||
}
|
||||
// The rows are already gone and their blobs are already
|
||||
// unreferenced; this is only the assembled trees, which nothing
|
||||
// would otherwise account for. A failure here is not worth failing
|
||||
// the request over — the startup sweep removes them as orphans.
|
||||
if err := a.deploy.RemoveProjectTrees(p.ID); err != nil {
|
||||
log.WarnContext(ctx, "could not remove the project's deployment trees",
|
||||
"project", p.Name, "err", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Recovery runs before the registry is built, and both run before any
|
||||
// listener exists. That order is what lets recovery assume it is alone with
|
||||
// the data directory, and it means the first request is answered from state
|
||||
// that has already been reconciled rather than from whatever the last crash
|
||||
// left behind.
|
||||
if err := a.deploy.Recover(ctx); err != nil {
|
||||
a.close()
|
||||
return nil, fmt.Errorf("recover: %w", err)
|
||||
}
|
||||
if err := a.deploy.LoadSites(ctx); err != nil {
|
||||
a.close()
|
||||
return nil, fmt.Errorf("load sites: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// pingDB checks that the read pool can still reach the database, with a
|
||||
// deadline of its own so a wedged store cannot hold a probe open indefinitely.
|
||||
func (a *app) pingDB(ctx context.Context) error {
|
||||
if a.db == nil {
|
||||
return errors.New("store not open")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
return a.db.Reader().PingContext(ctx)
|
||||
}
|
||||
|
||||
func (a *app) close() {
|
||||
if a.cas != nil {
|
||||
if err := a.cas.Close(); err != nil {
|
||||
a.log.Error("closing content store", "err", err)
|
||||
}
|
||||
}
|
||||
if a.db != nil {
|
||||
if err := a.db.Close(); err != nil {
|
||||
a.log.Error("closing store", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// middleware is the chain shared by both listeners, outermost first.
|
||||
//
|
||||
// The order is load-bearing. Recover must sit *inside* AccessLog: a panic that
|
||||
// unwound past AccessLog would skip its logging entirely, so the one request
|
||||
// that most needs a log line would be the one without one. With Recover
|
||||
// innermost the panic becomes an ordinary 500 return that AccessLog then records
|
||||
// normally, and Recover can see the shared request id and the recorder that
|
||||
// tells it whether a response has already begun. Panics in the middleware
|
||||
// itself are left to net/http, which closes the connection.
|
||||
func (a *app) middleware() []httpx.Middleware {
|
||||
return []httpx.Middleware{
|
||||
httpx.WithRequestID(a.cfg.TrustedProxies()),
|
||||
httpx.AccessLog(a.log, a.cfg.TrustedProxies()),
|
||||
httpx.Recover(a.log),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) siteHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
a.registerHealth(mux)
|
||||
|
||||
// Site routing is hand-parsed rather than expressed as a ServeMux pattern:
|
||||
// "/~{project}/{path...}" is rejected by net/http, whose wildcards must start
|
||||
// at the beginning of a path segment. Registering "/" still gets us the mux's
|
||||
// built-in ".."/"//" normalisation redirects.
|
||||
//
|
||||
// Those redirects are a convenience, not a defence: a percent-encoded
|
||||
// "/~a/%2e%2e/%2e%2e/etc/passwd" reaches the handler with r.URL.Path already
|
||||
// decoded to "/~a/../../etc/passwd" and no redirect issued (verified against
|
||||
// this server). The resolver therefore does its own path.Clean and validation
|
||||
// rather than assume the mux normalised anything.
|
||||
mux.Handle("/", &site.Handler{Registry: a.sites, CAS: a.cas, Log: a.log})
|
||||
return httpx.Chain(mux, a.middleware()...)
|
||||
}
|
||||
|
||||
func (a *app) apiHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
a.registerHealth(mux)
|
||||
a.admin.Register(mux)
|
||||
return httpx.Chain(mux, a.middleware()...)
|
||||
}
|
||||
|
||||
// registerHealth adds the probe endpoints to a mux. They live on both listeners
|
||||
// so a probe can target whichever one the deployment exposes.
|
||||
func (a *app) registerHealth(mux *http.ServeMux) {
|
||||
// Liveness: answers as long as the process can schedule a goroutine. It must
|
||||
// never touch the database, or a slow query would get the process killed.
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
fmt.Fprintln(w, "ok")
|
||||
})
|
||||
|
||||
// Readiness: reports whether this process can serve real traffic yet.
|
||||
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
status := http.StatusOK
|
||||
body := map[string]any{
|
||||
"status": "ready",
|
||||
"version": version.Short(),
|
||||
"uptime_s": int64(time.Since(a.started).Seconds()),
|
||||
}
|
||||
if !a.ready.Load() {
|
||||
status = http.StatusServiceUnavailable
|
||||
body["status"] = "starting"
|
||||
} else if err := a.pingDB(r.Context()); err != nil {
|
||||
// Unlike /healthz this may touch the database: a process that
|
||||
// cannot read its own store should be taken out of rotation, not
|
||||
// restarted.
|
||||
a.log.WarnContext(r.Context(), "readiness probe: store unreachable", "err", err)
|
||||
status = http.StatusServiceUnavailable
|
||||
body["status"] = "degraded"
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteJSON(w, status, body)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// forbidden names packages that must never reach the CLI binary, with the
|
||||
// reason, because a future contributor will hit this test and need to know
|
||||
// which of the two possible fixes applies: move the code, or widen the CLI's
|
||||
// remit deliberately.
|
||||
var forbidden = map[string]string{
|
||||
"modernc.org/sqlite": "the SQLite driver is megabytes of translated C; " +
|
||||
"the CLI must never link a database",
|
||||
"modernc.org/libc": "pulled in by the SQLite driver",
|
||||
"database/sql": "a probe: nothing the CLI does needs a database, so if this " +
|
||||
"appears, a server package leaked in through an import",
|
||||
"github.com/BurntSushi/toml": "server configuration only; the CLI's config " +
|
||||
"file is JSON precisely so this stays out",
|
||||
"github.com/iceBear67/simplepages/internal/store": "server-side storage",
|
||||
"github.com/iceBear67/simplepages/internal/site": "server-side serving",
|
||||
"github.com/iceBear67/simplepages/internal/deploy": "server-side deployment lifecycle",
|
||||
"github.com/iceBear67/simplepages/internal/cas": "server-side content store",
|
||||
"github.com/iceBear67/simplepages/internal/auth": "server-side verification; the CLI only carries a token",
|
||||
"github.com/iceBear67/simplepages/internal/adminapi": "server-side handlers; the wire types " +
|
||||
"the CLI needs live in api/",
|
||||
"net/http/httptest": "test-only helper that would bloat the shipped binary",
|
||||
}
|
||||
|
||||
// TestCLIImportGraph is the mechanism behind the "two binaries" decision: the
|
||||
// CLI is downloaded on every CI run, so its size and its build requirements
|
||||
// (no cgo, no C toolchain) are user-visible properties. A stray import is easy
|
||||
// to add and invisible until someone notices the binary doubled, so the rule is
|
||||
// enforced here rather than written down.
|
||||
func TestCLIImportGraph(t *testing.T) {
|
||||
for _, pkg := range depsOf(t, "./cmd/pages") {
|
||||
if why, bad := forbidden[pkg]; bad {
|
||||
t.Errorf("cmd/pages depends on %s\n %s", pkg, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIPackageIsStdlibOnly guards the other half of the arrangement: api/ is
|
||||
// imported by both binaries, so anything it depends on is automatically part of
|
||||
// the CLI. Keeping it to the standard library is what lets the server and the
|
||||
// client share wire types at all.
|
||||
func TestAPIPackageIsStdlibOnly(t *testing.T) {
|
||||
const self = "github.com/iceBear67/simplepages/api"
|
||||
for _, pkg := range depsOf(t, self) {
|
||||
// go list -deps includes the package itself.
|
||||
if pkg == self {
|
||||
continue
|
||||
}
|
||||
// A standard library import path never has a dot in its first segment.
|
||||
if first, _, _ := strings.Cut(pkg, "/"); strings.Contains(first, ".") {
|
||||
t.Errorf("api imports %s, but it must depend on the standard library only", pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func depsOf(t *testing.T, pkg string) []string {
|
||||
t.Helper()
|
||||
// The test's working directory is cmd/pages; a relative package pattern has
|
||||
// to be resolved from the module root two levels up.
|
||||
cmd := exec.Command("go", "list", "-deps", pkg)
|
||||
cmd.Dir = "../.."
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
t.Fatalf("go list -deps %s: %v\n%s", pkg, err, ee.Stderr)
|
||||
}
|
||||
t.Fatalf("go list -deps %s: %v", pkg, err)
|
||||
}
|
||||
return strings.Fields(string(out))
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Command pages is the client for a pages-server installation.
|
||||
//
|
||||
// It is a plain HTTP client on purpose: no SQLite, no server packages, nothing
|
||||
// that needs cgo. CI jobs download this binary on every run, so its size and
|
||||
// its dependency graph are features — see deps_test.go, which enforces both.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/clicmd"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
// Exit codes. A CI step distinguishes "the command was wrong" from "the server
|
||||
// said no" without parsing messages.
|
||||
const (
|
||||
exitOK = 0
|
||||
exitError = 1
|
||||
exitUsage = 2
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Ctrl-C cancels in-flight requests rather than killing the process
|
||||
// mid-upload, which would leave a deployment stuck in "uploading" until it
|
||||
// expires.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
os.Exit(run(ctx, os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) int {
|
||||
g := clicmd.NewGlobals()
|
||||
root := clicmd.Root(g)
|
||||
|
||||
err := cliutil.Run(ctx, root, args, g.Err, g.Register)
|
||||
switch {
|
||||
case err == nil:
|
||||
return exitOK
|
||||
|
||||
case errors.Is(err, cliutil.ErrUsage):
|
||||
// Run has already printed the usage text. The bare sentinel carries no
|
||||
// message of its own — flag or the command tree printed one.
|
||||
if err != cliutil.ErrUsage {
|
||||
fmt.Fprintf(g.Err, "pages: %v\n", err)
|
||||
}
|
||||
return exitUsage
|
||||
|
||||
case errors.Is(err, cliutil.ErrAborted):
|
||||
fmt.Fprintln(g.Err, "pages: aborted")
|
||||
return exitError
|
||||
|
||||
case errors.Is(err, context.Canceled):
|
||||
fmt.Fprintln(g.Err, "pages: interrupted")
|
||||
return exitError
|
||||
|
||||
default:
|
||||
fmt.Fprintf(g.Err, "pages: %v\n", err)
|
||||
hint(g, err)
|
||||
return exitError
|
||||
}
|
||||
}
|
||||
|
||||
// hint turns the codes the server uses into the one sentence that usually
|
||||
// resolves the problem.
|
||||
func hint(g *clicmd.Globals, err error) {
|
||||
var apiErr *api.Error
|
||||
if !errors.As(err, &apiErr) {
|
||||
return
|
||||
}
|
||||
switch apiErr.Code {
|
||||
case api.CodeUnauthorized:
|
||||
fmt.Fprintln(g.Err, "hint: the token is missing, malformed, expired or revoked; check PAGES_TOKEN")
|
||||
case api.CodeForbidden:
|
||||
fmt.Fprintln(g.Err, "hint: this key is not allowed here; a project key can only act on its own project")
|
||||
case api.CodeNotFound:
|
||||
fmt.Fprintln(g.Err, "hint: check the name, and that the key you are using can see it")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
# Operating simplepages
|
||||
|
||||
`pages-server` is a static-site host built around one guarantee: **a request
|
||||
either sees the whole old deployment or the whole new one, never a mixture.**
|
||||
Everything below either explains how to run it or tells you where that
|
||||
guarantee stops.
|
||||
|
||||
Read [Security model and its limits](#security-model-and-its-limits) before you
|
||||
put this on a network. Three of the items there are not bugs to be fixed later —
|
||||
they are properties of the design, and one of them (same-origin) decides whether
|
||||
this server is suitable for your workload at all.
|
||||
|
||||
---
|
||||
|
||||
## 1. What it does
|
||||
|
||||
```
|
||||
CI job pages-server visitor
|
||||
------ ------------ -------
|
||||
pages deploy ./dist ──► upload only the changed blobs
|
||||
assemble a directory tree
|
||||
┌─────────────────────────┐
|
||||
│ ONE atomic pointer swap │ ◄── GET /~demo/
|
||||
└─────────────────────────┘
|
||||
repoint $WEBROOT/~demo
|
||||
```
|
||||
|
||||
Unlike `rsync`, no file of the new version is visible until every file of it is
|
||||
present and verified. The switch itself is a single `atomic.Pointer` store, so a
|
||||
request that started before the swap keeps reading the old snapshot to
|
||||
completion — including a large file already halfway down the wire.
|
||||
|
||||
Rollback is the same operation pointed at an older deployment id, and costs the
|
||||
same single store.
|
||||
|
||||
---
|
||||
|
||||
## 2. Installation
|
||||
|
||||
Build both binaries. The server needs SQLite (pure Go, so no cgo and no C
|
||||
toolchain); the CLI deliberately shares no server code at all.
|
||||
|
||||
```sh
|
||||
CGO_ENABLED=0 go build -o /usr/local/bin/pages-server ./cmd/pages-server
|
||||
CGO_ENABLED=0 go build -o /usr/local/bin/pages ./cmd/pages
|
||||
```
|
||||
|
||||
`pages` is what goes into CI runners. Its dependency graph is enforced by a
|
||||
test (`go test ./cmd/pages -run TestCLIImportGraph`), so it can never
|
||||
accidentally start dragging in the database driver.
|
||||
|
||||
### Directories
|
||||
|
||||
| Path | Contents | Who writes it |
|
||||
|---|---|---|
|
||||
| `$DATA_DIR/pages.db` | projects, deployments, keys, blob refcounts | server only |
|
||||
| `$DATA_DIR/cas/` | content-addressed blobs, `0444` | server only |
|
||||
| `$DATA_DIR/deployments/<project_id>/<deployment_id>/` | assembled trees | server only |
|
||||
| `$WEBROOT/~<project>` | symlink to the active deployment | server only |
|
||||
|
||||
`$WEBROOT` and `$DATA_DIR` must not overlap; the server refuses to start if they
|
||||
do.
|
||||
|
||||
### systemd
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=simplepages static site server
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
ExecStartPre=/usr/local/bin/pages-server --config /etc/pages-server/config.toml --check-config
|
||||
ExecStart=/usr/local/bin/pages-server --config /etc/pages-server/config.toml
|
||||
Restart=on-failure
|
||||
StateDirectory=pages-server
|
||||
ReadWritePaths=/var/lib/pages-server /srv/www
|
||||
ProtectSystem=strict
|
||||
PrivateTmp=yes
|
||||
NoNewPrivileges=yes
|
||||
UMask=0022
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
`--check-config` validates and exits without binding a port, which is why it is
|
||||
safe in `ExecStartPre`.
|
||||
|
||||
**Do not use `DynamicUser=yes`.** The UID it allocates rotates, and the
|
||||
assembled trees are hardlinks whose ownership must stay stable across restarts.
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration
|
||||
|
||||
Precedence is **flag > `PAGES_*` environment variable > config file > default**.
|
||||
|
||||
```toml
|
||||
# /etc/pages-server/config.toml
|
||||
data_dir = "/var/lib/pages-server"
|
||||
webroot = "/srv/www"
|
||||
listen = ":8080" # static content
|
||||
api_listen = "127.0.0.1:8081" # management API
|
||||
site_url = "https://pages.example.com"
|
||||
|
||||
trusted_proxy_cidrs = ["127.0.0.1/32", "::1/128"]
|
||||
assemble_mode = "auto" # auto | hardlink | copy | none
|
||||
|
||||
log_level = "info"
|
||||
log_format = "json"
|
||||
|
||||
gc_interval = "15m"
|
||||
reconcile_interval = "5m"
|
||||
shutdown_grace = "30s"
|
||||
|
||||
read_header_timeout = "10s"
|
||||
read_timeout = "5m"
|
||||
idle_timeout = "120s"
|
||||
|
||||
[limits]
|
||||
max_file_bytes = 268435456 # 256 MiB
|
||||
max_manifest_files = 50000
|
||||
max_concurrent_uploads = 32
|
||||
max_manifest_bytes = 67108864 # 64 MiB of manifest JSON
|
||||
max_json_bytes = 1048576 # 1 MiB for ordinary API bodies
|
||||
```
|
||||
|
||||
Every key above has a matching `PAGES_*` variable (`PAGES_DATA_DIR`,
|
||||
`PAGES_API_LISTEN`, `PAGES_ASSEMBLE_MODE`, `PAGES_RECONCILE_INTERVAL`, …). The
|
||||
ones worth overriding on the command line also have flags: `--data-dir`,
|
||||
`--webroot`, `--listen`, `--api-listen`, `--site-url`, `--assemble-mode`,
|
||||
`--log-level`, `--log-format`, `--gc-interval`, `--shutdown-grace`, plus
|
||||
`--config`, `--check-config` and `--version`. Run `pages-server -h` for the
|
||||
authoritative list.
|
||||
|
||||
`listen` and `api_listen` must differ, and `webroot` may be neither equal to nor
|
||||
nested inside `data_dir` (in either direction) — the reconciler prunes symlinks
|
||||
from one and the GC deletes trees from the other, so overlapping them would let
|
||||
each destroy the other's state.
|
||||
|
||||
### The two listeners are separate on purpose
|
||||
|
||||
`listen` serves nothing but site content. `api_listen` serves nothing but the
|
||||
management API, and defaults to loopback. Neither answers for the other — there
|
||||
is a test that asserts exactly this. Expose the API through your reverse proxy on
|
||||
its own hostname, or not at all; nothing forces a management surface onto the
|
||||
origin your sites are served from.
|
||||
|
||||
TLS is the reverse proxy's job in both cases.
|
||||
|
||||
### `site_url`
|
||||
|
||||
Purely cosmetic: it is the origin API responses quote back to a CI job so the
|
||||
job can print where its build landed. Behind a proxy the server cannot learn
|
||||
this name, so you have to tell it. Leaving it empty just omits the `url` field.
|
||||
|
||||
### `assemble_mode`
|
||||
|
||||
- `auto` (default) — probe for hardlink support at startup, fall back to copy.
|
||||
- `hardlink` — require hardlinks; fail loudly if the filesystem refuses.
|
||||
- `copy` — always copy. Correct everywhere, uses disk proportional to content.
|
||||
- `none` — skip on-disk assembly entirely. Content is served straight from the
|
||||
CAS and **`$WEBROOT` symlinks are not maintained**. Choose this only if
|
||||
nothing outside `pages-server` reads the files.
|
||||
|
||||
On overlayfs (Docker's default upper layer, among others) cross-layer `link()`
|
||||
either triggers a copy-up or fails outright, so `auto` may silently land on
|
||||
copy. Check the startup log line reporting the detected mode if disk usage
|
||||
surprises you.
|
||||
|
||||
### `trusted_proxy_cidrs`
|
||||
|
||||
`X-Forwarded-For` is honoured **only** when the direct peer address falls inside
|
||||
one of these prefixes. The client IP is used for the failed-authentication rate
|
||||
limiter, so getting this wrong either lets one proxy IP absorb everyone's
|
||||
budget (too narrow) or lets a client forge its own identity (too wide). Set it
|
||||
to your proxy's address and nothing else.
|
||||
|
||||
---
|
||||
|
||||
## 4. First run
|
||||
|
||||
On first start, if no unrevoked admin key exists, the server mints one and
|
||||
writes the token to `$DATA_DIR/bootstrap-token` with mode `0600`, logging a
|
||||
warning. **This is the only place the server ever writes a token to disk.**
|
||||
|
||||
```fish
|
||||
set -x PAGES_SERVER http://127.0.0.1:8081
|
||||
set -x PAGES_TOKEN (cat /var/lib/pages-server/bootstrap-token)
|
||||
|
||||
pages project create demo
|
||||
pages key create --project demo --name github-actions -o json # token shown ONCE
|
||||
```
|
||||
|
||||
Then delete the bootstrap file. It is not recreated as long as one unrevoked
|
||||
admin key exists.
|
||||
|
||||
Give CI a **project-scoped** key, never the admin one. A project key can create,
|
||||
upload, finalize and activate deployments in its own project and read that
|
||||
project's settings — and nothing else, in any other project.
|
||||
|
||||
---
|
||||
|
||||
## 5. Deploying
|
||||
|
||||
```sh
|
||||
pages deploy ./dist --project demo
|
||||
```
|
||||
|
||||
What happens, and what each step buys you:
|
||||
|
||||
1. **Scan.** Walk the directory, hash every file with SHA-256 in parallel.
|
||||
Symlinks are refused by default (`--follow-symlinks` opts in, and still
|
||||
refuses targets outside the tree). Devices, sockets and FIFOs are always
|
||||
refused.
|
||||
2. **Create.** A `pending` deployment row. No filesystem is touched.
|
||||
3. **Manifest.** Send every path + digest + size; the server answers with the
|
||||
digests it does *not* already have. This is where the incremental win shows
|
||||
up — a rebuild that changes one file uploads one blob, regardless of how big
|
||||
the site is. The CLI prints the saving.
|
||||
4. **Upload.** Only the missing blobs, bounded concurrency (`--concurrency`),
|
||||
exponential backoff honouring `Retry-After` (`--retries`).
|
||||
5. **Finalize.** The server verifies nothing is missing, then assembles the tree
|
||||
into `…/<id>.staging`, fsyncs it, and renames it into place.
|
||||
6. **Activate** (unless `--activate=false`) — the atomic swap.
|
||||
|
||||
**Interrupted deploys need no special handling.** Re-run `pages deploy`. The
|
||||
blobs that made it are still in the CAS, so the new deployment's manifest
|
||||
negotiation returns a much smaller missing set. There is no resume protocol
|
||||
because none is needed.
|
||||
|
||||
### Rollback
|
||||
|
||||
```sh
|
||||
pages deployment list --project demo # newest first; ACTIVE marks what is live
|
||||
pages deployment activate dpl_… # one pointer store
|
||||
```
|
||||
|
||||
The superseded deployment stays `ready` on disk until retention expires it, so
|
||||
rollback costs nothing but the swap. `pages deployment show dpl_… --files` gives
|
||||
the manifest of any of them if you need to see what a version contained before
|
||||
switching to it, and `pages deployment delete dpl_…` removes one — except the
|
||||
active one, which answers `409 deployment_active` until something else is
|
||||
activated.
|
||||
|
||||
Every `pages deployment` command accepts a project key, so a CI job can roll its
|
||||
own project back without an admin credential. `pages system gc` and `pages
|
||||
system fsck` are server-wide and need an admin key.
|
||||
|
||||
### Base paths — the one thing that will bite you
|
||||
|
||||
Sites are served under `/~PROJECT/`. A build that assumes it lives at `/` will
|
||||
request `/assets/app.js`, get a 404, and look broken. **Set the base path at
|
||||
build time:**
|
||||
|
||||
| Tool | Setting |
|
||||
|---|---|
|
||||
| Vite | `vite build --base=/~demo/` |
|
||||
| Create React App | `PUBLIC_URL=/~demo` |
|
||||
| Next.js (static export) | `basePath: '/~demo'` |
|
||||
| Hugo | `--baseURL=/~demo/` |
|
||||
| plain HTML | `<base href="/~demo/">` |
|
||||
|
||||
This is inherent to prefix hosting; the server cannot fix it. If you need sites
|
||||
at the root of their own domain, you need host routing (§8).
|
||||
|
||||
### Project settings
|
||||
|
||||
```sh
|
||||
pages project update demo --spa --not-found-file 404.html --retention 20
|
||||
```
|
||||
|
||||
- `--index-file` (default `index.html`) — what a directory URL serves.
|
||||
- `--not-found-file` — served *with* a 404 status. Without it, 404s are bare.
|
||||
- `--spa` — serve the index document for unknown paths, **but only when the
|
||||
request's `Accept` header includes `text/html`.** That condition matters: it
|
||||
is why a missing `/assets/app.js` still returns a real 404 instead of an HTML
|
||||
page with status 200 and an afternoon of `Unexpected token '<'`.
|
||||
- `--cache-control` (default `public, max-age=0, must-revalidate`) — correct by
|
||||
construction here, because every response carries a strong content-hash ETag.
|
||||
Repeat visits are cheap 304s and deploys are visible immediately. Only raise
|
||||
`max-age` for content served under hashed filenames.
|
||||
- `--retention` / `--retention-grace` — see §6.
|
||||
|
||||
---
|
||||
|
||||
## 6. Garbage collection and retention
|
||||
|
||||
The sweep runs every `gc_interval`, and on demand via `pages system gc` (admin),
|
||||
which is `POST /api/v1/gc`. `pages system gc --dry-run` reports what would go
|
||||
without deleting anything — it cannot report the bytes, because nothing was
|
||||
deleted and the content is all still referenced.
|
||||
|
||||
Per project, a deployment is deleted when **all** of these hold:
|
||||
|
||||
- it is not active;
|
||||
- it was superseded more than `retention_grace` ago (default 1 h) — a
|
||||
deployment that was never activated is measured from when it was created, so
|
||||
an uploaded-but-unused one is kept for the grace period too;
|
||||
- it is not among the newest `retention_count` finished deployments (default 10).
|
||||
|
||||
Failed deployments are dropped after 24 h. Blobs are removed once their
|
||||
refcount reaches zero and they have been unreferenced for an hour.
|
||||
|
||||
**Why the grace period exists.** A request that has already taken its snapshot
|
||||
and is about to open a blob gets an hour of slack. Combined with POSIX's
|
||||
guarantee that an open file descriptor keeps an inode alive, an in-flight
|
||||
download survives even a forced GC. If the server ever logs a missing blob on
|
||||
the read path, that is a genuine bug — it is logged at ERROR precisely so it
|
||||
cannot be mistaken for normal behaviour.
|
||||
|
||||
`pages system fsck` (`POST /api/v1/fsck`, admin) recomputes every blob refcount
|
||||
from the manifests and reports drift; `--repair` (`{"repair": true}`) corrects
|
||||
it. On a healthy server it always reports none — the counts are maintained by
|
||||
database triggers — so it is for the cases outside normal operation: a restored
|
||||
backup, or a database edited by hand. A count that reads *low* is the dangerous
|
||||
one, because it is content the collector will delete while a deployment still
|
||||
needs it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security model and its limits
|
||||
|
||||
Three of these are accepted properties of the v1 design, not deferred work.
|
||||
They are stated here so the decision is yours and not a surprise.
|
||||
|
||||
### 7.1 ⚠️ Path routing is not a security boundary between projects
|
||||
|
||||
`/~a/` and `/~b/` are the **same origin**. That means project A's JavaScript
|
||||
can:
|
||||
|
||||
- `fetch('/~b/secret.json')` and read the response;
|
||||
- read and write cookies and `localStorage` for the whole host;
|
||||
- register a service worker scoped to `/`, intercepting **every request to
|
||||
every project on that host**, including future ones.
|
||||
|
||||
There is no browser mechanism that prevents this. `X-Content-Type-Options:
|
||||
nosniff` is always sent, and it does not help here.
|
||||
|
||||
**Only host projects that trust each other.** If you need to host sites from
|
||||
mutually distrusting tenants, give each project its own domain so each gets its
|
||||
own origin. That is host routing, which v1 does not implement.
|
||||
|
||||
### 7.2 ⚠️ Assembled files share inodes with CAS blobs
|
||||
|
||||
When hardlink assembly is in use (the default when supported), a file under
|
||||
`$WEBROOT/~demo/` and its blob in `$DATA_DIR/cas/` are **the same inode**.
|
||||
Editing that file in place corrupts the blob — and therefore corrupts every
|
||||
other project and every other deployment referencing the same content.
|
||||
|
||||
Mitigations in place: blobs and assembled files are `0444`, directories `0755`,
|
||||
and client-supplied file modes are ignored entirely (there is no `mode` column
|
||||
in the schema, so a setuid bit can never reach disk).
|
||||
|
||||
**Treat `$WEBROOT` as read-only to everything except `pages-server`.** Do not
|
||||
point `rsync --delete` at it, do not let a deploy script `chmod -R` it, do not
|
||||
edit a file "just to check something". If you must modify content, deploy it.
|
||||
|
||||
Setting `assemble_mode = "copy"` removes the shared inode at the cost of disk.
|
||||
|
||||
### 7.3 ⚠️ The manifest endpoint is a blob existence oracle
|
||||
|
||||
A project-scoped key can put any digest in a manifest and learn from the
|
||||
`missing` response whether the server already holds that content — including
|
||||
content belonging to another project.
|
||||
|
||||
The attacker must already know the exact SHA-256, so this only ever confirms
|
||||
"somebody here hosts this file I already have". It cannot be used to read
|
||||
content or to enumerate anything.
|
||||
|
||||
This is the price of cross-project deduplication, which is the feature that
|
||||
makes repeated CI deploys fast. If it matters to you, the fixes are: partition
|
||||
the CAS per project (losing all dedup), or always report digests the calling
|
||||
project has never referenced as missing (losing cross-project dedup only).
|
||||
|
||||
### 7.4 What is structurally prevented
|
||||
|
||||
- **Read-path traversal.** The only filesystem path built while serving is
|
||||
`cas/ab/cd/<64 hex>`, derived from a 32-byte digest that came out of an
|
||||
in-memory map lookup. No user-controlled byte reaches the filesystem on the
|
||||
read path, so traversal is not blocked — it is impossible.
|
||||
- **Write-path traversal.** Every manifest path must satisfy both
|
||||
`fs.ValidPath` and `filepath.Localize`, and NUL bytes, control characters,
|
||||
segments over 255 bytes, total lengths over 4096, duplicates, and
|
||||
case-insensitively colliding paths are all rejected.
|
||||
- **Poisoned blobs.** The server never trusts a client's digest: it recomputes
|
||||
SHA-256 over the received stream and rejects a mismatch. Without this, a
|
||||
client could claim another project's digest and overwrite shared content.
|
||||
- **Zip bombs and tar-slip.** There is no archive format and no server-side
|
||||
decompression. Files arrive individually. *Do not "improve" this into tarball
|
||||
upload without re-reviewing this section.*
|
||||
- **Symlink escape from uploaded content.** The server creates only directories
|
||||
and hardlinks inside a deployment tree; it never creates a symlink there.
|
||||
|
||||
### 7.5 Tokens
|
||||
|
||||
Format `pgs_<keyid>_<secret>`: an 80-bit key id and a 256-bit random secret.
|
||||
Stored as SHA-256 (not bcrypt — the secret is uniformly random, so there is no
|
||||
dictionary to defend against, and a KDF per request would hand every
|
||||
unauthenticated client a CPU-exhaustion lever), compared in constant time.
|
||||
|
||||
- Tokens are returned **once**, at creation.
|
||||
- They are **never logged, never accepted in a query string, never included in
|
||||
an error message.** A test asserts this by running a request through the
|
||||
middleware and grepping the log output.
|
||||
- Prefer `PAGES_TOKEN` or `--token-file` over `--token`. On a shared CI runner
|
||||
`argv` is world-readable through `/proc/<pid>/cmdline`.
|
||||
- Revocation is immediate: the auth cache is invalidated by a generation bump.
|
||||
- The CLI config file is written `0600`.
|
||||
|
||||
Failed authentications are rate limited per client IP (see
|
||||
`trusted_proxy_cidrs`). Successful ones are not.
|
||||
|
||||
---
|
||||
|
||||
## 8. Scaling limits
|
||||
|
||||
**Run exactly one `pages-server` process per database.** The in-memory registry
|
||||
is updated synchronously by the process that performed the write; a second
|
||||
process on the same SQLite file would keep serving stale content indefinitely,
|
||||
because nothing tells it the active deployment changed.
|
||||
|
||||
Vertically there is plenty of room — serving is a map lookup plus a file read,
|
||||
and reads never touch SQLite. The bound worth knowing is memory: resident
|
||||
manifests cost roughly 100 bytes per file, and only active deployments are
|
||||
resident. A thousand projects of fifty thousand files each would be about 6 GB.
|
||||
|
||||
Horizontal scaling would need a change-notification mechanism (polling
|
||||
`deployments.updated_at`, or external pub/sub). It is not in v1.
|
||||
|
||||
---
|
||||
|
||||
## 9. Monitoring and troubleshooting
|
||||
|
||||
`/healthz` and `/readyz` are served on **both** listeners, so either can be the
|
||||
one your load balancer targets. `/healthz` never touches the database;
|
||||
`/readyz` returns 503 until the database is reachable and the registry is
|
||||
loaded.
|
||||
|
||||
`GET /api/v1/system/info` (admin) reports version, uptime, project and
|
||||
deployment counts, CAS size and the detected link mode. `pages system info`
|
||||
formats it.
|
||||
|
||||
Access logs are one line per request with `method`, `path`, `status`, `bytes`,
|
||||
`dur_ms`, `project`, `deployment`, `key_id` and `req_id`.
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Site returns 503 | project exists, nothing activated yet | deploy, or activate an existing deployment |
|
||||
| Site returns 404 at `/~name/` | no such project (404 and 503 are deliberately different answers) | check the name |
|
||||
| Assets 404, page loads | build has the wrong base path | rebuild with `--base=/~name/` (§5) |
|
||||
| `$WEBROOT/~name` missing | external tool removed it, or `assemble_mode = "none"` | the reconciler restores it within `reconcile_interval` |
|
||||
| Disk grows steadily | retention too generous, or copy-mode assembly | lower `--retention`, run `pages system gc`, check the link mode in the startup log |
|
||||
| ERROR "blob missing from the content store" | genuine bug, or somebody deleted CAS files | run `pages system fsck`; redeploy affected projects |
|
||||
| Deploy hangs at upload | `max_concurrent_uploads` saturated by another job | wait, or raise the limit |
|
||||
|
||||
**Recovering a database with an intact CAS** is normal: the server marks blobs
|
||||
whose files are gone as absent at startup and the next deploy re-uploads them.
|
||||
Losing both means redeploying, which for a CI-driven site is one pipeline run.
|
||||
|
||||
### Graceful shutdown
|
||||
|
||||
`SIGINT`/`SIGTERM` stops accepting connections, gives in-flight requests
|
||||
`shutdown_grace` to finish, cancels background workers, checkpoints the WAL and
|
||||
closes cleanly. A second signal forces an immediate close.
|
||||
|
||||
---
|
||||
|
||||
## 10. Backups
|
||||
|
||||
Stop the server, or use `sqlite3 pages.db ".backup"` — a plain `cp` of a live
|
||||
WAL database is not safe. Then copy `$DATA_DIR/cas/`.
|
||||
|
||||
`$DATA_DIR/deployments/` does **not** need backing up: it is reconstructible
|
||||
from the CAS and the database. `$WEBROOT` does not need backing up either; it
|
||||
holds only symlinks, regenerated on the next activation and by the reconciler
|
||||
within `reconcile_interval`.
|
||||
@@ -0,0 +1,18 @@
|
||||
module github.com/iceBear67/simplepages
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.56.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
||||
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
@@ -0,0 +1,307 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/deploy"
|
||||
)
|
||||
|
||||
// readyDeployment runs a whole deployment through create, manifest, upload and
|
||||
// finalize, so an activation test starts from the only state activation accepts.
|
||||
func (e *env) readyDeployment(t *testing.T, token, project string, contents map[string]string) api.Deployment {
|
||||
t.Helper()
|
||||
dep := e.startDeployment(t, token, project)
|
||||
|
||||
files := make([]api.FileEntry, 0, len(contents))
|
||||
for path, content := range contents {
|
||||
files = append(files, entry(path, content))
|
||||
}
|
||||
status, body := e.do(t, http.MethodPost, api.PathManifest(project, dep.ID), token,
|
||||
api.ManifestRequest{Files: files})
|
||||
var mr api.ManifestResponse
|
||||
mustJSON(t, status, http.StatusOK, body, &mr)
|
||||
|
||||
for _, content := range contents {
|
||||
if status, body := e.putBlob(t, token, content); status != http.StatusCreated && status != http.StatusOK {
|
||||
t.Fatalf("upload: status = %d; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
status, body = e.do(t, http.MethodPost, api.PathFinalize(project, dep.ID), token, nil)
|
||||
var out api.Deployment
|
||||
mustJSON(t, status, http.StatusOK, body, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// symlink reads $WEBROOT/~project, failing if it is not a link.
|
||||
func (e *env) symlink(t *testing.T, project string) string {
|
||||
t.Helper()
|
||||
target, err := os.Readlink(filepath.Join(e.webrootDir, "~"+project))
|
||||
if err != nil {
|
||||
t.Fatalf("readlink ~%s: %v", project, err)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// TestActivate covers the endpoint the whole product turns on: what it answers,
|
||||
// what it publishes into the serving registry, and what it leaves on disk.
|
||||
func TestActivate(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
pid := e.projectID(t, "demo")
|
||||
|
||||
// Nothing is served before the first activation.
|
||||
sp, ok := e.sites.Lookup("demo")
|
||||
if !ok {
|
||||
t.Fatal("creating a project did not register it")
|
||||
}
|
||||
if sp.Active() != nil {
|
||||
t.Fatal("a project with no deployment is serving something")
|
||||
}
|
||||
|
||||
first := e.readyDeployment(t, token, "demo", map[string]string{
|
||||
"index.html": "<h1>v1</h1>",
|
||||
"assets/app.js": "console.log(1)",
|
||||
})
|
||||
|
||||
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", first.ID), token, nil)
|
||||
var got api.Deployment
|
||||
mustJSON(t, status, http.StatusOK, body, &got)
|
||||
if got.ID != first.ID || !got.Active || got.State != api.StateReady {
|
||||
t.Fatalf("activated = %+v", got)
|
||||
}
|
||||
if got.ActivatedAt == nil {
|
||||
t.Error("activated_at is missing from the response")
|
||||
}
|
||||
if got.URL != "" {
|
||||
t.Errorf("url = %q, want it omitted when no base URL is configured", got.URL)
|
||||
}
|
||||
|
||||
// The registry is what the site handler reads, so this is the assertion that
|
||||
// the deployment is actually being served and not merely recorded.
|
||||
d := sp.Active()
|
||||
if d == nil {
|
||||
t.Fatal("activation did not publish into the registry")
|
||||
}
|
||||
if d.ID != first.ID || d.FileCount != 2 {
|
||||
t.Fatalf("published snapshot = %+v", d)
|
||||
}
|
||||
if _, ok := d.Lookup("assets/app.js"); !ok {
|
||||
t.Error("the published snapshot does not know a file the manifest declared")
|
||||
}
|
||||
|
||||
wantDir := deploy.DeploymentDir(e.deployDir, pid, first.ID)
|
||||
if got := e.symlink(t, "demo"); got != wantDir {
|
||||
t.Errorf("~demo -> %q, want %q", got, wantDir)
|
||||
}
|
||||
|
||||
// The project listing now reports what is being served, without a query per
|
||||
// project.
|
||||
status, body = e.do(t, http.MethodGet, api.PathProject("demo"), token, nil)
|
||||
var p api.Project
|
||||
mustJSON(t, status, http.StatusOK, body, &p)
|
||||
if p.ActiveDeployment == nil || p.ActiveDeployment.ID != first.ID {
|
||||
t.Fatalf("active_deployment = %+v", p.ActiveDeployment)
|
||||
}
|
||||
if !p.ActiveDeployment.Active || p.ActiveDeployment.State != api.StateReady {
|
||||
t.Errorf("active_deployment = %+v", p.ActiveDeployment)
|
||||
}
|
||||
|
||||
// --- a second deployment takes over
|
||||
second := e.readyDeployment(t, token, "demo", map[string]string{
|
||||
"index.html": "<h1>v2</h1>",
|
||||
"assets/app.js": "console.log(1)", // unchanged, so it shares a blob
|
||||
})
|
||||
status, body = e.do(t, http.MethodPost, api.PathActivate("demo", second.ID), token, nil)
|
||||
mustJSON(t, status, http.StatusOK, body, &got)
|
||||
if got.ID != second.ID || !got.Active {
|
||||
t.Fatalf("second activation = %+v", got)
|
||||
}
|
||||
if d := sp.Active(); d == nil || d.ID != second.ID {
|
||||
t.Fatalf("the registry still serves %v", d)
|
||||
}
|
||||
if got := e.symlink(t, "demo"); got != deploy.DeploymentDir(e.deployDir, pid, second.ID) {
|
||||
t.Errorf("~demo -> %q, want the second deployment", got)
|
||||
}
|
||||
|
||||
// --- rollback is the same endpoint on an older deployment
|
||||
status, body = e.do(t, http.MethodPost, api.PathActivate("demo", first.ID), token, nil)
|
||||
mustJSON(t, status, http.StatusOK, body, &got)
|
||||
if got.ID != first.ID || !got.Active {
|
||||
t.Fatalf("rollback = %+v", got)
|
||||
}
|
||||
if d := sp.Active(); d == nil || d.ID != first.ID {
|
||||
t.Fatalf("the registry did not roll back: %v", d)
|
||||
}
|
||||
if got := e.symlink(t, "demo"); got != wantDir {
|
||||
t.Errorf("~demo -> %q, want the first deployment again", got)
|
||||
}
|
||||
|
||||
// Re-activating what is already active is a no-op that still answers.
|
||||
status, body = e.do(t, http.MethodPost, api.PathActivate("demo", first.ID), token, nil)
|
||||
mustJSON(t, status, http.StatusOK, body, &got)
|
||||
if !got.Active {
|
||||
t.Errorf("re-activation = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateReportsTheSiteURL(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.server.BaseURL = "https://pages.example.com"
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
dep := e.readyDeployment(t, token, "demo", map[string]string{"index.html": "<h1>hi</h1>"})
|
||||
|
||||
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, nil)
|
||||
var got api.Deployment
|
||||
mustJSON(t, status, http.StatusOK, body, &got)
|
||||
if want := api.SiteURL(e.server.BaseURL, "demo"); got.URL != want {
|
||||
t.Errorf("url = %q, want %q", got.URL, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Activation is the one operation that changes what the world sees, so every
|
||||
// deployment that is not finished must be refused — and refused without
|
||||
// disturbing whatever is being served.
|
||||
func TestActivateRequiresAReadyDeployment(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
|
||||
serving := e.readyDeployment(t, token, "demo", map[string]string{"index.html": "<h1>v1</h1>"})
|
||||
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", serving.ID), token, nil)
|
||||
mustJSON(t, status, http.StatusOK, body, nil)
|
||||
|
||||
t.Run("pending", func(t *testing.T) {
|
||||
dep := e.startDeployment(t, token, "demo")
|
||||
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, nil)
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409; body: %s", status, body)
|
||||
}
|
||||
if code := errCode(t, body); code != api.CodeDeploymentNotReady {
|
||||
t.Errorf("code = %q, want %q", code, api.CodeDeploymentNotReady)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uploading", func(t *testing.T) {
|
||||
dep := e.startDeployment(t, token, "demo")
|
||||
status, body := e.do(t, http.MethodPost, api.PathManifest("demo", dep.ID), token,
|
||||
api.ManifestRequest{Files: []api.FileEntry{entry("index.html", "never uploaded")}})
|
||||
mustJSON(t, status, http.StatusOK, body, nil)
|
||||
|
||||
status, body = e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, nil)
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", "dpl_ffffffffffffffff"), token, nil)
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", status, body)
|
||||
}
|
||||
if code := errCode(t, body); code != api.CodeNotFound {
|
||||
t.Errorf("code = %q, want %q", code, api.CodeNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
// Every refusal above left the site alone.
|
||||
sp, _ := e.sites.Lookup("demo")
|
||||
if d := sp.Active(); d == nil || d.ID != serving.ID {
|
||||
t.Errorf("a refused activation changed what is served: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// The security property: naming another project's deployment must not activate
|
||||
// it, whether the caller routes through their own project or the victim's.
|
||||
func TestActivateIsProjectScoped(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "victim")
|
||||
e.createProject(t, "attacker")
|
||||
victimToken := e.mintProject(t, e.projectID(t, "victim"), "ci")
|
||||
attackerToken := e.mintProject(t, e.projectID(t, "attacker"), "ci")
|
||||
|
||||
target := e.readyDeployment(t, victimToken, "victim", map[string]string{"index.html": "<h1>v1</h1>"})
|
||||
newer := e.readyDeployment(t, victimToken, "victim", map[string]string{"index.html": "<h1>v2</h1>"})
|
||||
status, body := e.do(t, http.MethodPost, api.PathActivate("victim", newer.ID), victimToken, nil)
|
||||
mustJSON(t, status, http.StatusOK, body, nil)
|
||||
|
||||
t.Run("through the victim's project", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodPost, api.PathActivate("victim", target.ID), attackerToken, nil)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("through the attacker's own project", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodPost, api.PathActivate("attacker", target.ID), attackerToken, nil)
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
|
||||
sp, _ := e.sites.Lookup("victim")
|
||||
if d := sp.Active(); d == nil || d.ID != newer.ID {
|
||||
t.Errorf("the victim is now serving %v", d)
|
||||
}
|
||||
if _, ok := e.sites.Lookup("attacker"); !ok {
|
||||
t.Fatal("the attacker's project vanished")
|
||||
}
|
||||
if sp, _ := e.sites.Lookup("attacker"); sp.Active() != nil {
|
||||
t.Error("the attacker ended up serving the victim's deployment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateRejectsABodyAndWrongMethods(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
dep := e.readyDeployment(t, token, "demo", map[string]string{"index.html": "<h1>hi</h1>"})
|
||||
|
||||
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token,
|
||||
map[string]string{"unexpected": "field"})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
||||
}
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} {
|
||||
resp := e.doResp(t, method, api.PathActivate("demo", dep.ID), token, nil)
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s: status = %d, want 405", method, resp.StatusCode)
|
||||
}
|
||||
if allow := resp.Header.Get("Allow"); allow != http.MethodPost {
|
||||
t.Errorf("%s: Allow = %q, want POST", method, allow)
|
||||
}
|
||||
}
|
||||
|
||||
if status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), "", nil); status != http.StatusUnauthorized {
|
||||
t.Errorf("unauthenticated: status = %d, want 401; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a project takes its symlink with it: the deployment tree is about to
|
||||
// be GC'd, and a link left pointing at it would dangle.
|
||||
func TestDeleteProjectUnpointsTheWebroot(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
dep := e.readyDeployment(t, token, "demo", map[string]string{"index.html": "<h1>hi</h1>"})
|
||||
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, nil)
|
||||
mustJSON(t, status, http.StatusOK, body, nil)
|
||||
e.symlink(t, "demo")
|
||||
|
||||
status, body = e.do(t, http.MethodDelete, api.PathProject("demo"), e.adminToken, nil)
|
||||
if status != http.StatusNoContent {
|
||||
t.Fatalf("delete: status = %d; body: %s", status, body)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(e.webrootDir, "~demo")); err == nil {
|
||||
t.Error("the symlink outlived the project")
|
||||
}
|
||||
if _, ok := e.sites.Lookup("demo"); ok {
|
||||
t.Error("the deleted project is still in the serving registry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/auth"
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/config"
|
||||
"github.com/iceBear67/simplepages/internal/deploy"
|
||||
"github.com/iceBear67/simplepages/internal/site"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
"github.com/iceBear67/simplepages/internal/webroot"
|
||||
)
|
||||
|
||||
// env is a management API running against a real SQLite file in a temp dir.
|
||||
// Nothing here is faked below the HTTP boundary: the tests exercise the same
|
||||
// store, verifier and route table the server assembles.
|
||||
type env struct {
|
||||
db *store.DB
|
||||
cas *cas.Store
|
||||
deployDir string
|
||||
webrootDir string
|
||||
sites *site.Registry
|
||||
verifier *auth.Verifier
|
||||
server *Server
|
||||
ts *httptest.Server
|
||||
adminToken string
|
||||
logBuf *bytes.Buffer
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
base := t.TempDir()
|
||||
db, err := store.Open(ctx, filepath.Join(base, "pages.db"), log)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
deployDir := filepath.Join(base, "deployments")
|
||||
cs, err := cas.Open(filepath.Join(base, "cas"), cas.Options{ProbeDir: deployDir, Log: log})
|
||||
if err != nil {
|
||||
t.Fatalf("open cas: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { cs.Close() })
|
||||
|
||||
webrootDir := filepath.Join(base, "www")
|
||||
wr, err := webroot.Open(webrootDir, deployDir)
|
||||
if err != nil {
|
||||
t.Fatalf("open webroot: %v", err)
|
||||
}
|
||||
|
||||
// The serving layer is wired up exactly as cmd/pages-server does it, so the
|
||||
// endpoints that publish into it — activate above all — are exercised against
|
||||
// the same collaborators they have in production.
|
||||
verifier := auth.NewVerifier(db, log, auth.DefaultCacheTTL)
|
||||
sites := site.NewRegistry()
|
||||
e := &env{
|
||||
db: db, cas: cs, deployDir: deployDir, webrootDir: webrootDir,
|
||||
sites: sites, verifier: verifier, logBuf: &buf,
|
||||
}
|
||||
e.server = &Server{
|
||||
DB: db,
|
||||
Deploy: &deploy.Service{
|
||||
DB: db, CAS: cs, Log: log, Dir: deployDir,
|
||||
Sites: sites, Webroot: wr,
|
||||
},
|
||||
Auth: &auth.Middleware{
|
||||
V: verifier,
|
||||
// A burst high enough that the limiter never fires by accident; the
|
||||
// throttling behaviour itself is tested in internal/auth.
|
||||
Limiter: auth.NewLimiter(10000, time.Minute, 128),
|
||||
Log: log,
|
||||
},
|
||||
Log: log,
|
||||
Limits: config.Default().Limits,
|
||||
Sites: sites,
|
||||
Started: time.Now().Add(-time.Minute),
|
||||
Hooks: Hooks{
|
||||
ProjectChanged: func(_ context.Context, p *store.Project) { sites.Put(p) },
|
||||
ProjectDeleted: func(_ context.Context, p *store.Project) {
|
||||
sites.Delete(p.Name)
|
||||
if err := wr.Unpoint(p.Name); err != nil {
|
||||
t.Errorf("unpoint %s: %v", p.Name, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
e.server.Register(mux)
|
||||
e.ts = httptest.NewServer(mux)
|
||||
t.Cleanup(e.ts.Close)
|
||||
|
||||
e.adminToken = e.mintAdmin(t, "test-admin")
|
||||
return e
|
||||
}
|
||||
|
||||
// mintAdmin creates an admin key directly in the store, the way the bootstrap
|
||||
// path does, and returns its token.
|
||||
func (e *env) mintAdmin(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
return e.mint(t, store.ScopeAdmin, nil, name)
|
||||
}
|
||||
|
||||
func (e *env) mintProject(t *testing.T, projectID int64, name string) string {
|
||||
t.Helper()
|
||||
return e.mint(t, store.ScopeProject, &projectID, name)
|
||||
}
|
||||
|
||||
func (e *env) mint(t *testing.T, scope store.Scope, projectID *int64, name string) string {
|
||||
t.Helper()
|
||||
return e.mintWith(t, scope, projectID, name, nil)
|
||||
}
|
||||
|
||||
// mintWith goes around the API so a test can create a key the API would refuse
|
||||
// to mint — an already-expired one, for instance.
|
||||
func (e *env) mintWith(t *testing.T, scope store.Scope, projectID *int64, name string, expires *time.Time) string {
|
||||
t.Helper()
|
||||
token, keyID, hash, err := auth.Mint()
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
k := &store.APIKey{
|
||||
ID: keyID, SecretHash: hash[:], Scope: scope,
|
||||
ProjectID: projectID, Name: name, ExpiresAt: expires,
|
||||
}
|
||||
if err := e.db.CreateKey(t.Context(), k); err != nil {
|
||||
t.Fatalf("create key: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// do issues a request and returns the status and raw body. The body is returned
|
||||
// undecoded so tests can assert on what is literally on the wire.
|
||||
func (e *env) do(t *testing.T, method, path, token string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
r = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(t.Context(), method, e.ts.URL+path, r)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := e.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
return resp.StatusCode, raw
|
||||
}
|
||||
|
||||
// doResp is do() for the tests that need to inspect response headers.
|
||||
func (e *env) doResp(t *testing.T, method, path, token string, body any) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
r = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(t.Context(), method, e.ts.URL+path, r)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := e.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
return resp
|
||||
}
|
||||
|
||||
// mustJSON decodes body into v, failing the test if the status is unexpected.
|
||||
func mustJSON(t *testing.T, status, want int, body []byte, v any) {
|
||||
t.Helper()
|
||||
if status != want {
|
||||
t.Fatalf("status = %d, want %d; body: %s", status, want, body)
|
||||
}
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(body, v); err != nil {
|
||||
t.Fatalf("decode %T: %v; body: %s", v, err, body)
|
||||
}
|
||||
}
|
||||
|
||||
// errCode extracts the machine-readable code from an error envelope.
|
||||
func errCode(t *testing.T, body []byte) api.Code {
|
||||
t.Helper()
|
||||
var env api.ErrorEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("decode error envelope: %v; body: %s", err, body)
|
||||
}
|
||||
return env.Error.Code
|
||||
}
|
||||
|
||||
// createProject is the fixture most tests start from.
|
||||
func (e *env) createProject(t *testing.T, name string) api.Project {
|
||||
t.Helper()
|
||||
status, body := e.do(t, http.MethodPost, api.PathProjects(), e.adminToken,
|
||||
api.CreateProjectRequest{Name: name})
|
||||
var p api.Project
|
||||
mustJSON(t, status, http.StatusCreated, body, &p)
|
||||
return p
|
||||
}
|
||||
|
||||
// projectID looks up the row id, which the wire format deliberately does not
|
||||
// carry.
|
||||
func (e *env) projectID(t *testing.T, name string) int64 {
|
||||
t.Helper()
|
||||
p, err := e.db.ProjectByName(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatalf("project %q: %v", name, err)
|
||||
}
|
||||
return p.ID
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/httpx"
|
||||
)
|
||||
|
||||
// putBlob handles PUT /api/v1/blobs/{digest}.
|
||||
//
|
||||
// Blobs are global rather than per-project because the store deduplicates
|
||||
// across projects, so any authenticated key may upload one — but only content
|
||||
// some manifest already declared, and only bytes that really hash to the digest
|
||||
// in the URL. Both checks live in deploy.Service and cas.Store; the digest here
|
||||
// is a claim until then.
|
||||
//
|
||||
// The route is deliberately not owner-guarded: there is no project in the path
|
||||
// to guard against. What a caller can do with it is bounded by the manifest
|
||||
// requirement, and the resulting cross-project existence oracle is the known,
|
||||
// documented trade-off of shared deduplication.
|
||||
func (s *Server) putBlob(w http.ResponseWriter, r *http.Request) error {
|
||||
digest, err := cas.ParseDigest(r.PathValue("digest"))
|
||||
if err != nil {
|
||||
return api.Errorf(api.CodeBadRequest, "%s", err)
|
||||
}
|
||||
|
||||
size, stored, err := s.Deploy.Upload(r.Context(), digest, r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status := http.StatusOK // already had it
|
||||
if stored {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
httpx.WriteJSON(w, status, api.BlobResponse{Digest: digest.String(), Size: size})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// projectOf renders a stored project for the wire.
|
||||
func (s *Server) projectOf(p *store.Project) api.Project {
|
||||
out := api.Project{
|
||||
Name: p.Name,
|
||||
DisplayName: p.DisplayName,
|
||||
IndexFile: p.IndexFile,
|
||||
NotFoundFile: p.NotFoundFile,
|
||||
SPAFallback: p.SPAFallback,
|
||||
CacheControl: p.CacheControl,
|
||||
RetentionCount: p.RetentionCount,
|
||||
RetentionGrace: p.RetentionGraceS,
|
||||
MaxFiles: p.MaxFiles,
|
||||
MaxFileBytes: p.MaxFileBytes,
|
||||
MaxTotalBytes: p.MaxTotalBytes,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
if s.BaseURL != "" {
|
||||
out.URL = api.SiteURL(s.BaseURL, p.Name)
|
||||
}
|
||||
out.ActiveDeployment = s.activeOf(p)
|
||||
return out
|
||||
}
|
||||
|
||||
// activeOf summarises what a project is serving right now.
|
||||
//
|
||||
// It reads the registry rather than the database so listing a hundred projects
|
||||
// stays one query. The summary carries what is being served — id, size,
|
||||
// timestamps — and not the row's metadata or error text; those come from
|
||||
// fetching the deployment itself.
|
||||
func (s *Server) activeOf(p *store.Project) *api.Deployment {
|
||||
if s.Sites == nil {
|
||||
return nil
|
||||
}
|
||||
sp, ok := s.Sites.Lookup(p.Name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
d := sp.Active()
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
out := &api.Deployment{
|
||||
ID: d.ID,
|
||||
Project: p.Name,
|
||||
State: api.StateReady, // only a ready deployment can be active
|
||||
Active: true,
|
||||
FileCount: d.FileCount,
|
||||
TotalBytes: d.TotalBytes,
|
||||
CreatedAt: d.CreatedAt,
|
||||
}
|
||||
if !d.ActivatedAt.IsZero() {
|
||||
t := d.ActivatedAt
|
||||
out.ActivatedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deploymentOf renders a stored deployment for the wire.
|
||||
//
|
||||
// The row id stays behind: the wire only ever names a deployment by its public
|
||||
// id, so nothing a client holds can be walked to a neighbouring row.
|
||||
func deploymentOf(p *store.Project, d *store.Deployment) api.Deployment {
|
||||
return api.Deployment{
|
||||
ID: d.PublicID,
|
||||
Project: p.Name,
|
||||
State: string(d.State),
|
||||
Active: d.Active,
|
||||
FileCount: d.FileCount,
|
||||
TotalBytes: d.TotalBytes,
|
||||
Meta: d.Meta,
|
||||
Error: d.Error,
|
||||
CreatedAt: d.CreatedAt,
|
||||
FinalizedAt: copyTime(d.FinalizedAt),
|
||||
ActivatedAt: copyTime(d.ActivatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// keyOf renders a stored key. It deliberately has no access to the secret: the
|
||||
// store never loads one in a form that could be rendered, only the hash.
|
||||
func keyOf(k *store.APIKey, projectName string) api.Key {
|
||||
return api.Key{
|
||||
ID: k.ID,
|
||||
Scope: string(k.Scope),
|
||||
Project: projectName,
|
||||
Name: k.Name,
|
||||
CreatedAt: k.CreatedAt,
|
||||
ExpiresAt: copyTime(k.ExpiresAt),
|
||||
LastUsed: copyTime(k.LastUsedAt),
|
||||
RevokedAt: copyTime(k.RevokedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// copyTime defensively copies an optional timestamp so a response value cannot
|
||||
// alias a cached store row.
|
||||
func copyTime(t *time.Time) *time.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
v := *t
|
||||
return &v
|
||||
}
|
||||
|
||||
// applyPatch folds a partial update into p, validating as it goes.
|
||||
//
|
||||
// A patch is all-or-nothing: it is applied to a copy by the caller, so a
|
||||
// rejected field leaves the stored project untouched rather than half-updated.
|
||||
func (s *Server) applyPatch(p *store.Project, patch *api.ProjectPatch) error {
|
||||
if patch == nil {
|
||||
return nil
|
||||
}
|
||||
if v := patch.DisplayName; v != nil {
|
||||
if err := checkText("display_name", *v, maxDisplayNameLen); err != nil {
|
||||
return err
|
||||
}
|
||||
p.DisplayName = *v
|
||||
}
|
||||
if v := patch.IndexFile; v != nil {
|
||||
if err := checkSitePath("index_file", *v); err != nil {
|
||||
return err
|
||||
}
|
||||
p.IndexFile = *v
|
||||
}
|
||||
if v := patch.NotFoundFile; v != nil {
|
||||
// The empty string is how a patch clears the custom 404 document; every
|
||||
// other value must name a real relative path.
|
||||
if *v != "" {
|
||||
if err := checkSitePath("not_found_file", *v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
p.NotFoundFile = *v
|
||||
}
|
||||
if v := patch.SPAFallback; v != nil {
|
||||
p.SPAFallback = *v
|
||||
}
|
||||
if v := patch.CacheControl; v != nil {
|
||||
if err := checkHeaderValue("cache_control", *v); err != nil {
|
||||
return err
|
||||
}
|
||||
p.CacheControl = *v
|
||||
}
|
||||
if v := patch.RetentionCount; v != nil {
|
||||
// At least one: retaining zero deployments would delete the active one.
|
||||
if *v < 1 || *v > 1000 {
|
||||
return api.Errorf(api.CodeBadRequest, "retention_count must be between 1 and 1000")
|
||||
}
|
||||
p.RetentionCount = *v
|
||||
}
|
||||
if v := patch.RetentionGrace; v != nil {
|
||||
if *v < 0 || *v > 30*24*3600 {
|
||||
return api.Errorf(api.CodeBadRequest,
|
||||
"retention_grace_s must be between 0 and %d", 30*24*3600)
|
||||
}
|
||||
p.RetentionGraceS = *v
|
||||
}
|
||||
if v := patch.MaxFiles; v != nil {
|
||||
if err := checkCeiling("max_files", int64(*v), int64(s.Limits.MaxManifestFiles)); err != nil {
|
||||
return err
|
||||
}
|
||||
p.MaxFiles = *v
|
||||
}
|
||||
if v := patch.MaxFileBytes; v != nil {
|
||||
if err := checkCeiling("max_file_bytes", *v, s.Limits.MaxFileBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
p.MaxFileBytes = *v
|
||||
}
|
||||
if v := patch.MaxTotalBytes; v != nil {
|
||||
if *v < 1 {
|
||||
return api.Errorf(api.CodeBadRequest, "max_total_bytes must be positive")
|
||||
}
|
||||
p.MaxTotalBytes = *v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCeiling enforces that a per-project limit is positive and does not
|
||||
// exceed the server-wide one. A project may lower its own ceiling but never
|
||||
// raise it past what the operator configured.
|
||||
func checkCeiling(field string, v, ceiling int64) error {
|
||||
if v < 1 {
|
||||
return api.Errorf(api.CodeBadRequest, "%s must be positive", field)
|
||||
}
|
||||
if ceiling > 0 && v > ceiling {
|
||||
return api.Errorf(api.CodeBadRequest,
|
||||
"%s must be at most the server limit of %d", field, ceiling)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/httpx"
|
||||
"github.com/iceBear67/simplepages/internal/pathutil"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// Caps on the deployment metadata a CI job may attach. Generous enough for a
|
||||
// commit sha, a branch, a run URL and an actor; small enough that the column
|
||||
// cannot become a place to store things.
|
||||
const (
|
||||
maxMetaEntries = 32
|
||||
maxMetaKeyLen = 64
|
||||
maxMetaValueLen = 512
|
||||
)
|
||||
|
||||
// createDeployment handles POST /api/v1/projects/{name}/deployments.
|
||||
func (s *Server) createDeployment(w http.ResponseWriter, r *http.Request) error {
|
||||
p, err := s.project(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The body is optional: a deployment that carries no metadata is a bare POST,
|
||||
// which is what `curl -X POST` and any shell-driven CI job send.
|
||||
var req api.CreateDeploymentRequest
|
||||
if r.ContentLength != 0 {
|
||||
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := checkMeta(req.Meta); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := s.identity(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dep, err := s.Deploy.Create(r.Context(), p, id.KeyID, req.Meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpx.LogAttr(r.Context(), "deployment", dep.PublicID)
|
||||
w.Header().Set("Location", api.PathDeployment(p.Name, dep.PublicID))
|
||||
httpx.WriteJSON(w, http.StatusCreated, deploymentOf(p, dep))
|
||||
return nil
|
||||
}
|
||||
|
||||
// setManifest handles POST .../deployments/{id}/manifest.
|
||||
//
|
||||
// The body is walked one entry at a time rather than unmarshalled whole: a
|
||||
// 50,000-file manifest would otherwise be resident twice over, once as raw JSON
|
||||
// and once as structs, for no benefit — nothing here needs to see the entries
|
||||
// together.
|
||||
func (s *Server) setManifest(w http.ResponseWriter, r *http.Request) error {
|
||||
p, dep, err := s.deployment(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.maxManifest())
|
||||
var (
|
||||
files []store.FileRow
|
||||
paths = pathutil.NewSet(0)
|
||||
unique = make(map[cas.Digest]struct{})
|
||||
totalBytes int64
|
||||
)
|
||||
err = decodeManifest(json.NewDecoder(r.Body), func(f api.FileEntry) error {
|
||||
if err := paths.Add(f.Path); err != nil {
|
||||
return api.Errorf(api.CodeInvalidPath, "%s", err)
|
||||
}
|
||||
digest, err := cas.ParseDigest(f.Digest)
|
||||
if err != nil {
|
||||
return api.Errorf(api.CodeBadRequest, "%s: %s", f.Path, err)
|
||||
}
|
||||
if f.Size < 0 {
|
||||
return api.Errorf(api.CodeBadRequest, "%s: size must not be negative", f.Path)
|
||||
}
|
||||
if f.Size > p.MaxFileBytes {
|
||||
return api.Errorf(api.CodeLimitExceeded,
|
||||
"%s is %d bytes; this project allows at most %d per file", f.Path, f.Size, p.MaxFileBytes)
|
||||
}
|
||||
if len(files) >= p.MaxFiles {
|
||||
return api.Errorf(api.CodeLimitExceeded,
|
||||
"a deployment of this project may contain at most %d files", p.MaxFiles)
|
||||
}
|
||||
totalBytes += f.Size
|
||||
if totalBytes > p.MaxTotalBytes {
|
||||
return api.Errorf(api.CodeLimitExceeded,
|
||||
"a deployment of this project may total at most %d bytes", p.MaxTotalBytes)
|
||||
}
|
||||
unique[digest] = struct{}{}
|
||||
files = append(files, store.FileRow{Path: f.Path, Digest: digest, Size: f.Size})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return api.Errorf(api.CodeBadRequest, "a manifest must list at least one file")
|
||||
}
|
||||
|
||||
missing, missingBytes, err := s.Deploy.SetManifest(r.Context(), dep, files)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := api.ManifestResponse{
|
||||
Missing: make([]string, 0, len(missing)),
|
||||
MissingBytes: missingBytes,
|
||||
Have: len(unique) - len(missing),
|
||||
FileCount: len(files),
|
||||
TotalBytes: totalBytes,
|
||||
}
|
||||
for _, d := range missing {
|
||||
out.Missing = append(out.Missing, d.String())
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// finalize handles POST .../deployments/{id}/finalize.
|
||||
func (s *Server) finalize(w http.ResponseWriter, r *http.Request) error {
|
||||
p, dep, err := s.deployment(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := httpx.NoBody(r); err != nil {
|
||||
return err
|
||||
}
|
||||
dep, err = s.Deploy.Finalize(r.Context(), p, dep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, deploymentOf(p, dep))
|
||||
return nil
|
||||
}
|
||||
|
||||
// activate handles POST .../deployments/{id}/activate.
|
||||
//
|
||||
// This is also the rollback endpoint: activating an older ready deployment is
|
||||
// the same operation, and costs the same single pointer store.
|
||||
func (s *Server) activate(w http.ResponseWriter, r *http.Request) error {
|
||||
p, dep, err := s.deployment(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := httpx.NoBody(r); err != nil {
|
||||
return err
|
||||
}
|
||||
dep, err = s.Deploy.Activate(r.Context(), p, dep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := deploymentOf(p, dep)
|
||||
if s.BaseURL != "" {
|
||||
out.URL = api.SiteURL(s.BaseURL, p.Name)
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listDeployments handles GET .../deployments.
|
||||
func (s *Server) listDeployments(w http.ResponseWriter, r *http.Request) error {
|
||||
p, err := s.project(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limit, err := intQuery(r, "limit", 100, 1, 500)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := parseState(r.URL.Query().Get("state"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deps, next, err := s.DB.ListDeployments(r.Context(), p.ID, state, limit, r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := api.DeploymentList{Deployments: make([]api.Deployment, 0, len(deps)), NextCursor: next}
|
||||
for _, dep := range deps {
|
||||
out.Deployments = append(out.Deployments, deploymentOf(p, dep))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDeployment handles GET .../deployments/{id}, with ?files=true adding the
|
||||
// manifest.
|
||||
//
|
||||
// The manifest is opt-in because it can be fifty thousand entries: a listing
|
||||
// that carried it by default would make `pages deployment list` unusable on a
|
||||
// large site for information nobody asked for.
|
||||
func (s *Server) getDeployment(w http.ResponseWriter, r *http.Request) error {
|
||||
p, dep, err := s.deployment(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := deploymentOf(p, dep)
|
||||
if r.URL.Query().Get("files") == "true" {
|
||||
files, err := s.DB.DeploymentFiles(r.Context(), dep.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Files = make([]api.FileEntry, 0, len(files))
|
||||
for _, f := range files {
|
||||
out.Files = append(out.Files, api.FileEntry{
|
||||
Path: f.Path, Digest: f.Digest.String(), Size: f.Size,
|
||||
})
|
||||
}
|
||||
}
|
||||
if dep.Active && s.BaseURL != "" {
|
||||
out.URL = api.SiteURL(s.BaseURL, p.Name)
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteDeployment handles DELETE .../deployments/{id}. Deleting the active one
|
||||
// is a 409: the client is expected to activate something else first, so that
|
||||
// the project is never left with nothing to serve by accident.
|
||||
func (s *Server) deleteDeployment(w http.ResponseWriter, r *http.Request) error {
|
||||
if err := httpx.NoBody(r); err != nil {
|
||||
return err
|
||||
}
|
||||
p, dep, err := s.deployment(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Deploy.Delete(r.Context(), p, dep); err != nil {
|
||||
return err
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseState validates the ?state= filter. The empty string means no filter;
|
||||
// anything else must be a state that exists, so a typo is a 400 rather than a
|
||||
// silently empty list.
|
||||
func parseState(raw string) (store.State, error) {
|
||||
switch raw {
|
||||
case "":
|
||||
return "", nil
|
||||
case api.StatePending, api.StateUploading, api.StateReady, api.StateFailed, api.StateDeleting:
|
||||
return store.State(raw), nil
|
||||
}
|
||||
return "", api.Errorf(api.CodeBadRequest, "unknown deployment state %q", raw)
|
||||
}
|
||||
|
||||
// deployment resolves both the {name} and {id} wildcards.
|
||||
//
|
||||
// The deployment is looked up within the project, never on its own: a
|
||||
// project-scoped caller that guessed another project's deployment id gets the
|
||||
// same "no such deployment" as one that guessed a nonexistent one.
|
||||
func (s *Server) deployment(r *http.Request) (*store.Project, *store.Deployment, error) {
|
||||
p, err := s.project(r)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
dep, err := s.DB.DeploymentByPublicID(r.Context(), p.ID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, nil, api.Errorf(api.CodeNotFound, "no such deployment")
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
httpx.LogAttr(r.Context(), "deployment", dep.PublicID)
|
||||
return p, dep, nil
|
||||
}
|
||||
|
||||
// decodeManifest streams {"files":[...]} and calls onFile for each entry.
|
||||
func decodeManifest(dec *json.Decoder, onFile func(api.FileEntry) error) error {
|
||||
if err := expectDelim(dec, '{', "manifest must be a JSON object"); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := false
|
||||
for dec.More() {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return badJSON(err)
|
||||
}
|
||||
key, _ := tok.(string)
|
||||
if key != "files" {
|
||||
// Unknown keys are skipped rather than rejected: a newer CLI may send
|
||||
// a field this server predates, and the body is bounded anyway.
|
||||
var skip json.RawMessage
|
||||
if err := dec.Decode(&skip); err != nil {
|
||||
return badJSON(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if err := expectDelim(dec, '[', "files must be an array"); err != nil {
|
||||
return err
|
||||
}
|
||||
for dec.More() {
|
||||
var f api.FileEntry
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return badJSON(err)
|
||||
}
|
||||
if err := onFile(f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := dec.Token(); err != nil { // closing ]
|
||||
return badJSON(err)
|
||||
}
|
||||
}
|
||||
if _, err := dec.Token(); err != nil { // closing }
|
||||
return badJSON(err)
|
||||
}
|
||||
if !seen {
|
||||
return api.Errorf(api.CodeBadRequest, "manifest is missing the files array")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func expectDelim(dec *json.Decoder, want json.Delim, msg string) error {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return badJSON(err)
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != want {
|
||||
return api.Errorf(api.CodeBadRequest, "%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// badJSON turns a decoder failure into a client error, keeping the body-size
|
||||
// rejection distinguishable from a syntax one.
|
||||
func badJSON(err error) error {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
return api.Errorf(api.CodePayloadTooLarge, "manifest exceeds %d bytes", maxErr.Limit)
|
||||
}
|
||||
var syn *json.SyntaxError
|
||||
if errors.As(err, &syn) {
|
||||
return api.Errorf(api.CodeBadRequest, "malformed JSON at byte %d", syn.Offset)
|
||||
}
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
return api.Errorf(api.CodeBadRequest, "field %q: want %s", typeErr.Field, typeErr.Type)
|
||||
}
|
||||
return api.Errorf(api.CodeBadRequest, "malformed manifest")
|
||||
}
|
||||
|
||||
func checkMeta(meta map[string]string) error {
|
||||
if len(meta) > maxMetaEntries {
|
||||
return api.Errorf(api.CodeBadRequest, "meta may hold at most %d entries", maxMetaEntries)
|
||||
}
|
||||
for k, v := range meta {
|
||||
if k == "" {
|
||||
return api.Errorf(api.CodeBadRequest, "meta keys must not be empty")
|
||||
}
|
||||
if err := checkText("meta key", k, maxMetaKeyLen); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkText("meta value of "+k, v, maxMetaValueLen); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) maxManifest() int64 {
|
||||
if s.Limits.MaxManifestBytes > 0 {
|
||||
return s.Limits.MaxManifestBytes
|
||||
}
|
||||
return 64 << 20
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/deploy"
|
||||
)
|
||||
|
||||
// entry is a manifest line for content the test holds.
|
||||
func entry(path, content string) api.FileEntry {
|
||||
return api.FileEntry{Path: path, Digest: cas.Sum([]byte(content)).String(), Size: int64(len(content))}
|
||||
}
|
||||
|
||||
// putBlob uploads raw bytes, which is the one endpoint that is not JSON in and
|
||||
// so cannot go through env.do.
|
||||
func (e *env) putBlob(t *testing.T, token, content string) (int, []byte) {
|
||||
t.Helper()
|
||||
return e.putBlobAs(t, token, cas.Sum([]byte(content)).String(), content)
|
||||
}
|
||||
|
||||
func (e *env) putBlobAs(t *testing.T, token, hexDigest, content string) (int, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut,
|
||||
e.ts.URL+api.PathBlob(hexDigest), strings.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
resp, err := e.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT blob: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp.StatusCode, raw
|
||||
}
|
||||
|
||||
// startDeployment is the fixture the manifest and finalize tests build on.
|
||||
func (e *env) startDeployment(t *testing.T, token, project string) api.Deployment {
|
||||
t.Helper()
|
||||
status, body := e.do(t, http.MethodPost, api.PathDeployments(project), token,
|
||||
api.CreateDeploymentRequest{})
|
||||
var dep api.Deployment
|
||||
mustJSON(t, status, http.StatusCreated, body, &dep)
|
||||
return dep
|
||||
}
|
||||
|
||||
// TestDeploymentFlow walks the three endpoints in the order a CI job does and
|
||||
// asserts on what a client can actually see at each step.
|
||||
func TestDeploymentFlow(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
|
||||
// --- create
|
||||
status, body := e.do(t, http.MethodPost, api.PathDeployments(p.Name), token,
|
||||
api.CreateDeploymentRequest{Meta: map[string]string{"git_sha": "abc123", "branch": "main"}})
|
||||
var dep api.Deployment
|
||||
mustJSON(t, status, http.StatusCreated, body, &dep)
|
||||
if !strings.HasPrefix(dep.ID, "dpl_") {
|
||||
t.Errorf("id = %q, want a dpl_ prefix", dep.ID)
|
||||
}
|
||||
if dep.State != api.StatePending || dep.Project != "demo" {
|
||||
t.Errorf("deployment = %+v", dep)
|
||||
}
|
||||
if dep.Meta["git_sha"] != "abc123" {
|
||||
t.Errorf("meta = %v, want the submitted values back", dep.Meta)
|
||||
}
|
||||
// A bare POST with no body is a deployment with no metadata, and the response
|
||||
// points at where the new deployment lives.
|
||||
resp := e.doResp(t, http.MethodPost, api.PathDeployments(p.Name), token, nil)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("bodyless create: status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); !strings.Contains(loc, api.PathDeployments(p.Name)+"/dpl_") {
|
||||
t.Errorf("Location = %q, want the new deployment's path", loc)
|
||||
}
|
||||
|
||||
// --- manifest
|
||||
contents := map[string]string{
|
||||
"index.html": "<h1>hello</h1>",
|
||||
"assets/app.js": "console.log(1)",
|
||||
"copy.html": "<h1>hello</h1>", // shares a blob with index.html
|
||||
}
|
||||
status, body = e.do(t, http.MethodPost, api.PathManifest(p.Name, dep.ID), token,
|
||||
api.ManifestRequest{Files: []api.FileEntry{
|
||||
entry("index.html", contents["index.html"]),
|
||||
entry("assets/app.js", contents["assets/app.js"]),
|
||||
entry("copy.html", contents["copy.html"]),
|
||||
}})
|
||||
var mr api.ManifestResponse
|
||||
mustJSON(t, status, http.StatusOK, body, &mr)
|
||||
if len(mr.Missing) != 2 {
|
||||
t.Fatalf("missing = %v, want the 2 distinct blobs", mr.Missing)
|
||||
}
|
||||
if mr.Have != 0 || mr.FileCount != 3 {
|
||||
t.Errorf("have = %d, file_count = %d, want 0 and 3", mr.Have, mr.FileCount)
|
||||
}
|
||||
if want := int64(len(contents["index.html"])*2 + len(contents["assets/app.js"])); mr.TotalBytes != want {
|
||||
t.Errorf("total_bytes = %d, want %d", mr.TotalBytes, want)
|
||||
}
|
||||
if mr.MissingBytes != int64(len(contents["index.html"])+len(contents["assets/app.js"])) {
|
||||
t.Errorf("missing_bytes = %d counts the shared blob twice", mr.MissingBytes)
|
||||
}
|
||||
|
||||
// --- finalize before the content arrives names what is outstanding
|
||||
status, body = e.do(t, http.MethodPost, api.PathFinalize(p.Name, dep.ID), token, nil)
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("premature finalize: status = %d, body: %s", status, body)
|
||||
}
|
||||
if code := errCode(t, body); code != api.CodeBlobsMissing {
|
||||
t.Errorf("code = %q, want %q", code, api.CodeBlobsMissing)
|
||||
}
|
||||
var env api.ErrorEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if missing, _ := env.Error.Details["missing"].([]any); len(missing) != 2 {
|
||||
t.Errorf("details.missing = %v, want the 2 digests to retry", env.Error.Details["missing"])
|
||||
}
|
||||
|
||||
// --- upload
|
||||
for _, name := range []string{"index.html", "assets/app.js"} {
|
||||
status, body := e.putBlob(t, token, contents[name])
|
||||
var br api.BlobResponse
|
||||
mustJSON(t, status, http.StatusCreated, body, &br)
|
||||
if br.Digest != cas.Sum([]byte(contents[name])).String() || br.Size != int64(len(contents[name])) {
|
||||
t.Errorf("%s: response = %+v", name, br)
|
||||
}
|
||||
}
|
||||
// A re-upload is a cheap 200 rather than a 201: this is what makes a retried
|
||||
// deploy fast, so the distinction is part of the contract.
|
||||
status, body = e.putBlob(t, token, contents["index.html"])
|
||||
mustJSON(t, status, http.StatusOK, body, nil)
|
||||
|
||||
// --- a second manifest now reports what the server already has
|
||||
dep2 := e.startDeployment(t, token, p.Name)
|
||||
status, body = e.do(t, http.MethodPost, api.PathManifest(p.Name, dep2.ID), token,
|
||||
api.ManifestRequest{Files: []api.FileEntry{
|
||||
entry("index.html", contents["index.html"]),
|
||||
entry("assets/app.js", contents["assets/app.js"]),
|
||||
entry("new.txt", "brand new"),
|
||||
}})
|
||||
mr = api.ManifestResponse{}
|
||||
mustJSON(t, status, http.StatusOK, body, &mr)
|
||||
if len(mr.Missing) != 1 || mr.Have != 2 {
|
||||
t.Errorf("missing = %v, have = %d; want only the new file to be asked for", mr.Missing, mr.Have)
|
||||
}
|
||||
|
||||
// --- finalize
|
||||
status, body = e.do(t, http.MethodPost, api.PathFinalize(p.Name, dep.ID), token, nil)
|
||||
var done api.Deployment
|
||||
mustJSON(t, status, http.StatusOK, body, &done)
|
||||
if done.State != api.StateReady || done.FileCount != 3 {
|
||||
t.Fatalf("finalized = %+v", done)
|
||||
}
|
||||
if done.Active {
|
||||
t.Error("finalize activated the deployment")
|
||||
}
|
||||
if done.FinalizedAt == nil {
|
||||
t.Error("finalized_at is missing from the response")
|
||||
}
|
||||
|
||||
// The tree really is on disk, and byte-identical to what was uploaded.
|
||||
dir := deploy.DeploymentDir(e.deployDir, e.projectID(t, "demo"), dep.ID)
|
||||
for name, want := range contents {
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("%s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Finalizing again is a no-op, not a second assembly.
|
||||
status, body = e.do(t, http.MethodPost, api.PathFinalize(p.Name, dep.ID), token, nil)
|
||||
mustJSON(t, status, http.StatusOK, body, nil)
|
||||
}
|
||||
|
||||
func TestManifestRejectsBadEntries(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
|
||||
good := entry("index.html", "hello")
|
||||
cases := []struct {
|
||||
name string
|
||||
files []api.FileEntry
|
||||
want api.Code
|
||||
}{
|
||||
{"escaping path", []api.FileEntry{entry("../etc/passwd", "x")}, api.CodeInvalidPath},
|
||||
{"absolute path", []api.FileEntry{entry("/etc/passwd", "x")}, api.CodeInvalidPath},
|
||||
{"empty path", []api.FileEntry{entry("", "x")}, api.CodeInvalidPath},
|
||||
{"trailing slash", []api.FileEntry{entry("dir/", "x")}, api.CodeInvalidPath},
|
||||
{"backslash", []api.FileEntry{entry(`a\b`, "x")}, api.CodeInvalidPath},
|
||||
{"NUL byte", []api.FileEntry{entry("a\x00b", "x")}, api.CodeInvalidPath},
|
||||
{"duplicate path", []api.FileEntry{good, good}, api.CodeInvalidPath},
|
||||
// A case-insensitive filesystem would silently collapse these two into
|
||||
// one file, so the manifest is refused rather than assembled wrongly.
|
||||
{"case collision", []api.FileEntry{entry("Index.html", "a"), entry("index.html", "b")}, api.CodeInvalidPath},
|
||||
{"file used as a directory", []api.FileEntry{entry("a", "x"), entry("a/b", "y")}, api.CodeInvalidPath},
|
||||
{"uppercase hex digest", []api.FileEntry{{Path: "a", Digest: strings.ToUpper(good.Digest), Size: 1}}, api.CodeBadRequest},
|
||||
{"short digest", []api.FileEntry{{Path: "a", Digest: "abc", Size: 1}}, api.CodeBadRequest},
|
||||
{"negative size", []api.FileEntry{{Path: "a", Digest: good.Digest, Size: -1}}, api.CodeBadRequest},
|
||||
{"no files", []api.FileEntry{}, api.CodeBadRequest},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dep := e.startDeployment(t, token, p.Name)
|
||||
status, body := e.do(t, http.MethodPost, api.PathManifest(p.Name, dep.ID), token,
|
||||
api.ManifestRequest{Files: tc.files})
|
||||
if status < 400 || status >= 500 {
|
||||
t.Fatalf("status = %d, want a 4xx; body: %s", status, body)
|
||||
}
|
||||
if code := errCode(t, body); code != tc.want {
|
||||
t.Errorf("code = %q, want %q; body: %s", code, tc.want, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestEnforcesProjectLimits(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.createProject(t, "demo")
|
||||
id := e.projectID(t, "demo")
|
||||
token := e.mintProject(t, id, "ci")
|
||||
|
||||
// Lower the project's own ceilings; a project may tighten but never raise
|
||||
// them, and these are the values the manifest is checked against.
|
||||
two := 2
|
||||
small := int64(4)
|
||||
status, body := e.do(t, http.MethodPatch, api.PathProject(p.Name), e.adminToken,
|
||||
api.ProjectPatch{MaxFiles: &two, MaxFileBytes: &small})
|
||||
mustJSON(t, status, http.StatusOK, body, nil)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
files []api.FileEntry
|
||||
}{
|
||||
{"too many files", []api.FileEntry{entry("a", "1"), entry("b", "2"), entry("c", "3")}},
|
||||
{"file too large", []api.FileEntry{entry("a", "much too long")}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dep := e.startDeployment(t, token, p.Name)
|
||||
status, body := e.do(t, http.MethodPost, api.PathManifest(p.Name, dep.ID), token,
|
||||
api.ManifestRequest{Files: tc.files})
|
||||
if code := errCode(t, body); code != api.CodeLimitExceeded {
|
||||
t.Errorf("status %d, code = %q, want %q; body: %s", status, code, api.CodeLimitExceeded, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("total too large", func(t *testing.T) {
|
||||
big := int64(3)
|
||||
status, body := e.do(t, http.MethodPatch, api.PathProject(p.Name), e.adminToken,
|
||||
api.ProjectPatch{MaxTotalBytes: &big})
|
||||
mustJSON(t, status, http.StatusOK, body, nil)
|
||||
|
||||
dep := e.startDeployment(t, token, p.Name)
|
||||
status, body = e.do(t, http.MethodPost, api.PathManifest(p.Name, dep.ID), token,
|
||||
api.ManifestRequest{Files: []api.FileEntry{entry("a", "12"), entry("b", "34")}})
|
||||
if code := errCode(t, body); code != api.CodeLimitExceeded {
|
||||
t.Errorf("status %d, code = %q, want %q; body: %s", status, code, api.CodeLimitExceeded, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The manifest body is bounded before it is parsed, so a 50,000-entry manifest
|
||||
// cannot be turned into an unbounded allocation.
|
||||
func TestManifestBodyIsBounded(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
e.server.Limits.MaxManifestBytes = 256
|
||||
|
||||
dep := e.startDeployment(t, token, p.Name)
|
||||
files := make([]api.FileEntry, 32)
|
||||
for i := range files {
|
||||
files[i] = entry(string(rune('a'+i%26))+strings.Repeat("x", i), "content")
|
||||
}
|
||||
status, body := e.do(t, http.MethodPost, api.PathManifest(p.Name, dep.ID), token,
|
||||
api.ManifestRequest{Files: files})
|
||||
if code := errCode(t, body); code != api.CodePayloadTooLarge {
|
||||
t.Errorf("status %d, code = %q, want %q; body: %s", status, code, api.CodePayloadTooLarge, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestRejectsMalformedBodies(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
|
||||
for _, tc := range []struct{ name, body string }{
|
||||
{"not an object", `[]`},
|
||||
{"files is not an array", `{"files":{}}`},
|
||||
{"entry is not an object", `{"files":["index.html"]}`},
|
||||
{"truncated", `{"files":[{"path":"a"`},
|
||||
{"no files key", `{"meta":{}}`},
|
||||
{"empty body", ``},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dep := e.startDeployment(t, token, p.Name)
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
e.ts.URL+api.PathManifest(p.Name, dep.ID), strings.NewReader(tc.body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := e.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", resp.StatusCode, raw)
|
||||
}
|
||||
if code := errCode(t, raw); code != api.CodeBadRequest {
|
||||
t.Errorf("code = %q, want %q", code, api.CodeBadRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown top-level keys are skipped so a newer CLI can add a field without
|
||||
// every older server rejecting its deployments.
|
||||
func TestManifestIgnoresUnknownTopLevelKeys(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
dep := e.startDeployment(t, token, p.Name)
|
||||
|
||||
body := `{"future_field":{"a":[1,2,3]},"files":[` +
|
||||
`{"path":"index.html","digest":"` + cas.Sum([]byte("hi")).String() + `","size":2}` +
|
||||
`],"another":"ignored"}`
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
e.ts.URL+api.PathManifest(p.Name, dep.ID), strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := e.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
var mr api.ManifestResponse
|
||||
mustJSON(t, resp.StatusCode, http.StatusOK, raw, &mr)
|
||||
if mr.FileCount != 1 {
|
||||
t.Errorf("file_count = %d, want 1", mr.FileCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDeploymentRejectsOversizedMeta(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
|
||||
cases := map[string]map[string]string{
|
||||
"too many entries": func() map[string]string {
|
||||
m := make(map[string]string, maxMetaEntries+1)
|
||||
for i := range maxMetaEntries + 1 {
|
||||
m[string(rune('a'+i%26))+strings.Repeat("k", i)] = "v"
|
||||
}
|
||||
return m
|
||||
}(),
|
||||
"key too long": {strings.Repeat("k", maxMetaKeyLen+1): "v"},
|
||||
"value too long": {"k": strings.Repeat("v", maxMetaValueLen+1)},
|
||||
"empty key": {"": "v"},
|
||||
}
|
||||
for name, meta := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodPost, api.PathDeployments(p.Name), token,
|
||||
api.CreateDeploymentRequest{Meta: meta})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A project-scoped key reaches its own project's deployments and nothing else.
|
||||
// The check compares resolved project ids, so it holds for the deployment id in
|
||||
// the path as well as for the project name.
|
||||
func TestDeploymentEndpointsAreProjectScoped(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "mine")
|
||||
e.createProject(t, "theirs")
|
||||
mine := e.mintProject(t, e.projectID(t, "mine"), "ci")
|
||||
theirs := e.mintProject(t, e.projectID(t, "theirs"), "ci")
|
||||
|
||||
// Their deployment, created with their own key.
|
||||
dep := e.startDeployment(t, theirs, "theirs")
|
||||
|
||||
t.Run("another project's endpoint is forbidden", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodPost, api.PathDeployments("theirs"), mine, api.CreateDeploymentRequest{})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("another project's deployment id is not found", func(t *testing.T) {
|
||||
// Named under the caller's own project, so the ownership guard passes and
|
||||
// the scoping that matters is the one in the lookup itself.
|
||||
status, body := e.do(t, http.MethodPost, api.PathFinalize("mine", dep.ID), mine, nil)
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", status, body)
|
||||
}
|
||||
if code := errCode(t, body); code != api.CodeNotFound {
|
||||
t.Errorf("code = %q, want %q", code, api.CodeNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an admin reaches every project", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodPost, api.PathDeployments("theirs"), e.adminToken,
|
||||
api.CreateDeploymentRequest{})
|
||||
mustJSON(t, status, http.StatusCreated, body, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBlobUpload(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
dep := e.startDeployment(t, token, "demo")
|
||||
|
||||
content := "hello"
|
||||
digest := cas.Sum([]byte(content)).String()
|
||||
|
||||
t.Run("content no manifest declared is refused", func(t *testing.T) {
|
||||
status, body := e.putBlob(t, token, "nobody asked for this")
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", status, body)
|
||||
}
|
||||
if code := errCode(t, body); code != api.CodeNotFound {
|
||||
t.Errorf("code = %q, want %q", code, api.CodeNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
status, body := e.do(t, http.MethodPost, api.PathManifest("demo", dep.ID), token,
|
||||
api.ManifestRequest{Files: []api.FileEntry{entry("index.html", content)}})
|
||||
mustJSON(t, status, http.StatusOK, body, nil)
|
||||
|
||||
t.Run("a digest that is not one is a 400", func(t *testing.T) {
|
||||
for _, bad := range []string{"nothex", strings.ToUpper(digest), digest + "00", "../../etc/passwd"} {
|
||||
status, body := e.putBlobAs(t, token, bad, content)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("%q: status = %d, want 400; body: %s", bad, status, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("content that does not hash to its digest is rejected", func(t *testing.T) {
|
||||
// The same length the manifest declared, so this is a digest rejection
|
||||
// and not the length check catching it first.
|
||||
status, body := e.putBlobAs(t, token, digest, "forge")
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
||||
}
|
||||
if code := errCode(t, body); code != api.CodeDigestMismatch {
|
||||
t.Errorf("code = %q, want %q", code, api.CodeDigestMismatch)
|
||||
}
|
||||
if has, _ := e.cas.Has(cas.Sum([]byte(content))); has {
|
||||
t.Fatal("the forged content was stored under the honest digest")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("more bytes than the manifest declared are rejected", func(t *testing.T) {
|
||||
dep := e.startDeployment(t, token, "demo")
|
||||
long := strings.Repeat("x", 1024)
|
||||
status, body := e.do(t, http.MethodPost, api.PathManifest("demo", dep.ID), token,
|
||||
api.ManifestRequest{Files: []api.FileEntry{{Path: "a", Digest: cas.Sum([]byte(long)).String(), Size: 4}}})
|
||||
mustJSON(t, status, http.StatusOK, body, nil)
|
||||
|
||||
status, body = e.putBlobAs(t, token, cas.Sum([]byte(long)).String(), long)
|
||||
if status < 400 || status >= 500 {
|
||||
t.Fatalf("status = %d, want a 4xx; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the honest content is accepted", func(t *testing.T) {
|
||||
status, body := e.putBlob(t, token, content)
|
||||
var br api.BlobResponse
|
||||
mustJSON(t, status, http.StatusCreated, body, &br)
|
||||
if br.Digest != digest || br.Size != int64(len(content)) {
|
||||
t.Errorf("response = %+v", br)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Every deployment route is authenticated, including the blob one that has no
|
||||
// project in its path to guard against.
|
||||
func TestDeploymentRoutesRequireAuthentication(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
dep := e.startDeployment(t, token, "demo")
|
||||
|
||||
for _, path := range []string{
|
||||
api.PathDeployments("demo"),
|
||||
api.PathManifest("demo", dep.ID),
|
||||
api.PathFinalize("demo", dep.ID),
|
||||
} {
|
||||
if status, body := e.do(t, http.MethodPost, path, "", nil); status != http.StatusUnauthorized {
|
||||
t.Errorf("POST %s unauthenticated: status = %d, want 401; body: %s", path, status, body)
|
||||
}
|
||||
}
|
||||
if status, body := e.putBlob(t, "", "anything"); status != http.StatusUnauthorized {
|
||||
t.Errorf("PUT blob unauthenticated: status = %d, want 401; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploymentRoutesRejectWrongMethods(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
dep := e.startDeployment(t, token, "demo")
|
||||
|
||||
for _, tc := range []struct{ method, path, allow string }{
|
||||
{http.MethodDelete, api.PathDeployments("demo"), "GET, POST"},
|
||||
{http.MethodPatch, api.PathDeployment("demo", dep.ID), "GET, DELETE"},
|
||||
{http.MethodGet, api.PathManifest("demo", dep.ID), "POST"},
|
||||
{http.MethodGet, api.PathFinalize("demo", dep.ID), "POST"},
|
||||
{http.MethodGet, api.PathBlob(cas.Sum([]byte("x")).String()), "PUT"},
|
||||
} {
|
||||
resp := e.doResp(t, tc.method, tc.path, token, nil)
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s %s: status = %d, want 405", tc.method, tc.path, resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Allow"); got != tc.allow {
|
||||
t.Errorf("%s %s: Allow = %q, want %q", tc.method, tc.path, got, tc.allow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeRejectsARequestBody(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
dep := e.startDeployment(t, token, "demo")
|
||||
|
||||
status, body := e.do(t, http.MethodPost, api.PathFinalize("demo", dep.ID), token,
|
||||
map[string]string{"activate": "true"})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing on these routes may put a credential on the wire or in the log, the
|
||||
// same guarantee the project and key endpoints carry.
|
||||
func TestDeploymentEndpointsNeverEchoTheToken(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
secret := token[strings.LastIndex(token, "_")+1:]
|
||||
|
||||
dep := e.startDeployment(t, token, "demo")
|
||||
_, manifestBody := e.do(t, http.MethodPost, api.PathManifest("demo", dep.ID), token,
|
||||
api.ManifestRequest{Files: []api.FileEntry{entry("index.html", "hi")}})
|
||||
_, blobBody := e.putBlob(t, token, "hi")
|
||||
_, finalizeBody := e.do(t, http.MethodPost, api.PathFinalize("demo", dep.ID), token, nil)
|
||||
|
||||
for name, body := range map[string][]byte{
|
||||
"manifest": manifestBody, "blob": blobBody, "finalize": finalizeBody,
|
||||
"log": e.logBuf.Bytes(),
|
||||
} {
|
||||
if bytes.Contains(body, []byte(secret)) || bytes.Contains(body, []byte(token)) {
|
||||
t.Errorf("the %s output contains the token", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/auth"
|
||||
"github.com/iceBear67/simplepages/internal/httpx"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// createAdminKey handles POST /api/v1/keys.
|
||||
func (s *Server) createAdminKey(w http.ResponseWriter, r *http.Request) error {
|
||||
return s.createKey(w, r, nil)
|
||||
}
|
||||
|
||||
// createProjectKey handles POST /api/v1/projects/{name}/keys.
|
||||
func (s *Server) createProjectKey(w http.ResponseWriter, r *http.Request) error {
|
||||
p, err := s.project(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.createKey(w, r, p)
|
||||
}
|
||||
|
||||
// createKey mints a key and returns it with its token.
|
||||
//
|
||||
// This is the only response in the whole API that carries a credential. It is
|
||||
// marked no-store, and the token is never written anywhere else: not to the
|
||||
// log, not to the database (only its SHA-256 is), and not to any later
|
||||
// response. Losing it means minting a new key.
|
||||
func (s *Server) createKey(w http.ResponseWriter, r *http.Request, project *store.Project) error {
|
||||
var req api.CreateKeyRequest
|
||||
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkText("name", req.Name, maxKeyNameLen); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.ExpiresAt != nil && !req.ExpiresAt.After(time.Now()) {
|
||||
return api.Errorf(api.CodeBadRequest, "expires_at is in the past")
|
||||
}
|
||||
|
||||
token, keyID, hash, err := auth.Mint()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k := &store.APIKey{
|
||||
ID: keyID,
|
||||
SecretHash: hash[:],
|
||||
Scope: store.ScopeAdmin,
|
||||
Name: req.Name,
|
||||
ExpiresAt: copyTime(req.ExpiresAt),
|
||||
}
|
||||
var projectName string
|
||||
if project != nil {
|
||||
k.Scope = store.ScopeProject
|
||||
k.ProjectID = &project.ID
|
||||
projectName = project.Name
|
||||
}
|
||||
if err := s.DB.CreateKey(r.Context(), k); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
httpx.LogAttr(r.Context(), "created_key_id", k.ID)
|
||||
noStore(w)
|
||||
httpx.WriteJSON(w, http.StatusCreated, api.CreateKeyResponse{
|
||||
Key: keyOf(k, projectName),
|
||||
Token: token,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// listKeys handles GET /api/v1/keys, admin only.
|
||||
func (s *Server) listKeys(w http.ResponseWriter, r *http.Request) error {
|
||||
ks, err := s.DB.ListKeys(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names, err := s.projectNames(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := api.KeyList{Keys: make([]api.Key, 0, len(ks))}
|
||||
for _, k := range ks {
|
||||
var name string
|
||||
if k.ProjectID != nil {
|
||||
name = names[*k.ProjectID]
|
||||
}
|
||||
out.Keys = append(out.Keys, keyOf(k, name))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listProjectKeys handles GET /api/v1/projects/{name}/keys.
|
||||
func (s *Server) listProjectKeys(w http.ResponseWriter, r *http.Request) error {
|
||||
p, err := s.project(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ks, err := s.DB.ListKeys(r.Context(), &p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := api.KeyList{Keys: make([]api.Key, 0, len(ks))}
|
||||
for _, k := range ks {
|
||||
out.Keys = append(out.Keys, keyOf(k, p.Name))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// revokeKey handles DELETE /api/v1/keys/{key_id}.
|
||||
//
|
||||
// Admins may revoke anything; a project key may revoke keys belonging to its
|
||||
// own project, which includes itself. That last case is deliberate: a CI runner
|
||||
// that believes its token leaked should be able to burn it without waiting for
|
||||
// an operator.
|
||||
func (s *Server) revokeKey(w http.ResponseWriter, r *http.Request) error {
|
||||
if err := httpx.NoBody(r); err != nil {
|
||||
return err
|
||||
}
|
||||
ident, err := s.identity(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := r.PathValue("key_id")
|
||||
if !auth.ValidKeyID(id) {
|
||||
// Malformed ids are rejected before the query so the endpoint cannot be
|
||||
// used to probe the table with arbitrary strings.
|
||||
return api.Errorf(api.CodeBadRequest, "malformed key id")
|
||||
}
|
||||
|
||||
k, err := s.DB.KeyByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// A non-admin must not be able to tell "no such key" from "someone
|
||||
// else's key": that would turn this endpoint into an oracle for
|
||||
// which key ids exist.
|
||||
if !ident.IsAdmin() {
|
||||
return errNotYourKey()
|
||||
}
|
||||
return api.Errorf(api.CodeNotFound, "no such key")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !ident.IsAdmin() {
|
||||
if k.ProjectID == nil || !ident.Owns(*k.ProjectID) {
|
||||
return errNotYourKey()
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.DB.RevokeKey(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return api.Errorf(api.CodeNotFound, "no such key")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Revocation must take effect now, not when the auth cache entry expires.
|
||||
s.Auth.V.Invalidate()
|
||||
httpx.LogAttr(r.Context(), "revoked_key_id", id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func errNotYourKey() error {
|
||||
return api.Errorf(api.CodeForbidden, "this key does not have access to that key")
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// createKey posts to path and returns the minted credential.
|
||||
func (e *env) createKey(t *testing.T, path, token string, req api.CreateKeyRequest) api.CreateKeyResponse {
|
||||
t.Helper()
|
||||
status, body := e.do(t, http.MethodPost, path, token, req)
|
||||
var out api.CreateKeyResponse
|
||||
mustJSON(t, status, http.StatusCreated, body, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCreateAdminKeyReturnsWorkingToken(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
out := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
||||
if out.Key.Scope != api.ScopeAdmin {
|
||||
t.Errorf("scope = %q, want %q", out.Key.Scope, api.ScopeAdmin)
|
||||
}
|
||||
if out.Key.Project != "" {
|
||||
t.Errorf("admin key reports project %q", out.Key.Project)
|
||||
}
|
||||
if !strings.HasPrefix(out.Token, "pgs_"+out.Key.ID+"_") {
|
||||
t.Errorf("token does not carry its own key id %q", out.Key.ID)
|
||||
}
|
||||
|
||||
// The new key authenticates and reports itself.
|
||||
status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), out.Token, nil)
|
||||
var who api.WhoAmI
|
||||
mustJSON(t, status, http.StatusOK, body, &who)
|
||||
if who.KeyID != out.Key.ID || who.Scope != api.ScopeAdmin || who.Name != "ci" {
|
||||
t.Errorf("whoami = %+v, want key %q scope admin name ci", who, out.Key.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// The token exists on the wire exactly once. If it were cacheable, a shared
|
||||
// proxy in front of the management API would keep a live credential on disk.
|
||||
func TestCreateKeyResponseIsNoStore(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
resp := e.doResp(t, http.MethodPost, api.PathKeys(), e.adminToken, api.CreateKeyRequest{})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "no-store") {
|
||||
t.Errorf("Cache-Control = %q, want it to contain no-store", cc)
|
||||
}
|
||||
}
|
||||
|
||||
// The one invariant that matters most in this package: no endpoint other than
|
||||
// creation may ever put a secret on the wire. Asserted against the raw bytes,
|
||||
// not a decoded struct, so a field added to api.Key later cannot leak one past
|
||||
// this test.
|
||||
func TestNoEndpointEverReturnsASecret(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
admin := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "admin-2"})
|
||||
proj := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
||||
|
||||
secrets := []string{
|
||||
admin.Token, secretOf(t, admin.Token),
|
||||
proj.Token, secretOf(t, proj.Token),
|
||||
e.adminToken, secretOf(t, e.adminToken),
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ method, path, token string }{
|
||||
{http.MethodGet, api.PathKeys(), e.adminToken},
|
||||
{http.MethodGet, api.PathProjectKeys("demo"), e.adminToken},
|
||||
{http.MethodGet, api.PathProjectKeys("demo"), proj.Token},
|
||||
{http.MethodGet, api.PathWhoAmI(), proj.Token},
|
||||
{http.MethodGet, api.PathProject("demo"), e.adminToken},
|
||||
{http.MethodGet, api.PathProjects(), e.adminToken},
|
||||
} {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
status, body := e.do(t, tc.method, tc.path, tc.token, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", status, body)
|
||||
}
|
||||
for _, s := range secrets {
|
||||
if bytes.Contains(body, []byte(s)) {
|
||||
t.Fatalf("response contains a credential")
|
||||
}
|
||||
}
|
||||
// The public half is fine to return, and the listings would be
|
||||
// useless without it — check the test is actually looking at keys.
|
||||
if strings.HasSuffix(tc.path, "/keys") && !bytes.Contains(body, []byte(proj.Key.ID)) &&
|
||||
!bytes.Contains(body, []byte(admin.Key.ID)) {
|
||||
t.Errorf("key listing mentions no key id at all: %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// And the log, which sees every request, never saw one either.
|
||||
if logged := e.logBuf.String(); logged != "" {
|
||||
for _, s := range secrets {
|
||||
if strings.Contains(logged, s) {
|
||||
t.Fatal("a credential reached the log")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// secretOf returns the half of a token that must never be seen again. The split
|
||||
// is bounded at three because the base64url secret may itself contain "_".
|
||||
func secretOf(t *testing.T, token string) string {
|
||||
t.Helper()
|
||||
parts := strings.SplitN(token, "_", 3)
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("token has %d parts, want 3", len(parts))
|
||||
}
|
||||
return parts[2]
|
||||
}
|
||||
|
||||
func TestCreateProjectKey(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
out := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
||||
if out.Key.Scope != api.ScopeProject {
|
||||
t.Errorf("scope = %q, want %q", out.Key.Scope, api.ScopeProject)
|
||||
}
|
||||
if out.Key.Project != "demo" {
|
||||
t.Errorf("project = %q, want demo", out.Key.Project)
|
||||
}
|
||||
|
||||
status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), out.Token, nil)
|
||||
var who api.WhoAmI
|
||||
mustJSON(t, status, http.StatusOK, body, &who)
|
||||
if who.Scope != api.ScopeProject || who.Project != "demo" {
|
||||
t.Errorf("whoami = %+v, want scope project on demo", who)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectKeyForUnknownProject(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
status, body := e.do(t, http.MethodPost, api.PathProjectKeys("ghost"), e.adminToken,
|
||||
api.CreateKeyRequest{})
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateKeyRejectsBadInput(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
past := time.Now().Add(-time.Minute)
|
||||
|
||||
t.Run("expired on arrival", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken,
|
||||
api.CreateKeyRequest{ExpiresAt: &past})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
t.Run("control characters in name", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken,
|
||||
api.CreateKeyRequest{Name: "ci\x1b[2Jrunner"})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
t.Run("oversized name", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken,
|
||||
api.CreateKeyRequest{Name: strings.Repeat("a", maxKeyNameLen+1)})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExpiredKeyDoesNotAuthenticate(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The API refuses to mint one already expired, so this goes in through the
|
||||
// store to exercise the verifier's expiry check rather than the validator's.
|
||||
future := time.Now().Add(time.Hour)
|
||||
live := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{ExpiresAt: &future})
|
||||
if status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), live.Token, nil); status != http.StatusOK {
|
||||
t.Fatalf("key expiring in an hour: status = %d, body: %s", status, body)
|
||||
}
|
||||
|
||||
past := time.Now().Add(-time.Hour)
|
||||
token := e.mintWith(t, store.ScopeAdmin, nil, "stale", &past)
|
||||
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), token, nil); status != http.StatusUnauthorized {
|
||||
t.Errorf("expired key authenticated: status = %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// Revocation must be visible on the next request, not when a cache entry ages
|
||||
// out — an operator revoking a leaked token is racing an attacker who has it.
|
||||
func TestRevokeTakesEffectImmediately(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
victim := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
||||
|
||||
// Warm the auth cache: without Invalidate() the revocation would not be
|
||||
// observed until the entry expired.
|
||||
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), victim.Token, nil); status != http.StatusOK {
|
||||
t.Fatalf("key did not work before revocation")
|
||||
}
|
||||
|
||||
status, body := e.do(t, http.MethodDelete, api.PathKey(victim.Key.ID), e.adminToken, nil)
|
||||
if status != http.StatusNoContent {
|
||||
t.Fatalf("revoke: status = %d, want 204; body: %s", status, body)
|
||||
}
|
||||
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), victim.Token, nil); status != http.StatusUnauthorized {
|
||||
t.Errorf("revoked key still authenticates: status = %d", status)
|
||||
}
|
||||
|
||||
// Revoking again is a no-op rather than an error: a retrying CI step must
|
||||
// not fail on the second attempt.
|
||||
if status, body := e.do(t, http.MethodDelete, api.PathKey(victim.Key.ID), e.adminToken, nil); status != http.StatusNoContent {
|
||||
t.Errorf("second revoke: status = %d, want 204; body: %s", status, body)
|
||||
}
|
||||
|
||||
// The listing keeps it, with a revocation timestamp, so an operator can see
|
||||
// what happened.
|
||||
status, body = e.do(t, http.MethodGet, api.PathKeys(), e.adminToken, nil)
|
||||
var list api.KeyList
|
||||
mustJSON(t, status, http.StatusOK, body, &list)
|
||||
var found bool
|
||||
for _, k := range list.Keys {
|
||||
if k.ID == victim.Key.ID {
|
||||
found = true
|
||||
if !k.Revoked() {
|
||||
t.Errorf("key %s is listed without revoked_at", k.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("revoked key vanished from the listing")
|
||||
}
|
||||
}
|
||||
|
||||
// A CI runner that believes its token leaked should be able to burn it without
|
||||
// waiting for an operator.
|
||||
func TestProjectKeyMayRevokeItself(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
k := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
||||
|
||||
status, body := e.do(t, http.MethodDelete, api.PathKey(k.Key.ID), k.Token, nil)
|
||||
if status != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", status, body)
|
||||
}
|
||||
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), k.Token, nil); status != http.StatusUnauthorized {
|
||||
t.Errorf("key survived revoking itself: status = %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeAuthorisation(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
e.createProject(t, "other")
|
||||
|
||||
mine := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
||||
sibling := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci-2"})
|
||||
foreign := e.createKey(t, api.PathProjectKeys("other"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
||||
adminKey := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "admin-2"})
|
||||
|
||||
t.Run("sibling in the same project", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodDelete, api.PathKey(sibling.Key.ID), mine.Token, nil)
|
||||
if status != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
t.Run("another project's key", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodDelete, api.PathKey(foreign.Key.ID), mine.Token, nil)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
t.Run("an admin key", func(t *testing.T) {
|
||||
// An admin key has no project, so a project-scoped caller can never own
|
||||
// it. Escalating by revoking the operator's credentials is the attack
|
||||
// this closes.
|
||||
status, body := e.do(t, http.MethodDelete, api.PathKey(adminKey.Key.ID), mine.Token, nil)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body: %s", status, body)
|
||||
}
|
||||
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), adminKey.Token, nil); status != http.StatusOK {
|
||||
t.Errorf("the admin key stopped working: status = %d", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 404 versus 403 is an oracle: a project key must not be able to walk the key
|
||||
// id space and learn which ones exist.
|
||||
func TestRevokeUnknownKey(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
proj := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
||||
const unknown = "abcdefghijklmnop" // well-formed, never minted
|
||||
|
||||
t.Run("admin", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodDelete, api.PathKey(unknown), e.adminToken, nil)
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
t.Run("project", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodDelete, api.PathKey(unknown), proj.Token, nil)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRevokeMalformedKeyID(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, id := range []string{"nope", "ABCDEFGHIJKLMNOP", "abcdefghijklmno1", strings.Repeat("a", 17)} {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodDelete, api.PathKey(id), e.adminToken, nil)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectKeysIsScoped(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
e.createProject(t, "other")
|
||||
mine := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
||||
foreign := e.createKey(t, api.PathProjectKeys("other"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
||||
|
||||
status, body := e.do(t, http.MethodGet, api.PathProjectKeys("demo"), mine.Token, nil)
|
||||
var list api.KeyList
|
||||
mustJSON(t, status, http.StatusOK, body, &list)
|
||||
if len(list.Keys) != 1 || list.Keys[0].ID != mine.Key.ID {
|
||||
t.Fatalf("listing = %+v, want just %s", list.Keys, mine.Key.ID)
|
||||
}
|
||||
if list.Keys[0].Project != "demo" {
|
||||
t.Errorf("project = %q, want demo", list.Keys[0].Project)
|
||||
}
|
||||
|
||||
if status, _ := e.do(t, http.MethodGet, api.PathProjectKeys("other"), mine.Token, nil); status != http.StatusForbidden {
|
||||
t.Errorf("read another project's keys: status = %d, want 403", status)
|
||||
}
|
||||
|
||||
// The admin listing spans projects and names each key's project.
|
||||
status, body = e.do(t, http.MethodGet, api.PathKeys(), e.adminToken, nil)
|
||||
var all api.KeyList
|
||||
mustJSON(t, status, http.StatusOK, body, &all)
|
||||
byID := make(map[string]api.Key, len(all.Keys))
|
||||
for _, k := range all.Keys {
|
||||
byID[k.ID] = k
|
||||
}
|
||||
if got := byID[foreign.Key.ID].Project; got != "other" {
|
||||
t.Errorf("foreign key's project = %q, want other", got)
|
||||
}
|
||||
if _, ok := byID[mine.Key.ID]; !ok {
|
||||
t.Errorf("admin listing is missing %s", mine.Key.ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/deploy"
|
||||
)
|
||||
|
||||
// exec runs a statement against the store. Tests use it to put the database
|
||||
// into a state the API cannot produce — drifted reference counts, above all,
|
||||
// which is the only thing fsck exists to find.
|
||||
func (e *env) exec(t *testing.T, query string, args ...any) {
|
||||
t.Helper()
|
||||
err := e.db.Tx(t.Context(), func(tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(t.Context(), query, args...)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", query, err)
|
||||
}
|
||||
}
|
||||
|
||||
// oneFile is a single-page deployment, enough to give each version content no
|
||||
// other version shares — which is what makes the collector's effect visible.
|
||||
func oneFile(version string) map[string]string {
|
||||
return map[string]string{"index.html": "<h1>" + version + "</h1>"}
|
||||
}
|
||||
|
||||
// hasContent reports whether the content store still holds these bytes.
|
||||
func (e *env) hasContent(t *testing.T, content string) bool {
|
||||
t.Helper()
|
||||
ok, err := e.cas.Has(cas.Sum([]byte(content)))
|
||||
if err != nil {
|
||||
t.Fatalf("cas has: %v", err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func (e *env) activate(t *testing.T, token, project, id string) {
|
||||
t.Helper()
|
||||
status, body := e.do(t, http.MethodPost, api.PathActivate(project, id), token, nil)
|
||||
mustJSON(t, status, http.StatusOK, body, nil)
|
||||
}
|
||||
|
||||
func (e *env) listDeployments(t *testing.T, token, project, query string) api.DeploymentList {
|
||||
t.Helper()
|
||||
status, body := e.do(t, http.MethodGet, api.PathDeployments(project)+query, token, nil)
|
||||
var out api.DeploymentList
|
||||
mustJSON(t, status, http.StatusOK, body, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func ids(list api.DeploymentList) []string {
|
||||
out := make([]string, 0, len(list.Deployments))
|
||||
for _, d := range list.Deployments {
|
||||
out = append(out, d.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestListDeployments(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
|
||||
d1 := e.readyDeployment(t, token, "demo", oneFile("v1"))
|
||||
d2 := e.readyDeployment(t, token, "demo", oneFile("v2"))
|
||||
// Left pending: a listing exists partly so an operator can see the
|
||||
// deployments that never finished.
|
||||
d3 := e.startDeployment(t, token, "demo")
|
||||
e.activate(t, token, "demo", d1.ID)
|
||||
|
||||
all := e.listDeployments(t, token, "demo", "")
|
||||
if got, want := ids(all), []string{d3.ID, d2.ID, d1.ID}; !equalStrings(got, want) {
|
||||
t.Errorf("ids = %v, want newest first %v", got, want)
|
||||
}
|
||||
if all.NextCursor != "" {
|
||||
t.Errorf("next_cursor = %q, want empty when a page is the whole list", all.NextCursor)
|
||||
}
|
||||
for _, d := range all.Deployments {
|
||||
if d.Project != "demo" {
|
||||
t.Errorf("deployment %s: project = %q", d.ID, d.Project)
|
||||
}
|
||||
if (d.ID == d1.ID) != d.Active {
|
||||
t.Errorf("deployment %s: active = %v, want it only for the activated one", d.ID, d.Active)
|
||||
}
|
||||
if len(d.Files) != 0 {
|
||||
t.Errorf("deployment %s: a listing carried %d manifest entries", d.ID, len(d.Files))
|
||||
}
|
||||
}
|
||||
|
||||
ready := e.listDeployments(t, token, "demo", "?state=ready")
|
||||
if got, want := ids(ready), []string{d2.ID, d1.ID}; !equalStrings(got, want) {
|
||||
t.Errorf("state=ready = %v, want %v", got, want)
|
||||
}
|
||||
if got := ids(e.listDeployments(t, token, "demo", "?state=failed")); len(got) != 0 {
|
||||
t.Errorf("state=failed = %v, want none", got)
|
||||
}
|
||||
|
||||
// Paging: one at a time, following the cursor, visits the same list.
|
||||
var paged []string
|
||||
cursor := ""
|
||||
for range 5 {
|
||||
q := "?limit=1"
|
||||
if cursor != "" {
|
||||
q += "&cursor=" + cursor
|
||||
}
|
||||
page := e.listDeployments(t, token, "demo", q)
|
||||
paged = append(paged, ids(page)...)
|
||||
cursor = page.NextCursor
|
||||
if cursor == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if want := []string{d3.ID, d2.ID, d1.ID}; !equalStrings(paged, want) {
|
||||
t.Errorf("paged = %v, want %v", paged, want)
|
||||
}
|
||||
|
||||
// A typo in the filter is an error, not an empty list that reads as "this
|
||||
// project has no deployments".
|
||||
status, body := e.do(t, http.MethodGet, api.PathDeployments("demo")+"?state=redy", token, nil)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("state=redy: status = %d; body: %s", status, body)
|
||||
}
|
||||
if code := errCode(t, body); code != api.CodeBadRequest {
|
||||
t.Errorf("code = %q, want %q", code, api.CodeBadRequest)
|
||||
}
|
||||
status, body = e.do(t, http.MethodGet, api.PathDeployments("demo")+"?limit=9000", token, nil)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("limit=9000: status = %d; body: %s", status, body)
|
||||
}
|
||||
|
||||
// Another project's key cannot read this project's deployments.
|
||||
e.createProject(t, "other")
|
||||
otherToken := e.mintProject(t, e.projectID(t, "other"), "other-ci")
|
||||
status, body = e.do(t, http.MethodGet, api.PathDeployments("demo"), otherToken, nil)
|
||||
if status != http.StatusForbidden {
|
||||
t.Errorf("cross-project list: status = %d; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDeployment(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
|
||||
contents := map[string]string{
|
||||
"index.html": "<h1>hello</h1>",
|
||||
"assets/app.js": "console.log(1)",
|
||||
}
|
||||
dep := e.readyDeployment(t, token, "demo", contents)
|
||||
|
||||
status, body := e.do(t, http.MethodGet, api.PathDeployment("demo", dep.ID), token, nil)
|
||||
var got api.Deployment
|
||||
mustJSON(t, status, http.StatusOK, body, &got)
|
||||
if got.ID != dep.ID || got.State != api.StateReady || got.FileCount != 2 {
|
||||
t.Errorf("deployment = %+v", got)
|
||||
}
|
||||
if got.Files != nil {
|
||||
t.Errorf("files = %v, want them withheld unless asked for", got.Files)
|
||||
}
|
||||
if got.URL != "" {
|
||||
t.Errorf("url = %q, want none: this deployment is not the one being served", got.URL)
|
||||
}
|
||||
|
||||
status, body = e.do(t, http.MethodGet, api.PathDeployment("demo", dep.ID)+"?files=true", token, nil)
|
||||
got = api.Deployment{}
|
||||
mustJSON(t, status, http.StatusOK, body, &got)
|
||||
if len(got.Files) != len(contents) {
|
||||
t.Fatalf("files = %+v, want %d entries", got.Files, len(contents))
|
||||
}
|
||||
for _, f := range got.Files {
|
||||
content, ok := contents[f.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected manifest path %q", f.Path)
|
||||
continue
|
||||
}
|
||||
if f.Digest != cas.Sum([]byte(content)).String() || f.Size != int64(len(content)) {
|
||||
t.Errorf("%s = %+v, want the digest and size of its content", f.Path, f)
|
||||
}
|
||||
}
|
||||
if got.Files[0].Path != "assets/app.js" {
|
||||
t.Errorf("files start at %q, want them ordered by path", got.Files[0].Path)
|
||||
}
|
||||
|
||||
status, body = e.do(t, http.MethodGet, api.PathDeployment("demo", "dpl_0000000000000000"), token, nil)
|
||||
if status != http.StatusNotFound || errCode(t, body) != api.CodeNotFound {
|
||||
t.Errorf("unknown id: status = %d; body: %s", status, body)
|
||||
}
|
||||
|
||||
// A deployment is looked up within its project, so naming it under another
|
||||
// project is "no such deployment" — not a way to read across the boundary,
|
||||
// and not a confirmation that the id exists somewhere.
|
||||
e.createProject(t, "other")
|
||||
status, body = e.do(t, http.MethodGet, api.PathDeployment("other", dep.ID), e.adminToken, nil)
|
||||
if status != http.StatusNotFound || errCode(t, body) != api.CodeNotFound {
|
||||
t.Errorf("cross-project read: status = %d; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDeploymentEndpoint(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
pid := e.projectID(t, "demo")
|
||||
token := e.mintProject(t, pid, "ci")
|
||||
|
||||
keep := e.readyDeployment(t, token, "demo", oneFile("v1"))
|
||||
spare := e.readyDeployment(t, token, "demo", oneFile("v2"))
|
||||
e.activate(t, token, "demo", keep.ID)
|
||||
|
||||
// The one being served is a conflict, and specifically not a 403: the
|
||||
// caller is allowed to do this, just not yet.
|
||||
status, body := e.do(t, http.MethodDelete, api.PathDeployment("demo", keep.ID), token, nil)
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("delete active: status = %d; body: %s", status, body)
|
||||
}
|
||||
if code := errCode(t, body); code != api.CodeDeploymentActive {
|
||||
t.Errorf("code = %q, want %q", code, api.CodeDeploymentActive)
|
||||
}
|
||||
|
||||
dir := deploy.DeploymentDir(e.deployDir, pid, spare.ID)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Fatalf("stat %s: %v", dir, err)
|
||||
}
|
||||
status, body = e.do(t, http.MethodDelete, api.PathDeployment("demo", spare.ID), token, nil)
|
||||
if status != http.StatusNoContent || len(body) != 0 {
|
||||
t.Fatalf("delete: status = %d; body: %s", status, body)
|
||||
}
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
t.Errorf("stat %s after delete: err = %v, want it gone", dir, err)
|
||||
}
|
||||
|
||||
// Gone means gone, and deleting it again says so rather than reporting a
|
||||
// success that did nothing.
|
||||
status, body = e.do(t, http.MethodGet, api.PathDeployment("demo", spare.ID), token, nil)
|
||||
if status != http.StatusNotFound {
|
||||
t.Errorf("get deleted: status = %d; body: %s", status, body)
|
||||
}
|
||||
status, body = e.do(t, http.MethodDelete, api.PathDeployment("demo", spare.ID), token, nil)
|
||||
if status != http.StatusNotFound || errCode(t, body) != api.CodeNotFound {
|
||||
t.Errorf("second delete: status = %d; body: %s", status, body)
|
||||
}
|
||||
|
||||
// The site is still being served by the deployment that was left alone.
|
||||
if sp, ok := e.sites.Lookup("demo"); !ok || sp.Active() == nil || sp.Active().ID != keep.ID {
|
||||
t.Errorf("serving %+v, want %s untouched", sp, keep.ID)
|
||||
}
|
||||
|
||||
status, body = e.do(t, http.MethodDelete, api.PathDeployment("demo", keep.ID), token,
|
||||
map[string]string{"unexpected": "body"})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("delete with a body: status = %d; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCEndpoint(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.createProject(t, "demo")
|
||||
pid := e.projectID(t, "demo")
|
||||
token := e.mintProject(t, pid, "ci")
|
||||
|
||||
// Keep one spare deployment and no grace, so retention has something to do
|
||||
// within the lifetime of a test. The blob grace is what protects content a
|
||||
// request may be about to open; an hour of it would outlast any test, so
|
||||
// this pass is told to collect immediately.
|
||||
zero, one := 0, 1
|
||||
status, body := e.do(t, http.MethodPatch, api.PathProject(p.Name), e.adminToken,
|
||||
api.ProjectPatch{RetentionCount: &one, RetentionGrace: &zero})
|
||||
mustJSON(t, status, http.StatusOK, body, nil)
|
||||
e.server.Deploy.BlobGrace = -time.Minute
|
||||
|
||||
d1 := e.readyDeployment(t, token, "demo", oneFile("v1"))
|
||||
d2 := e.readyDeployment(t, token, "demo", oneFile("v2"))
|
||||
d3 := e.readyDeployment(t, token, "demo", oneFile("v3"))
|
||||
e.activate(t, token, "demo", d1.ID)
|
||||
|
||||
// Active is excluded outright, then the newest inactive one is the single
|
||||
// deployment retention keeps — so d2 is what a pass would delete.
|
||||
status, body = e.do(t, http.MethodPost, api.PathGC(), e.adminToken, api.GCRequest{DryRun: true})
|
||||
var dry api.GCStats
|
||||
mustJSON(t, status, http.StatusOK, body, &dry)
|
||||
if !dry.DryRun || dry.DeploymentsDeleted != 1 {
|
||||
t.Errorf("dry run = %+v, want 1 deployment reported", dry)
|
||||
}
|
||||
status, _ = e.do(t, http.MethodGet, api.PathDeployment("demo", d2.ID), token, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("a dry run deleted %s", d2.ID)
|
||||
}
|
||||
|
||||
status, body = e.do(t, http.MethodPost, api.PathGC(), e.adminToken, api.GCRequest{})
|
||||
var stats api.GCStats
|
||||
mustJSON(t, status, http.StatusOK, body, &stats)
|
||||
if stats.DryRun || stats.DeploymentsDeleted != 1 {
|
||||
t.Fatalf("collect = %+v, want 1 deployment deleted", stats)
|
||||
}
|
||||
if stats.BlobsDeleted != 1 || stats.BytesFreed == 0 {
|
||||
t.Errorf("collect = %+v, want the content only that deployment held", stats)
|
||||
}
|
||||
if e.hasContent(t, oneFile("v2")["index.html"]) {
|
||||
t.Error("v2's content survived the deployment that referenced it")
|
||||
}
|
||||
for _, keep := range []string{"v1", "v3"} {
|
||||
if !e.hasContent(t, oneFile(keep)["index.html"]) {
|
||||
t.Errorf("%s's content was collected while a deployment still referenced it", keep)
|
||||
}
|
||||
}
|
||||
|
||||
status, _ = e.do(t, http.MethodGet, api.PathDeployment("demo", d2.ID), token, nil)
|
||||
if status != http.StatusNotFound {
|
||||
t.Errorf("get collected: status = %d, want 404", status)
|
||||
}
|
||||
for _, d := range []api.Deployment{d1, d3} {
|
||||
if status, _ := e.do(t, http.MethodGet, api.PathDeployment("demo", d.ID), token, nil); status != http.StatusOK {
|
||||
t.Errorf("%s: status = %d, want it kept", d.ID, status)
|
||||
}
|
||||
}
|
||||
|
||||
// A body is optional, and a second pass finds nothing left to do.
|
||||
status, body = e.do(t, http.MethodPost, api.PathGC(), e.adminToken, nil)
|
||||
stats = api.GCStats{}
|
||||
mustJSON(t, status, http.StatusOK, body, &stats)
|
||||
if stats.DeploymentsDeleted != 0 || stats.BlobsDeleted != 0 {
|
||||
t.Errorf("second pass = %+v, want nothing", stats)
|
||||
}
|
||||
|
||||
// Collection is server-wide, so it is admin-only however trusted the
|
||||
// project key is.
|
||||
status, body = e.do(t, http.MethodPost, api.PathGC(), token, api.GCRequest{})
|
||||
if status != http.StatusForbidden || errCode(t, body) != api.CodeForbidden {
|
||||
t.Errorf("project key: status = %d; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFsckEndpoint(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
e.readyDeployment(t, token, "demo", map[string]string{
|
||||
"index.html": "<h1>hello</h1>",
|
||||
"app.js": "console.log(1)",
|
||||
})
|
||||
|
||||
// Triggers maintain the counts, so a server that has only been used through
|
||||
// the API is clean by construction. Asserting that is the baseline the
|
||||
// injected drift below is measured against.
|
||||
status, body := e.do(t, http.MethodPost, api.PathFsck(), e.adminToken, api.FsckRequest{})
|
||||
var rep api.FsckReport
|
||||
mustJSON(t, status, http.StatusOK, body, &rep)
|
||||
if rep.Blobs != 2 || rep.DriftCount != 0 || len(rep.Drift) != 0 || rep.Repaired != 0 {
|
||||
t.Fatalf("clean report = %+v", rep)
|
||||
}
|
||||
|
||||
digest := cas.Sum([]byte("console.log(1)"))
|
||||
e.exec(t, `UPDATE blobs SET refcount = 7 WHERE digest = ?`, digest[:])
|
||||
|
||||
status, body = e.do(t, http.MethodPost, api.PathFsck(), e.adminToken, api.FsckRequest{})
|
||||
rep = api.FsckReport{}
|
||||
mustJSON(t, status, http.StatusOK, body, &rep)
|
||||
if rep.DriftCount != 1 || len(rep.Drift) != 1 {
|
||||
t.Fatalf("report = %+v, want the one drifted blob", rep)
|
||||
}
|
||||
if d := rep.Drift[0]; d.Digest != digest.String() || d.Stored != 7 || d.Actual != 1 {
|
||||
t.Errorf("drift = %+v, want stored 7 and actual 1 for %s", d, digest)
|
||||
}
|
||||
if rep.Repaired != 0 {
|
||||
t.Errorf("repaired = %d without being asked to", rep.Repaired)
|
||||
}
|
||||
|
||||
status, body = e.do(t, http.MethodPost, api.PathFsck(), e.adminToken, api.FsckRequest{Repair: true})
|
||||
rep = api.FsckReport{}
|
||||
mustJSON(t, status, http.StatusOK, body, &rep)
|
||||
if rep.DriftCount != 1 || rep.Repaired != 1 {
|
||||
t.Fatalf("repair = %+v", rep)
|
||||
}
|
||||
|
||||
status, body = e.do(t, http.MethodPost, api.PathFsck(), e.adminToken, nil)
|
||||
rep = api.FsckReport{}
|
||||
mustJSON(t, status, http.StatusOK, body, &rep)
|
||||
if rep.DriftCount != 0 {
|
||||
t.Errorf("after repair = %+v, want no drift", rep)
|
||||
}
|
||||
|
||||
status, body = e.do(t, http.MethodPost, api.PathFsck(), token, api.FsckRequest{})
|
||||
if status != http.StatusForbidden || errCode(t, body) != api.CodeForbidden {
|
||||
t.Errorf("project key: status = %d; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/httpx"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// createProject handles POST /api/v1/projects.
|
||||
func (s *Server) createProject(w http.ResponseWriter, r *http.Request) error {
|
||||
var req api.CreateProjectRequest
|
||||
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if err := checkProjectName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p := store.DefaultProject(name)
|
||||
s.clampDefaults(p)
|
||||
if err := s.applyPatch(p, req.Patch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.DB.CreateProject(r.Context(), p); err != nil {
|
||||
if errors.Is(err, store.ErrExists) {
|
||||
return api.Errorf(api.CodeProjectExists, "project %q already exists", name)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
httpx.LogAttr(r.Context(), "project", name)
|
||||
if s.Hooks.ProjectChanged != nil {
|
||||
s.Hooks.ProjectChanged(r.Context(), p)
|
||||
}
|
||||
w.Header().Set("Location", api.PathProject(name))
|
||||
httpx.WriteJSON(w, http.StatusCreated, s.projectOf(p))
|
||||
return nil
|
||||
}
|
||||
|
||||
// listProjects handles GET /api/v1/projects.
|
||||
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) error {
|
||||
limit, err := intQuery(r, "limit", 100, 1, 500)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ps, next, err := s.DB.ListProjects(r.Context(), limit, r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := api.ProjectList{Projects: make([]api.Project, 0, len(ps)), NextCursor: next}
|
||||
for _, p := range ps {
|
||||
out.Projects = append(out.Projects, s.projectOf(p))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getProject handles GET /api/v1/projects/{name}.
|
||||
func (s *Server) getProject(w http.ResponseWriter, r *http.Request) error {
|
||||
p, err := s.project(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, s.projectOf(p))
|
||||
return nil
|
||||
}
|
||||
|
||||
// patchProject handles PATCH /api/v1/projects/{name}.
|
||||
func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) error {
|
||||
p, err := s.project(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var patch api.ProjectPatch
|
||||
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &patch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply to a copy so a rejected field cannot leave the caller looking at a
|
||||
// partly-updated project in the error response.
|
||||
updated := *p
|
||||
if err := s.applyPatch(&updated, &patch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.DB.UpdateProject(r.Context(), &updated); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return api.Errorf(api.CodeNotFound, "no such project")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if s.Hooks.ProjectChanged != nil {
|
||||
s.Hooks.ProjectChanged(r.Context(), &updated)
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, s.projectOf(&updated))
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteProject handles DELETE /api/v1/projects/{name}.
|
||||
//
|
||||
// The cascade takes the project's keys, deployments and manifest rows with it;
|
||||
// blob refcounts fall as the manifest rows go, so the content is reclaimed by
|
||||
// the next GC pass rather than synchronously here.
|
||||
func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) error {
|
||||
if err := httpx.NoBody(r); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := s.project(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.DB.DeleteProject(r.Context(), p.ID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return api.Errorf(api.CodeNotFound, "no such project")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// The project's keys went with it. Nothing else invalidates the auth cache,
|
||||
// so without this a deleted project's key would keep working for up to the
|
||||
// cache TTL.
|
||||
s.Auth.V.Invalidate()
|
||||
if s.Hooks.ProjectDeleted != nil {
|
||||
s.Hooks.ProjectDeleted(r.Context(), p)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// project loads the project named by the {name} wildcard.
|
||||
//
|
||||
// The ownership guard already resolved the same name; looking it up again costs
|
||||
// one indexed read on a cold path and keeps the guard free of any obligation to
|
||||
// hand state to the handler.
|
||||
func (s *Server) project(r *http.Request) (*store.Project, error) {
|
||||
name := r.PathValue("name")
|
||||
if err := checkProjectName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := s.DB.ProjectByName(r.Context(), name)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, api.Errorf(api.CodeNotFound, "no such project")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
httpx.LogAttr(r.Context(), "project", p.Name)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// clampDefaults lowers the schema's per-project defaults to the server-wide
|
||||
// ceilings, so a new project on a server with tightened limits does not start
|
||||
// out already over them.
|
||||
func (s *Server) clampDefaults(p *store.Project) {
|
||||
if n := s.Limits.MaxManifestFiles; n > 0 && p.MaxFiles > n {
|
||||
p.MaxFiles = n
|
||||
}
|
||||
if n := s.Limits.MaxFileBytes; n > 0 && p.MaxFileBytes > n {
|
||||
p.MaxFileBytes = n
|
||||
}
|
||||
if p.MaxTotalBytes < p.MaxFileBytes {
|
||||
p.MaxTotalBytes = p.MaxFileBytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
)
|
||||
|
||||
func TestCreateProjectAppliesDefaults(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.createProject(t, "demo")
|
||||
if p.Name != "demo" {
|
||||
t.Errorf("name = %q", p.Name)
|
||||
}
|
||||
if p.IndexFile != "index.html" {
|
||||
t.Errorf("index_file = %q, want index.html", p.IndexFile)
|
||||
}
|
||||
if p.CacheControl == "" {
|
||||
t.Error("cache_control is empty; a project must always have one")
|
||||
}
|
||||
if p.RetentionCount != 10 {
|
||||
t.Errorf("retention_count = %d, want 10", p.RetentionCount)
|
||||
}
|
||||
if p.CreatedAt.IsZero() || p.UpdatedAt.IsZero() {
|
||||
t.Errorf("timestamps not set: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectSetsLocation(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
resp := e.doResp(t, http.MethodPost, api.PathProjects(), e.adminToken,
|
||||
api.CreateProjectRequest{Name: "demo"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != api.PathProject("demo") {
|
||||
t.Errorf("Location = %q, want %q", loc, api.PathProject("demo"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectRejectsBadNames(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The name becomes a "~name" entry in $WEBROOT, so anything that could carry
|
||||
// a separator or a traversal has to be impossible by construction.
|
||||
names := []string{
|
||||
"", " ", "Demo", "demo/evil", "demo\\evil", "..", ".hidden", "-lead",
|
||||
"_lead", "demo\x00", "demo project", "デモ", "demo\x1b", "~demo",
|
||||
strings.Repeat("a", 64),
|
||||
}
|
||||
for i, name := range names {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodPost, api.PathProjects(), e.adminToken,
|
||||
api.CreateProjectRequest{Name: name})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("%q: status = %d, want 400; body: %s", name, status, body)
|
||||
}
|
||||
if got := errCode(t, body); got != api.CodeInvalidProjectName {
|
||||
t.Errorf("%q: code = %q, want %q", name, got, api.CodeInvalidProjectName)
|
||||
}
|
||||
})
|
||||
}
|
||||
// The boundary the pattern actually allows.
|
||||
e.createProject(t, strings.Repeat("a", 63))
|
||||
}
|
||||
|
||||
// A name arriving from a shell pipeline often has a trailing newline. Trimming
|
||||
// it is a convenience, and it is the only normalisation the name gets — the
|
||||
// pattern decides everything else.
|
||||
func TestCreateProjectTrimsSurroundingSpace(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
status, body := e.do(t, http.MethodPost, api.PathProjects(), e.adminToken,
|
||||
api.CreateProjectRequest{Name: " demo\n"})
|
||||
var p api.Project
|
||||
mustJSON(t, status, http.StatusCreated, body, &p)
|
||||
if p.Name != "demo" {
|
||||
t.Fatalf("name = %q, want demo", p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectDuplicate(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
status, body := e.do(t, http.MethodPost, api.PathProjects(), e.adminToken,
|
||||
api.CreateProjectRequest{Name: "demo"})
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409; body: %s", status, body)
|
||||
}
|
||||
if got := errCode(t, body); got != api.CodeProjectExists {
|
||||
t.Errorf("code = %q, want %q", got, api.CodeProjectExists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectWithConfig(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
spa := true
|
||||
index := "app.html"
|
||||
notFound := "404.html"
|
||||
retention := 3
|
||||
status, body := e.do(t, http.MethodPost, api.PathProjects(), e.adminToken,
|
||||
api.CreateProjectRequest{
|
||||
Name: "demo",
|
||||
Patch: &api.ProjectPatch{
|
||||
IndexFile: &index,
|
||||
NotFoundFile: ¬Found,
|
||||
SPAFallback: &spa,
|
||||
RetentionCount: &retention,
|
||||
},
|
||||
})
|
||||
var p api.Project
|
||||
mustJSON(t, status, http.StatusCreated, body, &p)
|
||||
if p.IndexFile != index || p.NotFoundFile != notFound || !p.SPAFallback || p.RetentionCount != 3 {
|
||||
t.Errorf("config not applied: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectRejectsUnknownFields(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
e.ts.URL+api.PathProjects(), strings.NewReader(`{"name":"demo","retention_count":5}`))
|
||||
req.Header.Set("Authorization", "Bearer "+e.adminToken)
|
||||
resp, err := e.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// retention_count belongs under "config"; silently ignoring a misplaced key
|
||||
// would let a deploy script think it had configured something it had not.
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// cache_control is written verbatim into a response header on every request the
|
||||
// project serves. A CR or LF there is response splitting.
|
||||
func TestPatchRejectsHeaderInjection(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
|
||||
for _, bad := range []string{
|
||||
"public\r\nX-Evil: 1",
|
||||
"public\nX-Evil: 1",
|
||||
"public\rX-Evil: 1",
|
||||
"public\x00",
|
||||
"public, max-age=0\x7f",
|
||||
strings.Repeat("a", maxCacheControlLen+1),
|
||||
} {
|
||||
t.Run(strings.NewReplacer("\r", "CR", "\n", "LF", "\x00", "NUL").Replace(bad), func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
|
||||
api.ProjectPatch{CacheControl: &bad})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// And the project is untouched.
|
||||
status, body := e.do(t, http.MethodGet, api.PathProject("demo"), e.adminToken, nil)
|
||||
var p api.Project
|
||||
mustJSON(t, status, http.StatusOK, body, &p)
|
||||
if strings.ContainsAny(p.CacheControl, "\r\n") {
|
||||
t.Fatalf("cache_control was stored with a newline: %q", p.CacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
// Display names are printed to operator terminals and written into logs.
|
||||
func TestPatchRejectsControlCharacters(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
bad := "demo\x1b[31m site"
|
||||
status, body := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
|
||||
api.ProjectPatch{DisplayName: &bad})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchRejectsBadSitePaths(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
for i, bad := range []string{
|
||||
"", "/etc/passwd", "../secret", "a/../../b", ".", "a//b", "a/", "a\\b", "a\x00b",
|
||||
strings.Repeat("a", maxSitePathLen+1),
|
||||
} {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
v := bad
|
||||
status, body := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
|
||||
api.ProjectPatch{IndexFile: &v})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("%q: status = %d, want 400; body: %s", bad, status, body)
|
||||
}
|
||||
if got := errCode(t, body); got != api.CodeInvalidPath {
|
||||
t.Errorf("%q: code = %q, want %q", bad, got, api.CodeInvalidPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The empty string is the only way a client can clear a custom 404 document,
|
||||
// which is why not_found_file gets a different rule from index_file.
|
||||
func TestPatchClearsNotFoundFile(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
|
||||
set := "404.html"
|
||||
status, body := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
|
||||
api.ProjectPatch{NotFoundFile: &set})
|
||||
var p api.Project
|
||||
mustJSON(t, status, http.StatusOK, body, &p)
|
||||
if p.NotFoundFile != set {
|
||||
t.Fatalf("not_found_file = %q, want %q", p.NotFoundFile, set)
|
||||
}
|
||||
|
||||
// A fresh destination: not_found_file is omitempty, so decoding a cleared
|
||||
// project over the previous value would silently keep it.
|
||||
clear := ""
|
||||
status, body = e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
|
||||
api.ProjectPatch{NotFoundFile: &clear})
|
||||
var cleared api.Project
|
||||
mustJSON(t, status, http.StatusOK, body, &cleared)
|
||||
if cleared.NotFoundFile != "" {
|
||||
t.Fatalf("not_found_file = %q, want it cleared", cleared.NotFoundFile)
|
||||
}
|
||||
|
||||
// It must be SQL NULL, not the empty string, so the schema's "NULL means no
|
||||
// custom document" contract holds.
|
||||
row, err := e.db.ProjectByName(t.Context(), "demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.NotFoundFile != "" {
|
||||
t.Errorf("store kept %q", row.NotFoundFile)
|
||||
}
|
||||
}
|
||||
|
||||
// An omitted field means "leave alone"; only a present one changes anything.
|
||||
func TestPatchLeavesOmittedFieldsAlone(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
spa := true
|
||||
index := "app.html"
|
||||
e.do(t, http.MethodPost, api.PathProjects(), e.adminToken, api.CreateProjectRequest{
|
||||
Name: "demo",
|
||||
Patch: &api.ProjectPatch{IndexFile: &index, SPAFallback: &spa},
|
||||
})
|
||||
|
||||
display := "Demo Site"
|
||||
status, body := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
|
||||
api.ProjectPatch{DisplayName: &display})
|
||||
var p api.Project
|
||||
mustJSON(t, status, http.StatusOK, body, &p)
|
||||
if p.IndexFile != index || !p.SPAFallback {
|
||||
t.Errorf("patch clobbered untouched fields: %+v", p)
|
||||
}
|
||||
if p.DisplayName != display {
|
||||
t.Errorf("display_name = %q, want %q", p.DisplayName, display)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchEnforcesServerCeilings(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
ceiling := e.server.Limits.MaxFileBytes
|
||||
|
||||
over := ceiling + 1
|
||||
status, body := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
|
||||
api.ProjectPatch{MaxFileBytes: &over})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
||||
}
|
||||
|
||||
// Lowering below the server limit is always allowed.
|
||||
under := int64(1024)
|
||||
status, body = e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
|
||||
api.ProjectPatch{MaxFileBytes: &under})
|
||||
var p api.Project
|
||||
mustJSON(t, status, http.StatusOK, body, &p)
|
||||
if p.MaxFileBytes != under {
|
||||
t.Errorf("max_file_bytes = %d, want %d", p.MaxFileBytes, under)
|
||||
}
|
||||
|
||||
zero := 0
|
||||
status, _ = e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
|
||||
api.ProjectPatch{RetentionCount: &zero})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("retention_count = 0 accepted (status %d); it would delete the active deployment", status)
|
||||
}
|
||||
}
|
||||
|
||||
// A patch is applied to a copy, so a field rejected halfway through must leave
|
||||
// nothing behind.
|
||||
func TestRejectedPatchChangesNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
|
||||
display := "Fine"
|
||||
bad := "x\r\ny"
|
||||
status, _ := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
|
||||
api.ProjectPatch{DisplayName: &display, CacheControl: &bad})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", status)
|
||||
}
|
||||
row, err := e.db.ProjectByName(t.Context(), "demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.DisplayName != "" {
|
||||
t.Errorf("display_name = %q; the valid half of a rejected patch was applied", row.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectsPaging(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, n := range []string{"a", "b", "c", "d", "e"} {
|
||||
e.createProject(t, n)
|
||||
}
|
||||
|
||||
var seen []string
|
||||
cursor := ""
|
||||
for range 10 {
|
||||
path := api.PathProjects() + "?limit=2"
|
||||
if cursor != "" {
|
||||
path += "&cursor=" + cursor
|
||||
}
|
||||
status, body := e.do(t, http.MethodGet, path, e.adminToken, nil)
|
||||
var list api.ProjectList
|
||||
mustJSON(t, status, http.StatusOK, body, &list)
|
||||
for _, p := range list.Projects {
|
||||
seen = append(seen, p.Name)
|
||||
}
|
||||
if list.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = list.NextCursor
|
||||
}
|
||||
if got := strings.Join(seen, ","); got != "a,b,c,d,e" {
|
||||
t.Errorf("paged through %q, want a,b,c,d,e", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectsRejectsBadLimit(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, q := range []string{"?limit=0", "?limit=501", "?limit=abc", "?limit=-1"} {
|
||||
status, body := e.do(t, http.MethodGet, api.PathProjects()+q, e.adminToken, nil)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("%s: status = %d, want 400; body: %s", q, status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUnknownProject(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
status, body := e.do(t, http.MethodGet, api.PathProject("nope"), e.adminToken, nil)
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProjectRevokesItsKeysImmediately(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
|
||||
// Warm the auth cache so the test proves invalidation, not a cold lookup.
|
||||
if status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), token, nil); status != http.StatusOK {
|
||||
t.Fatalf("whoami before delete: %d %s", status, body)
|
||||
}
|
||||
|
||||
status, body := e.do(t, http.MethodDelete, api.PathProject("demo"), e.adminToken, nil)
|
||||
if status != http.StatusNoContent {
|
||||
t.Fatalf("delete: status = %d, want 204; body: %s", status, body)
|
||||
}
|
||||
if len(body) != 0 {
|
||||
t.Errorf("204 carried a body: %s", body)
|
||||
}
|
||||
|
||||
// The key cascaded away with the project. Without the cache invalidation in
|
||||
// the delete handler it would keep authenticating for the cache TTL.
|
||||
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), token, nil); status != http.StatusUnauthorized {
|
||||
t.Errorf("deleted project's key still works: status = %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectKeyCannotManageProjects(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
e.createProject(t, "other")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodPost, api.PathProjects(), token,
|
||||
api.CreateProjectRequest{Name: "sneaky"})
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body: %s", status, body)
|
||||
}
|
||||
})
|
||||
t.Run("list", func(t *testing.T) {
|
||||
if status, _ := e.do(t, http.MethodGet, api.PathProjects(), token, nil); status != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", status)
|
||||
}
|
||||
})
|
||||
t.Run("patch own", func(t *testing.T) {
|
||||
// Reconfiguring a project is an admin operation even for its own key:
|
||||
// a deploy credential should not be able to raise its own limits.
|
||||
display := "x"
|
||||
status, _ := e.do(t, http.MethodPatch, api.PathProject("demo"), token,
|
||||
api.ProjectPatch{DisplayName: &display})
|
||||
if status != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", status)
|
||||
}
|
||||
})
|
||||
t.Run("delete own", func(t *testing.T) {
|
||||
if status, _ := e.do(t, http.MethodDelete, api.PathProject("demo"), token, nil); status != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", status)
|
||||
}
|
||||
})
|
||||
t.Run("read own", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodGet, api.PathProject("demo"), token, nil)
|
||||
var p api.Project
|
||||
mustJSON(t, status, http.StatusOK, body, &p)
|
||||
if p.Name != "demo" {
|
||||
t.Errorf("name = %q", p.Name)
|
||||
}
|
||||
})
|
||||
t.Run("read other", func(t *testing.T) {
|
||||
if status, _ := e.do(t, http.MethodGet, api.PathProject("other"), token, nil); status != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", status)
|
||||
}
|
||||
})
|
||||
// A project key must not be able to use 404-vs-403 to enumerate which
|
||||
// project names exist.
|
||||
t.Run("read nonexistent", func(t *testing.T) {
|
||||
status, _ := e.do(t, http.MethodGet, api.PathProject("ghost"), token, nil)
|
||||
if status != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403; an unknown name must look like someone else's", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/auth"
|
||||
)
|
||||
|
||||
// The client builds URLs with api.Path* and the server registers wildcard
|
||||
// patterns. Nothing in the type system connects the two, so assert that every
|
||||
// path the client can build actually lands on the route it is meant to.
|
||||
func TestPathBuildersMatchRoutes(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
(&Server{Auth: &auth.Middleware{}}).Register(mux)
|
||||
|
||||
cases := []struct {
|
||||
method, path, want string
|
||||
}{
|
||||
{http.MethodGet, api.PathWhoAmI(), "GET " + patWhoAmI},
|
||||
{http.MethodGet, api.PathSystemInfo(), "GET " + patSystemInfo},
|
||||
{http.MethodPost, api.PathProjects(), "POST " + patProjects},
|
||||
{http.MethodGet, api.PathProjects(), "GET " + patProjects},
|
||||
{http.MethodGet, api.PathProject("demo"), "GET " + patProject},
|
||||
{http.MethodPatch, api.PathProject("demo"), "PATCH " + patProject},
|
||||
{http.MethodDelete, api.PathProject("demo"), "DELETE " + patProject},
|
||||
{http.MethodPost, api.PathProjectKeys("demo"), "POST " + patProjectKeys},
|
||||
{http.MethodGet, api.PathProjectKeys("demo"), "GET " + patProjectKeys},
|
||||
{http.MethodPost, api.PathKeys(), "POST " + patKeys},
|
||||
{http.MethodGet, api.PathKeys(), "GET " + patKeys},
|
||||
{http.MethodDelete, api.PathKey("abcdefghijklmnop"), "DELETE " + patKey},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
r := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
_, pattern := mux.Handler(r)
|
||||
if pattern != tc.want {
|
||||
t.Errorf("routed to %q, want %q", pattern, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A project name that needs escaping must not be able to reach a different
|
||||
// route by smuggling a slash through the path builder.
|
||||
func TestPathEscapingCannotCrossRoutes(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
(&Server{Auth: &auth.Middleware{}}).Register(mux)
|
||||
|
||||
built := api.PathProject("demo/keys")
|
||||
if strings.Contains(built, "demo/keys") {
|
||||
t.Fatalf("PathProject did not escape the slash: %q", built)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodGet, built, nil)
|
||||
_, pattern := mux.Handler(r)
|
||||
if pattern != "GET "+patProject {
|
||||
t.Errorf("routed to %q, want %q", pattern, "GET "+patProject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryRouteRequiresAuthentication(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.createProject(t, "demo")
|
||||
|
||||
cases := []struct{ method, path string }{
|
||||
{http.MethodGet, api.PathWhoAmI()},
|
||||
{http.MethodGet, api.PathSystemInfo()},
|
||||
{http.MethodPost, api.PathProjects()},
|
||||
{http.MethodGet, api.PathProjects()},
|
||||
{http.MethodGet, api.PathProject(p.Name)},
|
||||
{http.MethodPatch, api.PathProject(p.Name)},
|
||||
{http.MethodDelete, api.PathProject(p.Name)},
|
||||
{http.MethodPost, api.PathProjectKeys(p.Name)},
|
||||
{http.MethodGet, api.PathProjectKeys(p.Name)},
|
||||
{http.MethodPost, api.PathKeys()},
|
||||
{http.MethodGet, api.PathKeys()},
|
||||
{http.MethodDelete, api.PathKey("abcdefghijklmnop")},
|
||||
{http.MethodGet, api.Version + "/does-not-exist"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
status, body := e.do(t, tc.method, tc.path, "", nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401; body: %s", status, body)
|
||||
}
|
||||
if got := errCode(t, body); got != api.CodeUnauthorized {
|
||||
t.Errorf("code = %q, want %q", got, api.CodeUnauthorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown endpoint must answer with the same envelope as everything else, so
|
||||
// a client has exactly one error shape to parse.
|
||||
func TestUnknownEndpointReturnsEnvelope(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
status, body := e.do(t, http.MethodGet, api.Version+"/nope", e.adminToken, nil)
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body: %s", status, body)
|
||||
}
|
||||
if got := errCode(t, body); got != api.CodeNotFound {
|
||||
t.Errorf("code = %q, want %q", got, api.CodeNotFound)
|
||||
}
|
||||
// The path is not echoed back: there is no reason to reflect caller-supplied
|
||||
// bytes into a response body.
|
||||
if bytes := string(body); strings.Contains(bytes, "nope") {
|
||||
t.Errorf("response echoes the request path: %s", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// The catch-all matches every path under the API prefix, so without the
|
||||
// method-agnostic fallbacks a wrong verb on a real endpoint would report 404
|
||||
// "no such endpoint" — which sends a client looking for a typo that is not
|
||||
// there.
|
||||
func TestWrongMethodIsRejectedWithAllow(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
|
||||
cases := []struct {
|
||||
method, path, wantAllow string
|
||||
}{
|
||||
{http.MethodPut, api.PathProjects(), "POST"},
|
||||
{http.MethodPost, api.PathProject("demo"), "PATCH"},
|
||||
{http.MethodPut, api.PathKey("abcdefghijklmnop"), "DELETE"},
|
||||
{http.MethodDelete, api.PathWhoAmI(), "GET"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
resp := e.doResp(t, tc.method, tc.path, e.adminToken, nil)
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want 405", resp.StatusCode)
|
||||
}
|
||||
if allow := resp.Header.Get("Allow"); !strings.Contains(allow, tc.wantAllow) {
|
||||
t.Errorf("Allow = %q, want it to mention %s", allow, tc.wantAllow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Package adminapi implements the management API: projects, API keys and the
|
||||
// system endpoints. It owns the translation between storage and the wire
|
||||
// format, so the store never constructs api.Error values and the api package
|
||||
// never learns that SQLite exists.
|
||||
//
|
||||
// Every route in here is authenticated. Authorisation is expressed as
|
||||
// per-route middleware rather than as checks inside the handlers, so a new
|
||||
// endpoint that forgets its guard is visible in the route table instead of
|
||||
// being hidden three screens down in a handler body.
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/auth"
|
||||
"github.com/iceBear67/simplepages/internal/config"
|
||||
"github.com/iceBear67/simplepages/internal/deploy"
|
||||
"github.com/iceBear67/simplepages/internal/httpx"
|
||||
"github.com/iceBear67/simplepages/internal/site"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// Route patterns. They are spelled out here rather than reusing the builders in
|
||||
// api/paths.go because those escape their arguments to produce a concrete URL,
|
||||
// which is the opposite of what a wildcard pattern needs. TestPathsMatchRoutes
|
||||
// asserts the two stay in agreement.
|
||||
const (
|
||||
patProjects = api.Version + "/projects"
|
||||
patProject = api.Version + "/projects/{name}"
|
||||
patProjectKeys = api.Version + "/projects/{name}/keys"
|
||||
patKeys = api.Version + "/keys"
|
||||
patKey = api.Version + "/keys/{key_id}"
|
||||
patWhoAmI = api.Version + "/whoami"
|
||||
patSystemInfo = api.Version + "/system/info"
|
||||
patDeployments = patProject + "/deployments"
|
||||
patDeployment = patDeployments + "/{id}"
|
||||
patManifest = patDeployment + "/manifest"
|
||||
patFinalize = patDeployment + "/finalize"
|
||||
patActivate = patDeployment + "/activate"
|
||||
patBlob = api.Version + "/blobs/{digest}"
|
||||
patGC = api.Version + "/gc"
|
||||
patFsck = api.Version + "/fsck"
|
||||
patCatchAll = api.Version + "/"
|
||||
)
|
||||
|
||||
// Hooks let later milestones react to changes without adminapi taking a
|
||||
// dependency on the site registry, the webroot or the deployment service.
|
||||
// Every hook is optional and runs after the database transaction has committed,
|
||||
// so a hook failure can never leave the store and the caller disagreeing about
|
||||
// whether the change happened.
|
||||
type Hooks struct {
|
||||
// ProjectChanged fires after a project is created or reconfigured.
|
||||
ProjectChanged func(ctx context.Context, p *store.Project)
|
||||
// ProjectDeleted fires after a project and its cascade are gone.
|
||||
ProjectDeleted func(ctx context.Context, p *store.Project)
|
||||
}
|
||||
|
||||
// Server holds the collaborators the management handlers need.
|
||||
type Server struct {
|
||||
DB *store.DB
|
||||
Auth *auth.Middleware
|
||||
Deploy *deploy.Service
|
||||
Log *slog.Logger
|
||||
|
||||
// Limits are the server-wide ceilings a per-project setting may not exceed.
|
||||
Limits config.Limits
|
||||
|
||||
// Resolver maps a project name to its row id for the ownership guard. When
|
||||
// nil the database is consulted directly; the server substitutes the site
|
||||
// registry so the hot deployment endpoints do not pay a query for it.
|
||||
Resolver auth.ProjectResolver
|
||||
|
||||
// Sites is the serving registry, consulted to report what a project is
|
||||
// currently serving without a query per project. Nil in tests that do not
|
||||
// wire up a serving layer, in which case active_deployment is omitted.
|
||||
Sites *site.Registry
|
||||
|
||||
// BaseURL is the public origin serving site content, without a trailing
|
||||
// slash. Empty until the operator configures one, in which case responses
|
||||
// simply omit the url field.
|
||||
BaseURL string
|
||||
|
||||
// LinkMode reports how deployment trees are assembled. Filled in from the
|
||||
// CAS in M2.
|
||||
LinkMode string
|
||||
|
||||
Started time.Time
|
||||
Hooks Hooks
|
||||
}
|
||||
|
||||
// Register mounts the management routes on mux.
|
||||
//
|
||||
// It takes a mux rather than returning a handler so the caller can put the
|
||||
// health probes on the same listener without them inheriting authentication.
|
||||
func (s *Server) Register(mux *http.ServeMux) {
|
||||
admin := auth.RequireAdmin(s.Log)
|
||||
|
||||
resolver := s.Resolver
|
||||
if resolver == nil {
|
||||
resolver = auth.ResolverFunc(s.resolveProject)
|
||||
}
|
||||
// owner admits admins and the project's own key. Note it reads the {name}
|
||||
// wildcard, so it is only valid on patterns that declare one.
|
||||
owner := auth.RequireProject("name", resolver, s.Log)
|
||||
|
||||
s.route(mux, "GET "+patWhoAmI, s.whoami)
|
||||
s.route(mux, "GET "+patSystemInfo, s.systemInfo, admin)
|
||||
|
||||
s.route(mux, "POST "+patProjects, s.createProject, admin)
|
||||
s.route(mux, "GET "+patProjects, s.listProjects, admin)
|
||||
s.route(mux, "GET "+patProject, s.getProject, owner)
|
||||
s.route(mux, "PATCH "+patProject, s.patchProject, admin)
|
||||
s.route(mux, "DELETE "+patProject, s.deleteProject, admin)
|
||||
|
||||
s.route(mux, "POST "+patKeys, s.createAdminKey, admin)
|
||||
s.route(mux, "GET "+patKeys, s.listKeys, admin)
|
||||
s.route(mux, "DELETE "+patKey, s.revokeKey)
|
||||
s.route(mux, "POST "+patProjectKeys, s.createProjectKey, admin)
|
||||
s.route(mux, "GET "+patProjectKeys, s.listProjectKeys, owner)
|
||||
|
||||
s.route(mux, "POST "+patDeployments, s.createDeployment, owner)
|
||||
s.route(mux, "GET "+patDeployments, s.listDeployments, owner)
|
||||
s.route(mux, "GET "+patDeployment, s.getDeployment, owner)
|
||||
s.route(mux, "DELETE "+patDeployment, s.deleteDeployment, owner)
|
||||
s.route(mux, "POST "+patManifest, s.setManifest, owner)
|
||||
s.route(mux, "POST "+patFinalize, s.finalize, owner)
|
||||
s.route(mux, "POST "+patActivate, s.activate, owner)
|
||||
|
||||
s.route(mux, "POST "+patGC, s.gc, admin)
|
||||
s.route(mux, "POST "+patFsck, s.fsck, admin)
|
||||
// Blob uploads carry no project in the path and so have no owner to check
|
||||
// against. See putBlob for why authentication alone is the right guard.
|
||||
s.route(mux, "PUT "+patBlob, s.putBlob)
|
||||
|
||||
// A known path reached with an unregistered method must answer 405 and say
|
||||
// what it does accept. ServeMux would do that by itself, but only when no
|
||||
// pattern matches at all — and the catch-all below matches everything under
|
||||
// the prefix, which would turn every method mismatch into a 404. These
|
||||
// method-agnostic patterns are less specific than the ones above, so they
|
||||
// only see requests the real routes rejected.
|
||||
s.methods(mux, patProjects, http.MethodGet, http.MethodPost)
|
||||
s.methods(mux, patProject, http.MethodGet, http.MethodPatch, http.MethodDelete)
|
||||
s.methods(mux, patProjectKeys, http.MethodGet, http.MethodPost)
|
||||
s.methods(mux, patKeys, http.MethodGet, http.MethodPost)
|
||||
s.methods(mux, patKey, http.MethodDelete)
|
||||
s.methods(mux, patWhoAmI, http.MethodGet)
|
||||
s.methods(mux, patSystemInfo, http.MethodGet)
|
||||
s.methods(mux, patDeployments, http.MethodGet, http.MethodPost)
|
||||
s.methods(mux, patDeployment, http.MethodGet, http.MethodDelete)
|
||||
s.methods(mux, patManifest, http.MethodPost)
|
||||
s.methods(mux, patFinalize, http.MethodPost)
|
||||
s.methods(mux, patActivate, http.MethodPost)
|
||||
s.methods(mux, patBlob, http.MethodPut)
|
||||
s.methods(mux, patGC, http.MethodPost)
|
||||
s.methods(mux, patFsck, http.MethodPost)
|
||||
|
||||
// Unknown paths under the API prefix answer with the same envelope as every
|
||||
// other failure instead of net/http's plain-text 404, so a client has one
|
||||
// shape of error to parse. It sits behind authentication too: an
|
||||
// unauthenticated caller learns nothing about which endpoints exist.
|
||||
s.route(mux, patCatchAll, func(w http.ResponseWriter, r *http.Request) error {
|
||||
return api.Errorf(api.CodeNotFound, "no such endpoint")
|
||||
})
|
||||
}
|
||||
|
||||
// methods registers the 405 fallback for a path that exists under other verbs.
|
||||
func (s *Server) methods(mux *http.ServeMux, pattern string, allow ...string) {
|
||||
list := strings.Join(allow, ", ")
|
||||
s.route(mux, pattern, func(w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("Allow", list)
|
||||
return api.Errorf(api.CodeMethodNotAllowed, "method not allowed; this path accepts %s", list)
|
||||
})
|
||||
}
|
||||
|
||||
// handlerFunc is an http.HandlerFunc that may fail. Returning the error instead
|
||||
// of writing it means a handler cannot accidentally answer twice, and the
|
||||
// envelope is rendered in exactly one place.
|
||||
type handlerFunc func(w http.ResponseWriter, r *http.Request) error
|
||||
|
||||
func (s *Server) wrap(h handlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h(w, r); err != nil {
|
||||
httpx.WriteError(w, r, s.Log, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// route registers pattern behind authentication plus the given guards, which
|
||||
// run outermost first.
|
||||
func (s *Server) route(mux *http.ServeMux, pattern string, h handlerFunc, guards ...func(http.Handler) http.Handler) {
|
||||
var wrapped http.Handler = s.wrap(h)
|
||||
for i := len(guards) - 1; i >= 0; i-- {
|
||||
wrapped = guards[i](wrapped)
|
||||
}
|
||||
mux.Handle(pattern, s.Auth.Authenticate(wrapped))
|
||||
}
|
||||
|
||||
// resolveProject is the fallback ProjectResolver used until the site registry
|
||||
// exists.
|
||||
func (s *Server) resolveProject(ctx context.Context, name string) (int64, error) {
|
||||
p, err := s.DB.ProjectByName(ctx, name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return p.ID, nil
|
||||
}
|
||||
|
||||
// identity returns the caller. Authenticate guarantees one is present, so its
|
||||
// absence is a routing bug rather than a client error.
|
||||
func (s *Server) identity(r *http.Request) (*auth.Identity, error) {
|
||||
id, ok := auth.IdentityFrom(r.Context())
|
||||
if !ok {
|
||||
return nil, api.Errorf(api.CodeInternal, "handler reached without authentication")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Server) maxJSON() int64 {
|
||||
if s.Limits.MaxJSONBytes > 0 {
|
||||
return s.Limits.MaxJSONBytes
|
||||
}
|
||||
return 1 << 20
|
||||
}
|
||||
|
||||
// projectNames maps row ids to names for the key listings, which store an id
|
||||
// but report a name. One query beats one lookup per key.
|
||||
func (s *Server) projectNames(ctx context.Context) (map[int64]string, error) {
|
||||
ps, err := s.DB.AllProjects(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[int64]string, len(ps))
|
||||
for _, p := range ps {
|
||||
m[p.ID] = p.Name
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// intQuery reads a bounded integer query parameter.
|
||||
func intQuery(r *http.Request, name string, def, min, max int) (int, error) {
|
||||
raw := r.URL.Query().Get(name)
|
||||
if raw == "" {
|
||||
return def, nil
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, api.Errorf(api.CodeBadRequest, "%s must be an integer", name)
|
||||
}
|
||||
if v < min || v > max {
|
||||
return 0, api.Errorf(api.CodeBadRequest, "%s must be between %d and %d", name, min, max)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// noStore marks a response that must not be written to any cache. Used for the
|
||||
// one response in the API that carries a credential.
|
||||
func noStore(w http.ResponseWriter) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/httpx"
|
||||
"github.com/iceBear67/simplepages/internal/version"
|
||||
)
|
||||
|
||||
// whoami handles GET /api/v1/whoami. Any valid key may call it; a CI job uses
|
||||
// it to check that the token it was handed is the one it expected.
|
||||
func (s *Server) whoami(w http.ResponseWriter, r *http.Request) error {
|
||||
ident, err := s.identity(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := api.WhoAmI{
|
||||
KeyID: ident.KeyID,
|
||||
Scope: string(ident.Scope),
|
||||
Name: ident.Name,
|
||||
ExpiresAt: copyTime(ident.ExpiresAt),
|
||||
}
|
||||
if ident.ProjectID != nil {
|
||||
// The identity carries the row id, not the name; the name is what a
|
||||
// human wants to see. A missing row here would mean the project was
|
||||
// deleted between authentication and now, in which case reporting an
|
||||
// empty name is more honest than failing the request.
|
||||
if p, err := s.DB.ProjectByID(r.Context(), *ident.ProjectID); err == nil {
|
||||
out.Project = p.Name
|
||||
}
|
||||
}
|
||||
noStore(w)
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// systemInfo handles GET /api/v1/system/info, admin only.
|
||||
func (s *Server) systemInfo(w http.ResponseWriter, r *http.Request) error {
|
||||
counts, err := s.DB.Counts(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
schema, err := s.DB.SchemaVersion(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
linkMode := s.LinkMode
|
||||
if linkMode == "" {
|
||||
linkMode = "unknown"
|
||||
}
|
||||
uptime := int64(0)
|
||||
if !s.Started.IsZero() {
|
||||
uptime = int64(time.Since(s.Started).Seconds())
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, api.SystemInfo{
|
||||
Version: version.Short(),
|
||||
UptimeS: uptime,
|
||||
Projects: counts.Projects,
|
||||
Deployments: counts.Deployments,
|
||||
Blobs: counts.Blobs,
|
||||
CASBytes: counts.CASBytes,
|
||||
LinkMode: linkMode,
|
||||
SchemaVer: schema,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// gc handles POST /api/v1/gc, admin only.
|
||||
//
|
||||
// A collection pass also runs on a timer; this exists so an operator who needs
|
||||
// the disk back does not have to wait for it, and so that a dry run can answer
|
||||
// "what would you delete" before anything is deleted.
|
||||
func (s *Server) gc(w http.ResponseWriter, r *http.Request) error {
|
||||
var req api.GCRequest
|
||||
if r.ContentLength != 0 {
|
||||
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stats, err := s.Deploy.Collect(r.Context(), req.DryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, stats)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fsck handles POST /api/v1/fsck, admin only.
|
||||
//
|
||||
// Refcounts are maintained by triggers, so on a healthy server this always
|
||||
// reports zero drift. It exists for the cases outside normal operation — a
|
||||
// database restored from a backup, a schema touched by hand — because the
|
||||
// collector trusts those counters, and a count that reads low is the one way
|
||||
// this design can lose data.
|
||||
func (s *Server) fsck(w http.ResponseWriter, r *http.Request) error {
|
||||
var req api.FsckRequest
|
||||
if r.ContentLength != 0 {
|
||||
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rep, err := s.DB.Fsck(r.Context(), req.Repair)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := api.FsckReport{
|
||||
Blobs: rep.Blobs,
|
||||
DriftCount: rep.DriftCount,
|
||||
Repaired: rep.Repaired,
|
||||
Drift: make([]api.BlobDrift, 0, len(rep.Drift)),
|
||||
}
|
||||
for _, d := range rep.Drift {
|
||||
out.Drift = append(out.Drift, api.BlobDrift{
|
||||
Digest: d.Digest.String(), Stored: d.Stored, Actual: d.Actual,
|
||||
})
|
||||
}
|
||||
if rep.DriftCount > 0 {
|
||||
s.Log.WarnContext(r.Context(), "blob reference counts disagree with the manifests",
|
||||
"blobs", rep.Blobs, "drift", rep.DriftCount, "repaired", rep.Repaired)
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
)
|
||||
|
||||
func TestWhoAmIReportsTheCaller(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
future := time.Now().Add(time.Hour).Truncate(time.Second)
|
||||
proj := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken,
|
||||
api.CreateKeyRequest{Name: "ci", ExpiresAt: &future})
|
||||
|
||||
t.Run("project scope", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), proj.Token, nil)
|
||||
var who api.WhoAmI
|
||||
mustJSON(t, status, http.StatusOK, body, &who)
|
||||
if who.KeyID != proj.Key.ID {
|
||||
t.Errorf("key_id = %q, want %q", who.KeyID, proj.Key.ID)
|
||||
}
|
||||
if who.Scope != api.ScopeProject || who.Project != "demo" || who.Name != "ci" {
|
||||
t.Errorf("whoami = %+v, want scope project on demo named ci", who)
|
||||
}
|
||||
if who.ExpiresAt == nil || !who.ExpiresAt.Equal(future) {
|
||||
t.Errorf("expires_at = %v, want %v", who.ExpiresAt, future)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("admin scope", func(t *testing.T) {
|
||||
status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), e.adminToken, nil)
|
||||
var who api.WhoAmI
|
||||
mustJSON(t, status, http.StatusOK, body, &who)
|
||||
if who.Scope != api.ScopeAdmin {
|
||||
t.Errorf("scope = %q, want admin", who.Scope)
|
||||
}
|
||||
// An admin key belongs to no project, and saying otherwise would suggest
|
||||
// the caller is confined to one.
|
||||
if who.Project != "" {
|
||||
t.Errorf("project = %q, want empty for an admin key", who.Project)
|
||||
}
|
||||
})
|
||||
|
||||
// whoami names a credential, so it must not be cached by anything between
|
||||
// the CLI and the server.
|
||||
t.Run("no-store", func(t *testing.T) {
|
||||
resp := e.doResp(t, http.MethodGet, api.PathWhoAmI(), proj.Token, nil)
|
||||
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "no-store") {
|
||||
t.Errorf("Cache-Control = %q, want it to contain no-store", cc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSystemInfo(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "a")
|
||||
e.createProject(t, "b")
|
||||
|
||||
status, body := e.do(t, http.MethodGet, api.PathSystemInfo(), e.adminToken, nil)
|
||||
var info api.SystemInfo
|
||||
mustJSON(t, status, http.StatusOK, body, &info)
|
||||
|
||||
if info.Projects != 2 {
|
||||
t.Errorf("projects = %d, want 2", info.Projects)
|
||||
}
|
||||
if info.Deployments != 0 || info.Blobs != 0 || info.CASBytes != 0 {
|
||||
t.Errorf("expected an empty CAS, got %+v", info)
|
||||
}
|
||||
if info.SchemaVer < 1 {
|
||||
t.Errorf("schema_version = %d, want at least 1", info.SchemaVer)
|
||||
}
|
||||
if info.Version == "" {
|
||||
t.Error("version is empty")
|
||||
}
|
||||
// The link mode is not known until the CAS is opened in M2; reporting an
|
||||
// empty string would read as "no linking" rather than "not determined".
|
||||
if info.LinkMode != "unknown" {
|
||||
t.Errorf("link_mode = %q, want unknown", info.LinkMode)
|
||||
}
|
||||
if info.UptimeS < 0 {
|
||||
t.Errorf("uptime_s = %d", info.UptimeS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemInfoIsAdminOnly(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.createProject(t, "demo")
|
||||
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
|
||||
|
||||
status, body := e.do(t, http.MethodGet, api.PathSystemInfo(), token, nil)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body: %s", status, body)
|
||||
}
|
||||
if got := errCode(t, body); got != api.CodeForbidden {
|
||||
t.Errorf("code = %q, want %q", got, api.CodeForbidden)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/config"
|
||||
)
|
||||
|
||||
// Field length caps. They exist to stop a client filling the database with a
|
||||
// megabyte of display name, not to express any semantic limit.
|
||||
const (
|
||||
maxDisplayNameLen = 200
|
||||
maxKeyNameLen = 200
|
||||
maxSitePathLen = 1024
|
||||
maxCacheControlLen = 256
|
||||
)
|
||||
|
||||
// checkProjectName validates a name before it becomes a row and, later, a
|
||||
// "~name" entry inside $WEBROOT. The pattern is the only thing standing between
|
||||
// a project name and the filesystem, so this is a hard reject rather than a
|
||||
// normalisation.
|
||||
func checkProjectName(name string) error {
|
||||
if name == "" {
|
||||
return api.Errorf(api.CodeInvalidProjectName, "project name is required")
|
||||
}
|
||||
if !config.ProjectNamePattern.MatchString(name) {
|
||||
return api.Errorf(api.CodeInvalidProjectName,
|
||||
"project name must match %s", config.ProjectNamePattern.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkText rejects control characters in free-text fields.
|
||||
//
|
||||
// These strings are printed to operator terminals by "pages project list" and
|
||||
// written into structured logs. An embedded ESC would let whoever set the field
|
||||
// move the cursor, recolour the output or rewrite the line the operator is
|
||||
// reading; a newline would forge a second log record.
|
||||
func checkText(field, v string, max int) error {
|
||||
if len(v) > max {
|
||||
return api.Errorf(api.CodeBadRequest, "%s must be at most %d bytes", field, max)
|
||||
}
|
||||
for i := 0; i < len(v); i++ {
|
||||
if c := v[i]; c < 0x20 || c == 0x7f {
|
||||
return api.Errorf(api.CodeBadRequest, "%s must not contain control characters", field)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSitePath validates a path that names a document inside a deployment,
|
||||
// such as index_file or not_found_file.
|
||||
//
|
||||
// It is checked here as well as at serve time because a value that cannot
|
||||
// possibly resolve is a configuration mistake worth reporting at the moment it
|
||||
// is made, rather than as a silent 404 on every request afterwards.
|
||||
func checkSitePath(field, v string) error {
|
||||
if v == "" {
|
||||
return api.Errorf(api.CodeInvalidPath, "%s must not be empty", field)
|
||||
}
|
||||
if len(v) > maxSitePathLen {
|
||||
return api.Errorf(api.CodeInvalidPath, "%s must be at most %d bytes", field, maxSitePathLen)
|
||||
}
|
||||
// fs.ValidPath rejects "..", absolute paths, empty segments and a trailing
|
||||
// slash. It accepts ".", which is a directory rather than a document.
|
||||
if !fs.ValidPath(v) || v == "." {
|
||||
return api.Errorf(api.CodeInvalidPath,
|
||||
"%s must be a relative slash-separated path with no . or .. segments", field)
|
||||
}
|
||||
if strings.ContainsRune(v, '\\') {
|
||||
return api.Errorf(api.CodeInvalidPath, "%s must use forward slashes", field)
|
||||
}
|
||||
for i := 0; i < len(v); i++ {
|
||||
if c := v[i]; c < 0x20 || c == 0x7f {
|
||||
return api.Errorf(api.CodeInvalidPath, "%s must not contain control characters", field)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkHeaderValue validates a string that is emitted verbatim as a response
|
||||
// header value.
|
||||
//
|
||||
// A CR or LF here would be response splitting: the project could append headers
|
||||
// of its own choosing to every response it serves, and under path routing those
|
||||
// responses share an origin with every other project. net/http replaces newlines
|
||||
// with spaces on write, so this is defence in depth — but it is the layer that
|
||||
// keeps the bad value out of the database in the first place, and it is the one
|
||||
// that tells the operator they typed something wrong.
|
||||
func checkHeaderValue(field, v string) error {
|
||||
if len(v) > maxCacheControlLen {
|
||||
return api.Errorf(api.CodeBadRequest, "%s must be at most %d bytes", field, maxCacheControlLen)
|
||||
}
|
||||
for i := 0; i < len(v); i++ {
|
||||
c := v[i]
|
||||
if c == '\t' {
|
||||
continue
|
||||
}
|
||||
if c < 0x20 || c > 0x7e {
|
||||
return api.Errorf(api.CodeBadRequest,
|
||||
"%s must contain only printable ASCII (no newlines)", field)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
)
|
||||
|
||||
func TestCheckProjectName(t *testing.T) {
|
||||
valid := []string{
|
||||
"a", "0", "demo", "demo-site", "demo_site", "demo.site", "a1.b-c_d",
|
||||
strings.Repeat("z", 63),
|
||||
}
|
||||
for _, name := range valid {
|
||||
if err := checkProjectName(name); err != nil {
|
||||
t.Errorf("checkProjectName(%q) = %v, want nil", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Everything that could become a path separator, a traversal or a hidden
|
||||
// file once the name is turned into a "~name" entry in $WEBROOT.
|
||||
invalid := []string{
|
||||
"", ".", "..", "./x", "../x", "/demo", "demo/", "a/b", `a\b`,
|
||||
".hidden", "-lead", "_lead", "Demo", "DEMO", "démo", "demo ",
|
||||
" demo", "de mo", "demo\t", "demo\n", "demo\x00", "~demo", "demo%2f",
|
||||
strings.Repeat("z", 64),
|
||||
}
|
||||
for _, name := range invalid {
|
||||
err := checkProjectName(name)
|
||||
if err == nil {
|
||||
t.Errorf("checkProjectName(%q) = nil, want an error", name)
|
||||
continue
|
||||
}
|
||||
if got := api.CodeOf(err); got != api.CodeInvalidProjectName {
|
||||
t.Errorf("checkProjectName(%q) code = %q, want %q", name, got, api.CodeInvalidProjectName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSitePath(t *testing.T) {
|
||||
valid := []string{
|
||||
"index.html", "404.html", "a/b/c.html", "a.b/c", "_next/index.html",
|
||||
"страница.html", strings.Repeat("a", maxSitePathLen),
|
||||
}
|
||||
for _, p := range valid {
|
||||
if err := checkSitePath("index_file", p); err != nil {
|
||||
t.Errorf("checkSitePath(%q) = %v, want nil", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"", ".", "..", "/index.html", "index.html/", "a//b", "a/./b", "a/../b",
|
||||
"../../etc/passwd", `a\b`, "a\x00b", "a\nb", "a\x7fb",
|
||||
strings.Repeat("a", maxSitePathLen+1),
|
||||
}
|
||||
for _, p := range invalid {
|
||||
err := checkSitePath("index_file", p)
|
||||
if err == nil {
|
||||
t.Errorf("checkSitePath(%q) = nil, want an error", p)
|
||||
continue
|
||||
}
|
||||
if got := api.CodeOf(err); got != api.CodeInvalidPath {
|
||||
t.Errorf("checkSitePath(%q) code = %q, want %q", p, got, api.CodeInvalidPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHeaderValue(t *testing.T) {
|
||||
valid := []string{
|
||||
"", "public, max-age=0, must-revalidate", "no-store",
|
||||
"public,\tmax-age=31536000, immutable", strings.Repeat("a", maxCacheControlLen),
|
||||
}
|
||||
for _, v := range valid {
|
||||
if err := checkHeaderValue("cache_control", v); err != nil {
|
||||
t.Errorf("checkHeaderValue(%q) = %v, want nil", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A CR or LF here would let a project append headers of its own to every
|
||||
// response it serves, on an origin it shares with every other project.
|
||||
invalid := []string{
|
||||
"public\r\nX-Evil: 1", "public\nX-Evil: 1", "public\r", "public\x00",
|
||||
"public\x7f", "public é", strings.Repeat("a", maxCacheControlLen+1),
|
||||
}
|
||||
for _, v := range invalid {
|
||||
if err := checkHeaderValue("cache_control", v); err == nil {
|
||||
t.Errorf("checkHeaderValue(%q) = nil, want an error", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckText(t *testing.T) {
|
||||
// Free text is allowed to be anything printable, including non-ASCII: it is
|
||||
// a display name, not a header value.
|
||||
valid := []string{"", "Demo Site", "デモ", "a — b", strings.Repeat("x", maxDisplayNameLen)}
|
||||
for _, v := range valid {
|
||||
if err := checkText("display_name", v, maxDisplayNameLen); err != nil {
|
||||
t.Errorf("checkText(%q) = %v, want nil", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Control characters are not: these strings are printed to operator
|
||||
// terminals and written into structured logs.
|
||||
invalid := []string{
|
||||
"a\x1b[31mred", "line\nbreak", "car\rriage", "nul\x00", "del\x7f", "tab\there",
|
||||
strings.Repeat("x", maxDisplayNameLen+1),
|
||||
}
|
||||
for _, v := range invalid {
|
||||
if err := checkText("display_name", v, maxDisplayNameLen); err == nil {
|
||||
t.Errorf("checkText(%q) = nil, want an error", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// BootstrapKeyName is the name given to the first-run admin key, so an operator
|
||||
// listing keys can tell it apart from ones they minted themselves.
|
||||
const BootstrapKeyName = "bootstrap"
|
||||
|
||||
// EnsureAdminKey mints an admin key and writes its token to tokenPath when the
|
||||
// server has no usable admin key at all, and reports whether it did.
|
||||
//
|
||||
// This is the only place the server ever writes a token to disk, and it exists
|
||||
// because a fresh install would otherwise have no way to authenticate the call
|
||||
// that creates the first key. The file is 0600 and the log line tells the
|
||||
// operator to delete it once they have copied the token out.
|
||||
//
|
||||
// "Usable" excludes revoked and expired keys, so an installation whose only
|
||||
// admin key was revoked recovers by restarting rather than by hand-editing the
|
||||
// database.
|
||||
func EnsureAdminKey(ctx context.Context, db *store.DB, tokenPath string, log *slog.Logger) (bool, error) {
|
||||
n, err := db.CountUsableAdminKeys(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("count admin keys: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
// Nothing to mint. If a token file is still lying around from an earlier
|
||||
// bootstrap, say so: it is a live credential until that key is revoked.
|
||||
if _, err := os.Lstat(tokenPath); err == nil && log != nil {
|
||||
log.Warn("bootstrap token file still present; delete it once the token is stored elsewhere",
|
||||
"path", tokenPath)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
token, keyID, hash, err := Mint()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := writeTokenFile(tokenPath, token); err != nil {
|
||||
return false, err
|
||||
}
|
||||
k := &store.APIKey{
|
||||
ID: keyID,
|
||||
SecretHash: hash[:],
|
||||
Scope: store.ScopeAdmin,
|
||||
Name: BootstrapKeyName,
|
||||
}
|
||||
if err := db.CreateKey(ctx, k); err != nil {
|
||||
// The file names a key that does not exist. Remove it rather than leave
|
||||
// an operator holding a token that will never authenticate.
|
||||
_ = os.Remove(tokenPath)
|
||||
return false, fmt.Errorf("create bootstrap key: %w", err)
|
||||
}
|
||||
|
||||
if log != nil {
|
||||
// The key id is the public half of the token and is safe to log; the
|
||||
// token itself is not, which is the whole reason for the file.
|
||||
log.Warn("no admin key found; minted a bootstrap admin key",
|
||||
"key_id", keyID, "path", tokenPath,
|
||||
"action", "read the token, then delete this file")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeTokenFile writes the token with 0600 permissions.
|
||||
//
|
||||
// It writes to a temporary file in the same directory and renames it into
|
||||
// place. That is not for atomicity — nothing reads this concurrently — but
|
||||
// because rename replaces the destination without following it. Opening the
|
||||
// path directly would follow a symlink someone had planted there and write a
|
||||
// live credential wherever it pointed.
|
||||
func writeTokenFile(path, token string) error {
|
||||
dir := filepath.Dir(path)
|
||||
f, err := os.CreateTemp(dir, ".bootstrap-token-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create bootstrap token file: %w", err)
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer func() {
|
||||
f.Close()
|
||||
os.Remove(tmp) // no-op once the rename has succeeded
|
||||
}()
|
||||
|
||||
// CreateTemp already makes the file 0600, but say so explicitly: this is the
|
||||
// property that matters and it should not depend on a documented default.
|
||||
if err := f.Chmod(0o600); err != nil {
|
||||
return fmt.Errorf("chmod bootstrap token file: %w", err)
|
||||
}
|
||||
if _, err := f.WriteString(token + "\n"); err != nil {
|
||||
return fmt.Errorf("write bootstrap token: %w", err)
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
return fmt.Errorf("sync bootstrap token: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("close bootstrap token: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("install bootstrap token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// bootstrapEnv is a database, a token path in a directory of its own, and a log
|
||||
// buffer to assert against.
|
||||
type bootstrapEnv struct {
|
||||
db *store.DB
|
||||
dir string
|
||||
log *slog.Logger
|
||||
buf *bytes.Buffer
|
||||
}
|
||||
|
||||
func newBootstrapEnv(t *testing.T) *bootstrapEnv {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
return &bootstrapEnv{
|
||||
db: testStore(t),
|
||||
dir: t.TempDir(),
|
||||
log: slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
||||
buf: &buf,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *bootstrapEnv) path() string { return filepath.Join(e.dir, "bootstrap-token") }
|
||||
|
||||
func (e *bootstrapEnv) token(t *testing.T) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(e.path())
|
||||
if err != nil {
|
||||
t.Fatalf("read token file: %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(raw))
|
||||
}
|
||||
|
||||
func TestEnsureAdminKeyMintsOnEmptyDatabase(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
e := newBootstrapEnv(t)
|
||||
|
||||
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAdminKey: %v", err)
|
||||
}
|
||||
if !minted {
|
||||
t.Fatal("minted = false on an empty database")
|
||||
}
|
||||
|
||||
// The token in the file is the credential: it must actually authenticate.
|
||||
v := newVerifier(t, e.db)
|
||||
id, err := v.Verify(ctx, e.token(t))
|
||||
if err != nil {
|
||||
t.Fatalf("the bootstrap token does not authenticate: %v", err)
|
||||
}
|
||||
if !id.IsAdmin() {
|
||||
t.Errorf("scope = %q, want admin", id.Scope)
|
||||
}
|
||||
if id.Name != BootstrapKeyName {
|
||||
t.Errorf("name = %q, want %q", id.Name, BootstrapKeyName)
|
||||
}
|
||||
|
||||
// The operator is told where the file is and that it must be removed, and
|
||||
// the token itself never reaches the log — the file exists precisely so it
|
||||
// does not have to.
|
||||
logged := e.buf.String()
|
||||
if !strings.Contains(logged, e.path()) {
|
||||
t.Errorf("log does not name the token file: %s", logged)
|
||||
}
|
||||
if strings.Contains(logged, e.token(t)) {
|
||||
t.Fatal("the bootstrap token was written to the log")
|
||||
}
|
||||
if !strings.Contains(logged, id.KeyID) {
|
||||
t.Errorf("log does not name the key id, which is the safe half: %s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// The file holds a live admin credential. Anything wider than 0600 hands it to
|
||||
// every account on the host.
|
||||
func TestBootstrapTokenFileIsPrivate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
e := newBootstrapEnv(t)
|
||||
if _, err := EnsureAdminKey(ctx, e.db, e.path(), e.log); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi, err := os.Lstat(e.path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("mode = %#o, want 0600", perm)
|
||||
}
|
||||
if fi.Mode()&os.ModeSymlink != 0 {
|
||||
t.Error("the token path is a symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAdminKeyIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
e := newBootstrapEnv(t)
|
||||
|
||||
if _, err := EnsureAdminKey(ctx, e.db, e.path(), e.log); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := e.token(t)
|
||||
|
||||
// A restart must not mint a second admin key, and must not overwrite the
|
||||
// token the operator has not yet collected.
|
||||
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if minted {
|
||||
t.Error("minted a second bootstrap key")
|
||||
}
|
||||
if got := e.token(t); got != first {
|
||||
t.Error("the token file was rewritten on the second run")
|
||||
}
|
||||
|
||||
n, err := e.db.CountUsableAdminKeys(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("usable admin keys = %d, want 1", n)
|
||||
}
|
||||
|
||||
// A leftover file is a live credential; the operator gets told about it.
|
||||
if !strings.Contains(e.buf.String(), "still present") {
|
||||
t.Errorf("no warning about the leftover token file: %s", e.buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A revoked or expired admin key leaves the installation locked out, so
|
||||
// restarting must mint a fresh one rather than counting the dead key.
|
||||
func TestEnsureAdminKeyRecoversFromUnusableKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("revoked", func(t *testing.T) {
|
||||
e := newBootstrapEnv(t)
|
||||
_, keyID := mintInto(t, e.db, store.ScopeAdmin, nil)
|
||||
if err := e.db.RevokeKey(ctx, keyID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !minted {
|
||||
t.Fatal("minted = false with only a revoked admin key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired", func(t *testing.T) {
|
||||
e := newBootstrapEnv(t)
|
||||
token, _, hash, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
past := time.Now().Add(-time.Hour)
|
||||
k := &store.APIKey{ID: keyIDOf(t, token), SecretHash: hash[:],
|
||||
Scope: store.ScopeAdmin, Name: "old", ExpiresAt: &past}
|
||||
if err := e.db.CreateKey(ctx, k); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !minted {
|
||||
t.Fatal("minted = false with only an expired admin key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("project keys do not count", func(t *testing.T) {
|
||||
e := newBootstrapEnv(t)
|
||||
id := createProjectRow(t, e.db, "demo")
|
||||
mintInto(t, e.db, store.ScopeProject, &id)
|
||||
minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !minted {
|
||||
t.Fatal("minted = false with only a project key")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The token path is attacker-controllable on a host where the data directory is
|
||||
// created before the server runs. Writing through a planted symlink would put a
|
||||
// live admin credential wherever it pointed — /etc/cron.d, another user's
|
||||
// ~/.ssh, a world-readable log. The rename-into-place replaces the link instead
|
||||
// of following it.
|
||||
func TestBootstrapTokenDoesNotFollowASymlink(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
e := newBootstrapEnv(t)
|
||||
|
||||
target := filepath.Join(t.TempDir(), "victim")
|
||||
if err := os.WriteFile(target, []byte("original\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, e.path()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := EnsureAdminKey(ctx, e.db, e.path(), e.log); err != nil {
|
||||
t.Fatalf("EnsureAdminKey: %v", err)
|
||||
}
|
||||
|
||||
victim, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(victim) != "original\n" {
|
||||
t.Fatalf("the symlink target was overwritten with %q", victim)
|
||||
}
|
||||
fi, err := os.Lstat(e.path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode()&os.ModeSymlink != 0 {
|
||||
t.Fatal("the token path is still a symlink; the token went somewhere else")
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("mode = %#o, want 0600", perm)
|
||||
}
|
||||
if !strings.HasPrefix(e.token(t), Prefix+"_") {
|
||||
t.Errorf("the token file does not hold a token")
|
||||
}
|
||||
}
|
||||
|
||||
// A directory in the way must fail loudly rather than leave the server running
|
||||
// with an admin key nobody can use.
|
||||
func TestBootstrapTokenReportsAnUnwritablePath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
e := newBootstrapEnv(t)
|
||||
blocked := filepath.Join(e.dir, "blocked")
|
||||
if err := os.Mkdir(blocked, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := EnsureAdminKey(ctx, e.db, blocked, e.log); err == nil {
|
||||
t.Fatal("EnsureAdminKey = nil, want an error when the token cannot be stored")
|
||||
}
|
||||
// And no key was created: a key whose token was never delivered is just a
|
||||
// row nobody can authenticate with.
|
||||
n, err := e.db.CountUsableAdminKeys(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("usable admin keys = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// keyIDOf recovers the public half of a token, which is all a test needs to
|
||||
// store the row.
|
||||
func keyIDOf(t *testing.T, token string) string {
|
||||
t.Helper()
|
||||
keyID, _, err := Parse(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
return keyID
|
||||
}
|
||||
|
||||
// createProjectRow inserts a project so a project-scoped key has something to
|
||||
// point at, and returns its id.
|
||||
func createProjectRow(t *testing.T, db *store.DB, name string) int64 {
|
||||
t.Helper()
|
||||
p := store.DefaultProject(name)
|
||||
if err := db.CreateProject(context.Background(), p); err != nil {
|
||||
t.Fatalf("CreateProject: %v", err)
|
||||
}
|
||||
return p.ID
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/httpx"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const identityKey ctxKey = iota
|
||||
|
||||
// IdentityFrom returns the identity established by Authenticate.
|
||||
//
|
||||
// A handler mounted behind Authenticate can treat a false result as a
|
||||
// programming error: the middleware answers 401 itself and never calls through
|
||||
// without an identity.
|
||||
func IdentityFrom(ctx context.Context) (*Identity, bool) {
|
||||
id, ok := ctx.Value(identityKey).(*Identity)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// ContextWithIdentity is used by tests and by handlers that authenticate out of
|
||||
// band; ordinary request handling gets its identity from Authenticate.
|
||||
func ContextWithIdentity(ctx context.Context, id *Identity) context.Context {
|
||||
return context.WithValue(ctx, identityKey, id)
|
||||
}
|
||||
|
||||
// errUnauthorized is the single response every authentication failure produces.
|
||||
// Distinguishing "no such key" from "wrong secret" from "revoked" would tell an
|
||||
// unauthenticated caller which key ids are real.
|
||||
func errUnauthorized() *api.Error {
|
||||
return api.Errorf(api.CodeUnauthorized, "missing or invalid API token")
|
||||
}
|
||||
|
||||
// Middleware carries the collaborators the auth handlers need.
|
||||
type Middleware struct {
|
||||
V *Verifier
|
||||
Limiter *Limiter
|
||||
Trusted []netip.Prefix
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// Authenticate requires a valid bearer token and puts the identity in the
|
||||
// request context.
|
||||
//
|
||||
// The token is read only from the Authorization header, never from a query
|
||||
// parameter: query strings land in proxy access logs, browser history and
|
||||
// Referer headers, and a credential that ends up there is a credential leaked.
|
||||
func (m *Middleware) Authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := bearerToken(r)
|
||||
if !ok {
|
||||
m.reject(w, r, errors.New("auth: no bearer token"))
|
||||
return
|
||||
}
|
||||
|
||||
client := m.clientKey(r)
|
||||
if !m.Limiter.Allow(client) {
|
||||
if d := m.Limiter.RetryAfter(client); d > 0 {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(d.Seconds())))
|
||||
}
|
||||
httpx.WriteError(w, r, m.Log, api.Errorf(api.CodeRateLimited,
|
||||
"too many failed authentication attempts; slow down"))
|
||||
return
|
||||
}
|
||||
|
||||
id, err := m.V.Verify(r.Context(), token)
|
||||
if err != nil {
|
||||
m.Limiter.Fail(client)
|
||||
m.reject(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// The key id is the public half of the token and is safe to log; the
|
||||
// secret never leaves this function.
|
||||
httpx.LogAttr(r.Context(), "key_id", id.KeyID)
|
||||
httpx.LogAttr(r.Context(), "scope", string(id.Scope))
|
||||
next.ServeHTTP(w, r.WithContext(ContextWithIdentity(r.Context(), id)))
|
||||
})
|
||||
}
|
||||
|
||||
// reject logs why authentication failed and tells the client only that it did.
|
||||
func (m *Middleware) reject(w http.ResponseWriter, r *http.Request, cause error) {
|
||||
if m.Log != nil {
|
||||
// cause is one of this package's sentinels or a store error. None of
|
||||
// them embed the token, which is what makes it safe to log at all.
|
||||
m.Log.Debug("authentication failed",
|
||||
"reason", cause,
|
||||
"req_id", httpx.RequestIDFrom(r.Context()),
|
||||
"path", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="pages"`)
|
||||
httpx.WriteError(w, r, m.Log, errUnauthorized())
|
||||
}
|
||||
|
||||
// clientKey identifies the caller for rate limiting.
|
||||
func (m *Middleware) clientKey(r *http.Request) string {
|
||||
if addr, ok := httpx.ClientIP(r, m.Trusted); ok {
|
||||
return addr.String()
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// bearerToken extracts the credential from the Authorization header. The scheme
|
||||
// comparison is case-insensitive per RFC 7235; the token itself is not touched.
|
||||
func bearerToken(r *http.Request) (string, bool) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return "", false
|
||||
}
|
||||
scheme, token, ok := strings.Cut(h, " ")
|
||||
if !ok || !strings.EqualFold(scheme, "Bearer") {
|
||||
return "", false
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
|
||||
// RequireAdmin rejects identities that are not admin-scoped.
|
||||
func RequireAdmin(log *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := IdentityFrom(r.Context())
|
||||
if !ok {
|
||||
httpx.WriteError(w, r, log, errUnauthorized())
|
||||
return
|
||||
}
|
||||
if !id.IsAdmin() {
|
||||
httpx.WriteError(w, r, log, api.Errorf(api.CodeForbidden,
|
||||
"this operation requires an admin key"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ProjectResolver maps a project name from the URL to its row id.
|
||||
//
|
||||
// It is an interface rather than a concrete type so this package does not
|
||||
// depend on the site registry, which does not exist until the serving layer is
|
||||
// wired up, and so tests can supply a two-line fake.
|
||||
type ProjectResolver interface {
|
||||
ResolveProject(ctx context.Context, name string) (int64, error)
|
||||
}
|
||||
|
||||
// ResolverFunc adapts a function to ProjectResolver.
|
||||
type ResolverFunc func(ctx context.Context, name string) (int64, error)
|
||||
|
||||
func (f ResolverFunc) ResolveProject(ctx context.Context, name string) (int64, error) {
|
||||
return f(ctx, name)
|
||||
}
|
||||
|
||||
// RequireProject allows admins through and otherwise requires the caller's key
|
||||
// to belong to the project named by the {pathValue} URL wildcard.
|
||||
//
|
||||
// The comparison is on resolved row ids, never on the name string. Comparing
|
||||
// names would make the trust boundary depend on every handler normalising the
|
||||
// same way, and would break the moment two names can resolve to one project.
|
||||
func RequireProject(pathValue string, r ProjectResolver, log *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
id, ok := IdentityFrom(req.Context())
|
||||
if !ok {
|
||||
httpx.WriteError(w, req, log, errUnauthorized())
|
||||
return
|
||||
}
|
||||
name := req.PathValue(pathValue)
|
||||
if name == "" {
|
||||
httpx.WriteError(w, req, log, api.Errorf(api.CodeBadRequest, "missing project name"))
|
||||
return
|
||||
}
|
||||
|
||||
projectID, err := r.ResolveProject(req.Context(), name)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// A project-scoped key must not be able to probe which
|
||||
// project names exist, so an unknown name looks the same as
|
||||
// someone else's project.
|
||||
if !id.IsAdmin() {
|
||||
httpx.WriteError(w, req, log, forbiddenProject())
|
||||
return
|
||||
}
|
||||
httpx.WriteError(w, req, log,
|
||||
api.Errorf(api.CodeNotFound, "no such project: %s", name))
|
||||
return
|
||||
}
|
||||
httpx.WriteError(w, req, log, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !id.Owns(projectID) {
|
||||
httpx.WriteError(w, req, log, forbiddenProject())
|
||||
return
|
||||
}
|
||||
httpx.LogAttr(req.Context(), "project", name)
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func forbiddenProject() *api.Error {
|
||||
return api.Errorf(api.CodeForbidden, "this key does not have access to that project")
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/httpx"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// okHandler records that the request got past the middleware.
|
||||
func okHandler(reached *bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if reached != nil {
|
||||
*reached = true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
func errorCode(t *testing.T, body []byte) api.Code {
|
||||
t.Helper()
|
||||
var env api.ErrorEnvelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
t.Fatalf("response is not an error envelope: %v (%s)", err, body)
|
||||
}
|
||||
if env.Error.Code == "" {
|
||||
t.Fatalf("envelope has no error code: %s", body)
|
||||
}
|
||||
return env.Error.Code
|
||||
}
|
||||
|
||||
func newMiddleware(t *testing.T, db *store.DB, logTo *bytes.Buffer) *Middleware {
|
||||
t.Helper()
|
||||
var h slog.Handler = slog.NewTextHandler(logTo, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
log := slog.New(h)
|
||||
return &Middleware{
|
||||
V: NewVerifier(db, log, DefaultCacheTTL),
|
||||
Limiter: NewLimiter(5, time.Minute, 128),
|
||||
Trusted: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")},
|
||||
Log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateAcceptsValidToken(t *testing.T) {
|
||||
db := testStore(t)
|
||||
m := newMiddleware(t, db, &bytes.Buffer{})
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
|
||||
var gotID *Identity
|
||||
h := m.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := IdentityFrom(r.Context())
|
||||
if !ok {
|
||||
t.Error("no identity in context behind Authenticate")
|
||||
}
|
||||
gotID = id
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if gotID == nil || gotID.KeyID != keyID {
|
||||
t.Errorf("identity = %+v, want key %s", gotID, keyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateRejects(t *testing.T) {
|
||||
db := testStore(t)
|
||||
m := newMiddleware(t, db, &bytes.Buffer{})
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
_, secret, err := Parse(token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unknown, _, _, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
}{
|
||||
{"no header", ""},
|
||||
{"empty bearer", "Bearer "},
|
||||
{"wrong scheme", "Basic " + token},
|
||||
{"token without scheme", token},
|
||||
{"malformed token", "Bearer not-a-token"},
|
||||
{"unknown key", "Bearer " + unknown},
|
||||
{"truncated secret", "Bearer " + Prefix + "_" + keyID + "_" + secret[:42]},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
reached := false
|
||||
h := m.Authenticate(okHandler(&reached))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
|
||||
if tc.header != "" {
|
||||
req.Header.Set("Authorization", tc.header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if reached {
|
||||
t.Error("request reached the handler")
|
||||
}
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if got := errorCode(t, rec.Body.Bytes()); got != api.CodeUnauthorized {
|
||||
t.Errorf("code = %q, want %q", got, api.CodeUnauthorized)
|
||||
}
|
||||
if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Bearer") {
|
||||
t.Errorf("WWW-Authenticate = %q", got)
|
||||
}
|
||||
// The client must not learn which check failed.
|
||||
body := rec.Body.String()
|
||||
for _, leak := range []string{"revoked", "expired", "unknown key", "secret mismatch"} {
|
||||
if strings.Contains(strings.ToLower(body), leak) {
|
||||
t.Errorf("response distinguishes the failure reason (%q): %s", leak, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The scheme is case-insensitive per RFC 7235, and some CI clients send "bearer".
|
||||
func TestAuthenticateAcceptsAnyCaseScheme(t *testing.T) {
|
||||
db := testStore(t)
|
||||
m := newMiddleware(t, db, &bytes.Buffer{})
|
||||
token, _ := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
|
||||
for _, scheme := range []string{"Bearer", "bearer", "BEARER", "BeArEr"} {
|
||||
reached := false
|
||||
h := m.Authenticate(okHandler(&reached))
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req.Header.Set("Authorization", scheme+" "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if !reached {
|
||||
t.Errorf("scheme %q rejected: %d %s", scheme, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A token in the query string ends up in proxy logs and browser history, so it
|
||||
// must never be accepted as a credential.
|
||||
func TestTokenInQueryStringIsNotAccepted(t *testing.T) {
|
||||
db := testStore(t)
|
||||
m := newMiddleware(t, db, &bytes.Buffer{})
|
||||
token, _ := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
|
||||
reached := false
|
||||
h := m.Authenticate(okHandler(&reached))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami?token="+token+"&access_token="+token, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if reached {
|
||||
t.Fatal("a query-string token authenticated the request")
|
||||
}
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The load-bearing one: nothing this middleware logs may contain the secret.
|
||||
func TestAuthLogsNeverContainCredentials(t *testing.T) {
|
||||
db := testStore(t)
|
||||
var logBuf bytes.Buffer
|
||||
m := newMiddleware(t, db, &logBuf)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
_, secret, err := Parse(token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := httpx.Chain(
|
||||
m.Authenticate(okHandler(nil)),
|
||||
httpx.WithRequestID(m.Trusted),
|
||||
httpx.AccessLog(m.Log, m.Trusted),
|
||||
httpx.Recover(m.Log),
|
||||
)
|
||||
|
||||
// A successful request, a wrong-secret request, and a garbage request: the
|
||||
// three paths that each touch the token.
|
||||
for _, hdr := range []string{
|
||||
"Bearer " + token,
|
||||
"Bearer " + Prefix + "_" + keyID + "_" + strings.Repeat("z", 43),
|
||||
"Bearer " + token + "trailing",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
|
||||
req.Header.Set("Authorization", hdr)
|
||||
req.RemoteAddr = "10.1.2.3:5555"
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
out := logBuf.String()
|
||||
if out == "" {
|
||||
t.Fatal("nothing was logged; the test would pass vacuously")
|
||||
}
|
||||
for _, forbidden := range []string{secret, token, "Bearer", "Authorization"} {
|
||||
if strings.Contains(out, forbidden) {
|
||||
t.Errorf("log contains %q:\n%s", forbidden, out)
|
||||
}
|
||||
}
|
||||
// The public half is supposed to be there — otherwise an operator cannot
|
||||
// tell which key made a request.
|
||||
if !strings.Contains(out, keyID) {
|
||||
t.Errorf("log does not record the key id:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateRateLimitsFailures(t *testing.T) {
|
||||
db := testStore(t)
|
||||
m := newMiddleware(t, db, &bytes.Buffer{})
|
||||
m.Limiter = NewLimiter(3, time.Minute, 32)
|
||||
bad, _, _, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
good, _ := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
|
||||
send := func(token, remote string) *httptest.ResponseRecorder {
|
||||
h := m.Authenticate(okHandler(nil))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/whoami", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.RemoteAddr = remote
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if got := send(bad, "10.1.2.3:5555").Code; got != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: status = %d, want 401", i, got)
|
||||
}
|
||||
}
|
||||
rec := send(bad, "10.1.2.3:5555")
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status = %d, want 429: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if got := errorCode(t, rec.Body.Bytes()); got != api.CodeRateLimited {
|
||||
t.Errorf("code = %q, want %q", got, api.CodeRateLimited)
|
||||
}
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Error("429 without a Retry-After header")
|
||||
}
|
||||
|
||||
// A different address is unaffected...
|
||||
if got := send(bad, "10.9.9.9:5555").Code; got != http.StatusUnauthorized {
|
||||
t.Errorf("unrelated client got %d, want 401", got)
|
||||
}
|
||||
// ...and a client that had never failed can still authenticate.
|
||||
if got := send(good, "10.8.8.8:5555").Code; got != http.StatusNoContent {
|
||||
t.Errorf("valid token from a clean client got %d, want 204", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdmin(t *testing.T) {
|
||||
db := testStore(t)
|
||||
log := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
p := store.DefaultProject("demo")
|
||||
if err := db.CreateProject(context.Background(), p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
identity *Identity
|
||||
wantStatus int
|
||||
}{
|
||||
{"admin", &Identity{KeyID: "a", Scope: store.ScopeAdmin}, http.StatusNoContent},
|
||||
{"project", &Identity{KeyID: "b", Scope: store.ScopeProject, ProjectID: &p.ID}, http.StatusForbidden},
|
||||
{"none", nil, http.StatusUnauthorized},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
reached := false
|
||||
h := RequireAdmin(log)(okHandler(&reached))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil)
|
||||
if tc.identity != nil {
|
||||
req = req.WithContext(ContextWithIdentity(req.Context(), tc.identity))
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body)
|
||||
}
|
||||
if reached != (tc.wantStatus == http.StatusNoContent) {
|
||||
t.Errorf("handler reached = %v", reached)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireProject(t *testing.T) {
|
||||
db := testStore(t)
|
||||
ctx := context.Background()
|
||||
log := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
|
||||
mine := store.DefaultProject("mine")
|
||||
theirs := store.DefaultProject("theirs")
|
||||
if err := db.CreateProject(ctx, mine); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateProject(ctx, theirs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resolver := ResolverFunc(func(ctx context.Context, name string) (int64, error) {
|
||||
p, err := db.ProjectByName(ctx, name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return p.ID, nil
|
||||
})
|
||||
|
||||
admin := &Identity{KeyID: "admin00000000000", Scope: store.ScopeAdmin}
|
||||
owner := &Identity{KeyID: "owner00000000000", Scope: store.ScopeProject, ProjectID: &mine.ID}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
identity *Identity
|
||||
project string
|
||||
wantStatus int
|
||||
}{
|
||||
{"owner on own project", owner, "mine", http.StatusNoContent},
|
||||
{"owner on another project", owner, "theirs", http.StatusForbidden},
|
||||
{"admin on any project", admin, "theirs", http.StatusNoContent},
|
||||
{"admin on unknown project", admin, "ghost", http.StatusNotFound},
|
||||
// An unknown name must look exactly like someone else's project to a
|
||||
// project-scoped key, or the API becomes a project-name oracle.
|
||||
{"owner on unknown project", owner, "ghost", http.StatusForbidden},
|
||||
{"no identity", nil, "mine", http.StatusUnauthorized},
|
||||
{"missing name", owner, "", http.StatusBadRequest},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
reached := false
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /api/v1/projects/{name}", RequireProject("name", resolver, log)(okHandler(&reached)))
|
||||
// The "missing name" case cannot be produced through the mux, so it
|
||||
// exercises the handler directly.
|
||||
var h http.Handler = mux
|
||||
target := "/api/v1/projects/" + tc.project
|
||||
if tc.project == "" {
|
||||
h = RequireProject("name", resolver, log)(okHandler(&reached))
|
||||
target = "/api/v1/projects/"
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
if tc.identity != nil {
|
||||
req = req.WithContext(ContextWithIdentity(req.Context(), tc.identity))
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body)
|
||||
}
|
||||
if reached != (tc.wantStatus == http.StatusNoContent) {
|
||||
t.Errorf("handler reached = %v", reached)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ownership is decided on row ids. A project key must not gain access to a
|
||||
// project just because a name resolves to it.
|
||||
func TestOwnsComparesIDsNotNames(t *testing.T) {
|
||||
one := int64(1)
|
||||
two := int64(2)
|
||||
cases := []struct {
|
||||
name string
|
||||
id *Identity
|
||||
ask int64
|
||||
want bool
|
||||
}{
|
||||
{"admin owns anything", &Identity{Scope: store.ScopeAdmin}, 42, true},
|
||||
{"project owns itself", &Identity{Scope: store.ScopeProject, ProjectID: &one}, 1, true},
|
||||
{"project does not own another", &Identity{Scope: store.ScopeProject, ProjectID: &two}, 1, false},
|
||||
{"project key without a project owns nothing", &Identity{Scope: store.ScopeProject}, 1, false},
|
||||
{"nil identity owns nothing", nil, 1, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.id.Owns(tc.ask); got != tc.want {
|
||||
t.Errorf("Owns(%d) = %v, want %v", tc.ask, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cache"
|
||||
)
|
||||
|
||||
// Limiter throttles clients that keep presenting bad credentials.
|
||||
//
|
||||
// It exists for CPU, not for guessing. An 80-bit key id plus a 256-bit secret
|
||||
// cannot be brute forced, so the realistic attack is not "eventually guess a
|
||||
// token" but "make the server parse, look up and hash forever". Only failed
|
||||
// attempts consume budget, so a busy CI runner pushing hundreds of valid
|
||||
// requests a second is never touched.
|
||||
//
|
||||
// The bucket map is an LRU with a hard cap, because its keys are whatever
|
||||
// addresses show up: an unbounded map keyed by attacker-chosen input would turn
|
||||
// the defence into a memory exhaustion vector of its own.
|
||||
type Limiter struct {
|
||||
buckets *cache.Cache[string, *bucket]
|
||||
burst float64
|
||||
refill float64 // tokens per second
|
||||
|
||||
now func() time.Time // swapped in tests
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
mu sync.Mutex
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// NewLimiter allows burst consecutive failures per client, refilling to full
|
||||
// over period, and tracks at most maxClients addresses.
|
||||
func NewLimiter(burst int, period time.Duration, maxClients int) *Limiter {
|
||||
if burst < 1 {
|
||||
burst = 1
|
||||
}
|
||||
if period <= 0 {
|
||||
period = time.Minute
|
||||
}
|
||||
// Idle buckets are dropped after twice the refill period: by then a bucket
|
||||
// has refilled completely, so forgetting it and recreating it full are the
|
||||
// same thing.
|
||||
return &Limiter{
|
||||
buckets: cache.New[string, *bucket](maxClients, 2*period),
|
||||
burst: float64(burst),
|
||||
refill: float64(burst) / period.Seconds(),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow reports whether client has any budget left. It does not consume any:
|
||||
// a request that turns out to authenticate correctly should cost nothing.
|
||||
func (l *Limiter) Allow(client string) bool {
|
||||
if l == nil {
|
||||
return true
|
||||
}
|
||||
b := l.bucket(client)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
l.refillLocked(b)
|
||||
return b.tokens >= 1
|
||||
}
|
||||
|
||||
// Fail records one failed attempt for client.
|
||||
func (l *Limiter) Fail(client string) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
b := l.bucket(client)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
l.refillLocked(b)
|
||||
if b.tokens >= 1 {
|
||||
b.tokens--
|
||||
} else {
|
||||
b.tokens = 0
|
||||
}
|
||||
}
|
||||
|
||||
// RetryAfter estimates how long client must wait for one token, for the
|
||||
// Retry-After header. It rounds up to whole seconds, and never returns zero
|
||||
// while the client is actually throttled.
|
||||
func (l *Limiter) RetryAfter(client string) time.Duration {
|
||||
if l == nil {
|
||||
return 0
|
||||
}
|
||||
b := l.bucket(client)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
l.refillLocked(b)
|
||||
if b.tokens >= 1 {
|
||||
return 0
|
||||
}
|
||||
need := 1 - b.tokens
|
||||
d := time.Duration(need / l.refill * float64(time.Second))
|
||||
return d.Round(time.Second) + time.Second
|
||||
}
|
||||
|
||||
func (l *Limiter) bucket(client string) *bucket {
|
||||
return l.buckets.GetOrCreate(client, func() *bucket {
|
||||
return &bucket{tokens: l.burst, last: l.now()}
|
||||
})
|
||||
}
|
||||
|
||||
func (l *Limiter) refillLocked(b *bucket) {
|
||||
now := l.now()
|
||||
if elapsed := now.Sub(b.last); elapsed > 0 {
|
||||
b.tokens += elapsed.Seconds() * l.refill
|
||||
if b.tokens > l.burst {
|
||||
b.tokens = l.burst
|
||||
}
|
||||
b.last = now
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeClock drives the limiter's refill without sleeping.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.t = c.t.Add(d)
|
||||
}
|
||||
|
||||
func testLimiter(t *testing.T, burst int, period time.Duration, max int) (*Limiter, *fakeClock) {
|
||||
t.Helper()
|
||||
l := NewLimiter(burst, period, max)
|
||||
clk := &fakeClock{t: time.Unix(1_700_000_000, 0)}
|
||||
l.now = clk.now
|
||||
return l, clk
|
||||
}
|
||||
|
||||
// Successful requests must cost nothing: a CI runner pushing hundreds of valid
|
||||
// deploys a second is not the thing this limiter is defending against.
|
||||
func TestAllowDoesNotConsume(t *testing.T) {
|
||||
l, _ := testLimiter(t, 3, time.Minute, 100)
|
||||
for i := 0; i < 1000; i++ {
|
||||
if !l.Allow("10.0.0.1") {
|
||||
t.Fatalf("Allow denied a caller that never failed (i=%d)", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailExhaustsBudget(t *testing.T) {
|
||||
l, _ := testLimiter(t, 3, time.Minute, 100)
|
||||
const ip = "10.0.0.1"
|
||||
for i := 0; i < 3; i++ {
|
||||
if !l.Allow(ip) {
|
||||
t.Fatalf("denied before the burst was spent (i=%d)", i)
|
||||
}
|
||||
l.Fail(ip)
|
||||
}
|
||||
if l.Allow(ip) {
|
||||
t.Error("burst exhausted but the caller is still allowed")
|
||||
}
|
||||
// Failing while already throttled must not push the balance negative, or
|
||||
// the client would take proportionally longer to recover the more it tried.
|
||||
for i := 0; i < 100; i++ {
|
||||
l.Fail(ip)
|
||||
}
|
||||
if d := l.RetryAfter(ip); d > 2*time.Minute {
|
||||
t.Errorf("RetryAfter = %v; over-failing drove the bucket negative", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBudgetRefills(t *testing.T) {
|
||||
l, clk := testLimiter(t, 4, time.Minute, 100)
|
||||
const ip = "10.0.0.1"
|
||||
for i := 0; i < 4; i++ {
|
||||
l.Fail(ip)
|
||||
}
|
||||
if l.Allow(ip) {
|
||||
t.Fatal("expected to be throttled")
|
||||
}
|
||||
|
||||
// One quarter of the period restores one of four tokens.
|
||||
clk.advance(15 * time.Second)
|
||||
if !l.Allow(ip) {
|
||||
t.Error("no token after a quarter period")
|
||||
}
|
||||
|
||||
clk.advance(time.Hour)
|
||||
for i := 0; i < 4; i++ {
|
||||
if !l.Allow(ip) {
|
||||
t.Fatalf("bucket did not refill to full (i=%d)", i)
|
||||
}
|
||||
l.Fail(ip)
|
||||
}
|
||||
if l.Allow(ip) {
|
||||
t.Error("bucket refilled past its burst")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientsAreIndependent(t *testing.T) {
|
||||
l, _ := testLimiter(t, 2, time.Minute, 100)
|
||||
for i := 0; i < 2; i++ {
|
||||
l.Fail("10.0.0.1")
|
||||
}
|
||||
if l.Allow("10.0.0.1") {
|
||||
t.Error("attacker not throttled")
|
||||
}
|
||||
if !l.Allow("10.0.0.2") {
|
||||
t.Error("one bad client throttled an unrelated one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryAfter(t *testing.T) {
|
||||
l, clk := testLimiter(t, 2, time.Minute, 100)
|
||||
const ip = "10.0.0.1"
|
||||
if d := l.RetryAfter(ip); d != 0 {
|
||||
t.Errorf("RetryAfter with budget left = %v, want 0", d)
|
||||
}
|
||||
l.Fail(ip)
|
||||
l.Fail(ip)
|
||||
|
||||
d := l.RetryAfter(ip)
|
||||
if d <= 0 {
|
||||
t.Fatal("a throttled client must be told to wait a positive time")
|
||||
}
|
||||
if d > 2*time.Minute {
|
||||
t.Errorf("RetryAfter = %v, unreasonably long for a 1m period", d)
|
||||
}
|
||||
// Waiting the advertised time must actually be enough.
|
||||
clk.advance(d)
|
||||
if !l.Allow(ip) {
|
||||
t.Errorf("still throttled after waiting the advertised %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// The bucket map is keyed by attacker-chosen input, so its bound is load
|
||||
// bearing: without it the rate limiter becomes the memory exhaustion vector.
|
||||
func TestBucketMapIsBounded(t *testing.T) {
|
||||
l, _ := testLimiter(t, 2, time.Minute, 64)
|
||||
for i := 0; i < 10000; i++ {
|
||||
l.Fail(fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff))
|
||||
}
|
||||
if got := l.buckets.Len(); got > 64 {
|
||||
t.Errorf("tracking %d clients, cap is 64", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A nil limiter is the "rate limiting disabled" configuration and must not
|
||||
// panic in the request path.
|
||||
func TestNilLimiterAllowsEverything(t *testing.T) {
|
||||
var l *Limiter
|
||||
if !l.Allow("10.0.0.1") {
|
||||
t.Error("nil limiter denied a request")
|
||||
}
|
||||
l.Fail("10.0.0.1")
|
||||
if d := l.RetryAfter("10.0.0.1"); d != 0 {
|
||||
t.Errorf("RetryAfter = %v, want 0", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterConcurrentUse(t *testing.T) {
|
||||
l := NewLimiter(50, time.Minute, 256)
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 16; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 200; i++ {
|
||||
ip := fmt.Sprintf("10.0.0.%d", i%8)
|
||||
l.Allow(ip)
|
||||
if i%3 == 0 {
|
||||
l.Fail(ip)
|
||||
}
|
||||
l.RetryAfter(ip)
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Package auth mints and verifies API tokens.
|
||||
//
|
||||
// A token looks like pgs_<keyid>_<secret>. The key id is the public half: it is
|
||||
// stored in the clear, indexed, printed by `pages key list` and safe to log.
|
||||
// The secret is 256 bits of crypto/rand, shown to the operator exactly once at
|
||||
// creation and never stored — only its SHA-256.
|
||||
//
|
||||
// Splitting the two is what keeps verification a single indexed lookup instead
|
||||
// of a table scan comparing every hash, and it gives the CLI something
|
||||
// displayable that reveals nothing.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// Prefix marks a pages token. Its main job is to be greppable: secret
|
||||
// scanners and humans can both spot a leaked credential by shape alone.
|
||||
Prefix = "pgs"
|
||||
|
||||
keyIDBytes = 10 // 80 bits -> exactly 16 base32 characters, no padding
|
||||
secretBytes = 32 // 256 bits -> exactly 43 base64url characters, no padding
|
||||
|
||||
// KeyIDLen and secretLen are the encoded lengths. Parse checks them exactly
|
||||
// so a truncated or padded token is rejected before any lookup happens.
|
||||
KeyIDLen = 16
|
||||
secretLen = 43
|
||||
)
|
||||
|
||||
// keyIDEncoding is lowercase base32 so a key id can be typed, double-clicked
|
||||
// and pasted without case confusion. It is not standard base32; do not swap it
|
||||
// for base32.StdEncoding without a migration, because existing ids would stop
|
||||
// decoding.
|
||||
var keyIDEncoding = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
|
||||
|
||||
// ErrMalformedToken is returned for anything that is not shaped like a token.
|
||||
//
|
||||
// It deliberately carries no detail about which check failed and never embeds
|
||||
// the offending token: these errors reach logs, and a log line quoting a
|
||||
// near-miss credential is a credential leak.
|
||||
var ErrMalformedToken = errors.New("auth: malformed token")
|
||||
|
||||
// Mint generates a new token. The caller stores keyID and secretHash and hands
|
||||
// token to the operator; there is no way to recover token afterwards.
|
||||
func Mint() (token, keyID string, secretHash [32]byte, err error) {
|
||||
idRaw := make([]byte, keyIDBytes)
|
||||
if _, err := rand.Read(idRaw); err != nil {
|
||||
return "", "", [32]byte{}, err
|
||||
}
|
||||
secretRaw := make([]byte, secretBytes)
|
||||
if _, err := rand.Read(secretRaw); err != nil {
|
||||
return "", "", [32]byte{}, err
|
||||
}
|
||||
|
||||
keyID = keyIDEncoding.EncodeToString(idRaw)
|
||||
secret := base64.RawURLEncoding.EncodeToString(secretRaw)
|
||||
return Prefix + "_" + keyID + "_" + secret, keyID, sha256.Sum256([]byte(secret)), nil
|
||||
}
|
||||
|
||||
// Parse splits a token into its two halves, validating shape and alphabet.
|
||||
//
|
||||
// This runs before any database work, so it is also the cheap filter that keeps
|
||||
// junk from reaching the store: an unauthenticated flood of garbage tokens
|
||||
// costs a few string comparisons each, not a query.
|
||||
func Parse(token string) (keyID, secret string, err error) {
|
||||
rest, ok := strings.CutPrefix(token, Prefix+"_")
|
||||
if !ok {
|
||||
return "", "", ErrMalformedToken
|
||||
}
|
||||
keyID, secret, ok = strings.Cut(rest, "_")
|
||||
if !ok {
|
||||
return "", "", ErrMalformedToken
|
||||
}
|
||||
if len(keyID) != KeyIDLen || len(secret) != secretLen {
|
||||
return "", "", ErrMalformedToken
|
||||
}
|
||||
if !validKeyID(keyID) || !validSecret(secret) {
|
||||
return "", "", ErrMalformedToken
|
||||
}
|
||||
return keyID, secret, nil
|
||||
}
|
||||
|
||||
// ValidKeyID reports whether s could be a key id. Handlers that take a key id
|
||||
// from the URL use it to reject junk before querying.
|
||||
func ValidKeyID(s string) bool { return len(s) == KeyIDLen && validKeyID(s) }
|
||||
|
||||
func validKeyID(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= '2' && c <= '7') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validSecret(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// HashSecret returns the value stored in api_keys.secret_hash.
|
||||
//
|
||||
// A plain SHA-256, not bcrypt or argon2, and that is deliberate. Password
|
||||
// hashing exists to make low-entropy human-chosen secrets expensive to guess
|
||||
// offline; this secret is 256 uniformly random bits, so there is no dictionary
|
||||
// and no brute force to slow down. Running a KDF per request would instead add
|
||||
// 50-200ms to every API call and hand an unauthenticated client a CPU
|
||||
// exhaustion attack: each wrong token would force a full key derivation.
|
||||
func HashSecret(secret string) [32]byte {
|
||||
return sha256.Sum256([]byte(secret))
|
||||
}
|
||||
|
||||
// SecretMatches compares a presented secret against a stored hash in constant
|
||||
// time, so a caller cannot learn the hash byte by byte from response timing.
|
||||
func SecretMatches(secret string, storedHash []byte) bool {
|
||||
got := HashSecret(secret)
|
||||
return subtle.ConstantTimeCompare(got[:], storedHash) == 1
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMintParseRoundTrip(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
token, keyID, hash, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatalf("Mint: %v", err)
|
||||
}
|
||||
if seen[keyID] {
|
||||
t.Fatalf("Mint reused key id %q", keyID)
|
||||
}
|
||||
seen[keyID] = true
|
||||
|
||||
if !strings.HasPrefix(token, Prefix+"_") {
|
||||
t.Errorf("token %q lacks the %q prefix", token, Prefix)
|
||||
}
|
||||
gotID, secret, err := Parse(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(%q): %v", token, err)
|
||||
}
|
||||
if gotID != keyID {
|
||||
t.Errorf("Parse key id = %q, want %q", gotID, keyID)
|
||||
}
|
||||
if len(gotID) != KeyIDLen {
|
||||
t.Errorf("key id length = %d, want %d", len(gotID), KeyIDLen)
|
||||
}
|
||||
if !SecretMatches(secret, hash[:]) {
|
||||
t.Error("minted secret does not match its own hash")
|
||||
}
|
||||
if strings.Contains(token, keyID+"_"+keyID) {
|
||||
t.Error("secret must not repeat the key id")
|
||||
}
|
||||
if !ValidKeyID(keyID) {
|
||||
t.Errorf("ValidKeyID rejected a minted id %q", keyID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsMalformed(t *testing.T) {
|
||||
good, keyID, _, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, secret, err := Parse(good)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"no prefix", keyID + "_" + secret},
|
||||
{"wrong prefix", "xyz_" + keyID + "_" + secret},
|
||||
{"prefix only", "pgs_"},
|
||||
{"no separator", "pgs_" + keyID + secret},
|
||||
{"short key id", "pgs_" + keyID[:15] + "_" + secret},
|
||||
{"long key id", "pgs_" + keyID + "a_" + secret},
|
||||
{"short secret", "pgs_" + keyID + "_" + secret[:42]},
|
||||
{"long secret", "pgs_" + keyID + "_" + secret + "a"},
|
||||
{"uppercase key id", "pgs_" + strings.ToUpper(keyID) + "_" + secret},
|
||||
{"key id with 0 (not in base32)", "pgs_0" + keyID[1:] + "_" + secret},
|
||||
{"key id with 1 (not in base32)", "pgs_1" + keyID[1:] + "_" + secret},
|
||||
{"secret with padding", "pgs_" + keyID + "_" + secret[:42] + "="},
|
||||
{"secret with slash", "pgs_" + keyID + "_" + secret[:42] + "/"},
|
||||
{"secret with plus", "pgs_" + keyID + "_" + secret[:42] + "+"},
|
||||
{"embedded NUL", "pgs_" + keyID + "_" + secret[:42] + "\x00"},
|
||||
{"leading space", " " + good},
|
||||
{"newline", good + "\n"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, _, err := Parse(tc.token); err == nil {
|
||||
t.Errorf("Parse(%q) accepted a malformed token", tc.token)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The parse error must never quote the input: these errors reach logs.
|
||||
if _, _, err := Parse(good[:len(good)-1] + "x"); err != nil {
|
||||
if strings.Contains(err.Error(), secret[:20]) {
|
||||
t.Error("parse error leaks part of the presented secret")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretMatches(t *testing.T) {
|
||||
hash := HashSecret("correct horse battery staple")
|
||||
if !SecretMatches("correct horse battery staple", hash[:]) {
|
||||
t.Error("matching secret rejected")
|
||||
}
|
||||
if SecretMatches("correct horse battery stapl", hash[:]) {
|
||||
t.Error("truncated secret accepted")
|
||||
}
|
||||
if SecretMatches("", hash[:]) {
|
||||
t.Error("empty secret accepted")
|
||||
}
|
||||
if SecretMatches("correct horse battery staple", nil) {
|
||||
t.Error("nil stored hash accepted")
|
||||
}
|
||||
if SecretMatches("correct horse battery staple", hash[:16]) {
|
||||
t.Error("truncated stored hash accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidKeyID(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"abcdefghijklmnop", true},
|
||||
{"234567234567abcd", true},
|
||||
{"", false},
|
||||
{"abcdefghijklmno", false}, // 15
|
||||
{"abcdefghijklmnopq", false}, // 17
|
||||
{"ABCDEFGHIJKLMNOP", false},
|
||||
{"abcdefghijklmno0", false},
|
||||
{"abcdefghijklmno1", false},
|
||||
{"abcdefghijklmno8", false},
|
||||
{"abcdefghijklmno-", false},
|
||||
{"abcdefghijklmn/p", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ValidKeyID(tc.in); got != tc.want {
|
||||
t.Errorf("ValidKeyID(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// Identity is what a verified token proves. It is immutable once returned and
|
||||
// is shared by every request using that key, so callers must not modify it.
|
||||
type Identity struct {
|
||||
KeyID string
|
||||
Scope store.Scope
|
||||
ProjectID *int64 // nil for admin keys
|
||||
Name string
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the identity may act on every project.
|
||||
func (i *Identity) IsAdmin() bool { return i != nil && i.Scope == store.ScopeAdmin }
|
||||
|
||||
// Owns reports whether the identity may act on the project with this row id.
|
||||
// Admins own everything.
|
||||
//
|
||||
// Callers must pass a resolved row id, never a name from the URL: comparing
|
||||
// names would make the boundary depend on string handling in every handler.
|
||||
func (i *Identity) Owns(projectID int64) bool {
|
||||
if i == nil {
|
||||
return false
|
||||
}
|
||||
if i.Scope == store.ScopeAdmin {
|
||||
return true
|
||||
}
|
||||
return i.ProjectID != nil && *i.ProjectID == projectID
|
||||
}
|
||||
|
||||
// Failure reasons. All of them are reported to the client as one indistinct
|
||||
// 401: telling an unauthenticated caller whether a key exists, is revoked or
|
||||
// merely expired is free reconnaissance.
|
||||
var (
|
||||
ErrUnknownKey = errors.New("auth: unknown key id")
|
||||
ErrBadSecret = errors.New("auth: secret mismatch")
|
||||
ErrRevoked = errors.New("auth: key revoked")
|
||||
ErrExpired = errors.New("auth: key expired")
|
||||
)
|
||||
|
||||
// DefaultCacheTTL bounds how long a revocation can take to become visible if
|
||||
// the process that revoked it is not this one. Within one process, Invalidate
|
||||
// makes revocation immediate.
|
||||
const DefaultCacheTTL = 60 * time.Second
|
||||
|
||||
// Verifier turns a bearer token into an Identity.
|
||||
//
|
||||
// Verified keys are cached, because otherwise every deploy request would pay a
|
||||
// database round trip before doing any work. The cache stores only positive
|
||||
// results: caching unknown key ids would let anyone grow the map without bound
|
||||
// by presenting random tokens. An unknown id costs one indexed lookup on a
|
||||
// WITHOUT ROWID table, and the rate limiter covers the flood case.
|
||||
//
|
||||
// sync.Map fits this exactly — the key set is small and stable, entries are
|
||||
// written once and read many times, and different goroutines mostly touch
|
||||
// different keys.
|
||||
type Verifier struct {
|
||||
db *store.DB
|
||||
log *slog.Logger
|
||||
ttl time.Duration
|
||||
|
||||
cache sync.Map // keyID -> *cacheEntry
|
||||
gen atomic.Uint64
|
||||
|
||||
// Pending last-use timestamps, flushed in batches. Writing last_used_at per
|
||||
// request would funnel every authenticated read through the single write
|
||||
// connection, which is the contention the two-pool design exists to avoid.
|
||||
mu sync.Mutex
|
||||
touch map[string]time.Time
|
||||
|
||||
now func() time.Time // swapped in tests
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
ident *Identity
|
||||
hash []byte
|
||||
gen uint64
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
// NewVerifier returns a verifier reading from db. A ttl of zero means
|
||||
// DefaultCacheTTL.
|
||||
func NewVerifier(db *store.DB, log *slog.Logger, ttl time.Duration) *Verifier {
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultCacheTTL
|
||||
}
|
||||
return &Verifier{
|
||||
db: db,
|
||||
log: log,
|
||||
ttl: ttl,
|
||||
touch: make(map[string]time.Time),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Verify authenticates a bearer token.
|
||||
//
|
||||
// On success it also records the key as used; the timestamp is written to the
|
||||
// database later, in a batch, so it is approximate by design.
|
||||
func (v *Verifier) Verify(ctx context.Context, token string) (*Identity, error) {
|
||||
keyID, secret, err := Parse(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := v.now()
|
||||
gen := v.gen.Load()
|
||||
|
||||
entry, ok := v.lookupCache(keyID, gen, now)
|
||||
if !ok {
|
||||
key, err := v.db.KeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, ErrUnknownKey
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
entry = &cacheEntry{
|
||||
ident: identityOf(key),
|
||||
hash: key.SecretHash,
|
||||
gen: gen,
|
||||
exp: now.Add(v.ttl),
|
||||
}
|
||||
// Revoked keys are never cached: the entry would only ever produce a
|
||||
// rejection, and keeping it lets a caller pin memory with a dead key.
|
||||
if key.RevokedAt != nil {
|
||||
return nil, ErrRevoked
|
||||
}
|
||||
v.cache.Store(keyID, entry)
|
||||
}
|
||||
|
||||
// The comparison happens on every request, cache hit or not. The cache
|
||||
// saves the database round trip; it must never save the check itself.
|
||||
if !SecretMatches(secret, entry.hash) {
|
||||
return nil, ErrBadSecret
|
||||
}
|
||||
if entry.ident.ExpiresAt != nil && !now.Before(*entry.ident.ExpiresAt) {
|
||||
return nil, ErrExpired
|
||||
}
|
||||
|
||||
v.recordUse(keyID, now)
|
||||
return entry.ident, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) lookupCache(keyID string, gen uint64, now time.Time) (*cacheEntry, bool) {
|
||||
raw, ok := v.cache.Load(keyID)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
e := raw.(*cacheEntry)
|
||||
if e.gen != gen || !now.Before(e.exp) {
|
||||
v.cache.Delete(keyID)
|
||||
return nil, false
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
// Invalidate discards every cached identity.
|
||||
//
|
||||
// Called after any key or project change. Bumping a generation counter rather
|
||||
// than deleting individual entries is deliberate: a caller that forgets which
|
||||
// ids a change touched cannot leave a stale entry behind, and the cost is one
|
||||
// atomic load per verification.
|
||||
func (v *Verifier) Invalidate() { v.gen.Add(1) }
|
||||
|
||||
func (v *Verifier) recordUse(keyID string, at time.Time) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if prev, ok := v.touch[keyID]; !ok || at.After(prev) {
|
||||
v.touch[keyID] = at
|
||||
}
|
||||
}
|
||||
|
||||
// FlushTouches writes the accumulated last-use timestamps.
|
||||
//
|
||||
// The pending set is taken before the write and not restored on failure: a lost
|
||||
// last_used_at is a cosmetic loss, and retrying would let a persistently
|
||||
// failing write grow the map without bound.
|
||||
func (v *Verifier) FlushTouches(ctx context.Context) error {
|
||||
v.mu.Lock()
|
||||
pending := v.touch
|
||||
v.touch = make(map[string]time.Time)
|
||||
v.mu.Unlock()
|
||||
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
return v.db.TouchKeys(ctx, pending)
|
||||
}
|
||||
|
||||
// RunFlusher writes pending last-use timestamps every interval until ctx is
|
||||
// done, then flushes once more so a clean shutdown does not drop them.
|
||||
func (v *Verifier) RunFlusher(ctx context.Context, interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// ctx is already cancelled, so the final flush needs its own
|
||||
// deadline or TouchKeys would return immediately.
|
||||
final, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := v.FlushTouches(final); err != nil && v.log != nil {
|
||||
v.log.Warn("final last_used_at flush failed", "error", err)
|
||||
}
|
||||
return
|
||||
case <-t.C:
|
||||
if err := v.FlushTouches(ctx); err != nil && v.log != nil {
|
||||
v.log.Warn("last_used_at flush failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func identityOf(k *store.APIKey) *Identity {
|
||||
// Everything reachable from a cached Identity is copied: the value is shared
|
||||
// by every concurrent request using that key, so it must not alias a struct
|
||||
// the store still owns.
|
||||
id := &Identity{
|
||||
KeyID: k.ID,
|
||||
Scope: k.Scope,
|
||||
Name: k.Name,
|
||||
}
|
||||
if k.ProjectID != nil {
|
||||
pid := *k.ProjectID
|
||||
id.ProjectID = &pid
|
||||
}
|
||||
if k.ExpiresAt != nil {
|
||||
exp := *k.ExpiresAt
|
||||
id.ExpiresAt = &exp
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) *store.DB {
|
||||
t.Helper()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
db, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "pages.db"), log)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// mintInto creates a real key in the database and returns its token.
|
||||
func mintInto(t *testing.T, db *store.DB, scope store.Scope, projectID *int64) (token, keyID string) {
|
||||
t.Helper()
|
||||
token, keyID, hash, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k := &store.APIKey{
|
||||
ID: keyID,
|
||||
SecretHash: hash[:],
|
||||
Scope: scope,
|
||||
ProjectID: projectID,
|
||||
Name: "test",
|
||||
}
|
||||
if err := db.CreateKey(context.Background(), k); err != nil {
|
||||
t.Fatalf("CreateKey: %v", err)
|
||||
}
|
||||
return token, keyID
|
||||
}
|
||||
|
||||
func newVerifier(t *testing.T, db *store.DB) *Verifier {
|
||||
t.Helper()
|
||||
return NewVerifier(db, slog.New(slog.NewTextHandler(io.Discard, nil)), DefaultCacheTTL)
|
||||
}
|
||||
|
||||
func TestVerifyAdminKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
|
||||
id, err := v.Verify(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if id.KeyID != keyID {
|
||||
t.Errorf("KeyID = %q, want %q", id.KeyID, keyID)
|
||||
}
|
||||
if !id.IsAdmin() {
|
||||
t.Error("admin key did not produce an admin identity")
|
||||
}
|
||||
if !id.Owns(1) || !id.Owns(999) {
|
||||
t.Error("an admin must own every project")
|
||||
}
|
||||
if id.ProjectID != nil {
|
||||
t.Errorf("admin identity carries project %v", *id.ProjectID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyProjectKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
|
||||
p := store.DefaultProject("demo")
|
||||
if err := db.CreateProject(ctx, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _ := mintInto(t, db, store.ScopeProject, &p.ID)
|
||||
|
||||
id, err := v.Verify(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if id.IsAdmin() {
|
||||
t.Error("project key produced an admin identity")
|
||||
}
|
||||
if !id.Owns(p.ID) {
|
||||
t.Error("project key does not own its own project")
|
||||
}
|
||||
if id.Owns(p.ID + 1) {
|
||||
t.Error("project key owns someone else's project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejects(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
_, secret, err := Parse(token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A well-formed token for a key that was never created.
|
||||
otherToken, _, _, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The right key id with someone else's secret.
|
||||
strangerToken, _, _, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, wrong, err := Parse(strangerToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
want error
|
||||
}{
|
||||
{"garbage", "not-a-token", ErrMalformedToken},
|
||||
{"unknown key", otherToken, ErrUnknownKey},
|
||||
{"wrong secret", Prefix + "_" + keyID + "_" + wrong, ErrBadSecret},
|
||||
{"empty", "", ErrMalformedToken},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := v.Verify(ctx, tc.token); !errors.Is(err, tc.want) {
|
||||
t.Errorf("Verify: got %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Sanity: the real token still works after all those failures.
|
||||
if _, err := v.Verify(ctx, Prefix+"_"+keyID+"_"+secret); err != nil {
|
||||
t.Errorf("valid token rejected after failed attempts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsExpiredKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
|
||||
token, keyID, hash, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
past := time.Now().Add(-time.Hour)
|
||||
if err := db.CreateKey(ctx, &store.APIKey{
|
||||
ID: keyID, SecretHash: hash[:], Scope: store.ScopeAdmin, ExpiresAt: &past,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrExpired) {
|
||||
t.Errorf("got %v, want ErrExpired", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Expiry must be re-evaluated on every call, not frozen into the cache entry,
|
||||
// or a key cached one second before it expires would stay valid for the whole
|
||||
// cache TTL.
|
||||
func TestCachedKeyStillExpires(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
|
||||
now := time.Now()
|
||||
fake := now
|
||||
var mu sync.Mutex
|
||||
v.now = func() time.Time {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return fake
|
||||
}
|
||||
|
||||
token, keyID, hash, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp := now.Add(30 * time.Second)
|
||||
if err := db.CreateKey(ctx, &store.APIKey{
|
||||
ID: keyID, SecretHash: hash[:], Scope: store.ScopeAdmin, ExpiresAt: &exp,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := v.Verify(ctx, token); err != nil {
|
||||
t.Fatalf("key should be valid before expiry: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
fake = now.Add(31 * time.Second) // still inside the 60s cache TTL
|
||||
mu.Unlock()
|
||||
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrExpired) {
|
||||
t.Errorf("expired key still accepted from cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsRevokedKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
|
||||
if err := db.RevokeKey(ctx, keyID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrRevoked) {
|
||||
t.Errorf("got %v, want ErrRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Revocation has to take effect immediately in the process that performed it;
|
||||
// this both proves that and demonstrates the cache is really being consulted.
|
||||
func TestInvalidateMakesRevocationImmediate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
|
||||
if _, err := v.Verify(ctx, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Revoke behind the verifier's back. The cached entry is still live, so the
|
||||
// key keeps working — which is exactly what makes the next assertion mean
|
||||
// something.
|
||||
if err := db.RevokeKey(ctx, keyID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Verify(ctx, token); err != nil {
|
||||
t.Fatalf("cache was not consulted (or the test is not measuring it): %v", err)
|
||||
}
|
||||
|
||||
v.Invalidate()
|
||||
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrRevoked) {
|
||||
t.Errorf("after Invalidate: got %v, want ErrRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown key id must not create a cache entry: the id space is
|
||||
// attacker-chosen, so caching misses would be an unbounded memory sink.
|
||||
func TestUnknownKeysAreNotCached(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
|
||||
for i := 0; i < 200; i++ {
|
||||
token, _, _, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrUnknownKey) {
|
||||
t.Fatalf("got %v, want ErrUnknownKey", err)
|
||||
}
|
||||
}
|
||||
entries := 0
|
||||
v.cache.Range(func(any, any) bool { entries++; return true })
|
||||
if entries != 0 {
|
||||
t.Errorf("%d unknown key ids were cached", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// Same idea for revoked keys: a dead credential must not pin memory.
|
||||
func TestRevokedKeysAreNotCached(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
if err := db.RevokeKey(ctx, keyID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := v.Verify(ctx, token); !errors.Is(err, ErrRevoked) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
entries := 0
|
||||
v.cache.Range(func(any, any) bool { entries++; return true })
|
||||
if entries != 0 {
|
||||
t.Errorf("%d revoked keys were cached", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushTouches(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
|
||||
if err := v.FlushTouches(ctx); err != nil {
|
||||
t.Errorf("flushing an empty batch: %v", err)
|
||||
}
|
||||
before, err := db.KeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before.LastUsedAt != nil {
|
||||
t.Error("last_used_at set before any use")
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := v.Verify(ctx, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Nothing is written until the batch is flushed; that is the whole point of
|
||||
// keeping the write connection out of the request path.
|
||||
mid, err := db.KeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mid.LastUsedAt != nil {
|
||||
t.Error("last_used_at written per request instead of in a batch")
|
||||
}
|
||||
|
||||
if err := v.FlushTouches(ctx); err != nil {
|
||||
t.Fatalf("FlushTouches: %v", err)
|
||||
}
|
||||
after, err := db.KeyByID(ctx, keyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.LastUsedAt == nil {
|
||||
t.Fatal("last_used_at still unset after flush")
|
||||
}
|
||||
|
||||
// A flush drains the pending set, so a second one has nothing to do.
|
||||
v.mu.Lock()
|
||||
pending := len(v.touch)
|
||||
v.mu.Unlock()
|
||||
if pending != 0 {
|
||||
t.Errorf("%d pending touches survived the flush", pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFlusherFlushesOnShutdown(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, keyID := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
if _, err := v.Verify(ctx, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
v.RunFlusher(ctx, time.Hour) // never ticks; only the shutdown path runs
|
||||
}()
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
k, err := db.KeyByID(context.Background(), keyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k.LastUsedAt == nil {
|
||||
t.Error("shutdown flush dropped the pending timestamps")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyIsConcurrencySafe(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testStore(t)
|
||||
v := newVerifier(t, db)
|
||||
token, _ := mintInto(t, db, store.ScopeAdmin, nil)
|
||||
bad, _, _, err := Mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 16; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
if g%4 == 0 {
|
||||
if _, err := v.Verify(ctx, bad); err == nil {
|
||||
t.Error("bad token accepted")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := v.Verify(ctx, token); err != nil {
|
||||
t.Errorf("good token rejected: %v", err)
|
||||
return
|
||||
}
|
||||
if i%25 == 0 {
|
||||
v.Invalidate()
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Vendored
+179
@@ -0,0 +1,179 @@
|
||||
// Package cache provides a bounded, TTL-expiring LRU map.
|
||||
//
|
||||
// It exists for the one place that genuinely needs eviction: the failed-auth
|
||||
// rate limiter, whose key space is attacker-controlled and must not be allowed
|
||||
// to grow without bound. The other caches in this server deliberately do not
|
||||
// use it — the project registry and deployment manifests are copy-on-write
|
||||
// snapshots read without any lock at all, and adding an LRU there would put a
|
||||
// contended mutex on the hot serving path to solve a problem that does not
|
||||
// exist.
|
||||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type node[K comparable, V any] struct {
|
||||
key K
|
||||
val V
|
||||
expires time.Time
|
||||
prev, next *node[K, V]
|
||||
}
|
||||
|
||||
// Cache maps K to V with a size cap and a per-entry TTL. It is safe for
|
||||
// concurrent use. Entries are evicted when the cap is exceeded (least recently
|
||||
// used first) or when their TTL passes, whichever comes first.
|
||||
//
|
||||
// The zero value is not usable; call New.
|
||||
type Cache[K comparable, V any] struct {
|
||||
mu sync.Mutex
|
||||
m map[K]*node[K, V]
|
||||
head *node[K, V] // most recently used
|
||||
tail *node[K, V] // least recently used
|
||||
max int
|
||||
ttl time.Duration
|
||||
|
||||
// now is swapped out by tests. Production always uses time.Now.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New returns a cache holding at most max entries for at most ttl each.
|
||||
func New[K comparable, V any](max int, ttl time.Duration) *Cache[K, V] {
|
||||
if max < 1 {
|
||||
max = 1
|
||||
}
|
||||
return &Cache[K, V]{
|
||||
m: make(map[K]*node[K, V]),
|
||||
max: max,
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the value for k, refreshing its recency. A value whose TTL has
|
||||
// passed is reported as absent and dropped.
|
||||
func (c *Cache[K, V]) Get(k K) (V, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
n, ok := c.m[k]
|
||||
if !ok {
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
if !c.now().Before(n.expires) {
|
||||
c.remove(n)
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
c.moveToFront(n)
|
||||
return n.val, true
|
||||
}
|
||||
|
||||
// Put inserts or replaces the value for k and resets its TTL.
|
||||
func (c *Cache[K, V]) Put(k K, v V) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.put(k, v)
|
||||
}
|
||||
|
||||
// GetOrCreate returns the existing value for k, or stores and returns the one
|
||||
// newVal produces.
|
||||
//
|
||||
// The whole operation happens under the lock, which is what makes it usable for
|
||||
// the rate limiter: two concurrent requests from the same address must share
|
||||
// one token bucket, and a Get-then-Put pair would hand each of them its own.
|
||||
// newVal must not call back into the cache.
|
||||
func (c *Cache[K, V]) GetOrCreate(k K, newVal func() V) V {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if n, ok := c.m[k]; ok {
|
||||
if c.now().Before(n.expires) {
|
||||
c.moveToFront(n)
|
||||
return n.val
|
||||
}
|
||||
c.remove(n)
|
||||
}
|
||||
v := newVal()
|
||||
c.put(k, v)
|
||||
return v
|
||||
}
|
||||
|
||||
// Delete drops k if present.
|
||||
func (c *Cache[K, V]) Delete(k K) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if n, ok := c.m[k]; ok {
|
||||
c.remove(n)
|
||||
}
|
||||
}
|
||||
|
||||
// Len reports the number of entries, including any whose TTL has passed but
|
||||
// that have not been touched since. It is meant for tests and diagnostics.
|
||||
func (c *Cache[K, V]) Len() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.m)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- internals
|
||||
// All of these require c.mu.
|
||||
|
||||
func (c *Cache[K, V]) put(k K, v V) {
|
||||
if n, ok := c.m[k]; ok {
|
||||
n.val = v
|
||||
n.expires = c.now().Add(c.ttl)
|
||||
c.moveToFront(n)
|
||||
return
|
||||
}
|
||||
n := &node[K, V]{key: k, val: v, expires: c.now().Add(c.ttl)}
|
||||
c.m[k] = n
|
||||
c.pushFront(n)
|
||||
for len(c.m) > c.max {
|
||||
c.remove(c.tail)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) pushFront(n *node[K, V]) {
|
||||
n.prev = nil
|
||||
n.next = c.head
|
||||
if c.head != nil {
|
||||
c.head.prev = n
|
||||
}
|
||||
c.head = n
|
||||
if c.tail == nil {
|
||||
c.tail = n
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) moveToFront(n *node[K, V]) {
|
||||
if c.head == n {
|
||||
return
|
||||
}
|
||||
c.unlink(n)
|
||||
c.pushFront(n)
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) remove(n *node[K, V]) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
c.unlink(n)
|
||||
delete(c.m, n.key)
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) unlink(n *node[K, V]) {
|
||||
if n.prev != nil {
|
||||
n.prev.next = n.next
|
||||
} else if c.head == n {
|
||||
c.head = n.next
|
||||
}
|
||||
if n.next != nil {
|
||||
n.next.prev = n.prev
|
||||
} else if c.tail == n {
|
||||
c.tail = n.prev
|
||||
}
|
||||
n.prev, n.next = nil, nil
|
||||
}
|
||||
Vendored
+223
@@ -0,0 +1,223 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clock lets the TTL tests run without sleeping.
|
||||
type clock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *clock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *clock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.t = c.t.Add(d)
|
||||
}
|
||||
|
||||
func newTestCache[K comparable, V any](t *testing.T, max int, ttl time.Duration) (*Cache[K, V], *clock) {
|
||||
t.Helper()
|
||||
c := New[K, V](max, ttl)
|
||||
clk := &clock{t: time.Unix(1_700_000_000, 0)}
|
||||
c.now = clk.now
|
||||
return c, clk
|
||||
}
|
||||
|
||||
func TestGetPut(t *testing.T) {
|
||||
c, _ := newTestCache[string, int](t, 4, time.Minute)
|
||||
if _, ok := c.Get("missing"); ok {
|
||||
t.Error("empty cache returned a hit")
|
||||
}
|
||||
c.Put("a", 1)
|
||||
if v, ok := c.Get("a"); !ok || v != 1 {
|
||||
t.Errorf("Get(a) = %v, %v", v, ok)
|
||||
}
|
||||
c.Put("a", 2)
|
||||
if v, _ := c.Get("a"); v != 2 {
|
||||
t.Errorf("Put did not overwrite: %v", v)
|
||||
}
|
||||
if c.Len() != 1 {
|
||||
t.Errorf("Len = %d, want 1", c.Len())
|
||||
}
|
||||
c.Delete("a")
|
||||
if _, ok := c.Get("a"); ok {
|
||||
t.Error("deleted key still present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTLExpiry(t *testing.T) {
|
||||
c, clk := newTestCache[string, int](t, 4, time.Minute)
|
||||
c.Put("a", 1)
|
||||
|
||||
clk.advance(59 * time.Second)
|
||||
if _, ok := c.Get("a"); !ok {
|
||||
t.Error("entry expired early")
|
||||
}
|
||||
clk.advance(time.Second)
|
||||
if _, ok := c.Get("a"); ok {
|
||||
t.Error("entry outlived its TTL")
|
||||
}
|
||||
if c.Len() != 0 {
|
||||
t.Errorf("expired entry not dropped: Len = %d", c.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// The bound is the whole point: the rate limiter's key space is whatever
|
||||
// addresses show up, so an unbounded map would be a memory DoS.
|
||||
func TestEvictsLeastRecentlyUsed(t *testing.T) {
|
||||
c, _ := newTestCache[string, int](t, 3, time.Minute)
|
||||
c.Put("a", 1)
|
||||
c.Put("b", 2)
|
||||
c.Put("c", 3)
|
||||
|
||||
// Touching "a" makes "b" the least recently used.
|
||||
if _, ok := c.Get("a"); !ok {
|
||||
t.Fatal("a missing")
|
||||
}
|
||||
c.Put("d", 4)
|
||||
|
||||
if c.Len() != 3 {
|
||||
t.Errorf("Len = %d, want 3", c.Len())
|
||||
}
|
||||
if _, ok := c.Get("b"); ok {
|
||||
t.Error("b should have been evicted")
|
||||
}
|
||||
for _, k := range []string{"a", "c", "d"} {
|
||||
if _, ok := c.Get(k); !ok {
|
||||
t.Errorf("%s should have survived", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvictionKeepsListConsistent(t *testing.T) {
|
||||
c, _ := newTestCache[int, int](t, 8, time.Minute)
|
||||
for i := 0; i < 1000; i++ {
|
||||
c.Put(i, i)
|
||||
if i%3 == 0 {
|
||||
c.Get(i / 2)
|
||||
}
|
||||
if i%7 == 0 {
|
||||
c.Delete(i - 1)
|
||||
}
|
||||
if c.Len() > 8 {
|
||||
t.Fatalf("cap exceeded at i=%d: Len = %d", i, c.Len())
|
||||
}
|
||||
}
|
||||
// Every surviving node must be reachable from both ends, or a later
|
||||
// eviction would corrupt the list rather than free anything.
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
forward := 0
|
||||
for n := c.head; n != nil; n = n.next {
|
||||
forward++
|
||||
if forward > 100 {
|
||||
t.Fatal("forward walk did not terminate (cycle in the list)")
|
||||
}
|
||||
}
|
||||
backward := 0
|
||||
for n := c.tail; n != nil; n = n.prev {
|
||||
backward++
|
||||
if backward > 100 {
|
||||
t.Fatal("backward walk did not terminate (cycle in the list)")
|
||||
}
|
||||
}
|
||||
if forward != len(c.m) || backward != len(c.m) {
|
||||
t.Errorf("list holds %d/%d nodes, map holds %d", forward, backward, len(c.m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrCreate(t *testing.T) {
|
||||
c, clk := newTestCache[string, *int](t, 4, time.Minute)
|
||||
calls := 0
|
||||
newVal := func() *int {
|
||||
calls++
|
||||
v := calls
|
||||
return &v
|
||||
}
|
||||
|
||||
first := c.GetOrCreate("a", newVal)
|
||||
second := c.GetOrCreate("a", newVal)
|
||||
if first != second {
|
||||
t.Error("GetOrCreate must return the same value for a live entry")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("newVal called %d times, want 1", calls)
|
||||
}
|
||||
|
||||
clk.advance(2 * time.Minute)
|
||||
third := c.GetOrCreate("a", newVal)
|
||||
if third == first {
|
||||
t.Error("an expired entry must be replaced")
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("newVal called %d times, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Two callers racing on the same key must end up sharing one value — the rate
|
||||
// limiter relies on this to avoid handing every request its own token bucket.
|
||||
func TestGetOrCreateIsAtomic(t *testing.T) {
|
||||
c := New[string, *int](64, time.Minute)
|
||||
const goroutines = 32
|
||||
var wg sync.WaitGroup
|
||||
got := make([]*int, goroutines)
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
got[i] = c.GetOrCreate("shared", func() *int { v := 0; return &v })
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
for i := 1; i < goroutines; i++ {
|
||||
if got[i] != got[0] {
|
||||
t.Fatalf("goroutine %d got a different value", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentUse(t *testing.T) {
|
||||
c := New[string, int](16, time.Minute)
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 8; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 500; i++ {
|
||||
k := fmt.Sprintf("k%d", i%40)
|
||||
c.Put(k, i)
|
||||
c.Get(k)
|
||||
if i%10 == 0 {
|
||||
c.Delete(k)
|
||||
}
|
||||
c.GetOrCreate(k, func() int { return g })
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
if c.Len() > 16 {
|
||||
t.Errorf("Len = %d, want <= 16", c.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClampsMax(t *testing.T) {
|
||||
c := New[string, int](0, time.Minute)
|
||||
c.Put("a", 1)
|
||||
c.Put("b", 2)
|
||||
if c.Len() != 1 {
|
||||
t.Errorf("Len = %d, want 1", c.Len())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package cas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LinkMode is how an assembled deployment tree gets at a blob's content.
|
||||
type LinkMode string
|
||||
|
||||
const (
|
||||
// LinkHard hardlinks. A deployment tree then costs directory entries and
|
||||
// nothing else, however many deployments share the same files.
|
||||
LinkHard LinkMode = "hardlink"
|
||||
// LinkCopy copies. Correct everywhere, at the cost of disk proportional to
|
||||
// deployed content rather than to unique content.
|
||||
LinkCopy LinkMode = "copy"
|
||||
)
|
||||
|
||||
// Failures a caller distinguishes. Everything else is an I/O error and is
|
||||
// returned as it came back from the operating system.
|
||||
var (
|
||||
ErrDigestMismatch = errors.New("cas: content does not hash to the declared digest")
|
||||
ErrSizeMismatch = errors.New("cas: content length does not match the declared size")
|
||||
ErrTooLarge = errors.New("cas: content exceeds the maximum file size")
|
||||
ErrNotFound = errors.New("cas: no such blob")
|
||||
)
|
||||
|
||||
const (
|
||||
// tmpDir holds uploads in progress. Anything in it is unreferenced by
|
||||
// definition: a blob only becomes reachable by being renamed out of here.
|
||||
tmpDir = "tmp"
|
||||
|
||||
// blobMode is read-only for everyone, and that is load-bearing rather than
|
||||
// tidy. An assembled deployment file is usually a hardlink to the blob —
|
||||
// the same inode — so anything that writes through the copy in $WEBROOT
|
||||
// corrupts the blob itself, and with it every project that shares that
|
||||
// content. Read-only is the cheap barrier; docs/operations.md carries the
|
||||
// warning that $WEBROOT is read-only to outside consumers.
|
||||
blobMode = 0o444
|
||||
dirMode = 0o755
|
||||
tmpMode = 0o600
|
||||
)
|
||||
|
||||
// Options configure Open.
|
||||
type Options struct {
|
||||
// Mode forces a link mode. The zero value probes and falls back to copying.
|
||||
Mode LinkMode
|
||||
// ProbeDir is where the probe tries to place a link. It must be the
|
||||
// directory deployment trees are assembled in: hardlinks cannot cross
|
||||
// filesystems, so probing anywhere else answers a different question.
|
||||
ProbeDir string
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// Store is the on-disk blob store.
|
||||
type Store struct {
|
||||
dir string
|
||||
root *os.Root
|
||||
linkMode LinkMode
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Open prepares the store at dir, creating it if necessary.
|
||||
func Open(dir string, opt Options) (*Store, error) {
|
||||
if dir == "" {
|
||||
return nil, errors.New("cas: directory is required")
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(abs, tmpDir), dirMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log := opt.Log
|
||||
if log == nil {
|
||||
log = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
|
||||
// Every name handed to root below is derived from a digest, so traversal is
|
||||
// not the threat being defended against here. What os.Root buys is that a
|
||||
// symlink planted inside the store — by a restore from a bad backup, by a
|
||||
// misdirected rsync — cannot make a write land outside it.
|
||||
root, err := os.OpenRoot(abs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{dir: abs, root: root, log: log}
|
||||
|
||||
switch opt.Mode {
|
||||
case LinkCopy:
|
||||
s.linkMode = LinkCopy
|
||||
case LinkHard:
|
||||
if err := probeLink(abs, opt.ProbeDir); err != nil {
|
||||
root.Close()
|
||||
return nil, fmt.Errorf("cas: hardlinks were requested but %s cannot be hardlinked into %s: %w", abs, opt.ProbeDir, err)
|
||||
}
|
||||
s.linkMode = LinkHard
|
||||
case "":
|
||||
if err := probeLink(abs, opt.ProbeDir); err != nil {
|
||||
s.linkMode = LinkCopy
|
||||
log.Warn("hardlinks unavailable, deployment trees will be copies; disk use will be proportional to deployed content rather than to unique content",
|
||||
"cas_dir", abs, "deployments_dir", opt.ProbeDir, "err", err)
|
||||
} else {
|
||||
s.linkMode = LinkHard
|
||||
}
|
||||
default:
|
||||
root.Close()
|
||||
return nil, fmt.Errorf("cas: unknown link mode %q", opt.Mode)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Dir is the store's absolute root directory.
|
||||
func (s *Store) Dir() string { return s.dir }
|
||||
|
||||
// LinkMode reports how LinkInto will place content.
|
||||
func (s *Store) LinkMode() LinkMode { return s.linkMode }
|
||||
|
||||
// Close releases the store's directory handle.
|
||||
func (s *Store) Close() error { return s.root.Close() }
|
||||
|
||||
// Path is where a blob lives on disk. Only for hardlinking, which needs a name
|
||||
// the kernel resolves from the process's own root; every other operation goes
|
||||
// through the store's directory handle.
|
||||
func (s *Store) Path(d Digest) string {
|
||||
return filepath.Join(s.dir, filepath.FromSlash(d.Rel()))
|
||||
}
|
||||
|
||||
// Put stores r's bytes under want, if and only if they really hash to want.
|
||||
//
|
||||
// The verification is the reason cross-project deduplication is safe at all.
|
||||
// Blobs are shared: if the server took the caller's word for the digest, a
|
||||
// client could declare the digest of another project's index.html, upload
|
||||
// whatever it liked, and every project referencing that content would start
|
||||
// serving the attacker's bytes. So the hash is recomputed over the stream as it
|
||||
// arrives and the upload is discarded unless it matches — the claimed digest is
|
||||
// only ever a claim.
|
||||
//
|
||||
// declaredSize may be -1 when the caller genuinely does not know the length; any
|
||||
// other value must match what arrives. maxBytes is a hard ceiling and is
|
||||
// enforced by reading one byte past it rather than by trusting Content-Length.
|
||||
//
|
||||
// Returns the number of bytes stored. A blob that is already present is left
|
||||
// alone and the upload discarded, which is safe precisely because both are known
|
||||
// to hash to want.
|
||||
func (s *Store) Put(ctx context.Context, want Digest, declaredSize, maxBytes int64, r io.Reader) (int64, error) {
|
||||
if maxBytes <= 0 {
|
||||
return 0, fmt.Errorf("cas: maxBytes must be positive, got %d", maxBytes)
|
||||
}
|
||||
if declaredSize > maxBytes {
|
||||
return 0, fmt.Errorf("%w: declared %d bytes, limit is %d", ErrTooLarge, declaredSize, maxBytes)
|
||||
}
|
||||
|
||||
tmpRel, f, err := s.newTemp()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Every path out of this function that is not a successful rename has to
|
||||
// remove the temporary file, including the ones that are not our error — a
|
||||
// client that hung up mid-upload, a cancelled context. One defer covers all
|
||||
// of them.
|
||||
committed := false
|
||||
defer func() {
|
||||
f.Close() // no-op if already closed below
|
||||
if !committed {
|
||||
if err := s.root.Remove(tmpRel); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
s.log.Warn("removing abandoned upload", "path", tmpRel, "err", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(&ctxReader{ctx: ctx, r: r}, maxBytes+1))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n > maxBytes {
|
||||
return 0, fmt.Errorf("%w: limit is %d bytes", ErrTooLarge, maxBytes)
|
||||
}
|
||||
if declaredSize >= 0 && n != declaredSize {
|
||||
return 0, fmt.Errorf("%w: declared %d bytes, received %d", ErrSizeMismatch, declaredSize, n)
|
||||
}
|
||||
if got := Digest(h.Sum(nil)); got != want {
|
||||
return 0, fmt.Errorf("%w: declared %s, computed %s", ErrDigestMismatch, want, got)
|
||||
}
|
||||
|
||||
// Durability before visibility. Losing the content while keeping the
|
||||
// directory entry would be corruption — the database would say present=1
|
||||
// and the file would be a hole — whereas losing the rename is merely a blob
|
||||
// that has to be uploaded again, which deploy.Recover already handles by
|
||||
// setting present=0 for digests whose file is missing. So the file is
|
||||
// fsynced and the containing directory deliberately is not.
|
||||
if err := f.Sync(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := f.Chmod(blobMode); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
rel := want.Rel()
|
||||
if err := s.root.MkdirAll(path.Dir(rel), dirMode); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := s.root.Stat(rel); err == nil {
|
||||
// Two clients uploading identical content at once is a benign race, not
|
||||
// a conflict: keep what is there and drop ours. Losing this race to a
|
||||
// writer that renames between the Stat and here is equally benign,
|
||||
// since rename is atomic and both files hold the same bytes.
|
||||
return n, nil
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, err
|
||||
}
|
||||
if err := s.root.Rename(tmpRel, rel); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
committed = true
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Has reports whether the blob's bytes are on disk.
|
||||
func (s *Store) Has(d Digest) (bool, error) {
|
||||
if _, err := s.root.Stat(d.Rel()); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Open returns the blob for reading; the caller closes it.
|
||||
//
|
||||
// This is the read path of every request the server serves, and it is
|
||||
// deliberately the only filesystem call on it. The name comes from Rel(), which
|
||||
// is derived from 32 bytes that came out of an in-memory map, so no byte of
|
||||
// user input reaches the filesystem here. Traversal on the read path is not so
|
||||
// much prevented as inexpressible.
|
||||
func (s *Store) Open(d Digest) (*os.File, error) {
|
||||
f, err := s.root.Open(d.Rel())
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrNotFound, d)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// LinkInto places the blob's content at destDir/relPath.
|
||||
//
|
||||
// destDir is a staging directory the caller has just created and relPath has
|
||||
// passed pathutil.Validate, so the join cannot leave destDir and there is no
|
||||
// pre-existing symlink for it to follow.
|
||||
func (s *Store) LinkInto(d Digest, destDir, relPath string) error {
|
||||
dest := filepath.Join(destDir, filepath.FromSlash(relPath))
|
||||
if s.linkMode == LinkHard {
|
||||
err := os.Link(s.Path(d), dest)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
// The blob is gone, or a parent directory was never created. Either
|
||||
// is a bug here, not a filesystem limitation, and copying would only
|
||||
// fail again with a less informative error.
|
||||
return fmt.Errorf("%w: %s: %w", ErrNotFound, d, err)
|
||||
case errors.Is(err, fs.ErrExist):
|
||||
// pathutil.Set rejects duplicate paths within a manifest, so two
|
||||
// files claiming one name means the manifest was not checked.
|
||||
return fmt.Errorf("cas: %s already exists in the deployment tree: %w", relPath, err)
|
||||
}
|
||||
// Anything else — most often EMLINK, since ext4 caps a file at 65,000
|
||||
// links and a blob shared by enough deployments does reach that — is a
|
||||
// per-file limitation rather than a reason to fail the deployment.
|
||||
s.log.Debug("hardlink failed, copying this file instead", "digest", d, "path", relPath, "err", err)
|
||||
}
|
||||
return s.copyInto(d, dest)
|
||||
}
|
||||
|
||||
func (s *Store) copyInto(d Digest, dest string) error {
|
||||
src, err := s.Open(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// O_EXCL because a collision means two manifest entries claimed one path,
|
||||
// which pathutil.Set is supposed to have made impossible.
|
||||
dst, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, blobMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return err
|
||||
}
|
||||
// A copy is the only content in the deployment tree that is not already
|
||||
// durable — a hardlink shares the inode that Put fsynced.
|
||||
if err := dst.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
return dst.Close()
|
||||
}
|
||||
|
||||
// Remove deletes a blob. Missing is success, so GC can be re-run after a crash.
|
||||
func (s *Store) Remove(d Digest) error {
|
||||
if err := s.root.Remove(d.Rel()); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PurgeTemp deletes every upload in progress and reports how many. Startup
|
||||
// calls it: an interrupted upload is unreferenced garbage by construction.
|
||||
func (s *Store) PurgeTemp() (int, error) {
|
||||
entries, err := os.ReadDir(filepath.Join(s.dir, tmpDir))
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
if err := s.root.RemoveAll(tmpDir + "/" + e.Name()); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// newTemp creates a uniquely named file under tmp/ and returns its store-relative
|
||||
// name. 128 bits of randomness make a collision impossible; the retry loop costs
|
||||
// nothing and means a hypothetical one is not a failed upload.
|
||||
func (s *Store) newTemp() (string, *os.File, error) {
|
||||
var buf [16]byte
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
name := tmpDir + "/" + hex.EncodeToString(buf[:])
|
||||
f, err := s.root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, tmpMode)
|
||||
if err == nil {
|
||||
return name, f, nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrExist) {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
return "", nil, errors.New("cas: could not create a temporary file")
|
||||
}
|
||||
|
||||
// probeLink answers whether a hardlink from the blob store into the deployment
|
||||
// tree actually works here.
|
||||
//
|
||||
// It has to be a real attempt rather than a check of the filesystem type.
|
||||
// overlayfs — Docker's default, and this machine's /home — can fail or silently
|
||||
// copy up across layers; a bind mount or a separate volume for the deployment
|
||||
// tree puts the two directories on different devices and link(2) returns EXDEV;
|
||||
// some hardened mounts refuse link(2) outright. The only reliable answer is to
|
||||
// try it against the directory that will actually be used.
|
||||
func probeLink(casDir, probeDir string) error {
|
||||
if probeDir == "" {
|
||||
return errors.New("no deployments directory to probe against")
|
||||
}
|
||||
if err := os.MkdirAll(probeDir, dirMode); err != nil {
|
||||
return err
|
||||
}
|
||||
var buf [8]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
suffix := hex.EncodeToString(buf[:])
|
||||
src := filepath.Join(casDir, tmpDir, ".link-probe-"+suffix)
|
||||
dst := filepath.Join(probeDir, ".link-probe-"+suffix)
|
||||
if err := os.WriteFile(src, []byte("probe"), tmpMode); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(src)
|
||||
if err := os.Link(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(dst)
|
||||
}
|
||||
|
||||
// ctxReader makes a long upload cancellable. The HTTP server closes the body
|
||||
// when a client disappears, but Put also runs against local readers on recovery
|
||||
// paths where nothing else would notice a shutdown.
|
||||
type ctxReader struct {
|
||||
ctx context.Context
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (c *ctxReader) Read(p []byte) (int, error) {
|
||||
if err := c.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.r.Read(p)
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package cas
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T, mode LinkMode) (*Store, string) {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
deployDir := filepath.Join(base, "deployments")
|
||||
s, err := Open(filepath.Join(base, "cas"), Options{
|
||||
Mode: mode,
|
||||
ProbeDir: deployDir,
|
||||
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
if mode == LinkHard {
|
||||
// Not every filesystem supports link(2), which is the whole reason
|
||||
// Open probes. Skipping is honest here; TestOpenAutoFallsBackToCopy
|
||||
// covers what happens when it is unavailable.
|
||||
t.Skipf("hardlinks unavailable under %s: %v", base, err)
|
||||
}
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s, deployDir
|
||||
}
|
||||
|
||||
// tempFiles is how many uploads are sitting in tmp/. Every test that exercises a
|
||||
// failure path checks this: a leaked temporary file is a slow disk leak that no
|
||||
// other test would notice.
|
||||
func tempFiles(t *testing.T, s *Store) int {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(filepath.Join(s.Dir(), tmpDir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return len(entries)
|
||||
}
|
||||
|
||||
func put(t *testing.T, s *Store, content string) Digest {
|
||||
t.Helper()
|
||||
d := Sum([]byte(content))
|
||||
n, err := s.Put(context.Background(), d, int64(len(content)), 1<<20, strings.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Put(%q): %v", content, err)
|
||||
}
|
||||
if n != int64(len(content)) {
|
||||
t.Fatalf("Put returned %d bytes, want %d", n, len(content))
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestParseDigest(t *testing.T) {
|
||||
d := Sum([]byte("hello"))
|
||||
hexForm := d.String()
|
||||
|
||||
got, err := ParseDigest(hexForm)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDigest: %v", err)
|
||||
}
|
||||
if got != d {
|
||||
t.Errorf("round trip changed the digest")
|
||||
}
|
||||
|
||||
bad := []string{
|
||||
"",
|
||||
hexForm[:HexLen-1],
|
||||
hexForm + "0",
|
||||
strings.ToUpper(hexForm), // uppercase is a second spelling; see ParseDigest
|
||||
strings.Repeat("g", HexLen),
|
||||
hexForm[:HexLen-2] + "!!",
|
||||
"../../../etc/passwd",
|
||||
}
|
||||
for _, s := range bad {
|
||||
if _, err := ParseDigest(s); !errors.Is(err, ErrBadDigest) {
|
||||
t.Errorf("ParseDigest(%q) = %v, want ErrBadDigest", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestRelIsSharded(t *testing.T) {
|
||||
d := Sum([]byte("hello"))
|
||||
rel := d.Rel()
|
||||
hexForm := d.String()
|
||||
want := hexForm[0:2] + "/" + hexForm[2:4] + "/" + hexForm
|
||||
if rel != want {
|
||||
t.Errorf("Rel = %q, want %q", rel, want)
|
||||
}
|
||||
if !fs.ValidPath(rel) {
|
||||
t.Errorf("Rel = %q is not a valid path", rel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromBytes(t *testing.T) {
|
||||
d := Sum([]byte("hello"))
|
||||
// database/sql reuses its scan buffers, so a digest must not alias one.
|
||||
buf := append([]byte(nil), d.Bytes()...)
|
||||
got, err := FromBytes(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range buf {
|
||||
buf[i] = 0
|
||||
}
|
||||
if got != d {
|
||||
t.Error("FromBytes aliased its argument instead of copying")
|
||||
}
|
||||
if _, err := FromBytes(buf[:8]); err == nil {
|
||||
t.Error("a short digest must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutAndOpen(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
const content = "<h1>hello</h1>"
|
||||
d := put(t, s, content)
|
||||
|
||||
ok, err := s.Has(d)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Has = %v, %v", ok, err)
|
||||
}
|
||||
f, err := s.Open(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
got, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Errorf("read back %q, want %q", got, content)
|
||||
}
|
||||
if tempFiles(t, s) != 0 {
|
||||
t.Error("a successful Put left a temporary file behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutIsIdempotent(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
const content = "same bytes"
|
||||
d := put(t, s, content)
|
||||
|
||||
before, err := os.Stat(s.Path(d))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(t, s, content)
|
||||
after, err := os.Stat(s.Path(d))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !os.SameFile(before, after) {
|
||||
t.Error("re-uploading identical content replaced the blob instead of keeping it")
|
||||
}
|
||||
if tempFiles(t, s) != 0 {
|
||||
t.Error("a redundant Put left a temporary file behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMissingBlob(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
d := Sum([]byte("never uploaded"))
|
||||
if ok, err := s.Has(d); err != nil || ok {
|
||||
t.Fatalf("Has = %v, %v", ok, err)
|
||||
}
|
||||
if _, err := s.Open(d); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Open = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The heart of the store: a caller's digest is a claim, never a fact.
|
||||
func TestPutRejectsAClaimedDigest(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
ctx := context.Background()
|
||||
|
||||
// Stand in for another project's file, already deduplicated into the store.
|
||||
victim := "the real index.html"
|
||||
victimDigest := put(t, s, victim)
|
||||
|
||||
attack := "<script>document.location='//evil'</script>"
|
||||
_, err := s.Put(ctx, victimDigest, int64(len(attack)), 1<<20, strings.NewReader(attack))
|
||||
if !errors.Is(err, ErrDigestMismatch) {
|
||||
t.Fatalf("Put = %v, want ErrDigestMismatch", err)
|
||||
}
|
||||
|
||||
f, err := s.Open(victimDigest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
got, _ := io.ReadAll(f)
|
||||
if string(got) != victim {
|
||||
t.Fatalf("the existing blob was overwritten: %q", got)
|
||||
}
|
||||
if tempFiles(t, s) != 0 {
|
||||
t.Error("the rejected upload left a temporary file behind")
|
||||
}
|
||||
}
|
||||
|
||||
type errReader struct{ err error }
|
||||
|
||||
func (e errReader) Read([]byte) (int, error) { return 0, e.err }
|
||||
|
||||
// Every way Put can fail has to clean up after itself, or a store slowly fills
|
||||
// with the debris of clients that hung up.
|
||||
func TestPutCleansUpOnEveryFailure(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
const content = "some content"
|
||||
d := Sum([]byte(content))
|
||||
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
want Digest
|
||||
size int64
|
||||
max int64
|
||||
body io.Reader
|
||||
wantErr error
|
||||
}{
|
||||
{"digest mismatch", context.Background(), Sum([]byte("other")), int64(len(content)), 1 << 20, strings.NewReader(content), ErrDigestMismatch},
|
||||
{"size mismatch", context.Background(), d, int64(len(content)) + 1, 1 << 20, strings.NewReader(content), ErrSizeMismatch},
|
||||
{"over the limit", context.Background(), d, -1, 4, strings.NewReader(content), ErrTooLarge},
|
||||
{"declared over the limit", context.Background(), d, 1 << 30, 4, strings.NewReader(content), ErrTooLarge},
|
||||
{"reader failed", context.Background(), d, -1, 1 << 20, errReader{errors.New("connection reset")}, nil},
|
||||
{"context cancelled", cancelled, d, -1, 1 << 20, strings.NewReader(content), context.Canceled},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := s.Put(tc.ctx, tc.want, tc.size, tc.max, tc.body)
|
||||
if err == nil {
|
||||
t.Fatal("Put should have failed")
|
||||
}
|
||||
if tc.wantErr != nil && !errors.Is(err, tc.wantErr) {
|
||||
t.Errorf("err = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
if n := tempFiles(t, s); n != 0 {
|
||||
t.Errorf("%d temporary file(s) left behind", n)
|
||||
}
|
||||
if ok, _ := s.Has(tc.want); ok {
|
||||
t.Error("a failed Put made a blob visible")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A client that lies in Content-Length must not get past the ceiling; the limit
|
||||
// is enforced against the bytes that arrive, not against the declaration.
|
||||
func TestPutEnforcesTheLimitAgainstActualBytes(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
body := strings.Repeat("x", 100)
|
||||
d := Sum([]byte(body))
|
||||
// Declares that it fits, then sends more.
|
||||
_, err := s.Put(context.Background(), d, 4, 8, strings.NewReader(body))
|
||||
if err == nil {
|
||||
t.Fatal("Put should have failed")
|
||||
}
|
||||
if tempFiles(t, s) != 0 {
|
||||
t.Error("temporary file left behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutExactlyAtTheLimit(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
body := strings.Repeat("x", 64)
|
||||
d := Sum([]byte(body))
|
||||
if _, err := s.Put(context.Background(), d, 64, 64, strings.NewReader(body)); err != nil {
|
||||
t.Errorf("a file exactly at the limit must be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutEmptyBlob(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
d := put(t, s, "")
|
||||
f, err := s.Open(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
b, _ := io.ReadAll(f)
|
||||
if len(b) != 0 {
|
||||
t.Errorf("read %d bytes from the empty blob", len(b))
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent uploads of identical content are expected — two CI jobs deploying
|
||||
// the same vendored asset — and all of them must succeed with one file left.
|
||||
func TestConcurrentIdenticalPut(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
const content = "shared asset"
|
||||
d := Sum([]byte(content))
|
||||
|
||||
const n = 16
|
||||
errs := make([]error, n)
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, errs[i] = s.Put(context.Background(), d, int64(len(content)), 1<<20, strings.NewReader(content))
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Errorf("goroutine %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if tempFiles(t, s) != 0 {
|
||||
t.Error("temporary files left behind")
|
||||
}
|
||||
f, err := s.Open(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
got, _ := io.ReadAll(f)
|
||||
if string(got) != content {
|
||||
t.Errorf("blob = %q, want %q", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeTemp(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
d := put(t, s, "keep me")
|
||||
|
||||
for _, name := range []string{"aaaa", "bbbb"} {
|
||||
if err := os.WriteFile(filepath.Join(s.Dir(), tmpDir, name), []byte("interrupted"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
n, err := s.PurgeTemp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Errorf("purged %d, want 2", n)
|
||||
}
|
||||
if tempFiles(t, s) != 0 {
|
||||
t.Error("tmp is not empty")
|
||||
}
|
||||
if ok, _ := s.Has(d); !ok {
|
||||
t.Error("PurgeTemp removed a committed blob")
|
||||
}
|
||||
if n, err := s.PurgeTemp(); err != nil || n != 0 {
|
||||
t.Errorf("second PurgeTemp = %d, %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveIsIdempotent(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
d := put(t, s, "temporary")
|
||||
if err := s.Remove(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := s.Has(d); ok {
|
||||
t.Error("blob still present after Remove")
|
||||
}
|
||||
// GC re-running after a crash must not fail on what it already deleted.
|
||||
if err := s.Remove(d); err != nil {
|
||||
t.Errorf("second Remove: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkIntoCopyMode(t *testing.T) {
|
||||
s, deployDir := newStore(t, LinkCopy)
|
||||
const content = "console.log(1)\n"
|
||||
d := put(t, s, content)
|
||||
|
||||
dest := filepath.Join(deployDir, "assets")
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.LinkInto(d, dest, "app.js"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dest, "app.js"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Errorf("content = %q, want %q", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkIntoRejectsAMissingBlob(t *testing.T) {
|
||||
for _, mode := range []LinkMode{LinkCopy, LinkHard} {
|
||||
t.Run(string(mode), func(t *testing.T) {
|
||||
s, deployDir := newStore(t, mode)
|
||||
if err := os.MkdirAll(deployDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := Sum([]byte("never uploaded"))
|
||||
if err := s.LinkInto(d, deployDir, "x.html"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("LinkInto = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkIntoRejectsADuplicatePath(t *testing.T) {
|
||||
for _, mode := range []LinkMode{LinkCopy, LinkHard} {
|
||||
t.Run(string(mode), func(t *testing.T) {
|
||||
s, deployDir := newStore(t, mode)
|
||||
if err := os.MkdirAll(deployDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := put(t, s, "x")
|
||||
if err := s.LinkInto(d, deployDir, "x.html"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.LinkInto(d, deployDir, "x.html"); err == nil {
|
||||
t.Error("writing twice to one path must fail rather than overwrite")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRejectsAnUnknownMode(t *testing.T) {
|
||||
if _, err := Open(t.TempDir(), Options{Mode: "symlink"}); err == nil {
|
||||
t.Error("an unknown link mode must be refused at startup, not at deploy time")
|
||||
}
|
||||
}
|
||||
|
||||
// Forcing hardlinks has to fail loudly when they do not work, or an operator who
|
||||
// asked for them silently gets copies and a full disk.
|
||||
func TestOpenHardlinkModeFailsWithoutAProbeTarget(t *testing.T) {
|
||||
if _, err := Open(filepath.Join(t.TempDir(), "cas"), Options{Mode: LinkHard}); err == nil {
|
||||
t.Error("hardlink mode with nothing to probe against must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAutoFallsBackToCopy(t *testing.T) {
|
||||
// No ProbeDir, so the probe cannot succeed and auto must degrade rather than
|
||||
// refuse to start.
|
||||
s, err := Open(filepath.Join(t.TempDir(), "cas"), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("auto mode must start anyway: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
if s.LinkMode() != LinkCopy {
|
||||
t.Errorf("LinkMode = %q, want %q", s.LinkMode(), LinkCopy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobsAreReadOnly(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
d := put(t, s, "immutable")
|
||||
fi, err := os.Stat(s.Path(d))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A writable blob is a writable inode shared by every project referencing
|
||||
// that content; see blobMode.
|
||||
if perm := fi.Mode().Perm(); perm != blobMode {
|
||||
t.Errorf("blob mode = %04o, want %04o", perm, blobMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutLargeStream(t *testing.T) {
|
||||
s, _ := newStore(t, LinkCopy)
|
||||
body := bytes.Repeat([]byte("0123456789abcdef"), 1<<16) // 1 MiB
|
||||
d := Sum(body)
|
||||
n, err := s.Put(context.Background(), d, int64(len(body)), 4<<20, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != int64(len(body)) {
|
||||
t.Fatalf("stored %d bytes, want %d", n, len(body))
|
||||
}
|
||||
f, err := s.Open(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
got, _ := io.ReadAll(f)
|
||||
if !bytes.Equal(got, body) {
|
||||
t.Error("stored content differs from what was written")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//go:build unix
|
||||
|
||||
package cas
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func nlink(t *testing.T, path string) uint64 {
|
||||
t.Helper()
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
t.Skip("no stat_t on this platform")
|
||||
}
|
||||
return uint64(st.Nlink)
|
||||
}
|
||||
|
||||
// The deduplication claim, stated as a filesystem fact: a deployed file and its
|
||||
// blob are the same inode, so a second deployment of the same content costs a
|
||||
// directory entry and nothing more.
|
||||
func TestLinkIntoSharesTheInode(t *testing.T) {
|
||||
s, deployDir := newStore(t, LinkHard)
|
||||
const content = "shared across deployments\n"
|
||||
d := put(t, s, content)
|
||||
|
||||
if got := nlink(t, s.Path(d)); got != 1 {
|
||||
t.Fatalf("a fresh blob has %d links, want 1", got)
|
||||
}
|
||||
|
||||
first := filepath.Join(deployDir, "dpl_one")
|
||||
second := filepath.Join(deployDir, "dpl_two")
|
||||
for _, dir := range []string{first, second} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.LinkInto(d, dir, "index.html"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := nlink(t, s.Path(d)); got != 3 {
|
||||
t.Errorf("blob has %d links after two deployments, want 3 (blob + 2)", got)
|
||||
}
|
||||
blob, err := os.Stat(s.Path(d))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, dir := range []string{first, second} {
|
||||
deployed, err := os.Stat(filepath.Join(dir, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !os.SameFile(blob, deployed) {
|
||||
t.Errorf("%s is a copy, not a hardlink to the blob", dir)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(dir, "index.html")); err != nil || string(got) != content {
|
||||
t.Errorf("%s: content = %q, %v", dir, got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Removing a deployment tree must not take the blob with it: the other
|
||||
// deployment still references it, and so may other projects.
|
||||
if err := os.RemoveAll(first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := nlink(t, s.Path(d)); got != 2 {
|
||||
t.Errorf("blob has %d links after one tree was removed, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The mode is what stops a deployed file from being written through into the
|
||||
// blob every other project shares.
|
||||
func TestDeployedFilesAreReadOnly(t *testing.T) {
|
||||
for _, mode := range []LinkMode{LinkHard, LinkCopy} {
|
||||
t.Run(string(mode), func(t *testing.T) {
|
||||
s, deployDir := newStore(t, mode)
|
||||
d := put(t, s, "content")
|
||||
if err := os.MkdirAll(deployDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.LinkInto(d, deployDir, "index.html"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi, err := os.Stat(filepath.Join(deployDir, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != blobMode {
|
||||
t.Errorf("deployed file mode = %04o, want %04o", perm, blobMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A copy must be a copy: writing through it may not reach the blob.
|
||||
func TestCopyModeDoesNotShareTheInode(t *testing.T) {
|
||||
s, deployDir := newStore(t, LinkCopy)
|
||||
d := put(t, s, "content")
|
||||
if err := os.MkdirAll(deployDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.LinkInto(d, deployDir, "index.html"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := nlink(t, s.Path(d)); got != 1 {
|
||||
t.Errorf("blob has %d links in copy mode, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Package cas is the content-addressed blob store. Every file any deployment
|
||||
// contains is stored once, named by the SHA-256 of its bytes, and shared by
|
||||
// every deployment and every project that references that content.
|
||||
//
|
||||
// Sharing is what makes redeploying a mostly-unchanged site nearly free, and it
|
||||
// is only safe because the store never takes a caller's word for a digest: see
|
||||
// Store.Put.
|
||||
package cas
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
// Size is a digest's length in bytes.
|
||||
Size = sha256.Size
|
||||
// HexLen is a digest's length in its hex form.
|
||||
HexLen = 2 * Size
|
||||
)
|
||||
|
||||
// Digest is the SHA-256 of a blob's contents.
|
||||
//
|
||||
// An array rather than a slice, so it can be a map key, compared with ==, and
|
||||
// copied out of a database buffer instead of aliasing one — database/sql reuses
|
||||
// the byte slices it scans into.
|
||||
type Digest [Size]byte
|
||||
|
||||
// ErrBadDigest rejects anything that is not a digest in the canonical form.
|
||||
var ErrBadDigest = errors.New("cas: not a 64-character lowercase hex sha-256")
|
||||
|
||||
// ParseDigest decodes the hex form used on the wire and in URLs.
|
||||
//
|
||||
// Strict about case rather than normalising, because a digest is simultaneously
|
||||
// a primary key in the blobs table and a component of a filesystem path.
|
||||
// Accepting two spellings of one value would mean two rows, two files, and
|
||||
// deduplication that quietly stops deduplicating.
|
||||
func ParseDigest(s string) (Digest, error) {
|
||||
var d Digest
|
||||
if len(s) != HexLen {
|
||||
return d, ErrBadDigest
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
// encoding/hex accepts uppercase; this loop is what makes lowercase the
|
||||
// only accepted spelling.
|
||||
c := s[i]
|
||||
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
|
||||
continue
|
||||
}
|
||||
return d, ErrBadDigest
|
||||
}
|
||||
if _, err := hex.Decode(d[:], []byte(s)); err != nil {
|
||||
return Digest{}, ErrBadDigest
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// FromBytes converts the raw 32 bytes stored in the database. It copies, so the
|
||||
// result does not alias the scan buffer it came from.
|
||||
func FromBytes(b []byte) (Digest, error) {
|
||||
var d Digest
|
||||
if len(b) != Size {
|
||||
return d, fmt.Errorf("cas: digest is %d bytes, want %d", len(b), Size)
|
||||
}
|
||||
copy(d[:], b)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// String is the canonical hex form.
|
||||
func (d Digest) String() string { return hex.EncodeToString(d[:]) }
|
||||
|
||||
// Bytes is the raw form stored in the database. The slice belongs to the
|
||||
// caller's copy of the digest, so writing to it cannot affect anything else.
|
||||
func (d Digest) Bytes() []byte { return d[:] }
|
||||
|
||||
// Rel is the blob's path inside the store, sharded two levels deep:
|
||||
// "ab/cd/abcd…". Two hex characters per level gives 65,536 leaf directories, so
|
||||
// even a store with millions of blobs keeps every directory small enough that
|
||||
// readdir and lookup stay fast on ext4 and xfs alike.
|
||||
func (d Digest) Rel() string {
|
||||
s := d.String()
|
||||
return s[0:2] + "/" + s[2:4] + "/" + s
|
||||
}
|
||||
|
||||
// Sum digests b.
|
||||
func Sum(b []byte) Digest { return sha256.Sum256(b) }
|
||||
@@ -0,0 +1,118 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ConfigFile is the CLI's on-disk configuration.
|
||||
//
|
||||
// JSON rather than TOML: encoding/json is already linked into the binary for
|
||||
// the API, and adding a TOML parser to read four fields would cost more than
|
||||
// the file saves. CI never reads it at all — there, everything comes from
|
||||
// PAGES_* environment variables.
|
||||
type ConfigFile struct {
|
||||
Server string `json:"server,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultConfigPath is $PAGES_CONFIG, else $XDG_CONFIG_HOME/pages/config.json,
|
||||
// else ~/.config/pages/config.json. It returns "" when no home directory can be
|
||||
// determined, which simply means there is no config file.
|
||||
func DefaultConfigPath() string {
|
||||
if p := os.Getenv("PAGES_CONFIG"); p != "" {
|
||||
return p
|
||||
}
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, "pages", "config.json")
|
||||
}
|
||||
|
||||
// LoadConfig reads path. A missing file is not an error — it is the normal
|
||||
// state on a CI runner. warn receives a note if the file is readable by anyone
|
||||
// but its owner, because it may hold a token.
|
||||
func LoadConfig(path string, warn io.Writer) (ConfigFile, error) {
|
||||
var f ConfigFile
|
||||
if path == "" {
|
||||
return f, nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return f, nil
|
||||
}
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
if fi, err := os.Stat(path); err == nil && fi.Mode().Perm()&0o077 != 0 && warn != nil {
|
||||
fmt.Fprintf(warn, "warning: %s is mode %#o and may contain a token; run: chmod 600 %s\n",
|
||||
path, fi.Mode().Perm(), path)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return ConfigFile{}, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// SaveConfig writes f to path with mode 0600, replacing any existing file
|
||||
// atomically so a crash cannot leave a truncated config behind.
|
||||
func SaveConfig(path string, f ConfigFile) error {
|
||||
if path == "" {
|
||||
return errors.New("no config path: set PAGES_CONFIG or pass --config")
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create config directory: %w", err)
|
||||
}
|
||||
raw, err := json.MarshalIndent(f, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
|
||||
// Written in the destination directory so the rename stays within one
|
||||
// filesystem, and created 0600 from the start so the token is never briefly
|
||||
// world-readable.
|
||||
tmp, err := os.CreateTemp(dir, ".config-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary config: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||
return fmt.Errorf("install config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// redactToken shows enough of a token to recognise which one it is and nothing
|
||||
// that could be used with it. The key id is the public half by construction
|
||||
// (pgs_<keyid>_<secret>), so it is safe to print in full.
|
||||
func redactToken(token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(token, "_", 3)
|
||||
if len(parts) == 3 {
|
||||
return parts[0] + "_" + parts[1] + "_…"
|
||||
}
|
||||
return "…"
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveConfigIsOwnerOnlyAndAtomic(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
|
||||
want := ConfigFile{Server: "https://p.example.com", Token: "pgs_abcdefghijklmnop_secret", Output: "json"}
|
||||
if err := SaveConfig(path, want); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("config mode = %#o, want 0600 — it holds a token", perm)
|
||||
}
|
||||
if di, err := os.Stat(dir); err == nil {
|
||||
if perm := di.Mode().Perm(); perm&0o077 != 0 {
|
||||
t.Errorf("config directory mode = %#o, want no group or other bits", perm)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := LoadConfig(path, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("round trip = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// The temporary file is written in the destination directory; leaving one
|
||||
// behind would leave a mode-0600 copy of the token lying around.
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ents) != 1 || ents[0].Name() != "config.json" {
|
||||
names := make([]string, len(ents))
|
||||
for i, e := range ents {
|
||||
names[i] = e.Name()
|
||||
}
|
||||
t.Errorf("directory contains %q, want only config.json", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfigReplacesInPlace(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := SaveConfig(path, ConfigFile{Server: "https://one.example.com"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveConfig(path, ConfigFile{Server: "https://two.example.com"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadConfig(path, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Server != "https://two.example.com" {
|
||||
t.Errorf("server = %q, want the second write", got.Server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
t.Run("a missing file is the normal state on CI", func(t *testing.T) {
|
||||
got, err := LoadConfig(filepath.Join(t.TempDir(), "absent.json"), io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if got != (ConfigFile{}) {
|
||||
t.Errorf("got %+v, want the zero value", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an empty path means there is no config file", func(t *testing.T) {
|
||||
if _, err := LoadConfig("", io.Discard); err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON is reported, not ignored", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(path, []byte("{not json"), 0o600)
|
||||
if _, err := LoadConfig(path, io.Discard); err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a readable-by-others config warns", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
os.WriteFile(path, []byte(`{"token":"pgs_abcdefghijklmnop_secret"}`), 0o644)
|
||||
var warn strings.Builder
|
||||
if _, err := LoadConfig(path, &warn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(warn.String(), "chmod 600") {
|
||||
t.Errorf("warning = %q, want it to say how to fix the mode", warn.String())
|
||||
}
|
||||
if strings.Contains(warn.String(), "secret") {
|
||||
t.Error("the warning printed the token it was warning about")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRedactToken(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"pgs_abcdefghijklmnop_thesecrethalf", "pgs_abcdefghijklmnop_…"},
|
||||
{"", ""},
|
||||
{"garbage", "…"},
|
||||
{"pgs_onlytwo", "…"},
|
||||
// The secret half is base64url, so it can itself contain underscores;
|
||||
// SplitN with n=3 keeps them in the part that gets dropped.
|
||||
{"pgs_abcdefghijklmnop_a_b_c", "pgs_abcdefghijklmnop_…"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := redactToken(tc.in); got != tc.want {
|
||||
t.Errorf("redactToken(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfigPathPrefersEnv(t *testing.T) {
|
||||
t.Setenv("PAGES_CONFIG", "/tmp/explicit.json")
|
||||
if got := DefaultConfigPath(); got != "/tmp/explicit.json" {
|
||||
t.Errorf("DefaultConfigPath = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/client"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
// stringList is a flag that may be given more than once.
|
||||
type stringList []string
|
||||
|
||||
func (s *stringList) String() string { return strings.Join(*s, ",") }
|
||||
|
||||
func (s *stringList) Set(v string) error {
|
||||
*s = append(*s, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func deployCmd(g *Globals) *cliutil.Command {
|
||||
var (
|
||||
activate = true
|
||||
meta stringList
|
||||
include stringList
|
||||
exclude stringList
|
||||
concurrency int
|
||||
retries int
|
||||
follow bool
|
||||
dryRun bool
|
||||
)
|
||||
return &cliutil.Command{
|
||||
Name: "deploy",
|
||||
Args: "<dir>",
|
||||
Short: "Upload a directory and switch the site to it",
|
||||
Long: "Hashes the directory, uploads only the files the server does not\n" +
|
||||
"already have, then switches the project over in one step — a visitor\n" +
|
||||
"sees the old site or the new one, never a mixture.\n\n" +
|
||||
"Interrupting a deploy is safe: the blobs that made it are kept, so\n" +
|
||||
"running the same command again uploads only what is still missing.\n\n" +
|
||||
"Commit metadata is read from the CI environment (GitHub Actions,\n" +
|
||||
"GitLab CI) and can be set or overridden with --meta.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&activate, "activate", true, "switch the project to this deployment once it is uploaded")
|
||||
fs.Var(&meta, "meta", "`key=value` recorded with the deployment; repeatable")
|
||||
fs.Var(&include, "include", "only upload files matching `glob`; repeatable")
|
||||
fs.Var(&exclude, "exclude", "skip files and directories matching `glob`; repeatable")
|
||||
fs.IntVar(&concurrency, "concurrency", 8, "`number` of blobs uploaded at once")
|
||||
fs.IntVar(&retries, "retries", 4, "`number` of extra attempts per blob on a transient failure")
|
||||
fs.BoolVar(&follow, "follow-symlinks", false, "upload what symlinks point at instead of refusing them")
|
||||
fs.BoolVar(&dryRun, "dry-run", false, "scan and report without contacting the server")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 1, "one directory"); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := args[0]
|
||||
|
||||
metaMap, err := parseMeta(meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
src, err := client.Scan(ctx, dir, client.ScanOptions{
|
||||
Include: include,
|
||||
Exclude: exclude,
|
||||
FollowSymlinks: follow,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
if dryRun {
|
||||
// Deliberately offline: negotiating the manifest to find out what is
|
||||
// missing would create a deployment on the server, which is exactly
|
||||
// what --dry-run promises not to do.
|
||||
return p.Print(dryRunResult{
|
||||
Dir: src.Dir,
|
||||
FileCount: len(src.Files),
|
||||
TotalBytes: src.TotalBytes,
|
||||
UniqueBlobs: src.UniqueBlobs(),
|
||||
Files: src.Files,
|
||||
}, func() *cliutil.Table {
|
||||
t := cliutil.NewTable("PATH", "SIZE", "DIGEST")
|
||||
for _, f := range src.Files {
|
||||
t.Row(f.Path, cliutil.Bytes(f.Size), f.Digest[:12])
|
||||
}
|
||||
t.Row("", "", "")
|
||||
t.Row(fmt.Sprintf("%d files", len(src.Files)),
|
||||
cliutil.Bytes(src.TotalBytes),
|
||||
fmt.Sprintf("%d unique", src.UniqueBlobs()))
|
||||
return t
|
||||
})
|
||||
}
|
||||
|
||||
project, err := g.ProjectName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := c.Deploy(ctx, client.DeployOptions{
|
||||
Project: project,
|
||||
Source: src,
|
||||
Meta: metaMap,
|
||||
Activate: activate,
|
||||
Concurrency: concurrency,
|
||||
Retries: retries,
|
||||
// Progress goes to stderr so it stays out of a piped -o json
|
||||
// document, and is shown even without --verbose: the deduplication
|
||||
// win is the reason this tool exists, and a CI log should record it.
|
||||
Progress: func(msg string) { fmt.Fprintln(g.Err, msg) },
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return p.Print(res, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("deployment", res.Deployment.ID)
|
||||
t.Row("project", project)
|
||||
t.Row("state", res.Deployment.State)
|
||||
t.Row("files", strconv.Itoa(res.FileCount))
|
||||
t.Row("total_bytes", cliutil.Bytes(res.TotalBytes))
|
||||
t.Row("uploaded", fmt.Sprintf("%s, %s",
|
||||
cliutil.Plural(res.Uploaded, "blob"), cliutil.Bytes(res.UploadedBytes)))
|
||||
t.Row("reused", strconv.Itoa(res.Deduplicated))
|
||||
t.Row("activated", cliutil.Bool(res.Activated))
|
||||
t.Row("url", cliutil.Str(res.URL))
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// dryRunResult is what --dry-run reports: everything decided locally, and
|
||||
// nothing that would need the server.
|
||||
type dryRunResult struct {
|
||||
Dir string `json:"dir"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
UniqueBlobs int `json:"unique_blobs"`
|
||||
Files []client.LocalFile `json:"files"`
|
||||
}
|
||||
|
||||
// parseMeta folds --meta over whatever the CI environment reveals, so an
|
||||
// explicit flag always wins over a guessed value.
|
||||
func parseMeta(pairs []string) (map[string]string, error) {
|
||||
out := detectCIMeta()
|
||||
for _, p := range pairs {
|
||||
k, v, ok := strings.Cut(p, "=")
|
||||
if !ok {
|
||||
return nil, cliutil.UsageErrorf("--meta %q: expected key=value", p)
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
return nil, cliutil.UsageErrorf("--meta %q: empty key", p)
|
||||
}
|
||||
if out == nil {
|
||||
out = make(map[string]string, len(pairs))
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// detectCIMeta reads the commit metadata the common CI systems export, so a
|
||||
// deployment can be traced back to what produced it without every pipeline
|
||||
// having to spell out the same four --meta flags.
|
||||
func detectCIMeta() map[string]string {
|
||||
out := make(map[string]string, 4)
|
||||
set := func(key string, envs ...string) {
|
||||
for _, e := range envs {
|
||||
if v := strings.TrimSpace(os.Getenv(e)); v != "" {
|
||||
out[key] = v
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
set("git_sha", "GITHUB_SHA", "CI_COMMIT_SHA", "GIT_COMMIT")
|
||||
set("git_ref", "GITHUB_REF_NAME", "CI_COMMIT_REF_NAME", "GIT_BRANCH")
|
||||
set("ci_run", "GITHUB_RUN_ID", "CI_PIPELINE_ID", "BUILD_NUMBER")
|
||||
set("actor", "GITHUB_ACTOR", "GITLAB_USER_LOGIN")
|
||||
|
||||
if url := ciRunURL(); url != "" {
|
||||
out["ci_url"] = url
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ciRunURL reconstructs a link back to the job. GitHub does not export one
|
||||
// directly; GitLab does.
|
||||
func ciRunURL() string {
|
||||
if v := strings.TrimSpace(os.Getenv("CI_PIPELINE_URL")); v != "" {
|
||||
return v
|
||||
}
|
||||
server := strings.TrimRight(os.Getenv("GITHUB_SERVER_URL"), "/")
|
||||
repo := os.Getenv("GITHUB_REPOSITORY")
|
||||
run := os.Getenv("GITHUB_RUN_ID")
|
||||
if server != "" && repo != "" && run != "" {
|
||||
return server + "/" + repo + "/actions/runs/" + run
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/client"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
func deploymentCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "deployment",
|
||||
Short: "Inspect, roll back and remove deployments",
|
||||
Long: "Every deployment a project has ever finished is kept until retention\n" +
|
||||
"drops it, which is what makes a rollback one command rather than a\n" +
|
||||
"rebuild. A project key may manage only its own project's deployments.",
|
||||
Sub: []*cliutil.Command{
|
||||
deploymentListCmd(g),
|
||||
deploymentShowCmd(g),
|
||||
deploymentActivateCmd(g),
|
||||
deploymentDeleteCmd(g),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func deploymentListCmd(g *Globals) *cliutil.Command {
|
||||
var (
|
||||
state string
|
||||
limit int
|
||||
)
|
||||
return &cliutil.Command{
|
||||
Name: "list",
|
||||
Short: "List a project's deployments",
|
||||
Long: "Newest first. The one marked active is what the site is serving, and\n" +
|
||||
"is the one to roll back from; any other ready deployment can be rolled\n" +
|
||||
"back to with \"pages deployment activate\".",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.StringVar(&state, "state", "", "show only deployments in this `state`: pending, uploading, ready, failed or deleting")
|
||||
fs.IntVar(&limit, "limit", 0, "stop after this many deployments; 0 lists them all")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := g.ProjectName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deps, err := listDeployments(ctx, c, project, state, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The API's own shape, so -o json is the same document whether it came
|
||||
// from here or from curl, and an empty listing prints as [] not null.
|
||||
out := api.DeploymentList{Deployments: deps}
|
||||
if out.Deployments == nil {
|
||||
out.Deployments = []api.Deployment{}
|
||||
}
|
||||
return p.Print(out, func() *cliutil.Table {
|
||||
t := cliutil.NewTable("ID", "STATE", "ACTIVE", "FILES", "SIZE", "CREATED", "COMMIT")
|
||||
for _, d := range deps {
|
||||
t.Row(d.ID, d.State, cliutil.Bool(d.Active),
|
||||
strconv.Itoa(d.FileCount), cliutil.Bytes(d.TotalBytes),
|
||||
cliutil.Time(d.CreatedAt),
|
||||
cliutil.Str(cliutil.Truncate(d.Meta["git_sha"], 12)))
|
||||
}
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// listDeployments follows the cursor, stopping at limit when one was given.
|
||||
// Paging on the caller's behalf matters here for the same reason it does for
|
||||
// projects: a listing that silently showed the first page would be a lie, and
|
||||
// this one is read to decide which deployment to roll back to.
|
||||
func listDeployments(ctx context.Context, c *client.Client, project, state string, limit int) ([]api.Deployment, error) {
|
||||
opts := client.DeploymentListOptions{State: state}
|
||||
opts.Limit = 500
|
||||
if limit > 0 && limit < opts.Limit {
|
||||
opts.Limit = limit
|
||||
}
|
||||
var all []api.Deployment
|
||||
for {
|
||||
page, err := c.ListDeployments(ctx, project, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, page.Deployments...)
|
||||
if limit > 0 && len(all) >= limit {
|
||||
return all[:limit], nil
|
||||
}
|
||||
if page.NextCursor == "" || len(page.Deployments) == 0 {
|
||||
return all, nil
|
||||
}
|
||||
opts.Cursor = page.NextCursor
|
||||
}
|
||||
}
|
||||
|
||||
func deploymentShowCmd(g *Globals) *cliutil.Command {
|
||||
var files bool
|
||||
return &cliutil.Command{
|
||||
Name: "show",
|
||||
Args: "<id>",
|
||||
Short: "Show one deployment",
|
||||
Long: "With --files, also lists the manifest: every path, its size and the\n" +
|
||||
"digest of its content. That is one line per file, so it is a lot of\n" +
|
||||
"output for a large site.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&files, "files", false, "also list the deployment's files")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 1, "one deployment id"); err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := g.ProjectName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := c.GetDeployment(ctx, project, args[0], files)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(d, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("id", d.ID)
|
||||
t.Row("project", d.Project)
|
||||
t.Row("state", d.State)
|
||||
t.Row("active", cliutil.Bool(d.Active))
|
||||
t.Row("files", strconv.Itoa(d.FileCount))
|
||||
t.Row("total_bytes", cliutil.Bytes(d.TotalBytes))
|
||||
t.Row("created_at", cliutil.Time(d.CreatedAt))
|
||||
t.Row("finalized_at", cliutil.TimePtr(d.FinalizedAt))
|
||||
t.Row("activated_at", cliutil.TimePtr(d.ActivatedAt))
|
||||
if d.URL != "" {
|
||||
t.Row("url", d.URL)
|
||||
}
|
||||
if d.Error != "" {
|
||||
t.Row("error", d.Error)
|
||||
}
|
||||
for _, k := range sortedKeys(d.Meta) {
|
||||
t.Row("meta."+k, d.Meta[k])
|
||||
}
|
||||
for _, f := range d.Files {
|
||||
t.Row(f.Path, fmt.Sprintf("%s %s",
|
||||
cliutil.Bytes(f.Size), cliutil.Truncate(f.Digest, 12)))
|
||||
}
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// sortedKeys gives map-backed output a stable order, so two runs of the same
|
||||
// command produce the same lines and a diff of them means something.
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
return slices.Sorted(maps.Keys(m))
|
||||
}
|
||||
|
||||
func deploymentActivateCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "activate",
|
||||
Args: "<id>",
|
||||
Short: "Switch the site to a deployment",
|
||||
Long: "This is how a rollback is done: name an older deployment and the\n" +
|
||||
"project serves it again. The switch is atomic and costs nothing —\n" +
|
||||
"the content is still on disk — so it takes effect immediately.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 1, "one deployment id"); err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := g.ProjectName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := c.Activate(ctx, project, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(d, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("deployment", d.ID)
|
||||
t.Row("project", project)
|
||||
t.Row("state", d.State)
|
||||
t.Row("active", cliutil.Bool(d.Active))
|
||||
t.Row("files", strconv.Itoa(d.FileCount))
|
||||
t.Row("url", cliutil.Str(d.URL))
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func deploymentDeleteCmd(g *Globals) *cliutil.Command {
|
||||
var yes bool
|
||||
return &cliutil.Command{
|
||||
Name: "delete",
|
||||
Args: "<id>",
|
||||
Short: "Delete a deployment",
|
||||
Long: "The deployment the project is serving cannot be deleted; activate\n" +
|
||||
"another one first. Content no other deployment references is\n" +
|
||||
"reclaimed by the next garbage collection, not immediately.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&yes, "yes", false, "do not ask for confirmation")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 1, "one deployment id"); err != nil {
|
||||
return err
|
||||
}
|
||||
id := args[0]
|
||||
project, err := g.ProjectName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
if err := cliutil.Confirm(g.In, g.Err,
|
||||
fmt.Sprintf("Delete deployment %s of project %q?", id, project)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.DeleteDeployment(ctx, project, id); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Printf("deleted deployment %s\n", id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ upkeep
|
||||
//
|
||||
// These two are server-wide rather than per-project, which is why they sit
|
||||
// under "pages system" next to "system info" and not under "deployment".
|
||||
|
||||
func systemGCCmd(g *Globals) *cliutil.Command {
|
||||
var dryRun bool
|
||||
return &cliutil.Command{
|
||||
Name: "gc",
|
||||
Short: "Run a garbage collection pass now",
|
||||
Long: "The server collects on a timer anyway; this is for an operator who\n" +
|
||||
"wants the disk back sooner. --dry-run reports what would be deleted\n" +
|
||||
"without deleting it, though it cannot count the content the listed\n" +
|
||||
"deployments hold — nothing was deleted, so it is all still in use.\n" +
|
||||
"Requires an admin key.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&dryRun, "dry-run", false, "report what would be deleted without deleting it")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := c.Collect(ctx, dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(stats, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("dry_run", cliutil.Bool(stats.DryRun))
|
||||
t.Row("deployments_deleted", strconv.Itoa(stats.DeploymentsDeleted))
|
||||
t.Row("blobs_deleted", strconv.Itoa(stats.BlobsDeleted))
|
||||
t.Row("bytes_freed", cliutil.Bytes(stats.BytesFreed))
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func systemFsckCmd(g *Globals) *cliutil.Command {
|
||||
var repair bool
|
||||
return &cliutil.Command{
|
||||
Name: "fsck",
|
||||
Short: "Check the stored reference counts against the manifests",
|
||||
Long: "On a healthy server this always reports no drift: the counts are\n" +
|
||||
"maintained by database triggers. It is for the cases outside normal\n" +
|
||||
"operation — a restored backup, a database edited by hand — because a\n" +
|
||||
"count that reads low is content the collector will delete while a\n" +
|
||||
"deployment still needs it. --repair rewrites the counts from the\n" +
|
||||
"manifests. Requires an admin key.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&repair, "repair", false, "correct the counts that disagree")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rep, err := c.Fsck(ctx, repair)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(rep, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("blobs", strconv.FormatInt(rep.Blobs, 10))
|
||||
t.Row("drift", strconv.Itoa(rep.DriftCount))
|
||||
t.Row("repaired", strconv.Itoa(rep.Repaired))
|
||||
for _, d := range rep.Drift {
|
||||
t.Row(cliutil.Truncate(d.Digest, 12),
|
||||
fmt.Sprintf("stored %d, actual %d", d.Stored, d.Actual))
|
||||
}
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
// cliToken is syntactically plausible and otherwise meaningless: none of the
|
||||
// fake servers below look at it, but Globals refuses to build a client without
|
||||
// one.
|
||||
const cliToken = "pgs_abcdefghijklmnop_secret"
|
||||
|
||||
// runCLI drives the real command tree the way main does and returns what the
|
||||
// user would have seen. Going through Root rather than calling a command's Exec
|
||||
// directly is the point: it covers the flag registration and the dispatch that
|
||||
// a hand-built call would skip.
|
||||
func runCLI(t *testing.T, server, stdin string, args ...string) (stdout, stderr string, err error) {
|
||||
t.Helper()
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TOKEN", cliToken)
|
||||
var out, errOut strings.Builder
|
||||
g := &Globals{
|
||||
In: strings.NewReader(stdin),
|
||||
Out: &out,
|
||||
Err: &errOut,
|
||||
Config: filepath.Join(t.TempDir(), "absent.json"),
|
||||
Server: server,
|
||||
}
|
||||
err = cliutil.Run(context.Background(), Root(g), args, &errOut, g.Register)
|
||||
return out.String(), errOut.String(), err
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fakeDeployment(id string) api.Deployment {
|
||||
return api.Deployment{
|
||||
ID: id, Project: "demo", State: "ready",
|
||||
FileCount: 2, TotalBytes: 4096,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// indexOf is strings.Index with a failure message, used to assert ordering.
|
||||
func indexOf(t *testing.T, haystack, needle string) int {
|
||||
t.Helper()
|
||||
i := strings.Index(haystack, needle)
|
||||
if i < 0 {
|
||||
t.Fatalf("output does not mention %q:\n%s", needle, haystack)
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// TestDeploymentListFollowsTheCursor: the listing is read to decide which
|
||||
// deployment to roll back to, so one that silently showed the first page would
|
||||
// be worse than one that failed.
|
||||
func TestDeploymentListFollowsTheCursor(t *testing.T) {
|
||||
var queries []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
queries = append(queries, r.URL.RawQuery)
|
||||
if r.URL.Query().Get("cursor") == "dpl_2" {
|
||||
writeJSON(t, w, api.DeploymentList{
|
||||
Deployments: []api.Deployment{fakeDeployment("dpl_1")},
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, api.DeploymentList{
|
||||
Deployments: []api.Deployment{fakeDeployment("dpl_3"), fakeDeployment("dpl_2")},
|
||||
NextCursor: "dpl_2",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, _, err := runCLI(t, srv.URL, "", "deployment", "list", "--project", "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment list: %v", err)
|
||||
}
|
||||
// Newest first, as the server returned them: the order is what tells the
|
||||
// reader which one is the previous release.
|
||||
first := indexOf(t, out, "dpl_3")
|
||||
second := indexOf(t, out, "dpl_2")
|
||||
third := indexOf(t, out, "dpl_1")
|
||||
if !(first < second && second < third) {
|
||||
t.Errorf("rows out of order:\n%s", out)
|
||||
}
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("made %d requests (%q), want 2", len(queries), queries)
|
||||
}
|
||||
if !strings.Contains(queries[1], "cursor=dpl_2") {
|
||||
t.Errorf("second request query = %q, want the cursor from the first page", queries[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentListStopsAtTheLimit — the server here always offers another
|
||||
// page, so a --limit that was not honoured would page until the test timed out.
|
||||
func TestDeploymentListStopsAtTheLimit(t *testing.T) {
|
||||
var requests int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
if requests > 4 {
|
||||
t.Errorf("still paging after %d requests; --limit was ignored", requests)
|
||||
writeJSON(t, w, api.DeploymentList{})
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, api.DeploymentList{
|
||||
Deployments: []api.Deployment{fakeDeployment("dpl_3"), fakeDeployment("dpl_2")},
|
||||
NextCursor: "dpl_2",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, _, err := runCLI(t, srv.URL, "", "deployment", "list", "--project", "demo", "--limit", "2")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment list: %v", err)
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Errorf("made %d requests, want 1: two rows already satisfy --limit 2", requests)
|
||||
}
|
||||
if !strings.Contains(out, "dpl_3") || !strings.Contains(out, "dpl_2") {
|
||||
t.Errorf("output is missing a row:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentListEmptyIsAnEmptyArray: -o json is what a CI step parses, and
|
||||
// jq treats null and [] very differently.
|
||||
func TestDeploymentListEmptyIsAnEmptyArray(t *testing.T) {
|
||||
var query string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
query = r.URL.RawQuery
|
||||
writeJSON(t, w, api.DeploymentList{})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, _, err := runCLI(t, srv.URL, "",
|
||||
"deployment", "list", "--project", "demo", "--state", "failed", "-o", "json")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment list: %v", err)
|
||||
}
|
||||
if !strings.Contains(query, "state=failed") {
|
||||
t.Errorf("query = %q, want the state filter", query)
|
||||
}
|
||||
if !strings.Contains(out, `"deployments": []`) {
|
||||
t.Errorf("output = %s, want an empty array", out)
|
||||
}
|
||||
if strings.Contains(out, "null") {
|
||||
t.Errorf("output = %s, want no null", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentShowAsksForFilesOnlyWhenTold — the manifest is one line per
|
||||
// file, so a large site's would drown the rest of the output.
|
||||
func TestDeploymentShowAsksForFilesOnlyWhenTold(t *testing.T) {
|
||||
d := fakeDeployment("dpl_1")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
out := d
|
||||
if r.URL.Query().Get("files") == "true" {
|
||||
out.Files = []api.FileEntry{
|
||||
{Path: "assets/app.js", Digest: strings.Repeat("ab", 32), Size: 15},
|
||||
{Path: "index.html", Digest: strings.Repeat("cd", 32), Size: 42},
|
||||
}
|
||||
}
|
||||
writeJSON(t, w, out)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
plain, _, err := runCLI(t, srv.URL, "", "deployment", "show", "dpl_1", "--project", "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment show: %v", err)
|
||||
}
|
||||
if strings.Contains(plain, "assets/app.js") {
|
||||
t.Errorf("the manifest was printed without --files:\n%s", plain)
|
||||
}
|
||||
|
||||
full, _, err := runCLI(t, srv.URL, "", "deployment", "show", "dpl_1", "--project", "demo", "--files")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment show --files: %v", err)
|
||||
}
|
||||
if !strings.Contains(full, "assets/app.js") || !strings.Contains(full, "index.html") {
|
||||
t.Errorf("--files did not list the manifest:\n%s", full)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentDeleteAsksFirst. Deleting the wrong deployment is not
|
||||
// recoverable from the CLI, so the prompt is the safety net and --yes is the
|
||||
// documented way past it.
|
||||
func TestDeploymentDeleteAsksFirst(t *testing.T) {
|
||||
newServer := func(seen *[]string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
*seen = append(*seen, r.Method+" "+r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
}
|
||||
|
||||
t.Run("declining deletes nothing", func(t *testing.T) {
|
||||
var seen []string
|
||||
srv := newServer(&seen)
|
||||
defer srv.Close()
|
||||
|
||||
_, errOut, err := runCLI(t, srv.URL, "n\n", "deployment", "delete", "dpl_1", "--project", "demo")
|
||||
if !errors.Is(err, cliutil.ErrAborted) {
|
||||
t.Fatalf("err = %v, want it to report the abort", err)
|
||||
}
|
||||
if len(seen) != 0 {
|
||||
t.Errorf("requests = %q, want none", seen)
|
||||
}
|
||||
if !strings.Contains(errOut, "dpl_1") {
|
||||
t.Errorf("prompt = %q, want it to name the deployment", errOut)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("confirming deletes", func(t *testing.T) {
|
||||
var seen []string
|
||||
srv := newServer(&seen)
|
||||
defer srv.Close()
|
||||
|
||||
out, _, err := runCLI(t, srv.URL, "y\n", "deployment", "delete", "dpl_1", "--project", "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment delete: %v", err)
|
||||
}
|
||||
want := "DELETE " + api.PathDeployment("demo", "dpl_1")
|
||||
if len(seen) != 1 || seen[0] != want {
|
||||
t.Errorf("requests = %q, want [%q]", seen, want)
|
||||
}
|
||||
if !strings.Contains(out, "dpl_1") {
|
||||
t.Errorf("output = %q, want it to confirm what was deleted", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--yes does not prompt", func(t *testing.T) {
|
||||
var seen []string
|
||||
srv := newServer(&seen)
|
||||
defer srv.Close()
|
||||
|
||||
// Empty stdin: without --yes this would abort rather than delete.
|
||||
_, errOut, err := runCLI(t, srv.URL, "", "deployment", "delete", "dpl_1", "--project", "demo", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("deployment delete --yes: %v", err)
|
||||
}
|
||||
if len(seen) != 1 {
|
||||
t.Errorf("requests = %q, want one delete", seen)
|
||||
}
|
||||
if strings.Contains(errOut, "[y/N]") {
|
||||
t.Errorf("stderr = %q, want no prompt", errOut)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSystemGCPostsTheDryRunFlag: a dry run that silently ran for real is the
|
||||
// worst bug this command could have, so the flag's trip to the wire is checked
|
||||
// rather than assumed.
|
||||
func TestSystemGCPostsTheDryRunFlag(t *testing.T) {
|
||||
var gotPath, gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := make([]byte, 256)
|
||||
n, _ := r.Body.Read(body)
|
||||
gotPath, gotBody = r.Method+" "+r.URL.Path, string(body[:n])
|
||||
writeJSON(t, w, api.GCStats{DryRun: true, DeploymentsDeleted: 3, BlobsDeleted: 4, BytesFreed: 5120})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, _, err := runCLI(t, srv.URL, "", "system", "gc", "--dry-run")
|
||||
if err != nil {
|
||||
t.Fatalf("system gc: %v", err)
|
||||
}
|
||||
if want := "POST " + api.PathGC(); gotPath != want {
|
||||
t.Errorf("request = %q, want %q", gotPath, want)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"dry_run":true`) {
|
||||
t.Errorf("body = %q, want dry_run set", gotBody)
|
||||
}
|
||||
for _, want := range []string{"dry_run", "yes", "deployments_deleted", "3", "blobs_deleted", "4"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemFsckReportsDrift — the report exists for the case where the counts
|
||||
// are wrong, so the drifting digests have to reach the operator's screen.
|
||||
func TestSystemFsckReportsDrift(t *testing.T) {
|
||||
digest := strings.Repeat("ab", 32)
|
||||
var gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := make([]byte, 256)
|
||||
n, _ := r.Body.Read(body)
|
||||
gotBody = string(body[:n])
|
||||
writeJSON(t, w, api.FsckReport{
|
||||
Blobs: 9,
|
||||
DriftCount: 1,
|
||||
Repaired: 1,
|
||||
Drift: []api.BlobDrift{{Digest: digest, Stored: 7, Actual: 1}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out, _, err := runCLI(t, srv.URL, "", "system", "fsck", "--repair")
|
||||
if err != nil {
|
||||
t.Fatalf("system fsck: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"repair":true`) {
|
||||
t.Errorf("body = %q, want repair set", gotBody)
|
||||
}
|
||||
// Truncate spends its last column on the ellipsis, so twelve columns of
|
||||
// digest are eleven characters and a marker that there is more.
|
||||
if !strings.Contains(out, digest[:11]+"…") {
|
||||
t.Errorf("output does not name the drifting blob:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "stored 7, actual 1") {
|
||||
t.Errorf("output does not give the counts:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Package clicmd implements the pages subcommands.
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/client"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
// Globals are the settings every command shares.
|
||||
//
|
||||
// Precedence is flag > environment > config file > default. Flags are parsed
|
||||
// into these fields first, so Resolve fills in only what is still empty — which
|
||||
// is why a flag given as the empty string counts as absent.
|
||||
type Globals struct {
|
||||
Server string
|
||||
Token string
|
||||
TokenFile string
|
||||
Project string
|
||||
Config string
|
||||
Output string
|
||||
Timeout time.Duration
|
||||
Verbose bool
|
||||
|
||||
In io.Reader
|
||||
Out io.Writer
|
||||
Err io.Writer
|
||||
|
||||
file ConfigFile
|
||||
resolved bool
|
||||
}
|
||||
|
||||
// NewGlobals returns Globals wired to the process's standard streams.
|
||||
func NewGlobals() *Globals {
|
||||
return &Globals{In: os.Stdin, Out: os.Stdout, Err: os.Stderr}
|
||||
}
|
||||
|
||||
// Register adds the global flags to fs.
|
||||
//
|
||||
// Every flag's default is the field's current value, so registering on a
|
||||
// subcommand's FlagSet preserves what an outer parse already set. Passing a
|
||||
// fixed default here would make "pages --server X project list" lose --server
|
||||
// the moment the subcommand registered its own copy.
|
||||
func (g *Globals) Register(fs *flag.FlagSet) {
|
||||
fs.StringVar(&g.Server, "server", g.Server, "management API base `URL` (env PAGES_SERVER)")
|
||||
fs.StringVar(&g.Token, "token", g.Token,
|
||||
"API `token`; avoid it — argv is world-readable via /proc on shared runners, so prefer --token-file or PAGES_TOKEN")
|
||||
fs.StringVar(&g.TokenFile, "token-file", g.TokenFile, "read the API token from `path` (env PAGES_TOKEN_FILE)")
|
||||
fs.StringVar(&g.Project, "project", g.Project, "project `name` to operate on (env PAGES_PROJECT)")
|
||||
fs.StringVar(&g.Config, "config", g.Config, "config file `path` (env PAGES_CONFIG)")
|
||||
fs.StringVar(&g.Output, "o", g.Output, "output `format`: table or json (env PAGES_OUTPUT)")
|
||||
fs.StringVar(&g.Output, "output", g.Output, "output `format`: table or json")
|
||||
fs.DurationVar(&g.Timeout, "timeout", g.Timeout, "per-request `timeout`")
|
||||
fs.BoolVar(&g.Verbose, "v", g.Verbose, "verbose progress on stderr")
|
||||
fs.BoolVar(&g.Verbose, "verbose", g.Verbose, "verbose progress on stderr")
|
||||
}
|
||||
|
||||
// Resolve applies the environment, then the config file, to anything the flags
|
||||
// did not set. It runs once; later calls are no-ops.
|
||||
func (g *Globals) Resolve() error {
|
||||
if g.resolved {
|
||||
return nil
|
||||
}
|
||||
g.resolved = true
|
||||
|
||||
if g.Config == "" {
|
||||
g.Config = DefaultConfigPath()
|
||||
}
|
||||
f, err := LoadConfig(g.Config, g.Err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.file = f
|
||||
|
||||
g.Server = firstNonEmpty(g.Server, os.Getenv("PAGES_SERVER"), f.Server)
|
||||
g.Project = firstNonEmpty(g.Project, os.Getenv("PAGES_PROJECT"), f.Project)
|
||||
|
||||
if err := g.resolveToken(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
g.Output = firstNonEmpty(g.Output, os.Getenv("PAGES_OUTPUT"), f.Output, cliutil.FormatTable)
|
||||
if !cliutil.ValidFormat(g.Output) {
|
||||
return fmt.Errorf("unknown output format %q: use table or json", g.Output)
|
||||
}
|
||||
|
||||
if g.Timeout == 0 {
|
||||
if s := os.Getenv("PAGES_TIMEOUT"); s != "" {
|
||||
d, err := cliutil.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("PAGES_TIMEOUT: %w", err)
|
||||
}
|
||||
g.Timeout = d
|
||||
}
|
||||
}
|
||||
if g.Timeout <= 0 {
|
||||
g.Timeout = client.DefaultTimeout
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveToken picks the token from the most specific source that has one:
|
||||
// --token, --token-file, $PAGES_TOKEN, $PAGES_TOKEN_FILE, config file.
|
||||
func (g *Globals) resolveToken() error {
|
||||
if g.Token != "" {
|
||||
// Only a flag can have set this: the environment and the config file are
|
||||
// read below. Say so once — on a shared runner every other user can read
|
||||
// this token out of /proc/<pid>/cmdline for as long as the process lives.
|
||||
fmt.Fprintln(g.Err, "warning: --token puts the token in the process list; prefer --token-file or PAGES_TOKEN")
|
||||
return nil
|
||||
}
|
||||
if g.TokenFile != "" {
|
||||
t, err := readTokenFile(g.TokenFile, g.Err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.Token = t
|
||||
return nil
|
||||
}
|
||||
if t := os.Getenv("PAGES_TOKEN"); t != "" {
|
||||
g.Token = strings.TrimSpace(t)
|
||||
return nil
|
||||
}
|
||||
if p := os.Getenv("PAGES_TOKEN_FILE"); p != "" {
|
||||
t, err := readTokenFile(p, g.Err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.Token = t
|
||||
return nil
|
||||
}
|
||||
g.Token = g.file.Token
|
||||
return nil
|
||||
}
|
||||
|
||||
// readTokenFile reads a token, tolerating the trailing newline that every text
|
||||
// editor and `pages key create ... > token` adds.
|
||||
func readTokenFile(path string, warn io.Writer) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read token file: %w", err)
|
||||
}
|
||||
if fi, err := os.Stat(path); err == nil && fi.Mode().Perm()&0o077 != 0 && warn != nil {
|
||||
fmt.Fprintf(warn, "warning: token file %s is mode %#o; run: chmod 600 %s\n",
|
||||
path, fi.Mode().Perm(), path)
|
||||
}
|
||||
t := strings.TrimSpace(string(raw))
|
||||
if t == "" {
|
||||
return "", fmt.Errorf("token file %s is empty", path)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Client builds an API client from the resolved settings.
|
||||
func (g *Globals) Client() (*client.Client, error) {
|
||||
if err := g.Resolve(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.New(client.Config{
|
||||
BaseURL: g.Server,
|
||||
Token: g.Token,
|
||||
Timeout: g.Timeout,
|
||||
})
|
||||
}
|
||||
|
||||
// Printer renders command output in the requested format.
|
||||
func (g *Globals) Printer() (*cliutil.Printer, error) {
|
||||
if err := g.Resolve(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cliutil.Printer{Out: g.Out, Format: g.Output}, nil
|
||||
}
|
||||
|
||||
// ProjectName returns the project to operate on, or an explanation of how to
|
||||
// name one.
|
||||
func (g *Globals) ProjectName() (string, error) {
|
||||
if err := g.Resolve(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if g.Project == "" {
|
||||
return "", errors.New("no project: pass --project or set PAGES_PROJECT")
|
||||
}
|
||||
return g.Project, nil
|
||||
}
|
||||
|
||||
// logf writes a progress note to stderr under --verbose.
|
||||
func (g *Globals) logf(format string, args ...any) {
|
||||
if g.Verbose {
|
||||
fmt.Fprintf(g.Err, format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// exactArgs is the argument-count check every leaf command starts with. It
|
||||
// reports a usage error, so the command's own usage text follows the message.
|
||||
func exactArgs(args []string, n int, want string) error {
|
||||
if len(args) != n {
|
||||
return cliutil.UsageErrorf("expected %s, got %d argument(s)", want, len(args))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
// clearEnv makes a test independent of whatever the developer has exported.
|
||||
func clearEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range []string{
|
||||
"PAGES_SERVER", "PAGES_TOKEN", "PAGES_TOKEN_FILE",
|
||||
"PAGES_PROJECT", "PAGES_OUTPUT", "PAGES_TIMEOUT", "PAGES_CONFIG",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
// writeConfig returns the path to a config file holding f.
|
||||
func writeConfig(t *testing.T, f ConfigFile) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := SaveConfig(path, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// newTestGlobals is what NewGlobals would give a command, with the streams
|
||||
// captured and the config file pinned so no real one can interfere.
|
||||
func newTestGlobals(config string) (*Globals, *strings.Builder, *strings.Builder) {
|
||||
var out, errOut strings.Builder
|
||||
return &Globals{In: strings.NewReader(""), Out: &out, Err: &errOut, Config: config}, &out, &errOut
|
||||
}
|
||||
|
||||
// TestPrecedence is the rule stated in the help text: flag, then environment,
|
||||
// then config file, then default. It is easy to get subtly wrong, and wrong
|
||||
// here means a CI job deploying to the wrong server.
|
||||
func TestPrecedence(t *testing.T) {
|
||||
file := ConfigFile{Server: "https://file.example.com", Project: "fileproj", Output: "json"}
|
||||
|
||||
t.Run("flag wins", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_SERVER", "https://env.example.com")
|
||||
t.Setenv("PAGES_PROJECT", "envproj")
|
||||
g, _, _ := newTestGlobals(writeConfig(t, file))
|
||||
g.Server, g.Project = "https://flag.example.com", "flagproj"
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Server != "https://flag.example.com" || g.Project != "flagproj" {
|
||||
t.Errorf("server=%q project=%q, want the flag values", g.Server, g.Project)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("environment beats the config file", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_SERVER", "https://env.example.com")
|
||||
t.Setenv("PAGES_PROJECT", "envproj")
|
||||
g, _, _ := newTestGlobals(writeConfig(t, file))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Server != "https://env.example.com" || g.Project != "envproj" {
|
||||
t.Errorf("server=%q project=%q, want the environment values", g.Server, g.Project)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the config file is the last word before defaults", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(writeConfig(t, file))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Server != "https://file.example.com" || g.Project != "fileproj" || g.Output != "json" {
|
||||
t.Errorf("server=%q project=%q output=%q, want the file values", g.Server, g.Project, g.Output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaults fill the rest", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Output != cliutil.FormatTable {
|
||||
t.Errorf("output = %q, want %q", g.Output, cliutil.FormatTable)
|
||||
}
|
||||
if g.Timeout <= 0 {
|
||||
t.Errorf("timeout = %v, want a positive default", g.Timeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFlagsSurviveTheSubcommandParse covers the trap that makes this design
|
||||
// work: every global flag is re-registered on each subcommand's FlagSet, and
|
||||
// registering with a fixed default would wipe a value given before the
|
||||
// subcommand name.
|
||||
func TestFlagsSurviveTheSubcommandParse(t *testing.T) {
|
||||
clearEnv(t)
|
||||
cfg := writeConfig(t, ConfigFile{})
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"--server", "https://flag.example.com", "--config", cfg, "probe", "demo"},
|
||||
{"probe", "--server", "https://flag.example.com", "--config", cfg, "demo"},
|
||||
{"--server", "https://flag.example.com", "probe", "demo", "--config", cfg},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
g, _, _ := newTestGlobals("")
|
||||
var gotProject string
|
||||
root := &cliutil.Command{
|
||||
Name: "pages",
|
||||
Sub: []*cliutil.Command{{
|
||||
Name: "probe",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := g.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 1 {
|
||||
gotProject = args[0]
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
}
|
||||
if err := cliutil.Run(context.Background(), root, args, io.Discard, g.Register); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if g.Server != "https://flag.example.com" {
|
||||
t.Errorf("server = %q, want the flag to survive dispatch", g.Server)
|
||||
}
|
||||
if gotProject != "demo" {
|
||||
t.Errorf("positional = %q, want demo", gotProject)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenPrecedence(t *testing.T) {
|
||||
const (
|
||||
flagTok = "pgs_flagflagflagfl_secret"
|
||||
flagFileTok = "pgs_flagfileflagfi_secret"
|
||||
envTok = "pgs_envenvenvenven_secret"
|
||||
envFileTok = "pgs_envfileenvfile_secret"
|
||||
fileTok = "pgs_fileconfigfile_secret"
|
||||
)
|
||||
tokenFile := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
t.Run("--token wins but warns", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TOKEN", envTok)
|
||||
g, _, errOut := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
|
||||
g.Token = flagTok
|
||||
g.TokenFile = tokenFile(t, flagFileTok)
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Token != flagTok {
|
||||
t.Errorf("token = %q, want the flag", g.Token)
|
||||
}
|
||||
// argv is world-readable through /proc on a shared runner, so this
|
||||
// warning is the whole reason --token is documented as a last resort.
|
||||
if !strings.Contains(errOut.String(), "process list") {
|
||||
t.Errorf("stderr = %q, want a warning about the process list", errOut.String())
|
||||
}
|
||||
if strings.Contains(errOut.String(), "secret") {
|
||||
t.Error("the warning printed the token")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--token-file beats the environment", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TOKEN", envTok)
|
||||
g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
|
||||
g.TokenFile = tokenFile(t, flagFileTok)
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Token != flagFileTok {
|
||||
t.Errorf("token = %q, want the one from --token-file", g.Token)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PAGES_TOKEN beats PAGES_TOKEN_FILE", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TOKEN", envTok)
|
||||
t.Setenv("PAGES_TOKEN_FILE", tokenFile(t, envFileTok))
|
||||
g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Token != envTok {
|
||||
t.Errorf("token = %q, want PAGES_TOKEN", g.Token)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PAGES_TOKEN_FILE beats the config file", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TOKEN_FILE", tokenFile(t, envFileTok))
|
||||
g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Token != envFileTok {
|
||||
t.Errorf("token = %q, want PAGES_TOKEN_FILE", g.Token)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the config file is the fallback", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok}))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Token != fileTok {
|
||||
t.Errorf("token = %q, want the config file's", g.Token)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTokenFileTrimsTrailingNewline: `pages key create ... > token` and every
|
||||
// text editor add one.
|
||||
func TestTokenFileTrimsTrailingNewline(t *testing.T) {
|
||||
clearEnv(t)
|
||||
p := filepath.Join(t.TempDir(), "token")
|
||||
os.WriteFile(p, []byte("pgs_abcdefghijklmnop_secret\n"), 0o600)
|
||||
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
g.TokenFile = p
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Token != "pgs_abcdefghijklmnop_secret" {
|
||||
t.Errorf("token = %q, want the newline trimmed", g.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFileProblemsAreReported(t *testing.T) {
|
||||
t.Run("missing", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
g.TokenFile = filepath.Join(t.TempDir(), "nope")
|
||||
if err := g.Resolve(); err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
p := filepath.Join(t.TempDir(), "token")
|
||||
os.WriteFile(p, []byte("\n\n"), 0o600)
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
g.TokenFile = p
|
||||
err := g.Resolve()
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("err = %v, want it to say the file is empty", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("world readable warns", func(t *testing.T) {
|
||||
clearEnv(t)
|
||||
p := filepath.Join(t.TempDir(), "token")
|
||||
os.WriteFile(p, []byte("pgs_abcdefghijklmnop_secret"), 0o644)
|
||||
g, _, errOut := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
g.TokenFile = p
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "chmod 600") {
|
||||
t.Errorf("stderr = %q, want a mode warning", errOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveRejectsUnknownOutputFormat(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
g.Output = "yaml"
|
||||
err := g.Resolve()
|
||||
if err == nil || !strings.Contains(err.Error(), "table or json") {
|
||||
t.Fatalf("err = %v, want it to list the formats", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveParsesFriendlyTimeouts(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TIMEOUT", "2m")
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
if err := g.Resolve(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.Timeout.Minutes() != 2 {
|
||||
t.Errorf("timeout = %v, want 2m", g.Timeout)
|
||||
}
|
||||
|
||||
clearEnv(t)
|
||||
t.Setenv("PAGES_TIMEOUT", "later")
|
||||
g2, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
err := g2.Resolve()
|
||||
if err == nil || !strings.Contains(err.Error(), "PAGES_TIMEOUT") {
|
||||
t.Fatalf("err = %v, want it to name the variable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectNameExplainsHowToSetIt(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
_, err := g.ProjectName()
|
||||
if err == nil || !strings.Contains(err.Error(), "PAGES_PROJECT") {
|
||||
t.Fatalf("err = %v, want it to name the flag and the variable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientNeedsServerAndToken: the two settings a fresh CI job forgets.
|
||||
func TestClientNeedsServerAndToken(t *testing.T) {
|
||||
clearEnv(t)
|
||||
g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
if _, err := g.Client(); err == nil || !strings.Contains(err.Error(), "PAGES_SERVER") {
|
||||
t.Fatalf("err = %v, want it to name PAGES_SERVER", err)
|
||||
}
|
||||
|
||||
clearEnv(t)
|
||||
g2, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json"))
|
||||
g2.Server = "https://p.example.com"
|
||||
if _, err := g2.Client(); err == nil || !strings.Contains(err.Error(), "PAGES_TOKEN") {
|
||||
t.Fatalf("err = %v, want it to name PAGES_TOKEN", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
func keyCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "key",
|
||||
Short: "Manage API keys",
|
||||
Sub: []*cliutil.Command{
|
||||
keyCreateCmd(g),
|
||||
keyListCmd(g),
|
||||
keyRevokeCmd(g),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func keyCreateCmd(g *Globals) *cliutil.Command {
|
||||
var (
|
||||
name string
|
||||
admin bool
|
||||
expires string
|
||||
)
|
||||
return &cliutil.Command{
|
||||
Name: "create",
|
||||
Short: "Mint a key",
|
||||
Long: "The token is printed once and cannot be retrieved again — the server\n" +
|
||||
"stores only its hash. Redirect it straight into a file or a secret\n" +
|
||||
"store; do not let it reach a CI log.\n\n" +
|
||||
"Without --admin the key is scoped to one project and can do nothing\n" +
|
||||
"outside it. Requires an admin key either way.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.StringVar(&name, "name", "", "`label` recorded with the key, e.g. github-actions")
|
||||
fs.BoolVar(&admin, "admin", false, "mint an admin key instead of a project key")
|
||||
fs.StringVar(&expires, "expires", "", "expire after this `duration`, e.g. 90d; default never")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
req := api.CreateKeyRequest{Name: name}
|
||||
if expires != "" {
|
||||
d, err := cliutil.ParseDuration(expires)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d <= 0 {
|
||||
return errors.New("--expires must be positive")
|
||||
}
|
||||
// Computed here, sent absolute: a clock difference between this
|
||||
// machine and the server then shifts nothing.
|
||||
t := time.Now().Add(d).UTC().Truncate(time.Second)
|
||||
req.ExpiresAt = &t
|
||||
}
|
||||
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out api.CreateKeyResponse
|
||||
if admin {
|
||||
out, err = c.CreateAdminKey(ctx, req)
|
||||
} else {
|
||||
var project string
|
||||
if project, err = g.ProjectName(); err != nil {
|
||||
return fmt.Errorf("%w, or pass --admin for a server-wide key", err)
|
||||
}
|
||||
out, err = c.CreateProjectKey(ctx, project, req)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Format == cliutil.FormatJSON {
|
||||
return p.JSON(out)
|
||||
}
|
||||
// Table format prints the token on a line of its own so it survives
|
||||
// a copy-paste and so `pages key create | tail -1` is not tempting.
|
||||
if err := keyTable(out.Key).Write(g.Out); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(g.Out, "\n%s\n", out.Token)
|
||||
fmt.Fprintln(g.Err, "this token is shown once and cannot be recovered; store it now")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func keyListCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "list",
|
||||
Short: "List keys",
|
||||
Long: "With --project, lists that project's keys; a project key may list its\n" +
|
||||
"own. Without it, lists every key on the server and requires an admin\n" +
|
||||
"key. Secrets are never listed — only key ids.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var list api.KeyList
|
||||
if g.Project != "" {
|
||||
list, err = c.ListProjectKeys(ctx, g.Project)
|
||||
} else {
|
||||
list, err = c.ListKeys(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if list.Keys == nil {
|
||||
list.Keys = []api.Key{}
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(list, func() *cliutil.Table {
|
||||
t := cliutil.NewTable("ID", "SCOPE", "PROJECT", "NAME", "CREATED", "EXPIRES", "LAST USED", "STATE")
|
||||
for _, k := range list.Keys {
|
||||
t.Row(k.ID, k.Scope, cliutil.Str(k.Project), cliutil.Str(k.Name),
|
||||
cliutil.Time(k.CreatedAt), cliutil.TimePtr(k.ExpiresAt),
|
||||
cliutil.TimePtr(k.LastUsed), keyState(k))
|
||||
}
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func keyRevokeCmd(g *Globals) *cliutil.Command {
|
||||
var yes bool
|
||||
return &cliutil.Command{
|
||||
Name: "revoke",
|
||||
Args: "<key-id>",
|
||||
Short: "Revoke a key",
|
||||
Long: "Takes effect immediately across the server. The key id is the middle\n" +
|
||||
"segment of a token (pgs_<key-id>_<secret>) and is what `key list`\n" +
|
||||
"shows. Revoking an already-revoked key succeeds.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&yes, "yes", false, "do not ask for confirmation")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 1, "one key id"); err != nil {
|
||||
return err
|
||||
}
|
||||
id := args[0]
|
||||
if !yes {
|
||||
if err := cliutil.Confirm(g.In, g.Err, fmt.Sprintf("Revoke key %s?", id)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.RevokeKey(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Printf("revoked key %s\n", id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// keyState collapses the two timestamps that decide whether a key still works.
|
||||
func keyState(k api.Key) string {
|
||||
switch {
|
||||
case k.Revoked():
|
||||
return "revoked"
|
||||
case k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()):
|
||||
return "expired"
|
||||
default:
|
||||
return "active"
|
||||
}
|
||||
}
|
||||
|
||||
func keyTable(k api.Key) *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("id", k.ID)
|
||||
t.Row("scope", k.Scope)
|
||||
t.Row("project", cliutil.Str(k.Project))
|
||||
t.Row("name", cliutil.Str(k.Name))
|
||||
t.Row("created_at", cliutil.Time(k.CreatedAt))
|
||||
t.Row("expires_at", cliutil.TimePtr(k.ExpiresAt))
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
)
|
||||
|
||||
// projectFlags are the settings shared by "project create" and "project
|
||||
// update". They map one-to-one onto api.ProjectPatch, whose fields are pointers
|
||||
// so an absent flag and an explicitly emptied one stay distinguishable.
|
||||
type projectFlags struct {
|
||||
displayName cliutil.OptString
|
||||
indexFile cliutil.OptString
|
||||
notFoundFile cliutil.OptString
|
||||
spa cliutil.OptBool
|
||||
cacheControl cliutil.OptString
|
||||
retention cliutil.OptInt
|
||||
grace cliutil.OptDuration
|
||||
maxFiles cliutil.OptInt
|
||||
maxFileBytes cliutil.OptBytes
|
||||
maxTotalBytes cliutil.OptBytes
|
||||
}
|
||||
|
||||
func (p *projectFlags) register(fs *flag.FlagSet) {
|
||||
fs.Var(&p.displayName, "display-name", "human-readable `name` shown in listings")
|
||||
fs.Var(&p.indexFile, "index-file", "document served for a directory, e.g. `index.html`")
|
||||
fs.Var(&p.notFoundFile, "not-found-file", "document served with 404, e.g. `404.html`; empty clears it")
|
||||
fs.Var(&p.spa, "spa", "serve the index document for unknown paths that accept HTML")
|
||||
fs.Var(&p.cacheControl, "cache-control", "Cache-Control `header` sent with every file")
|
||||
fs.Var(&p.retention, "retention", "`count` of finished deployments to keep per project")
|
||||
fs.Var(&p.grace, "retention-grace", "`duration` a deployment stays after being replaced, e.g. 1h")
|
||||
fs.Var(&p.maxFiles, "max-files", "`count` of files allowed in one deployment")
|
||||
fs.Var(&p.maxFileBytes, "max-file-bytes", "largest single file, e.g. `256MiB`")
|
||||
fs.Var(&p.maxTotalBytes, "max-total-bytes", "largest total deployment, e.g. `2GiB`")
|
||||
}
|
||||
|
||||
func (p *projectFlags) patch() api.ProjectPatch {
|
||||
return api.ProjectPatch{
|
||||
DisplayName: p.displayName.Ptr(),
|
||||
IndexFile: p.indexFile.Ptr(),
|
||||
NotFoundFile: p.notFoundFile.Ptr(),
|
||||
SPAFallback: p.spa.Ptr(),
|
||||
CacheControl: p.cacheControl.Ptr(),
|
||||
RetentionCount: p.retention.Ptr(),
|
||||
RetentionGrace: p.grace.SecondsPtr(),
|
||||
MaxFiles: p.maxFiles.Ptr(),
|
||||
MaxFileBytes: p.maxFileBytes.Ptr(),
|
||||
MaxTotalBytes: p.maxTotalBytes.Ptr(),
|
||||
}
|
||||
}
|
||||
|
||||
func projectCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "project",
|
||||
Short: "Manage projects",
|
||||
Sub: []*cliutil.Command{
|
||||
projectCreateCmd(g),
|
||||
projectListCmd(g),
|
||||
projectShowCmd(g),
|
||||
projectUpdateCmd(g),
|
||||
projectDeleteCmd(g),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func projectCreateCmd(g *Globals) *cliutil.Command {
|
||||
var pf projectFlags
|
||||
return &cliutil.Command{
|
||||
Name: "create",
|
||||
Args: "<name>",
|
||||
Short: "Create a project",
|
||||
Long: "The name becomes the URL prefix, so it is restricted to lowercase\n" +
|
||||
"letters, digits, dot, dash and underscore, and cannot be changed later.\n" +
|
||||
"Settings left unset take the server's defaults. Requires an admin key.",
|
||||
Flags: pf.register,
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 1, "one project name"); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patch := pf.patch()
|
||||
p, err := c.CreateProject(ctx, api.CreateProjectRequest{Name: args[0], Patch: &patch})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.printProject(p)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func projectListCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "list",
|
||||
Short: "List projects",
|
||||
Long: "Requires an admin key. Follows paging to the end.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ps, err := c.ListAllProjects(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// api.ProjectList rather than the bare slice, so -o json produces the
|
||||
// same shape the API returns and a nil slice still prints as [].
|
||||
out := api.ProjectList{Projects: ps}
|
||||
if out.Projects == nil {
|
||||
out.Projects = []api.Project{}
|
||||
}
|
||||
return p.Print(out, func() *cliutil.Table {
|
||||
t := cliutil.NewTable("NAME", "DISPLAY NAME", "INDEX", "SPA", "KEEP", "UPDATED")
|
||||
for _, pr := range ps {
|
||||
t.Row(pr.Name, cliutil.Str(cliutil.Truncate(pr.DisplayName, 32)),
|
||||
pr.IndexFile, cliutil.Bool(pr.SPAFallback),
|
||||
strconv.Itoa(pr.RetentionCount), cliutil.Time(pr.UpdatedAt))
|
||||
}
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func projectShowCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "show",
|
||||
Args: "[name]",
|
||||
Short: "Show one project",
|
||||
Long: "Defaults to --project. A project key may read only its own project.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
name, err := g.oneProject(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := c.GetProject(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.printProject(p)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func projectUpdateCmd(g *Globals) *cliutil.Command {
|
||||
var pf projectFlags
|
||||
return &cliutil.Command{
|
||||
Name: "update",
|
||||
Args: "[name]",
|
||||
Short: "Change a project's settings",
|
||||
Long: "Only the settings named by flags are changed. Requires an admin key.\n" +
|
||||
"--not-found-file= with an empty value clears the custom 404 document.",
|
||||
Flags: pf.register,
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
name, err := g.oneProject(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patch := pf.patch()
|
||||
if patch == (api.ProjectPatch{}) {
|
||||
return fmt.Errorf("nothing to change: pass at least one setting flag")
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := c.PatchProject(ctx, name, patch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.printProject(p)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func projectDeleteCmd(g *Globals) *cliutil.Command {
|
||||
var yes bool
|
||||
return &cliutil.Command{
|
||||
Name: "delete",
|
||||
Args: "[name]",
|
||||
Short: "Delete a project and everything in it",
|
||||
Long: "Removes the project's keys and deployments and unpublishes the site.\n" +
|
||||
"The uploaded content is reclaimed by the next garbage collection.\n" +
|
||||
"Requires an admin key.",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&yes, "yes", false, "do not ask for confirmation")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
name, err := g.oneProject(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
if err := cliutil.Confirm(g.In, g.Err,
|
||||
fmt.Sprintf("Delete project %q, its keys and all its deployments?", name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.DeleteProject(ctx, name); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Printf("deleted project %s\n", name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// oneProject takes the project from the positional argument, falling back to
|
||||
// --project. Both are accepted because "pages project show demo" reads better
|
||||
// at a prompt while "--project" is what a CI job already has set.
|
||||
func (g *Globals) oneProject(args []string) (string, error) {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return g.ProjectName()
|
||||
case 1:
|
||||
return args[0], nil
|
||||
default:
|
||||
return "", fmt.Errorf("expected at most one project name")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Globals) printProject(pr api.Project) error {
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(pr, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("name", pr.Name)
|
||||
t.Row("display_name", cliutil.Str(pr.DisplayName))
|
||||
t.Row("url", cliutil.Str(pr.URL))
|
||||
t.Row("index_file", pr.IndexFile)
|
||||
t.Row("not_found_file", cliutil.Str(pr.NotFoundFile))
|
||||
t.Row("spa_fallback", cliutil.Bool(pr.SPAFallback))
|
||||
t.Row("cache_control", pr.CacheControl)
|
||||
t.Row("retention_count", strconv.Itoa(pr.RetentionCount))
|
||||
t.Row("retention_grace", (time.Duration(pr.RetentionGrace) * time.Second).String())
|
||||
t.Row("max_files", strconv.Itoa(pr.MaxFiles))
|
||||
t.Row("max_file_bytes", cliutil.Bytes(pr.MaxFileBytes))
|
||||
t.Row("max_total_bytes", cliutil.Bytes(pr.MaxTotalBytes))
|
||||
t.Row("created_at", cliutil.Time(pr.CreatedAt))
|
||||
t.Row("updated_at", cliutil.Time(pr.UpdatedAt))
|
||||
if d := pr.ActiveDeployment; d != nil {
|
||||
t.Row("active_deployment", d.ID)
|
||||
t.Row("active_files", strconv.Itoa(d.FileCount))
|
||||
t.Row("active_bytes", cliutil.Bytes(d.TotalBytes))
|
||||
t.Row("activated_at", cliutil.TimePtr(d.ActivatedAt))
|
||||
}
|
||||
return t
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package clicmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cliutil"
|
||||
"github.com/iceBear67/simplepages/internal/version"
|
||||
)
|
||||
|
||||
// Root builds the command tree.
|
||||
func Root(g *Globals) *cliutil.Command {
|
||||
var showVersion bool
|
||||
return &cliutil.Command{
|
||||
Name: "pages",
|
||||
Short: "Deploy static sites atomically",
|
||||
Long: "Settings are taken from flags first, then PAGES_* environment\n" +
|
||||
"variables, then the config file, then built-in defaults.\n\n" +
|
||||
" PAGES_SERVER management API base URL\n" +
|
||||
" PAGES_TOKEN API token\n" +
|
||||
" PAGES_TOKEN_FILE file holding the API token\n" +
|
||||
" PAGES_PROJECT default project\n" +
|
||||
" PAGES_OUTPUT table or json\n" +
|
||||
" PAGES_TIMEOUT per-request timeout\n" +
|
||||
" PAGES_CONFIG config file path",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&showVersion, "version", false, "print the version and exit")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if showVersion {
|
||||
fmt.Fprintln(g.Out, version.String())
|
||||
return nil
|
||||
}
|
||||
return cliutil.ErrUsage
|
||||
},
|
||||
Sub: []*cliutil.Command{
|
||||
deployCmd(g),
|
||||
projectCmd(g),
|
||||
deploymentCmd(g),
|
||||
keyCmd(g),
|
||||
whoamiCmd(g),
|
||||
systemCmd(g),
|
||||
configCmd(g),
|
||||
versionCmd(g),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func versionCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "version",
|
||||
Short: "Print the version",
|
||||
Long: "Reports the client build only; it does not contact the server.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
fmt.Fprintln(g.Out, version.String())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func whoamiCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "whoami",
|
||||
Short: "Show which key is being used",
|
||||
Long: "Useful for confirming a CI runner picked up the credential you meant.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
who, err := c.WhoAmI(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(who, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("key_id", who.KeyID)
|
||||
t.Row("scope", who.Scope)
|
||||
t.Row("project", cliutil.Str(who.Project))
|
||||
t.Row("name", cliutil.Str(who.Name))
|
||||
t.Row("expires_at", cliutil.TimePtr(who.ExpiresAt))
|
||||
t.Row("server", c.BaseURL())
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func systemCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "system",
|
||||
Short: "Inspect and maintain the server",
|
||||
Sub: []*cliutil.Command{systemInfoCmd(g), systemGCCmd(g), systemFsckCmd(g)},
|
||||
}
|
||||
}
|
||||
|
||||
func systemInfoCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "info",
|
||||
Short: "Show server version and storage counters",
|
||||
Long: "Requires an admin key.",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := g.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := c.SystemInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(info, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("version", info.Version)
|
||||
t.Row("uptime", (time.Duration(info.UptimeS) * time.Second).String())
|
||||
t.Row("schema_version", strconv.Itoa(info.SchemaVer))
|
||||
t.Row("projects", strconv.FormatInt(info.Projects, 10))
|
||||
t.Row("deployments", strconv.FormatInt(info.Deployments, 10))
|
||||
t.Row("blobs", strconv.FormatInt(info.Blobs, 10))
|
||||
t.Row("cas_bytes", cliutil.Bytes(info.CASBytes))
|
||||
// How deployment trees are built on this host: hardlink shares
|
||||
// inodes with the store, copy does not, and the difference shows
|
||||
// up as disk usage.
|
||||
t.Row("link_mode", info.LinkMode)
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- config
|
||||
|
||||
func configCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "config",
|
||||
Short: "Read and write the CLI config file",
|
||||
Long: "The config file is JSON, mode 0600, at $PAGES_CONFIG or\n" +
|
||||
"$XDG_CONFIG_HOME/pages/config.json. It is a convenience for a\n" +
|
||||
"workstation; CI should use PAGES_* environment variables instead.",
|
||||
Sub: []*cliutil.Command{
|
||||
configShowCmd(g),
|
||||
configSetCmd(g),
|
||||
configPathCmd(g),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// configView is what "config show" prints: the file's contents with the token
|
||||
// reduced to its public half.
|
||||
type configView struct {
|
||||
Path string `json:"path"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
func configShowCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "show",
|
||||
Short: "Show the config file, with the token redacted",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
// From the file, not from the resolved settings: this command answers
|
||||
// "what is stored here", and printing an environment-supplied token
|
||||
// back at the user would be actively misleading.
|
||||
view := configView{
|
||||
Path: g.Config,
|
||||
Server: g.file.Server,
|
||||
Token: redactToken(g.file.Token),
|
||||
Project: g.file.Project,
|
||||
Output: g.file.Output,
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Print(view, func() *cliutil.Table {
|
||||
t := cliutil.NewTable()
|
||||
t.Row("path", cliutil.Str(view.Path))
|
||||
t.Row("server", cliutil.Str(view.Server))
|
||||
t.Row("token", cliutil.Str(view.Token))
|
||||
t.Row("project", cliutil.Str(view.Project))
|
||||
t.Row("output", cliutil.Str(view.Output))
|
||||
return t
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func configSetCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "set",
|
||||
Args: "<server|token|project|output> <value>",
|
||||
Short: "Set one config value",
|
||||
Long: "An empty value removes the setting. The file is created mode 0600.\n" +
|
||||
"Reading a token from a file avoids putting it in your shell history:\n" +
|
||||
" pages config set token \"$(cat token.txt)\"",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 2, "a key and a value"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
key, value := args[0], args[1]
|
||||
f := g.file
|
||||
switch key {
|
||||
case "server":
|
||||
f.Server = value
|
||||
case "token":
|
||||
f.Token = value
|
||||
case "project":
|
||||
f.Project = value
|
||||
case "output":
|
||||
if value != "" && !cliutil.ValidFormat(value) {
|
||||
return fmt.Errorf("unknown output format %q: use table or json", value)
|
||||
}
|
||||
f.Output = value
|
||||
default:
|
||||
return cliutil.UsageErrorf("unknown config key %q", key)
|
||||
}
|
||||
if err := SaveConfig(g.Config, f); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := g.Printer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Printf("set %s in %s\n", key, g.Config)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func configPathCmd(g *Globals) *cliutil.Command {
|
||||
return &cliutil.Command{
|
||||
Name: "path",
|
||||
Short: "Print the config file path",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := exactArgs(args, 0, "no arguments"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(g.Out, g.Config)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- identity
|
||||
|
||||
// WhoAmI describes the credential the client is using.
|
||||
func (c *Client) WhoAmI(ctx context.Context) (api.WhoAmI, error) {
|
||||
var out api.WhoAmI
|
||||
err := c.do(ctx, http.MethodGet, api.PathWhoAmI(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SystemInfo returns server-wide counters. Admin scope only.
|
||||
func (c *Client) SystemInfo(ctx context.Context) (api.SystemInfo, error) {
|
||||
var out api.SystemInfo
|
||||
err := c.do(ctx, http.MethodGet, api.PathSystemInfo(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- projects
|
||||
|
||||
// CreateProject creates a project. Admin scope only.
|
||||
func (c *Client) CreateProject(ctx context.Context, req api.CreateProjectRequest) (api.Project, error) {
|
||||
var out api.Project
|
||||
err := c.do(ctx, http.MethodPost, api.PathProjects(), req, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ListOptions pages a listing endpoint. A zero Limit takes the server default.
|
||||
type ListOptions struct {
|
||||
Limit int
|
||||
Cursor string
|
||||
}
|
||||
|
||||
func (o ListOptions) query() string {
|
||||
q := url.Values{}
|
||||
if o.Limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(o.Limit))
|
||||
}
|
||||
if o.Cursor != "" {
|
||||
q.Set("cursor", o.Cursor)
|
||||
}
|
||||
if len(q) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "?" + q.Encode()
|
||||
}
|
||||
|
||||
// ListProjects returns one page of projects. Admin scope only.
|
||||
func (c *Client) ListProjects(ctx context.Context, opts ListOptions) (api.ProjectList, error) {
|
||||
var out api.ProjectList
|
||||
err := c.do(ctx, http.MethodGet, api.PathProjects()+opts.query(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ListAllProjects follows the cursor to the end.
|
||||
//
|
||||
// The CLI pages on the user's behalf because "pages project list" that silently
|
||||
// showed the first hundred of three hundred projects would be a lie; a caller
|
||||
// that wants one page asks for ListProjects.
|
||||
func (c *Client) ListAllProjects(ctx context.Context) ([]api.Project, error) {
|
||||
var all []api.Project
|
||||
opts := ListOptions{Limit: 500}
|
||||
for {
|
||||
page, err := c.ListProjects(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, page.Projects...)
|
||||
if page.NextCursor == "" || len(page.Projects) == 0 {
|
||||
return all, nil
|
||||
}
|
||||
opts.Cursor = page.NextCursor
|
||||
}
|
||||
}
|
||||
|
||||
// GetProject reads one project. A project-scoped key may read only its own.
|
||||
func (c *Client) GetProject(ctx context.Context, name string) (api.Project, error) {
|
||||
var out api.Project
|
||||
err := c.do(ctx, http.MethodGet, api.PathProject(name), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// PatchProject applies a partial update. Admin scope only.
|
||||
func (c *Client) PatchProject(ctx context.Context, name string, patch api.ProjectPatch) (api.Project, error) {
|
||||
var out api.Project
|
||||
err := c.do(ctx, http.MethodPatch, api.PathProject(name), patch, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// DeleteProject removes a project and everything under it. Admin scope only.
|
||||
func (c *Client) DeleteProject(ctx context.Context, name string) error {
|
||||
return c.do(ctx, http.MethodDelete, api.PathProject(name), nil, nil)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- keys
|
||||
|
||||
// CreateAdminKey mints an admin key. Admin scope only.
|
||||
//
|
||||
// The returned token is the only copy that will ever exist; the caller shows it
|
||||
// once and must not log it.
|
||||
func (c *Client) CreateAdminKey(ctx context.Context, req api.CreateKeyRequest) (api.CreateKeyResponse, error) {
|
||||
var out api.CreateKeyResponse
|
||||
err := c.do(ctx, http.MethodPost, api.PathKeys(), req, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// CreateProjectKey mints a key scoped to one project. Admin scope only.
|
||||
func (c *Client) CreateProjectKey(ctx context.Context, project string, req api.CreateKeyRequest) (api.CreateKeyResponse, error) {
|
||||
var out api.CreateKeyResponse
|
||||
err := c.do(ctx, http.MethodPost, api.PathProjectKeys(project), req, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ListKeys returns every key on the server. Admin scope only.
|
||||
func (c *Client) ListKeys(ctx context.Context) (api.KeyList, error) {
|
||||
var out api.KeyList
|
||||
err := c.do(ctx, http.MethodGet, api.PathKeys(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ListProjectKeys returns the keys of one project.
|
||||
func (c *Client) ListProjectKeys(ctx context.Context, project string) (api.KeyList, error) {
|
||||
var out api.KeyList
|
||||
err := c.do(ctx, http.MethodGet, api.PathProjectKeys(project), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// RevokeKey revokes a key by id. It takes effect immediately, and is
|
||||
// idempotent: revoking an already-revoked key succeeds.
|
||||
func (c *Client) RevokeKey(ctx context.Context, keyID string) error {
|
||||
return c.do(ctx, http.MethodDelete, api.PathKey(keyID), nil, nil)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- deployments
|
||||
|
||||
// DeploymentListOptions selects and pages a project's deployments. A zero State
|
||||
// lists every state.
|
||||
type DeploymentListOptions struct {
|
||||
ListOptions
|
||||
State string
|
||||
}
|
||||
|
||||
func (o DeploymentListOptions) query() string {
|
||||
q := url.Values{}
|
||||
if o.Limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(o.Limit))
|
||||
}
|
||||
if o.Cursor != "" {
|
||||
q.Set("cursor", o.Cursor)
|
||||
}
|
||||
if o.State != "" {
|
||||
q.Set("state", o.State)
|
||||
}
|
||||
if len(q) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "?" + q.Encode()
|
||||
}
|
||||
|
||||
// ListDeployments returns one page of a project's deployments, newest first.
|
||||
func (c *Client) ListDeployments(ctx context.Context, project string, opts DeploymentListOptions) (api.DeploymentList, error) {
|
||||
var out api.DeploymentList
|
||||
err := c.do(ctx, http.MethodGet, api.PathDeployments(project)+opts.query(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetDeployment reads one deployment. withFiles asks for its manifest too,
|
||||
// which for a large site is a great deal more response than the rest of it.
|
||||
func (c *Client) GetDeployment(ctx context.Context, project, id string, withFiles bool) (api.Deployment, error) {
|
||||
var out api.Deployment
|
||||
path := api.PathDeployment(project, id)
|
||||
if withFiles {
|
||||
path += "?files=true"
|
||||
}
|
||||
err := c.do(ctx, http.MethodGet, path, nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// DeleteDeployment removes a deployment and its files. The active one cannot be
|
||||
// deleted; the server answers deployment_active until something else is
|
||||
// activated.
|
||||
func (c *Client) DeleteDeployment(ctx context.Context, project, id string) error {
|
||||
return c.do(ctx, http.MethodDelete, api.PathDeployment(project, id), nil, nil)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- upkeep
|
||||
|
||||
// Collect runs a garbage collection pass. Admin scope only.
|
||||
func (c *Client) Collect(ctx context.Context, dryRun bool) (api.GCStats, error) {
|
||||
var out api.GCStats
|
||||
err := c.do(ctx, http.MethodPost, api.PathGC(), api.GCRequest{DryRun: dryRun}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Fsck checks the blob reference counts against the manifests, optionally
|
||||
// correcting them. Admin scope only.
|
||||
func (c *Client) Fsck(ctx context.Context, repair bool) (api.FsckReport, error) {
|
||||
var out api.FsckReport
|
||||
err := c.do(ctx, http.MethodPost, api.PathFsck(), api.FsckRequest{Repair: repair}, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Package client is the HTTP client for the pages management API.
|
||||
//
|
||||
// It depends on the standard library and github.com/iceBear67/simplepages/api
|
||||
// only — see cmd/pages/deps_test.go, which fails the build if a server-side
|
||||
// package ever reaches the CLI through here.
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/version"
|
||||
)
|
||||
|
||||
// DefaultTimeout bounds a single management request.
|
||||
const DefaultTimeout = 30 * time.Second
|
||||
|
||||
// maxErrorBody caps how much of a non-2xx body is read before giving up on
|
||||
// finding an error envelope in it. A misrouted request can land on something
|
||||
// that answers with a megabyte of HTML.
|
||||
const maxErrorBody = 64 << 10
|
||||
|
||||
// Config configures a Client.
|
||||
type Config struct {
|
||||
// BaseURL is the management API root, e.g. "https://pages.example.com".
|
||||
// Any trailing slash is trimmed.
|
||||
BaseURL string
|
||||
// Token is the bearer credential. It is sent in the Authorization header
|
||||
// and must never appear in a URL, a log line or an error message.
|
||||
Token string
|
||||
// Timeout bounds each request. Zero means DefaultTimeout.
|
||||
Timeout time.Duration
|
||||
// HTTP overrides the underlying client. Its Timeout field is ignored:
|
||||
// deadlines come from the request context so the deploy path can give a
|
||||
// large blob upload longer than a management call.
|
||||
HTTP *http.Client
|
||||
// UserAgent overrides the default "pages/<version>".
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// Client talks to the management API.
|
||||
type Client struct {
|
||||
base string
|
||||
token string
|
||||
timeout time.Duration
|
||||
http *http.Client
|
||||
agent string
|
||||
}
|
||||
|
||||
// New validates cfg and returns a client.
|
||||
func New(cfg Config) (*Client, error) {
|
||||
raw := strings.TrimSpace(cfg.BaseURL)
|
||||
if raw == "" {
|
||||
return nil, errors.New("no server URL: pass --server or set PAGES_SERVER")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid server URL %q: %w", raw, err)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
case "":
|
||||
return nil, fmt.Errorf("invalid server URL %q: missing scheme, try https://%s", raw, raw)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid server URL %q: scheme must be http or https", raw)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid server URL %q: missing host", raw)
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
return nil, errors.New("no token: pass --token-file or set PAGES_TOKEN")
|
||||
}
|
||||
|
||||
hc := cfg.HTTP
|
||||
if hc == nil {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
// The deploy path uploads blobs concurrently to one host.
|
||||
tr.MaxIdleConnsPerHost = 32
|
||||
hc = &http.Client{Transport: tr}
|
||||
}
|
||||
// A redirect is a misconfigured --server, and following it silently would
|
||||
// send the bearer token somewhere the operator did not name. Report it and
|
||||
// let them fix the URL. (Go strips Authorization across hosts anyway, which
|
||||
// would turn the redirect into a confusing 401 instead.)
|
||||
hc.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return fmt.Errorf("server redirected to %s — use that as --server", req.URL.Redacted())
|
||||
}
|
||||
|
||||
agent := cfg.UserAgent
|
||||
if agent == "" {
|
||||
agent = "pages/" + version.Version
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
|
||||
return &Client{
|
||||
base: strings.TrimRight(u.String(), "/"),
|
||||
token: cfg.Token,
|
||||
timeout: timeout,
|
||||
http: hc,
|
||||
agent: agent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BaseURL returns the server root the client was configured with.
|
||||
func (c *Client) BaseURL() string { return c.base }
|
||||
|
||||
// do performs a request with a JSON body and decodes a JSON response.
|
||||
//
|
||||
// body may be nil for a bodyless request; out may be nil to discard the
|
||||
// response (204, or a response the caller does not need).
|
||||
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode request: %w", err)
|
||||
}
|
||||
rdr = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", c.agent)
|
||||
if rdr != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return requestError(method, path, err)
|
||||
}
|
||||
defer func() {
|
||||
// Drain a little so the connection can be reused; a huge unread body is
|
||||
// not worth keeping the connection for.
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return responseError(resp)
|
||||
}
|
||||
if out == nil || resp.StatusCode == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decode %s %s response: %w", method, path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestError describes a transport-level failure. The URL is included; the
|
||||
// token is not, and cannot be — it never leaves the Authorization header.
|
||||
func requestError(method, path string, err error) error {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return fmt.Errorf("%s %s: timed out; raise --timeout if the server is slow", method, path)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
|
||||
// responseError turns a non-2xx response into an *api.Error, so callers can
|
||||
// switch on api.CodeOf and the CLI can print "project_exists: ...".
|
||||
func responseError(resp *http.Response) error {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
|
||||
|
||||
var env api.ErrorEnvelope
|
||||
if err := json.Unmarshal(raw, &env); err == nil && env.Error.Code != "" {
|
||||
return &env.Error
|
||||
}
|
||||
|
||||
// Not an envelope: a proxy, a wrong --server, or a bug. Say what arrived
|
||||
// instead of pretending to a code we did not receive.
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200] + "…"
|
||||
}
|
||||
if msg == "" {
|
||||
msg = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
return &api.Error{Code: codeForStatus(resp.StatusCode), Message: msg}
|
||||
}
|
||||
|
||||
func codeForStatus(status int) api.Code {
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
return api.CodeBadRequest
|
||||
case http.StatusUnauthorized:
|
||||
return api.CodeUnauthorized
|
||||
case http.StatusForbidden:
|
||||
return api.CodeForbidden
|
||||
case http.StatusNotFound:
|
||||
return api.CodeNotFound
|
||||
case http.StatusMethodNotAllowed:
|
||||
return api.CodeMethodNotAllowed
|
||||
case http.StatusConflict:
|
||||
return api.CodeConflict
|
||||
case http.StatusRequestEntityTooLarge:
|
||||
return api.CodePayloadTooLarge
|
||||
case http.StatusTooManyRequests:
|
||||
return api.CodeRateLimited
|
||||
case http.StatusServiceUnavailable:
|
||||
return api.CodeUnavailable
|
||||
default:
|
||||
return api.CodeInternal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
)
|
||||
|
||||
const testToken = "pgs_abcdefghijklmnop_ThisIsTheSecretHalfAndMustNotLeakAnywhere"
|
||||
|
||||
func TestNewValidatesConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
cfg Config
|
||||
wantErr string
|
||||
}{
|
||||
{"no server", Config{Token: testToken}, "no server URL"},
|
||||
{"no token", Config{BaseURL: "https://p.example.com"}, "no token"},
|
||||
{"missing scheme", Config{BaseURL: "p.example.com", Token: testToken}, "missing scheme"},
|
||||
{"wrong scheme", Config{BaseURL: "ftp://p.example.com", Token: testToken}, "scheme must be http or https"},
|
||||
{"missing host", Config{BaseURL: "https://", Token: testToken}, "missing host"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
_, err := New(tc.cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("err = %v, want one containing %q", err, tc.wantErr)
|
||||
}
|
||||
// A rejected URL is echoed back so the operator can see the typo, but
|
||||
// the token must never turn up in a message they might paste anywhere.
|
||||
if err != nil && strings.Contains(err.Error(), testToken) {
|
||||
t.Error("error message contains the token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTrimsTrailingSlash(t *testing.T) {
|
||||
c, err := New(Config{BaseURL: "https://p.example.com/", Token: testToken})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := c.BaseURL(); got != "https://p.example.com" {
|
||||
t.Errorf("BaseURL = %q, want no trailing slash", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestCarriesTokenInHeaderOnly is the wire half of the rule that a
|
||||
// token never reaches a proxy access log: it belongs in Authorization, and
|
||||
// nowhere near the URL.
|
||||
func TestRequestCarriesTokenInHeaderOnly(t *testing.T) {
|
||||
var gotAuth, gotURL, gotAccept, gotAgent string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth, gotURL = r.Header.Get("Authorization"), r.URL.String()
|
||||
gotAccept, gotAgent = r.Header.Get("Accept"), r.Header.Get("User-Agent")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"key_id":"abcdefghijklmnop","scope":"admin"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
who, err := c.WhoAmI(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if who.KeyID != "abcdefghijklmnop" || who.Scope != "admin" {
|
||||
t.Errorf("decoded %+v", who)
|
||||
}
|
||||
if gotAuth != "Bearer "+testToken {
|
||||
t.Errorf("Authorization = %q", gotAuth)
|
||||
}
|
||||
if strings.Contains(gotURL, "pgs_") {
|
||||
t.Errorf("token reached the URL: %q", gotURL)
|
||||
}
|
||||
if gotAccept != "application/json" {
|
||||
t.Errorf("Accept = %q", gotAccept)
|
||||
}
|
||||
if !strings.HasPrefix(gotAgent, "pages/") {
|
||||
t.Errorf("User-Agent = %q", gotAgent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedirectIsRefused: following a redirect would send the bearer token to a
|
||||
// host the operator never named.
|
||||
func TestRedirectIsRefused(t *testing.T) {
|
||||
elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("request followed the redirect and reached %s", r.Host)
|
||||
}))
|
||||
defer elsewhere.Close()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, elsewhere.URL+r.URL.Path, http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
_, err := c.WhoAmI(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "redirected") {
|
||||
t.Errorf("err = %v, want it to explain the redirect", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorResponses(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
status int
|
||||
body string
|
||||
contentType string
|
||||
wantCode api.Code
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
desc: "server envelope is used verbatim",
|
||||
status: http.StatusConflict,
|
||||
body: `{"error":{"code":"project_exists","message":"project demo already exists"}}`,
|
||||
wantCode: "project_exists",
|
||||
wantMsg: "project demo already exists",
|
||||
},
|
||||
{
|
||||
desc: "a bare proxy error still gets a code from the status",
|
||||
status: http.StatusForbidden,
|
||||
body: "<html>403 Forbidden</html>",
|
||||
wantCode: api.CodeForbidden,
|
||||
wantMsg: "<html>403 Forbidden</html>",
|
||||
},
|
||||
{
|
||||
desc: "an empty body falls back to the status text",
|
||||
status: http.StatusBadGateway,
|
||||
body: "",
|
||||
wantCode: api.CodeInternal,
|
||||
wantMsg: "Bad Gateway",
|
||||
},
|
||||
{
|
||||
desc: "an envelope without a code is not an envelope",
|
||||
status: http.StatusNotFound,
|
||||
body: `{"error":{"message":"nope"}}`,
|
||||
wantCode: api.CodeNotFound,
|
||||
wantMsg: `{"error":{"message":"nope"}}`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
w.Write([]byte(tc.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
_, err := c.WhoAmI(context.Background())
|
||||
|
||||
var apiErr *api.Error
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("err = %v (%T), want *api.Error", err, err)
|
||||
}
|
||||
if apiErr.Code != tc.wantCode {
|
||||
t.Errorf("code = %q, want %q", apiErr.Code, tc.wantCode)
|
||||
}
|
||||
if apiErr.Message != tc.wantMsg {
|
||||
t.Errorf("message = %q, want %q", apiErr.Message, tc.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHugeErrorBodyIsTruncated: a wrong --server can point at something that
|
||||
// answers every request with a megabyte of HTML.
|
||||
func TestHugeErrorBodyIsTruncated(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(strings.Repeat("x", 1<<20)))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
_, err := c.WhoAmI(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if len(err.Error()) > 400 {
|
||||
t.Errorf("error message is %d bytes; it should be truncated", len(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoContentNeedsNoBody(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Errorf("method = %s", r.Method)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
if err := c.DeleteProject(context.Background(), "demo"); err != nil {
|
||||
t.Fatalf("DeleteProject: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeoutSaysWhichFlagToRaise — the timeout is the one failure a user can
|
||||
// fix from the message alone, so the message has to name the flag.
|
||||
func TestTimeoutSaysWhichFlagToRaise(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-release
|
||||
}))
|
||||
defer func() { close(release); srv.Close() }()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken, Timeout: 50 * time.Millisecond})
|
||||
_, err := c.WhoAmI(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected a timeout")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--timeout") {
|
||||
t.Errorf("err = %v, want it to mention --timeout", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeploymentQueriesTravelInTheURL. State, limit and cursor are the three
|
||||
// knobs of a deployment listing and all three ride the query string; one that
|
||||
// went missing would leave a listing that quietly answers a different question.
|
||||
func TestDeploymentQueriesTravelInTheURL(t *testing.T) {
|
||||
var got string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = r.URL.RequestURI()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"deployments":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
ctx := context.Background()
|
||||
list := api.PathDeployments("demo")
|
||||
one := api.PathDeployment("demo", "dpl_1")
|
||||
|
||||
cases := []struct {
|
||||
desc string
|
||||
call func() error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"no options, no query",
|
||||
func() error { _, err := c.ListDeployments(ctx, "demo", DeploymentListOptions{}); return err },
|
||||
list,
|
||||
},
|
||||
{
|
||||
"every option set",
|
||||
func() error {
|
||||
opts := DeploymentListOptions{State: "ready"}
|
||||
opts.Limit, opts.Cursor = 50, "dpl_9"
|
||||
_, err := c.ListDeployments(ctx, "demo", opts)
|
||||
return err
|
||||
},
|
||||
list + "?cursor=dpl_9&limit=50&state=ready",
|
||||
},
|
||||
{
|
||||
"a manifest is not fetched by default",
|
||||
func() error { _, err := c.GetDeployment(ctx, "demo", "dpl_1", false); return err },
|
||||
one,
|
||||
},
|
||||
{
|
||||
"asking for the manifest",
|
||||
func() error { _, err := c.GetDeployment(ctx, "demo", "dpl_1", true); return err },
|
||||
one + "?files=true",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
if err := tc.call(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("requested %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpkeepPostsItsFlag: a --dry-run that silently ran for real, or a fsck
|
||||
// that repaired without being asked, are the two ways these calls can be
|
||||
// dangerous, and both live in the request body.
|
||||
func TestUpkeepPostsItsFlag(t *testing.T) {
|
||||
var gotMethod, gotPath, gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotMethod, gotPath, gotBody = r.Method, r.URL.Path, string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := c.Collect(ctx, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost || gotPath != api.PathGC() {
|
||||
t.Errorf("collect sent %s %s", gotMethod, gotPath)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"dry_run":true`) {
|
||||
t.Errorf("collect body = %q, want dry_run set", gotBody)
|
||||
}
|
||||
|
||||
if _, err := c.Fsck(ctx, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost || gotPath != api.PathFsck() {
|
||||
t.Errorf("fsck sent %s %s", gotMethod, gotPath)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"repair":true`) {
|
||||
t.Errorf("fsck body = %q, want repair set", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAllProjectsFollowsTheCursor: paging is invisible to the CLI, so the
|
||||
// only place it can break is here.
|
||||
func TestListAllProjectsFollowsTheCursor(t *testing.T) {
|
||||
var seen []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
seen = append(seen, cursor)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch cursor {
|
||||
case "":
|
||||
w.Write([]byte(`{"projects":[{"name":"a"},{"name":"b"}],"next_cursor":"b"}`))
|
||||
case "b":
|
||||
w.Write([]byte(`{"projects":[{"name":"c"}]}`))
|
||||
default:
|
||||
t.Errorf("unexpected cursor %q", cursor)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, _ := New(Config{BaseURL: srv.URL, Token: testToken})
|
||||
got, err := c.ListAllProjects(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, p := range got {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
if strings.Join(names, ",") != "a,b,c" {
|
||||
t.Errorf("projects = %v, want a,b,c", names)
|
||||
}
|
||||
if len(seen) != 2 || seen[0] != "" || seen[1] != "b" {
|
||||
t.Errorf("cursors requested = %q", seen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------ single calls
|
||||
|
||||
// CreateDeployment opens a deployment. Nothing is served until it is finalized
|
||||
// and activated.
|
||||
func (c *Client) CreateDeployment(ctx context.Context, project string, meta map[string]string) (api.Deployment, error) {
|
||||
var out api.Deployment
|
||||
req := api.CreateDeploymentRequest{Meta: meta}
|
||||
err := c.do(ctx, http.MethodPost, api.PathDeployments(project), req, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SetManifest declares the deployment's complete file list and returns the
|
||||
// digests the server does not have yet.
|
||||
//
|
||||
// A large manifest is both slow to send and slow to insert, so it gets a
|
||||
// deadline scaled to its size rather than the management timeout.
|
||||
func (c *Client) SetManifest(ctx context.Context, project, id string, files []api.FileEntry) (api.ManifestResponse, error) {
|
||||
var out api.ManifestResponse
|
||||
req := api.ManifestRequest{Files: files}
|
||||
// Roughly a millisecond per file on top of the base timeout: a 50,000-file
|
||||
// manifest gets a minute of headroom, a small one gets no extra.
|
||||
extra := time.Duration(len(files)) * time.Millisecond
|
||||
err := c.doWithTimeout(ctx, c.timeout+extra, http.MethodPost,
|
||||
api.PathManifest(project, id), req, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Finalize verifies every blob arrived and assembles the deployment.
|
||||
//
|
||||
// It returns an *api.Error with code blobs_missing when uploads are outstanding;
|
||||
// the digests are in details["missing"].
|
||||
func (c *Client) Finalize(ctx context.Context, project, id string, fileCount int) (api.Deployment, error) {
|
||||
var out api.Deployment
|
||||
// Assembly hardlinks or copies every file, so this scales with the file
|
||||
// count in the same way the manifest insert does.
|
||||
extra := time.Duration(fileCount) * time.Millisecond
|
||||
err := c.doWithTimeout(ctx, c.timeout+extra, http.MethodPost,
|
||||
api.PathFinalize(project, id), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Activate switches the project to this deployment. Passing an older id is how
|
||||
// a rollback is performed.
|
||||
func (c *Client) Activate(ctx context.Context, project, id string) (api.Deployment, error) {
|
||||
var out api.Deployment
|
||||
err := c.do(ctx, http.MethodPost, api.PathActivate(project, id), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// PutBlob uploads one blob's contents.
|
||||
//
|
||||
// It bypasses do, which is JSON-only: the body is raw bytes, the length is
|
||||
// declared up front so the server can refuse an oversized file before reading
|
||||
// it, and the deadline has to accommodate a file rather than an API call.
|
||||
//
|
||||
// A digest the server already holds is a fast 200 — that is what makes a
|
||||
// re-run of a failed deploy cheap.
|
||||
func (c *Client) PutBlob(ctx context.Context, digest string, size int64, body io.Reader) (api.BlobResponse, error) {
|
||||
var out api.BlobResponse
|
||||
path := api.PathBlob(digest)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, uploadTimeout(c.timeout, size))
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.base+path, body)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
req.ContentLength = size
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", c.agent)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return out, requestError(http.MethodPut, path, err)
|
||||
}
|
||||
defer func() {
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return out, wrapRetryable(resp, responseError(resp))
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return out, fmt.Errorf("decode PUT %s response: %w", path, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// uploadTimeout gives a transfer the base timeout plus enough time to move its
|
||||
// bytes over a slow link. The floor is deliberately pessimistic — a CI runner
|
||||
// on a hotel connection should finish, not time out halfway and start over.
|
||||
func uploadTimeout(base time.Duration, size int64) time.Duration {
|
||||
const bytesPerSecond = 128 << 10
|
||||
return base + time.Duration(size/bytesPerSecond)*time.Second
|
||||
}
|
||||
|
||||
// doWithTimeout is do with an explicit deadline instead of the client's.
|
||||
//
|
||||
// do reads c.timeout, and one Client is shared by every upload goroutine, so
|
||||
// the field cannot be swapped in place. The struct is a string, a duration and
|
||||
// two pointers; copying it is cheaper than the synchronisation would be.
|
||||
func (c *Client) doWithTimeout(ctx context.Context, timeout time.Duration, method, path string, body, out any) error {
|
||||
tmp := *c
|
||||
tmp.timeout = timeout
|
||||
return tmp.do(ctx, method, path, body, out)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ retries
|
||||
|
||||
// throttled marks an error the deploy loop should retry, and carries the
|
||||
// server's Retry-After when it sent one.
|
||||
//
|
||||
// The delay rides on a wrapper rather than on api.Error because api.Error is
|
||||
// the wire type: a field that never appears in JSON does not belong in it.
|
||||
// api.CodeOf unwraps, so callers still see the underlying code.
|
||||
type throttled struct {
|
||||
err error
|
||||
after time.Duration
|
||||
}
|
||||
|
||||
func (t *throttled) Error() string { return t.err.Error() }
|
||||
func (t *throttled) Unwrap() error { return t.err }
|
||||
|
||||
// wrapRetryable tags the responses that are worth trying again: rate limiting,
|
||||
// and anything the server reports as a transient failure of its own.
|
||||
func wrapRetryable(resp *http.Response, err error) error {
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusTooManyRequests,
|
||||
resp.StatusCode >= 500:
|
||||
return &throttled{err: err, after: retryAfter(resp)}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// retryAfter reads the header in its delay-seconds form. The HTTP-date form is
|
||||
// ignored on purpose: honouring it means trusting the server's clock against
|
||||
// ours, and the backoff below is a perfectly good fallback.
|
||||
func retryAfter(resp *http.Response) time.Duration {
|
||||
v := strings.TrimSpace(resp.Header.Get("Retry-After"))
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
secs, err := strconv.Atoi(v)
|
||||
if err != nil || secs < 0 {
|
||||
return 0
|
||||
}
|
||||
const maxWait = 60 * time.Second
|
||||
d := time.Duration(secs) * time.Second
|
||||
return min(d, maxWait)
|
||||
}
|
||||
|
||||
// retryable reports whether err is worth another attempt, and how long to wait
|
||||
// before it if the server asked for a specific delay.
|
||||
//
|
||||
// Transport errors are retried because a dropped connection mid-upload is the
|
||||
// single most common failure on a CI runner. A 4xx other than 429 is not: the
|
||||
// request is wrong and repeating it will not fix it.
|
||||
func retryable(err error) (time.Duration, bool) {
|
||||
if err == nil {
|
||||
return 0, false
|
||||
}
|
||||
var t *throttled
|
||||
if errors.As(err, &t) {
|
||||
return t.after, true
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
// A deadline that came from the caller's context means give up; one from
|
||||
// our own per-upload timeout is indistinguishable here, so treat both as
|
||||
// fatal rather than risk a retry storm against a wedged server.
|
||||
return 0, false
|
||||
}
|
||||
var apiErr *api.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
// An envelope arrived, so the server is reachable and answered on
|
||||
// purpose. Only rate limiting is worth repeating.
|
||||
return 0, apiErr.Code == api.CodeRateLimited
|
||||
}
|
||||
// Anything else is a transport failure.
|
||||
return 0, true
|
||||
}
|
||||
|
||||
// backoff is exponential with jitter, capped. The jitter matters when a CI
|
||||
// fleet retries in lockstep after a server restart.
|
||||
func backoff(attempt int, seed uint64) time.Duration {
|
||||
const (
|
||||
base = 250 * time.Millisecond
|
||||
longest = 15 * time.Second
|
||||
)
|
||||
d := min(base<<min(attempt, 6), longest)
|
||||
// Full jitter over [d/2, d), derived from the digest so the goroutines
|
||||
// spread out without needing a shared random source.
|
||||
half := d / 2
|
||||
return half + time.Duration(seed%uint64(half))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- deploy flow
|
||||
|
||||
// DeployOptions drives Deploy.
|
||||
type DeployOptions struct {
|
||||
Project string
|
||||
Source *Source
|
||||
Meta map[string]string
|
||||
// Activate switches the project over once the upload is complete.
|
||||
Activate bool
|
||||
// Concurrency bounds simultaneous blob uploads. Zero means 8.
|
||||
Concurrency int
|
||||
// Retries is the number of additional attempts per blob. Zero means 4.
|
||||
Retries int
|
||||
// Progress receives human-readable step messages. Calls are serialised, so
|
||||
// an implementation needs no locking of its own. It may be nil.
|
||||
Progress func(string)
|
||||
}
|
||||
|
||||
// DeployResult reports what a deploy did.
|
||||
type DeployResult struct {
|
||||
Deployment api.Deployment `json:"deployment"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
// Uploaded counts blobs this run actually sent; Deduplicated counts the ones
|
||||
// the server already had. Their sum is the number of distinct digests.
|
||||
Uploaded int `json:"uploaded"`
|
||||
UploadedBytes int64 `json:"uploaded_bytes"`
|
||||
Deduplicated int `json:"deduplicated"`
|
||||
Activated bool `json:"activated"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// Deploy runs the full sequence: create, negotiate the manifest, upload what is
|
||||
// missing, finalize, and optionally activate.
|
||||
//
|
||||
// Interrupting it is safe and cheap to recover from. Blobs that made it are
|
||||
// already in the content-addressed store, so re-running the same deploy
|
||||
// negotiates a much smaller missing set — resumption falls out of the protocol
|
||||
// rather than needing one of its own.
|
||||
func (c *Client) Deploy(ctx context.Context, opts DeployOptions) (*DeployResult, error) {
|
||||
if opts.Source == nil || len(opts.Source.Files) == 0 {
|
||||
return nil, errors.New("nothing to deploy")
|
||||
}
|
||||
if opts.Concurrency <= 0 {
|
||||
opts.Concurrency = 8
|
||||
}
|
||||
if opts.Retries <= 0 {
|
||||
opts.Retries = 4
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
report := func(format string, args ...any) {
|
||||
if opts.Progress == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
opts.Progress(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
src := opts.Source
|
||||
res := &DeployResult{FileCount: len(src.Files), TotalBytes: src.TotalBytes}
|
||||
|
||||
dep, err := c.CreateDeployment(ctx, opts.Project, opts.Meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Deployment = dep
|
||||
report("created deployment %s", dep.ID)
|
||||
|
||||
man, err := c.SetManifest(ctx, opts.Project, dep.ID, src.Manifest())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Deduplicated = man.Have
|
||||
report("%d files, %s; %s, %s to upload",
|
||||
man.FileCount, humanBytes(man.TotalBytes),
|
||||
plural(len(man.Missing), "new blob"), humanBytes(man.MissingBytes))
|
||||
|
||||
if len(man.Missing) > 0 {
|
||||
up, bytes, err := c.upload(ctx, src, man.Missing, opts, report)
|
||||
res.Uploaded, res.UploadedBytes = up, bytes
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
fin, err := c.Finalize(ctx, opts.Project, dep.ID, len(src.Files))
|
||||
if err != nil {
|
||||
// The server may have lost a blob between our upload and the finalize —
|
||||
// a GC race, or a restart mid-write. It tells us exactly which, so send
|
||||
// those again and finalize once more rather than failing the build.
|
||||
missing, ok := missingFrom(err)
|
||||
if !ok {
|
||||
return nil, err
|
||||
}
|
||||
report("server is missing %s after upload; resending", plural(len(missing), "blob"))
|
||||
up, bytes, uerr := c.upload(ctx, src, missing, opts, report)
|
||||
res.Uploaded += up
|
||||
res.UploadedBytes += bytes
|
||||
if uerr != nil {
|
||||
return nil, uerr
|
||||
}
|
||||
fin, err = c.Finalize(ctx, opts.Project, dep.ID, len(src.Files))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
res.Deployment = fin
|
||||
res.URL = fin.URL
|
||||
report("finalized %s (%d files, %s)", fin.ID, fin.FileCount, humanBytes(fin.TotalBytes))
|
||||
|
||||
if opts.Activate {
|
||||
act, err := c.Activate(ctx, opts.Project, dep.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Deployment = act
|
||||
res.Activated = true
|
||||
if act.URL != "" {
|
||||
res.URL = act.URL
|
||||
}
|
||||
report("activated %s", act.ID)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// upload sends the named digests, at most Concurrency at a time.
|
||||
func (c *Client) upload(ctx context.Context, src *Source, digests []string,
|
||||
opts DeployOptions, report func(string, ...any)) (int, int64, error) {
|
||||
|
||||
// A digest may back several paths; any one of them has the bytes.
|
||||
byDigest := make(map[string]LocalFile, len(src.Files))
|
||||
for _, f := range src.Files {
|
||||
if _, ok := byDigest[f.Digest]; !ok {
|
||||
byDigest[f.Digest] = f
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
count int
|
||||
sent int64
|
||||
)
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(opts.Concurrency)
|
||||
for _, digest := range digests {
|
||||
f, ok := byDigest[digest]
|
||||
if !ok {
|
||||
// The server asked for something we never offered. Failing here beats
|
||||
// finalizing into a deployment that can never become ready.
|
||||
return count, sent, fmt.Errorf("server reported digest %s as missing, "+
|
||||
"but it is not in the manifest", digest)
|
||||
}
|
||||
g.Go(func() error {
|
||||
if err := c.putRetrying(ctx, src, f, opts.Retries, report); err != nil {
|
||||
return err
|
||||
}
|
||||
mu.Lock()
|
||||
count++
|
||||
sent += f.Size
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
err := g.Wait()
|
||||
if err == nil {
|
||||
report("uploaded %s (%s)", plural(count, "blob"), humanBytes(sent))
|
||||
}
|
||||
return count, sent, err
|
||||
}
|
||||
|
||||
// putRetrying uploads one blob, retrying transient failures.
|
||||
//
|
||||
// The body is reopened for every attempt: an io.Reader that has already been
|
||||
// partly consumed cannot be replayed, and a retry that sent the tail of a file
|
||||
// would be rejected as a digest mismatch — correctly, but confusingly.
|
||||
func (c *Client) putRetrying(ctx context.Context, src *Source, f LocalFile,
|
||||
retries int, report func(string, ...any)) error {
|
||||
|
||||
var last error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
if attempt > 0 {
|
||||
wait, _ := retryable(last)
|
||||
if wait == 0 {
|
||||
wait = backoff(attempt-1, seedOf(f.Digest))
|
||||
}
|
||||
report("retrying %s in %s (%v)", shortDigest(f.Digest), wait.Round(time.Millisecond), last)
|
||||
t := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
|
||||
body, err := src.Open(f.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", f.Path, err)
|
||||
}
|
||||
_, err = c.PutBlob(ctx, f.Digest, f.Size, body)
|
||||
body.Close()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
last = err
|
||||
if _, ok := retryable(err); !ok {
|
||||
return fmt.Errorf("upload %s: %w", f.Path, err)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("upload %s: giving up after %d attempts: %w", f.Path, retries+1, last)
|
||||
}
|
||||
|
||||
// missingFrom extracts the digest list from a blobs_missing error.
|
||||
func missingFrom(err error) ([]string, bool) {
|
||||
var apiErr *api.Error
|
||||
if !errors.As(err, &apiErr) || apiErr.Code != api.CodeBlobsMissing {
|
||||
return nil, false
|
||||
}
|
||||
raw, ok := apiErr.Details["missing"].([]any)
|
||||
if !ok || len(raw) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// seedOf derives a per-blob jitter seed from its digest, so retries of
|
||||
// different blobs spread out without a shared random source.
|
||||
func seedOf(digest string) uint64 {
|
||||
var h uint64 = 1469598103934665603 // FNV-1a offset basis
|
||||
for i := 0; i < len(digest); i++ {
|
||||
h ^= uint64(digest[i])
|
||||
h *= 1099511628211
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func shortDigest(d string) string {
|
||||
if len(d) > 12 {
|
||||
return d[:12]
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func plural(n int, noun string) string {
|
||||
if n == 1 {
|
||||
return "1 " + noun
|
||||
}
|
||||
return strconv.Itoa(n) + " " + noun + "s"
|
||||
}
|
||||
|
||||
// humanBytes renders a size the way a person reads it. It lives here rather
|
||||
// than in cliutil because the progress messages are produced by this package.
|
||||
func humanBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return strconv.FormatInt(n, 10) + " B"
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for v := n / unit; v >= unit && exp < 4; v /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTP"[exp])
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/pathutil"
|
||||
)
|
||||
|
||||
// skipDirs are never walked into. A build output directory should not contain
|
||||
// them at all, but "pages deploy ." on a repository root is a mistake people
|
||||
// make once, and uploading a .git directory publishes the whole history.
|
||||
var skipDirs = []string{".git", ".hg", ".svn"}
|
||||
|
||||
// LocalFile is one file of a scanned directory, already hashed.
|
||||
type LocalFile struct {
|
||||
// Path is site-relative and slash-separated: it is what the URL will be.
|
||||
Path string `json:"path"`
|
||||
// Digest is the lowercase hex SHA-256 of the contents.
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ScanOptions filters and paces a scan.
|
||||
type ScanOptions struct {
|
||||
// Include, when non-empty, keeps only files matching at least one pattern.
|
||||
// Exclude drops files matching any pattern, and prunes whole directories.
|
||||
// A pattern is path.Match syntax, tried against the site-relative path and
|
||||
// against the base name, so both "assets/*.map" and "*.map" work.
|
||||
Include []string
|
||||
Exclude []string
|
||||
|
||||
// FollowSymlinks reads through symbolic links instead of refusing them.
|
||||
// Links are still resolved inside the scanned directory, so one pointing at
|
||||
// /etc/passwd fails rather than publishing it.
|
||||
FollowSymlinks bool
|
||||
|
||||
// Concurrency bounds the hashing goroutines. Zero means GOMAXPROCS.
|
||||
Concurrency int
|
||||
}
|
||||
|
||||
// Source is a scanned directory: the manifest it produced, plus the handle the
|
||||
// upload path reads the contents back through.
|
||||
//
|
||||
// Files are read through an os.Root rather than by path, so a symlink swapped
|
||||
// in between the scan and the upload still cannot reach outside the directory
|
||||
// the user named.
|
||||
type Source struct {
|
||||
Dir string
|
||||
Files []LocalFile
|
||||
TotalBytes int64
|
||||
|
||||
root *os.Root
|
||||
}
|
||||
|
||||
// Scan walks dir, hashes what it finds, and returns the result. The caller must
|
||||
// Close the Source.
|
||||
func Scan(ctx context.Context, dir string, opts ScanOptions) (*Source, error) {
|
||||
if err := checkPatterns("include", opts.Include); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkPatterns("exclude", opts.Exclude); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", dir, err)
|
||||
}
|
||||
fi, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return nil, fmt.Errorf("%s is not a directory", dir)
|
||||
}
|
||||
root, err := os.OpenRoot(abs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Source{Dir: abs, root: root}
|
||||
if err := s.walk(ctx, opts); err != nil {
|
||||
root.Close()
|
||||
return nil, err
|
||||
}
|
||||
if len(s.Files) == 0 {
|
||||
root.Close()
|
||||
return nil, fmt.Errorf("%s contains no files to deploy", dir)
|
||||
}
|
||||
if err := s.hash(ctx, opts.Concurrency); err != nil {
|
||||
root.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close releases the directory handle.
|
||||
func (s *Source) Close() error {
|
||||
if s == nil || s.root == nil {
|
||||
return nil
|
||||
}
|
||||
return s.root.Close()
|
||||
}
|
||||
|
||||
// Open reads one of the scanned files.
|
||||
func (s *Source) Open(p string) (*os.File, error) {
|
||||
return s.root.Open(filepath.FromSlash(p))
|
||||
}
|
||||
|
||||
// Manifest renders the scan as the wire form the server expects.
|
||||
func (s *Source) Manifest() []api.FileEntry {
|
||||
out := make([]api.FileEntry, len(s.Files))
|
||||
for i, f := range s.Files {
|
||||
out[i] = api.FileEntry{Path: f.Path, Digest: f.Digest, Size: f.Size}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UniqueBlobs counts distinct digests, which is what the deduplicating upload
|
||||
// actually has to deal with.
|
||||
func (s *Source) UniqueBlobs() int {
|
||||
seen := make(map[string]struct{}, len(s.Files))
|
||||
for _, f := range s.Files {
|
||||
seen[f.Digest] = struct{}{}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// walk collects the paths and sizes. Hashing is a separate pass so it can run
|
||||
// concurrently over a list that is already known to be valid: finding out on
|
||||
// file 40,000 that file 3 has an unusable name would waste the whole scan.
|
||||
func (s *Source) walk(ctx context.Context, opts ScanOptions) error {
|
||||
set := pathutil.NewSet(0)
|
||||
return filepath.WalkDir(s.Dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(s.Dir, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
name := filepath.ToSlash(rel)
|
||||
|
||||
if d.IsDir() {
|
||||
if slices.Contains(skipDirs, d.Name()) || matchAny(opts.Exclude, name) {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WalkDir reports entry types from Lstat, so a symlink arrives as a
|
||||
// symlink and is never silently followed.
|
||||
var size int64
|
||||
switch {
|
||||
case d.Type()&fs.ModeSymlink != 0:
|
||||
if !opts.FollowSymlinks {
|
||||
return fmt.Errorf("%s is a symbolic link; a deployment holds regular files only "+
|
||||
"(pass --follow-symlinks to upload what it points at)", name)
|
||||
}
|
||||
fi, err := s.root.Stat(filepath.FromSlash(name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s points at a %s, not a regular file", name, kindOf(fi.Mode()))
|
||||
}
|
||||
size = fi.Size()
|
||||
case d.Type().IsRegular():
|
||||
fi, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size = fi.Size()
|
||||
default:
|
||||
return fmt.Errorf("%s is a %s; a deployment holds regular files only",
|
||||
name, kindOf(d.Type()))
|
||||
}
|
||||
|
||||
if !keep(opts, name) {
|
||||
return nil
|
||||
}
|
||||
// The same checks the server runs, so a name that could never be stored
|
||||
// is reported here — with the local path in hand — instead of as a
|
||||
// rejected manifest after the walk.
|
||||
if err := set.Add(name); err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
s.Files = append(s.Files, LocalFile{Path: name, Size: size})
|
||||
s.TotalBytes += size
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// hash fills in every digest. It is CPU-bound on small files and IO-bound on
|
||||
// large ones, so it runs at GOMAXPROCS by default.
|
||||
func (s *Source) hash(ctx context.Context, concurrency int) error {
|
||||
if concurrency <= 0 {
|
||||
concurrency = runtime.GOMAXPROCS(0)
|
||||
}
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(concurrency)
|
||||
for i := range s.Files {
|
||||
g.Go(func() error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
f := &s.Files[i]
|
||||
digest, size, err := s.digest(f.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", f.Path, err)
|
||||
}
|
||||
// The file may have been rewritten between the walk and now. The
|
||||
// digest and the size have to describe the same bytes, so take both
|
||||
// from the read that produced the digest.
|
||||
f.Digest, f.Size = digest, size
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Sorted output makes "pages deploy --dry-run" diffable between runs.
|
||||
slices.SortFunc(s.Files, func(a, b LocalFile) int {
|
||||
if a.Path < b.Path {
|
||||
return -1
|
||||
}
|
||||
if a.Path > b.Path {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
s.TotalBytes = 0
|
||||
for _, f := range s.Files {
|
||||
s.TotalBytes += f.Size
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Source) digest(p string) (string, int64, error) {
|
||||
f, err := s.Open(p)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, f)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, nil
|
||||
}
|
||||
|
||||
// keep applies the include/exclude filters to a file.
|
||||
func keep(opts ScanOptions, name string) bool {
|
||||
if len(opts.Include) > 0 && !matchAny(opts.Include, name) {
|
||||
return false
|
||||
}
|
||||
return !matchAny(opts.Exclude, name)
|
||||
}
|
||||
|
||||
// matchAny reports whether name matches a pattern, either as a whole path or by
|
||||
// its base name. Matching the base name too is what makes "--exclude '*.map'"
|
||||
// behave the way everyone expects, since path.Match's "*" does not cross "/".
|
||||
func matchAny(patterns []string, name string) bool {
|
||||
base := path.Base(name)
|
||||
for _, pat := range patterns {
|
||||
if ok, _ := path.Match(pat, name); ok {
|
||||
return true
|
||||
}
|
||||
if ok, _ := path.Match(pat, base); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// checkPatterns rejects malformed globs up front. path.Match reports a bad
|
||||
// pattern only when it is tried, so an unchecked one would silently match
|
||||
// nothing and quietly deploy the wrong file set.
|
||||
func checkPatterns(flag string, patterns []string) error {
|
||||
for _, pat := range patterns {
|
||||
if _, err := path.Match(pat, "x"); err != nil {
|
||||
return fmt.Errorf("--%s %q: %w", flag, pat, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func kindOf(m fs.FileMode) string {
|
||||
switch {
|
||||
case m&fs.ModeDir != 0:
|
||||
return "directory"
|
||||
case m&fs.ModeSymlink != 0:
|
||||
return "symbolic link"
|
||||
case m&fs.ModeDevice != 0:
|
||||
return "device file"
|
||||
case m&fs.ModeNamedPipe != 0:
|
||||
return "named pipe"
|
||||
case m&fs.ModeSocket != 0:
|
||||
return "socket"
|
||||
default:
|
||||
return "special file"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Package cliutil is the CLI's plumbing: a small subcommand tree over the
|
||||
// standard flag package, and table/JSON rendering.
|
||||
//
|
||||
// It is deliberately not a CLI framework, but not for the reason usually
|
||||
// given. Measured on this machine, net/http and crypto/tls alone put the floor
|
||||
// for any Go HTTP client at 5.4 MB stripped; the pages binary is 6.0 MB, and
|
||||
// the same program written with cobra came out at 5.9 MB. Half a megabyte
|
||||
// either way is noise next to the TLS stack, so "cobra is too big" would be a
|
||||
// claim the numbers do not support.
|
||||
//
|
||||
// The actual reason is dependency surface. This tool has a fixed set of about
|
||||
// a dozen commands whose flags are plain strings, bools and ints; it needs no
|
||||
// shell completion, no generated man pages, no dynamic command registration.
|
||||
// The tree below is the part of a framework it would use, and it is small
|
||||
// enough to read in one sitting — which matters more for a binary that CI jobs
|
||||
// download and run with a deployment credential in the environment.
|
||||
package cliutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
// ErrUsage is returned when the command line does not name something runnable.
|
||||
// The caller prints the usage text that accompanies it and exits non-zero.
|
||||
var ErrUsage = errors.New("usage")
|
||||
|
||||
// Command is one node of the command tree. A node either runs (Exec) or has
|
||||
// children (Sub), never both.
|
||||
type Command struct {
|
||||
// Name is the single word that selects this command.
|
||||
Name string
|
||||
// Args describes the positional arguments for the usage line, e.g. "<name>".
|
||||
Args string
|
||||
// Short is the one-line summary listed by the parent.
|
||||
Short string
|
||||
// Long is optional additional prose printed by --help.
|
||||
Long string
|
||||
|
||||
// Flags registers this command's flags. It runs once per invocation, before
|
||||
// parsing, so the variables it binds are the ones Exec reads.
|
||||
Flags func(fs *flag.FlagSet)
|
||||
|
||||
// Exec runs the command with the positional arguments left after parsing.
|
||||
Exec func(ctx context.Context, args []string) error
|
||||
|
||||
// Sub are the child commands, if any.
|
||||
Sub []*Command
|
||||
|
||||
// parent is filled in during dispatch so usage text can print the full path.
|
||||
parent *Command
|
||||
}
|
||||
|
||||
// Persistent registers flags that every command in the tree accepts.
|
||||
//
|
||||
// The values it binds must already hold whatever an outer parse produced, and
|
||||
// it must register them with those values as the defaults — see Globals.Register
|
||||
// in internal/clicmd. Registering with a fixed default instead would reset a
|
||||
// flag given before the subcommand name ("pages --server X project list"),
|
||||
// because flag.StringVar assigns the default at registration time.
|
||||
type Persistent func(fs *flag.FlagSet)
|
||||
|
||||
// Run parses args against c and dispatches. out receives usage and help text.
|
||||
func Run(ctx context.Context, c *Command, args []string, out io.Writer, persistent Persistent) error {
|
||||
fs := flag.NewFlagSet(c.path(), flag.ContinueOnError)
|
||||
fs.SetOutput(out)
|
||||
fs.Usage = func() { c.printUsage(out, fs) }
|
||||
if persistent != nil {
|
||||
persistent(fs)
|
||||
}
|
||||
if c.Flags != nil {
|
||||
c.Flags(fs)
|
||||
}
|
||||
if len(c.Sub) == 0 {
|
||||
args = permute(fs, args)
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
// flag has already printed the usage via fs.Usage.
|
||||
return nil
|
||||
}
|
||||
// flag printed the message; adding our own would double it.
|
||||
return ErrUsage
|
||||
}
|
||||
rest := fs.Args()
|
||||
|
||||
if len(c.Sub) > 0 && len(rest) > 0 {
|
||||
for _, sub := range c.Sub {
|
||||
if sub.Name == rest[0] {
|
||||
sub.parent = c
|
||||
return Run(ctx, sub, rest[1:], out, persistent)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(out, "unknown command %q\n\n", rest[0])
|
||||
c.printUsage(out, fs)
|
||||
return ErrUsage
|
||||
}
|
||||
|
||||
// A leaf, or a parent invoked without naming a child. The latter still gets
|
||||
// its Exec so the root can answer --version before falling back to usage.
|
||||
if c.Exec == nil {
|
||||
c.printUsage(out, fs)
|
||||
return ErrUsage
|
||||
}
|
||||
err := c.Exec(ctx, rest)
|
||||
if errors.Is(err, ErrUsage) {
|
||||
c.printUsage(out, fs)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// permute moves flags ahead of positional arguments, so that
|
||||
//
|
||||
// pages project create demo --spa
|
||||
//
|
||||
// works as well as the order the flag package wants:
|
||||
//
|
||||
// pages project create --spa demo
|
||||
//
|
||||
// Stopping at the first non-flag argument is right for a command that has
|
||||
// children — the first positional there names the child, and its flags belong
|
||||
// to it, not to us. A leaf has no such ambiguity, so it gets the permuting
|
||||
// parse people expect from every other CLI they use.
|
||||
//
|
||||
// Two tokens are left exactly where they are: "--" terminates flags for good,
|
||||
// and an unrecognised flag is passed through alone so that flag.Parse reports
|
||||
// it rather than this function silently swallowing the argument after it.
|
||||
func permute(fs *flag.FlagSet, args []string) []string {
|
||||
known := map[string]*flag.Flag{}
|
||||
fs.VisitAll(func(f *flag.Flag) { known[f.Name] = f })
|
||||
|
||||
var flags, rest []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "--" {
|
||||
rest = append(rest, args[i+1:]...)
|
||||
break
|
||||
}
|
||||
// "-" alone is the conventional name for stdin, not a flag.
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
rest = append(rest, a)
|
||||
continue
|
||||
}
|
||||
flags = append(flags, a)
|
||||
name := strings.TrimLeft(a, "-")
|
||||
if strings.ContainsRune(name, '=') {
|
||||
continue // --name=value carries its own argument
|
||||
}
|
||||
// A non-boolean flag takes the next token with it. Booleans must not
|
||||
// swallow anything: "--spa demo" is a flag and a positional.
|
||||
if f, ok := known[name]; ok && !isBoolFlag(f) && i+1 < len(args) {
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
}
|
||||
// The separator makes the tail positional even if a value there looks like
|
||||
// a flag, which matters for paths and metadata values.
|
||||
return append(flags, append([]string{"--"}, rest...)...)
|
||||
}
|
||||
|
||||
func isBoolFlag(f *flag.Flag) bool {
|
||||
b, ok := f.Value.(interface{ IsBoolFlag() bool })
|
||||
return ok && b.IsBoolFlag()
|
||||
}
|
||||
|
||||
// UsageErrorf returns an error that makes Run print the command's usage after
|
||||
// the caller reports the message. Use it for "wrong number of arguments" and
|
||||
// friends, where the fix is visible in the usage text.
|
||||
func UsageErrorf(format string, args ...any) error {
|
||||
return &usageError{msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
type usageError struct{ msg string }
|
||||
|
||||
func (e *usageError) Error() string { return e.msg }
|
||||
|
||||
// Is makes errors.Is(err, ErrUsage) true for any usageError.
|
||||
func (e *usageError) Is(target error) bool { return target == ErrUsage }
|
||||
|
||||
// path is the space-separated command path, used in usage text and as the
|
||||
// FlagSet name so flag's own error messages name the right command.
|
||||
func (c *Command) path() string {
|
||||
if c.parent == nil {
|
||||
return c.Name
|
||||
}
|
||||
return c.parent.path() + " " + c.Name
|
||||
}
|
||||
|
||||
func (c *Command) printUsage(out io.Writer, fs *flag.FlagSet) {
|
||||
if c.Short != "" {
|
||||
fmt.Fprintf(out, "%s — %s\n\n", c.path(), c.Short)
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "Usage:\n %s", c.path())
|
||||
if len(c.Sub) > 0 {
|
||||
fmt.Fprint(out, " <command>")
|
||||
}
|
||||
if hasFlags(fs) {
|
||||
fmt.Fprint(out, " [flags]")
|
||||
}
|
||||
if c.Args != "" {
|
||||
fmt.Fprintf(out, " %s", c.Args)
|
||||
}
|
||||
fmt.Fprint(out, "\n")
|
||||
|
||||
if len(c.Sub) > 0 {
|
||||
fmt.Fprint(out, "\nCommands:\n")
|
||||
tw := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0)
|
||||
subs := append([]*Command(nil), c.Sub...)
|
||||
sort.Slice(subs, func(i, j int) bool { return subs[i].Name < subs[j].Name })
|
||||
for _, sub := range subs {
|
||||
fmt.Fprintf(tw, " %s\t%s\n", sub.Name, sub.Short)
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
if hasFlags(fs) {
|
||||
fmt.Fprint(out, "\nFlags:\n")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
|
||||
if c.Long != "" {
|
||||
fmt.Fprintf(out, "\n%s\n", strings.TrimSpace(c.Long))
|
||||
}
|
||||
}
|
||||
|
||||
func hasFlags(fs *flag.FlagSet) bool {
|
||||
n := 0
|
||||
fs.VisitAll(func(*flag.Flag) { n++ })
|
||||
return n > 0
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package cliutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// optBool is the minimal stand-in for clicmd's optional flags: a flag.Value
|
||||
// that announces it takes no argument.
|
||||
type optBool struct{ v bool }
|
||||
|
||||
func (o *optBool) String() string { return "" }
|
||||
func (o *optBool) Set(string) error { o.v = true; return nil }
|
||||
func (o *optBool) IsBoolFlag() bool { return true }
|
||||
|
||||
func TestPermute(t *testing.T) {
|
||||
newFS := func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
fs.String("name", "", "")
|
||||
fs.Bool("yes", false, "")
|
||||
fs.Var(&optBool{}, "spa", "")
|
||||
return fs
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
desc string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
desc: "flags after the positional, which is what people type",
|
||||
in: []string{"demo", "--name", "Demo", "--spa"},
|
||||
want: []string{"--name", "Demo", "--spa", "--", "demo"},
|
||||
},
|
||||
{
|
||||
desc: "already in flag package order, unchanged apart from the separator",
|
||||
in: []string{"--name", "Demo", "demo"},
|
||||
want: []string{"--name", "Demo", "--", "demo"},
|
||||
},
|
||||
{
|
||||
desc: "a bool flag must not swallow the positional that follows it",
|
||||
in: []string{"--yes", "demo"},
|
||||
want: []string{"--yes", "--", "demo"},
|
||||
},
|
||||
{
|
||||
desc: "single-dash spelling is the same flag",
|
||||
in: []string{"demo", "-name", "Demo"},
|
||||
want: []string{"-name", "Demo", "--", "demo"},
|
||||
},
|
||||
{
|
||||
desc: "--flag=value carries its own argument",
|
||||
in: []string{"demo", "--name=Demo", "other"},
|
||||
want: []string{"--name=Demo", "--", "demo", "other"},
|
||||
},
|
||||
{
|
||||
desc: "a value that looks like a flag is still the flag's value",
|
||||
in: []string{"demo", "--name", "-weird"},
|
||||
want: []string{"--name", "-weird", "--", "demo"},
|
||||
},
|
||||
{
|
||||
desc: "everything after -- stays positional",
|
||||
in: []string{"--yes", "--", "--name", "demo"},
|
||||
want: []string{"--yes", "--", "--name", "demo"},
|
||||
},
|
||||
{
|
||||
desc: "a lone dash is a positional, not a flag",
|
||||
in: []string{"-"},
|
||||
want: []string{"--", "-"},
|
||||
},
|
||||
{
|
||||
desc: "an unknown flag is passed through alone so flag.Parse reports it",
|
||||
in: []string{"--nope", "demo"},
|
||||
want: []string{"--nope", "--", "demo"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := permute(newFS(), tc.in)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("permute(%q)\n got %q\nwant %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermutedFlagsReachExec is the property the permutation exists for: the
|
||||
// command sees the same flags and the same positionals either way round.
|
||||
func TestPermutedFlagsReachExec(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"create", "demo", "--name", "Demo", "--spa"},
|
||||
{"create", "--name", "Demo", "--spa", "demo"},
|
||||
{"create", "--name", "Demo", "demo", "--spa"},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
var (
|
||||
name string
|
||||
spa optBool
|
||||
rest []string
|
||||
)
|
||||
root := &Command{
|
||||
Name: "test",
|
||||
Sub: []*Command{{
|
||||
Name: "create",
|
||||
Flags: func(fs *flag.FlagSet) {
|
||||
fs.StringVar(&name, "name", "", "")
|
||||
fs.Var(&spa, "spa", "")
|
||||
},
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
rest = args
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
}
|
||||
if err := Run(context.Background(), root, args, io.Discard, nil); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if name != "Demo" || !spa.v {
|
||||
t.Errorf("flags: name=%q spa=%v, want Demo/true", name, spa.v)
|
||||
}
|
||||
if !reflect.DeepEqual(rest, []string{"demo"}) {
|
||||
t.Errorf("positionals: %q, want [demo]", rest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParentDoesNotPermute guards the other half of the rule: a parent must
|
||||
// leave a child's flags alone, or "pages project create --spa" would try to
|
||||
// parse --spa against the project command and fail.
|
||||
func TestParentDoesNotPermute(t *testing.T) {
|
||||
var spa bool
|
||||
root := &Command{
|
||||
Name: "test",
|
||||
Sub: []*Command{{
|
||||
Name: "project",
|
||||
Sub: []*Command{{
|
||||
Name: "create",
|
||||
Flags: func(fs *flag.FlagSet) { fs.BoolVar(&spa, "spa", false, "") },
|
||||
Exec: func(context.Context, []string) error { return nil },
|
||||
}},
|
||||
}},
|
||||
}
|
||||
if err := Run(context.Background(), root, []string{"project", "create", "--spa"}, io.Discard, nil); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !spa {
|
||||
t.Error("--spa did not reach the leaf command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageErrors(t *testing.T) {
|
||||
root := &Command{
|
||||
Name: "test",
|
||||
Sub: []*Command{{
|
||||
Name: "leaf",
|
||||
Exec: func(context.Context, []string) error { return UsageErrorf("expected %d args", 1) },
|
||||
}},
|
||||
}
|
||||
|
||||
t.Run("unknown command", func(t *testing.T) {
|
||||
var out strings.Builder
|
||||
err := Run(context.Background(), root, []string{"nope"}, &out, nil)
|
||||
if !errors.Is(err, ErrUsage) {
|
||||
t.Fatalf("err = %v, want ErrUsage", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `unknown command "nope"`) {
|
||||
t.Errorf("output did not name the unknown command:\n%s", out.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a parent with no child named is usage, not a crash", func(t *testing.T) {
|
||||
err := Run(context.Background(), root, nil, io.Discard, nil)
|
||||
if !errors.Is(err, ErrUsage) {
|
||||
t.Fatalf("err = %v, want ErrUsage", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UsageErrorf prints the command's usage and keeps its message", func(t *testing.T) {
|
||||
var out strings.Builder
|
||||
err := Run(context.Background(), root, []string{"leaf"}, &out, nil)
|
||||
if !errors.Is(err, ErrUsage) {
|
||||
t.Fatalf("err = %v, want ErrUsage", err)
|
||||
}
|
||||
if err.Error() != "expected 1 args" {
|
||||
t.Errorf("message = %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(out.String(), "Usage:\n test leaf") {
|
||||
t.Errorf("usage text missing or misnamed:\n%s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package cliutil
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParseDuration accepts Go's duration syntax plus the day, week and year
|
||||
// suffixes an operator actually types for a key lifetime ("90d", "1y").
|
||||
//
|
||||
// A year is 365 days and a day is 24 hours: no calendar arithmetic, no time
|
||||
// zones, no leap seconds. The value becomes an absolute expiry timestamp on the
|
||||
// client precisely so a difference of a few hours cannot matter.
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, errors.New("empty duration")
|
||||
}
|
||||
var mult time.Duration
|
||||
switch s[len(s)-1] {
|
||||
case 'd':
|
||||
mult = 24 * time.Hour
|
||||
case 'w':
|
||||
mult = 7 * 24 * time.Hour
|
||||
case 'y':
|
||||
mult = 365 * 24 * time.Hour
|
||||
default:
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q: use 30s, 5m, 12h, 90d, 2w or 1y", s)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
n, err := strconv.ParseFloat(s[:len(s)-1], 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q: use 30s, 5m, 12h, 90d, 2w or 1y", s)
|
||||
}
|
||||
return time.Duration(n * float64(mult)), nil
|
||||
}
|
||||
|
||||
// ParseBytes accepts a plain byte count or one with a unit suffix: "1048576",
|
||||
// "1MiB", "256M", "1.5G".
|
||||
//
|
||||
// K, M, G and T all mean the binary multiple, whether or not the suffix is
|
||||
// spelled with an "i". Disk-quota flags are set to round binary numbers, and a
|
||||
// tool that quietly read "256MB" as 256,000,000 would produce a limit its user
|
||||
// did not ask for.
|
||||
func ParseBytes(s string) (int64, error) {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" {
|
||||
return 0, errors.New("empty size")
|
||||
}
|
||||
digits := strings.TrimRight(t, "bBiIkKmMgGtT")
|
||||
// Uppercase before trimming so "MiB", "MIB" and "mib" all reduce to "M".
|
||||
unit := strings.ToUpper(t[len(digits):])
|
||||
unit = strings.TrimSuffix(unit, "B")
|
||||
unit = strings.TrimSuffix(unit, "I")
|
||||
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(digits), 64)
|
||||
if err != nil || n < 0 {
|
||||
return 0, fmt.Errorf("invalid size %q: use a byte count or 256MiB, 2GiB", s)
|
||||
}
|
||||
var mult float64 = 1
|
||||
switch unit {
|
||||
case "":
|
||||
case "K":
|
||||
mult = 1 << 10
|
||||
case "M":
|
||||
mult = 1 << 20
|
||||
case "G":
|
||||
mult = 1 << 30
|
||||
case "T":
|
||||
mult = 1 << 40
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid size %q: unknown unit %q", s, unit)
|
||||
}
|
||||
return int64(n * mult), nil
|
||||
}
|
||||
|
||||
// KeyValue splits a "k=v" argument. The value may contain further '=' signs.
|
||||
func KeyValue(s string) (key, value string, err error) {
|
||||
k, v, ok := strings.Cut(s, "=")
|
||||
if !ok || k == "" {
|
||||
return "", "", fmt.Errorf("expected key=value, got %q", s)
|
||||
}
|
||||
return k, v, nil
|
||||
}
|
||||
|
||||
// ErrAborted is returned when the user declines a confirmation prompt.
|
||||
var ErrAborted = errors.New("aborted")
|
||||
|
||||
// Confirm asks for a yes before a destructive operation.
|
||||
//
|
||||
// It refuses rather than prompts when stdin is not a terminal: a pipeline that
|
||||
// blocks forever on a prompt nobody can see is worse than one that fails and
|
||||
// tells the operator to pass --yes.
|
||||
func Confirm(in io.Reader, out io.Writer, prompt string) error {
|
||||
if f, ok := in.(*os.File); ok && !isTerminal(f) {
|
||||
return fmt.Errorf("%w: refusing to prompt for confirmation with no terminal; pass --yes", ErrAborted)
|
||||
}
|
||||
fmt.Fprintf(out, "%s [y/N]: ", prompt)
|
||||
line, err := bufio.NewReader(in).ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return ErrAborted
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes":
|
||||
return nil
|
||||
}
|
||||
return ErrAborted
|
||||
}
|
||||
|
||||
// isTerminal reports whether f is a character device. This is the cheap
|
||||
// stdlib-only approximation of a tty check; it is used to decide whether to
|
||||
// prompt and whether to draw progress, never for anything security-relevant.
|
||||
func isTerminal(f *os.File) bool {
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// IsTerminal reports whether f is attached to a terminal.
|
||||
func IsTerminal(f *os.File) bool { return isTerminal(f) }
|
||||
@@ -0,0 +1,169 @@
|
||||
package cliutil
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The Opt* types are flag.Value implementations that remember whether they were
|
||||
// given on the command line.
|
||||
//
|
||||
// They exist for PATCH: api.ProjectPatch uses a pointer per field so the server
|
||||
// can tell "leave this alone" from "set it to the zero value". A plain
|
||||
// fs.StringVar cannot express that difference — an unset --not-found-file and
|
||||
// an explicit --not-found-file="" both arrive as "". Each type's Ptr method
|
||||
// produces exactly the pointer the patch field wants.
|
||||
|
||||
// OptString is an optional string flag.
|
||||
type OptString struct {
|
||||
Val string
|
||||
Present bool
|
||||
}
|
||||
|
||||
func (o *OptString) String() string {
|
||||
if o == nil {
|
||||
return ""
|
||||
}
|
||||
return o.Val
|
||||
}
|
||||
|
||||
func (o *OptString) Set(s string) error {
|
||||
o.Val, o.Present = s, true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ptr returns nil unless the flag was given.
|
||||
func (o *OptString) Ptr() *string {
|
||||
if !o.Present {
|
||||
return nil
|
||||
}
|
||||
return &o.Val
|
||||
}
|
||||
|
||||
// OptBool is an optional boolean flag. It may be written as --flag as well as
|
||||
// --flag=false.
|
||||
type OptBool struct {
|
||||
Val bool
|
||||
Present bool
|
||||
}
|
||||
|
||||
func (o *OptBool) String() string {
|
||||
if o == nil {
|
||||
return "false"
|
||||
}
|
||||
return strconv.FormatBool(o.Val)
|
||||
}
|
||||
|
||||
func (o *OptBool) Set(s string) error {
|
||||
v, err := strconv.ParseBool(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.Val, o.Present = v, true
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsBoolFlag lets flag accept "--spa" without an explicit value.
|
||||
func (o *OptBool) IsBoolFlag() bool { return true }
|
||||
|
||||
// Ptr returns nil unless the flag was given.
|
||||
func (o *OptBool) Ptr() *bool {
|
||||
if !o.Present {
|
||||
return nil
|
||||
}
|
||||
return &o.Val
|
||||
}
|
||||
|
||||
// OptInt is an optional integer flag.
|
||||
type OptInt struct {
|
||||
Val int
|
||||
Present bool
|
||||
}
|
||||
|
||||
func (o *OptInt) String() string {
|
||||
if o == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(o.Val)
|
||||
}
|
||||
|
||||
func (o *OptInt) Set(s string) error {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.Val, o.Present = v, true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ptr returns nil unless the flag was given.
|
||||
func (o *OptInt) Ptr() *int {
|
||||
if !o.Present {
|
||||
return nil
|
||||
}
|
||||
return &o.Val
|
||||
}
|
||||
|
||||
// OptBytes is an optional byte count, written as a plain number or with a unit
|
||||
// suffix ("256MiB").
|
||||
type OptBytes struct {
|
||||
Val int64
|
||||
Present bool
|
||||
}
|
||||
|
||||
func (o *OptBytes) String() string {
|
||||
if o == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(o.Val, 10)
|
||||
}
|
||||
|
||||
func (o *OptBytes) Set(s string) error {
|
||||
v, err := ParseBytes(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.Val, o.Present = v, true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ptr returns nil unless the flag was given.
|
||||
func (o *OptBytes) Ptr() *int64 {
|
||||
if !o.Present {
|
||||
return nil
|
||||
}
|
||||
return &o.Val
|
||||
}
|
||||
|
||||
// OptDuration is an optional duration, accepting the same suffixes as
|
||||
// ParseDuration.
|
||||
type OptDuration struct {
|
||||
Val time.Duration
|
||||
Present bool
|
||||
}
|
||||
|
||||
func (o *OptDuration) String() string {
|
||||
if o == nil {
|
||||
return ""
|
||||
}
|
||||
return o.Val.String()
|
||||
}
|
||||
|
||||
func (o *OptDuration) Set(s string) error {
|
||||
v, err := ParseDuration(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.Val, o.Present = v, true
|
||||
return nil
|
||||
}
|
||||
|
||||
// SecondsPtr returns the duration in whole seconds, or nil if the flag was not
|
||||
// given. The API expresses grace periods as an integer number of seconds.
|
||||
func (o *OptDuration) SecondsPtr() *int {
|
||||
if !o.Present {
|
||||
return nil
|
||||
}
|
||||
s := int(o.Val / time.Second)
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package cliutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Output formats.
|
||||
const (
|
||||
FormatTable = "table"
|
||||
FormatJSON = "json"
|
||||
)
|
||||
|
||||
// ValidFormat reports whether s names an output format.
|
||||
func ValidFormat(s string) bool { return s == FormatTable || s == FormatJSON }
|
||||
|
||||
// Printer writes command output in the format the user asked for.
|
||||
type Printer struct {
|
||||
Out io.Writer
|
||||
Format string
|
||||
}
|
||||
|
||||
// Print renders v. In table format it calls table to build the rendering; a nil
|
||||
// table means the value has no tabular form and JSON is used regardless.
|
||||
func (p *Printer) Print(v any, table func() *Table) error {
|
||||
if p.Format == FormatJSON || table == nil {
|
||||
return p.JSON(v)
|
||||
}
|
||||
return table().Write(p.Out)
|
||||
}
|
||||
|
||||
// JSON writes v as indented JSON with a trailing newline.
|
||||
func (p *Printer) JSON(v any) error {
|
||||
enc := json.NewEncoder(p.Out)
|
||||
enc.SetIndent("", " ")
|
||||
// The output is read by humans and by jq, neither of which wants &, < and >
|
||||
// spelled as & and friends.
|
||||
enc.SetEscapeHTML(false)
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
// Printf writes a human-readable line, and nothing at all in JSON format —
|
||||
// progress chatter must never end up in a stream something is parsing.
|
||||
func (p *Printer) Printf(format string, args ...any) {
|
||||
if p.Format == FormatJSON {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(p.Out, format, args...)
|
||||
}
|
||||
|
||||
// Table is a column-aligned rendering built up row by row.
|
||||
type Table struct {
|
||||
header []string
|
||||
rows [][]string
|
||||
}
|
||||
|
||||
// NewTable starts a table with the given column headings.
|
||||
func NewTable(header ...string) *Table { return &Table{header: header} }
|
||||
|
||||
// Row appends a row. Cells beyond the header count are kept: a ragged table is
|
||||
// a formatting annoyance, not a reason to drop data.
|
||||
func (t *Table) Row(cells ...string) { t.rows = append(t.rows, cells) }
|
||||
|
||||
// Len reports how many rows have been added.
|
||||
func (t *Table) Len() int { return len(t.rows) }
|
||||
|
||||
// Write renders the table. An empty table prints its header only, so a caller
|
||||
// can tell "no rows" from "the command did nothing".
|
||||
func (t *Table) Write(w io.Writer) error {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
if len(t.header) > 0 {
|
||||
fmt.Fprintln(tw, strings.Join(t.header, "\t"))
|
||||
}
|
||||
for _, row := range t.rows {
|
||||
fmt.Fprintln(tw, strings.Join(row, "\t"))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ cell helpers
|
||||
|
||||
// Dash is what an empty cell shows, so a missing value is visibly missing
|
||||
// rather than looking like a column-alignment mistake.
|
||||
const Dash = "-"
|
||||
|
||||
// Str renders a string cell, showing Dash when empty.
|
||||
func Str(s string) string {
|
||||
if s == "" {
|
||||
return Dash
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Bool renders a boolean cell.
|
||||
func Bool(b bool) string {
|
||||
if b {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
// Plural renders a count with its noun, adding "s" for anything but one.
|
||||
// Only regular nouns are pluralized correctly, which is all this CLI has.
|
||||
func Plural(n int, noun string) string {
|
||||
if n == 1 {
|
||||
return "1 " + noun
|
||||
}
|
||||
return strconv.Itoa(n) + " " + noun + "s"
|
||||
}
|
||||
|
||||
// Time renders an absolute local timestamp to the second. Absolute rather than
|
||||
// relative: these values go into CI logs that are read days later, where "2
|
||||
// hours ago" has lost its reference point.
|
||||
func Time(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return Dash
|
||||
}
|
||||
return t.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// TimePtr renders an optional timestamp.
|
||||
func TimePtr(t *time.Time) string {
|
||||
if t == nil {
|
||||
return Dash
|
||||
}
|
||||
return Time(*t)
|
||||
}
|
||||
|
||||
// Bytes renders a byte count in binary units, as a human reads it.
|
||||
func Bytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return strconv.FormatInt(n, 10) + " B"
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n/div >= unit && exp < 4 {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTP"[exp])
|
||||
}
|
||||
|
||||
// Truncate shortens s to at most max runes, marking the cut with an ellipsis so
|
||||
// a clipped value is never mistaken for a complete one.
|
||||
func Truncate(s string, max int) string {
|
||||
if max <= 1 {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Package config defines the pages-server configuration: its shape, defaults,
|
||||
// and validation. Loading (and the flag > env > file > default precedence) lives
|
||||
// in load.go.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProjectNamePattern constrains project names. It is deliberately strict: the
|
||||
// name becomes a "~name" entry inside $WEBROOT, so anything that could contain
|
||||
// a path separator, a "..", or a leading dot must be impossible by construction.
|
||||
var ProjectNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
|
||||
|
||||
// AssembleMode selects how deployment directories are built from the CAS.
|
||||
type AssembleMode string
|
||||
|
||||
const (
|
||||
// AssembleAuto probes for hardlink support at startup and falls back to copy.
|
||||
AssembleAuto AssembleMode = "auto"
|
||||
// AssembleHardlink requires hardlinks and fails loudly if unavailable.
|
||||
AssembleHardlink AssembleMode = "hardlink"
|
||||
// AssembleCopy always copies. Correct but uses disk proportional to content.
|
||||
AssembleCopy AssembleMode = "copy"
|
||||
// AssembleNone skips on-disk assembly entirely; content is served straight
|
||||
// from the CAS and $WEBROOT symlinks are not maintained.
|
||||
AssembleNone AssembleMode = "none"
|
||||
)
|
||||
|
||||
func (m AssembleMode) valid() bool {
|
||||
switch m {
|
||||
case AssembleAuto, AssembleHardlink, AssembleCopy, AssembleNone:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Duration wraps time.Duration so it can be written as "15m" in TOML.
|
||||
type Duration time.Duration
|
||||
|
||||
var _ encoding.TextUnmarshaler = (*Duration)(nil)
|
||||
|
||||
func (d *Duration) UnmarshalText(text []byte) error {
|
||||
v, err := time.ParseDuration(string(text))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set implements flag.Value, so the same type backs both the TOML field and the
|
||||
// command-line flag.
|
||||
func (d *Duration) Set(s string) error { return d.UnmarshalText([]byte(s)) }
|
||||
|
||||
func (d Duration) MarshalText() ([]byte, error) { return []byte(d.String()), nil }
|
||||
func (d Duration) String() string { return time.Duration(d).String() }
|
||||
func (d Duration) D() time.Duration { return time.Duration(d) }
|
||||
|
||||
// Limits bounds what a single deployment may push. Per-project overrides live
|
||||
// in the projects table; these are the server-wide ceilings.
|
||||
type Limits struct {
|
||||
MaxFileBytes int64 `toml:"max_file_bytes"`
|
||||
MaxManifestFiles int `toml:"max_manifest_files"`
|
||||
MaxConcurrentUploads int `toml:"max_concurrent_uploads"`
|
||||
MaxManifestBytes int64 `toml:"max_manifest_bytes"`
|
||||
MaxJSONBytes int64 `toml:"max_json_bytes"`
|
||||
}
|
||||
|
||||
// Config is the fully resolved server configuration.
|
||||
type Config struct {
|
||||
DataDir string `toml:"data_dir"`
|
||||
Webroot string `toml:"webroot"`
|
||||
|
||||
Listen string `toml:"listen"` // public static-content listener
|
||||
APIListen string `toml:"api_listen"` // management API listener
|
||||
|
||||
// SiteURL is the public origin the static listener is reachable at, as seen
|
||||
// from outside — behind a reverse proxy that is a name the server itself
|
||||
// never learns. It exists only so API responses can tell a CI job where its
|
||||
// deployment landed; nothing about serving depends on it, so leaving it
|
||||
// empty simply omits the url field.
|
||||
SiteURL string `toml:"site_url"`
|
||||
|
||||
TrustedProxyCIDRs []string `toml:"trusted_proxy_cidrs"`
|
||||
AssembleMode AssembleMode `toml:"assemble_mode"`
|
||||
|
||||
LogLevel string `toml:"log_level"`
|
||||
LogFormat string `toml:"log_format"`
|
||||
|
||||
GCInterval Duration `toml:"gc_interval"`
|
||||
ReconcileInterval Duration `toml:"reconcile_interval"`
|
||||
ShutdownGrace Duration `toml:"shutdown_grace"`
|
||||
|
||||
// ReadHeaderTimeout and ReadTimeout guard both listeners. There is
|
||||
// deliberately no WriteTimeout: a global write deadline on the static
|
||||
// listener would kill legitimate large downloads over slow links.
|
||||
ReadHeaderTimeout Duration `toml:"read_header_timeout"`
|
||||
ReadTimeout Duration `toml:"read_timeout"`
|
||||
IdleTimeout Duration `toml:"idle_timeout"`
|
||||
|
||||
Limits Limits `toml:"limits"`
|
||||
|
||||
// trustedNets is the parsed form of TrustedProxyCIDRs, filled by Validate.
|
||||
trustedNets []netip.Prefix
|
||||
}
|
||||
|
||||
// Default returns the baseline configuration. Every other layer (file, env,
|
||||
// flags) is applied on top of this.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
DataDir: "/var/lib/pages-server",
|
||||
Webroot: "/srv/www",
|
||||
Listen: ":8080",
|
||||
APIListen: "127.0.0.1:8081",
|
||||
TrustedProxyCIDRs: []string{"127.0.0.1/32", "::1/128"},
|
||||
AssembleMode: AssembleAuto,
|
||||
LogLevel: "info",
|
||||
LogFormat: "json",
|
||||
GCInterval: Duration(15 * time.Minute),
|
||||
ReconcileInterval: Duration(5 * time.Minute),
|
||||
ShutdownGrace: Duration(30 * time.Second),
|
||||
ReadHeaderTimeout: Duration(10 * time.Second),
|
||||
ReadTimeout: Duration(5 * time.Minute),
|
||||
IdleTimeout: Duration(120 * time.Second),
|
||||
Limits: Limits{
|
||||
MaxFileBytes: 256 << 20, // 256 MiB
|
||||
MaxManifestFiles: 50000,
|
||||
MaxConcurrentUploads: 32,
|
||||
MaxManifestBytes: 64 << 20, // 64 MiB of manifest JSON
|
||||
MaxJSONBytes: 1 << 20, // 1 MiB for ordinary API bodies
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DBPath is the SQLite database file.
|
||||
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "pages.db") }
|
||||
|
||||
// CASDir holds content-addressed blobs.
|
||||
func (c *Config) CASDir() string { return filepath.Join(c.DataDir, "cas") }
|
||||
|
||||
// DeploymentsDir holds assembled deployment trees.
|
||||
func (c *Config) DeploymentsDir() string { return filepath.Join(c.DataDir, "deployments") }
|
||||
|
||||
// BootstrapTokenPath is where the first-run admin token is written.
|
||||
func (c *Config) BootstrapTokenPath() string { return filepath.Join(c.DataDir, "bootstrap-token") }
|
||||
|
||||
// TrustedProxies returns the parsed trusted-proxy prefixes. Only requests whose
|
||||
// direct peer falls inside one of these may have their X-Forwarded-For honoured.
|
||||
func (c *Config) TrustedProxies() []netip.Prefix { return c.trustedNets }
|
||||
|
||||
// Validate checks the configuration and normalises it in place. It performs no
|
||||
// I/O beyond stat-ing the configured directories' parents, so it is safe to run
|
||||
// from --check-config without binding ports.
|
||||
func (c *Config) Validate() error {
|
||||
if c.DataDir == "" {
|
||||
return fmt.Errorf("data_dir is required")
|
||||
}
|
||||
abs, err := filepath.Abs(c.DataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("data_dir: %w", err)
|
||||
}
|
||||
c.DataDir = filepath.Clean(abs)
|
||||
|
||||
if c.AssembleMode != AssembleNone {
|
||||
if c.Webroot == "" {
|
||||
return fmt.Errorf("webroot is required unless assemble_mode is %q", AssembleNone)
|
||||
}
|
||||
abs, err = filepath.Abs(c.Webroot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webroot: %w", err)
|
||||
}
|
||||
c.Webroot = filepath.Clean(abs)
|
||||
|
||||
// $WEBROOT must not sit inside $DATA_DIR (or vice versa): the reconciler
|
||||
// removes stale "~name" symlinks from the webroot, and the GC removes
|
||||
// trees from the data dir. Overlapping them makes each capable of
|
||||
// deleting the other's state.
|
||||
if c.Webroot == c.DataDir {
|
||||
return fmt.Errorf("webroot and data_dir must differ (both %q)", c.DataDir)
|
||||
}
|
||||
if isUnder(c.Webroot, c.DataDir) || isUnder(c.DataDir, c.Webroot) {
|
||||
return fmt.Errorf("webroot %q and data_dir %q must not be nested", c.Webroot, c.DataDir)
|
||||
}
|
||||
}
|
||||
|
||||
if !c.AssembleMode.valid() {
|
||||
return fmt.Errorf("assemble_mode: want auto|hardlink|copy|none, got %q", c.AssembleMode)
|
||||
}
|
||||
|
||||
for _, spec := range []struct{ name, addr string }{
|
||||
{"listen", c.Listen},
|
||||
{"api_listen", c.APIListen},
|
||||
} {
|
||||
if spec.addr == "" {
|
||||
return fmt.Errorf("%s is required", spec.name)
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(spec.addr); err != nil {
|
||||
return fmt.Errorf("%s %q: %w", spec.name, spec.addr, err)
|
||||
}
|
||||
}
|
||||
if c.Listen == c.APIListen {
|
||||
return fmt.Errorf("listen and api_listen must differ (both %q); "+
|
||||
"the management API must not share an origin with served content", c.Listen)
|
||||
}
|
||||
|
||||
if c.SiteURL != "" {
|
||||
// Stored without the trailing slash so api.SiteURL can append one and
|
||||
// get exactly one back.
|
||||
c.SiteURL = strings.TrimRight(c.SiteURL, "/")
|
||||
u, err := url.Parse(c.SiteURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("site_url %q: %w", c.SiteURL, err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("site_url %q must be an absolute http:// or https:// URL", c.SiteURL)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("site_url %q is missing a host", c.SiteURL)
|
||||
}
|
||||
if u.RawQuery != "" || u.Fragment != "" {
|
||||
return fmt.Errorf("site_url %q must not carry a query or fragment", c.SiteURL)
|
||||
}
|
||||
}
|
||||
|
||||
c.trustedNets = c.trustedNets[:0]
|
||||
for _, s := range c.TrustedProxyCIDRs {
|
||||
p, err := netip.ParsePrefix(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return fmt.Errorf("trusted_proxy_cidrs %q: %w", s, err)
|
||||
}
|
||||
c.trustedNets = append(c.trustedNets, p)
|
||||
}
|
||||
|
||||
switch c.LogLevel {
|
||||
case "debug", "info", "warn", "error":
|
||||
default:
|
||||
return fmt.Errorf("log_level: want debug|info|warn|error, got %q", c.LogLevel)
|
||||
}
|
||||
switch c.LogFormat {
|
||||
case "json", "text":
|
||||
default:
|
||||
return fmt.Errorf("log_format: want json|text, got %q", c.LogFormat)
|
||||
}
|
||||
|
||||
for _, spec := range []struct {
|
||||
name string
|
||||
d Duration
|
||||
}{
|
||||
{"gc_interval", c.GCInterval},
|
||||
{"reconcile_interval", c.ReconcileInterval},
|
||||
{"shutdown_grace", c.ShutdownGrace},
|
||||
{"read_header_timeout", c.ReadHeaderTimeout},
|
||||
{"read_timeout", c.ReadTimeout},
|
||||
{"idle_timeout", c.IdleTimeout},
|
||||
} {
|
||||
if spec.d <= 0 {
|
||||
return fmt.Errorf("%s must be positive, got %s", spec.name, spec.d)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Limits.MaxFileBytes <= 0 {
|
||||
return fmt.Errorf("limits.max_file_bytes must be positive")
|
||||
}
|
||||
if c.Limits.MaxManifestFiles <= 0 {
|
||||
return fmt.Errorf("limits.max_manifest_files must be positive")
|
||||
}
|
||||
if c.Limits.MaxConcurrentUploads <= 0 {
|
||||
return fmt.Errorf("limits.max_concurrent_uploads must be positive")
|
||||
}
|
||||
if c.Limits.MaxManifestBytes <= 0 {
|
||||
return fmt.Errorf("limits.max_manifest_bytes must be positive")
|
||||
}
|
||||
if c.Limits.MaxJSONBytes <= 0 {
|
||||
return fmt.Errorf("limits.max_json_bytes must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDirs creates the data directory layout. Separated from Validate so
|
||||
// --check-config stays read-only.
|
||||
func (c *Config) EnsureDirs() error {
|
||||
dirs := []string{c.DataDir, c.CASDir(), filepath.Join(c.CASDir(), "tmp"), c.DeploymentsDir()}
|
||||
if c.AssembleMode != AssembleNone {
|
||||
dirs = append(dirs, c.Webroot)
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return fmt.Errorf("create %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUnder reports whether path is lexically inside base.
|
||||
func isUnder(path, base string) bool {
|
||||
rel, err := filepath.Rel(base, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultIsValid(t *testing.T) {
|
||||
cfg := Default()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("the built-in default must validate: %v", err)
|
||||
}
|
||||
if len(cfg.TrustedProxies()) != 2 {
|
||||
t.Errorf("trusted proxies = %v, want loopback v4 + v6", cfg.TrustedProxies())
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// Precedence is flag > env > file > default, and — the part that is easy to get
|
||||
// wrong — a flag left unset must not shadow the file or the environment with its
|
||||
// default value.
|
||||
func TestLoadPrecedence(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
data_dir = "/srv/from-file"
|
||||
webroot = "/var/www/from-file"
|
||||
listen = ":9001"
|
||||
log_level = "warn"
|
||||
gc_interval = "9m"
|
||||
`)
|
||||
|
||||
t.Setenv("PAGES_WEBROOT", "/var/www/from-env")
|
||||
t.Setenv("PAGES_LOG_LEVEL", "debug")
|
||||
|
||||
opts, err := Load([]string{
|
||||
"-config", path,
|
||||
"-log-level", "error",
|
||||
"-gc-interval", "1m",
|
||||
}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
cfg := opts.Config
|
||||
|
||||
if cfg.LogLevel != "error" {
|
||||
t.Errorf("log_level = %q, want the flag to win", cfg.LogLevel)
|
||||
}
|
||||
if cfg.Webroot != "/var/www/from-env" {
|
||||
t.Errorf("webroot = %q, want the env to beat the file", cfg.Webroot)
|
||||
}
|
||||
if cfg.DataDir != "/srv/from-file" {
|
||||
t.Errorf("data_dir = %q, want the file to beat the default", cfg.DataDir)
|
||||
}
|
||||
if cfg.Listen != ":9001" {
|
||||
t.Errorf("listen = %q, want the file value (no flag, no env)", cfg.Listen)
|
||||
}
|
||||
if cfg.GCInterval.D() != time.Minute {
|
||||
t.Errorf("gc_interval = %s, want the flag to win", cfg.GCInterval)
|
||||
}
|
||||
if cfg.APIListen != Default().APIListen {
|
||||
t.Errorf("api_listen = %q, want the untouched default", cfg.APIListen)
|
||||
}
|
||||
}
|
||||
|
||||
// An unset flag defaults to the same value as Default(), so a naive
|
||||
// implementation silently overwrites whatever the file said with that default.
|
||||
func TestUnsetFlagDoesNotShadowFile(t *testing.T) {
|
||||
path := writeConfig(t, "log_format = \"text\"\napi_listen = \"127.0.0.1:9999\"\n")
|
||||
opts, err := Load([]string{"-config", path}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if opts.Config.LogFormat != "text" {
|
||||
t.Errorf("log_format = %q, want text", opts.Config.LogFormat)
|
||||
}
|
||||
if opts.Config.APIListen != "127.0.0.1:9999" {
|
||||
t.Errorf("api_listen = %q, want the file value", opts.Config.APIListen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownKey(t *testing.T) {
|
||||
path := writeConfig(t, "data_dir = \"/srv/x\"\nlog_levle = \"debug\"\n")
|
||||
_, err := Load([]string{"-config", path}, io.Discard)
|
||||
if err == nil {
|
||||
t.Fatal("a typo in the config file must fail loudly")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "log_levle") {
|
||||
t.Errorf("error should name the offending key, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownArgs(t *testing.T) {
|
||||
if _, err := Load([]string{"serve"}, io.Discard); err == nil {
|
||||
t.Fatal("positional arguments must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvDurationError(t *testing.T) {
|
||||
t.Setenv("PAGES_GC_INTERVAL", "fifteen minutes")
|
||||
_, err := Load(nil, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "PAGES_GC_INTERVAL") {
|
||||
t.Fatalf("want an error naming the variable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTrustedProxiesFromEnv(t *testing.T) {
|
||||
t.Setenv("PAGES_TRUSTED_PROXY_CIDRS", "10.0.0.0/8, 192.168.0.0/16 ,")
|
||||
opts, err := Load(nil, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
got := opts.Config.TrustedProxies()
|
||||
if len(got) != 2 || got[0].String() != "10.0.0.0/8" || got[1].String() != "192.168.0.0/16" {
|
||||
t.Errorf("trusted proxies = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErr string
|
||||
}{
|
||||
{"webroot inside data_dir", func(c *Config) {
|
||||
c.DataDir = "/var/lib/pages"
|
||||
c.Webroot = "/var/lib/pages/www"
|
||||
}, "nested"},
|
||||
{"data_dir inside webroot", func(c *Config) {
|
||||
c.DataDir = "/srv/www/state"
|
||||
c.Webroot = "/srv/www"
|
||||
}, "nested"},
|
||||
{"identical dirs", func(c *Config) {
|
||||
c.DataDir = "/srv/www"
|
||||
c.Webroot = "/srv/www"
|
||||
}, "must differ"},
|
||||
{"listeners collide", func(c *Config) {
|
||||
c.Listen = "127.0.0.1:8080"
|
||||
c.APIListen = "127.0.0.1:8080"
|
||||
}, "must differ"},
|
||||
{"listen without port", func(c *Config) { c.Listen = "8080" }, "listen"},
|
||||
{"bad cidr", func(c *Config) { c.TrustedProxyCIDRs = []string{"10.0.0.1"} }, "trusted_proxy_cidrs"},
|
||||
{"bad assemble mode", func(c *Config) { c.AssembleMode = "symlink" }, "assemble_mode"},
|
||||
{"bad log level", func(c *Config) { c.LogLevel = "verbose" }, "log_level"},
|
||||
{"zero interval", func(c *Config) { c.GCInterval = 0 }, "gc_interval"},
|
||||
{"negative limit", func(c *Config) { c.Limits.MaxFileBytes = -1 }, "max_file_bytes"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tc.mutate(&cfg)
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("want an error mentioning %q", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Errorf("error = %v, want it to mention %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assemble_mode = "none" means no webroot is maintained, so the webroot checks
|
||||
// must not fire.
|
||||
func TestValidateAllowsEmptyWebrootWhenAssemblyDisabled(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.AssembleMode = AssembleNone
|
||||
cfg.Webroot = ""
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMakesPathsAbsolute(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.DataDir = "state/./"
|
||||
cfg.Webroot = "www"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if !filepath.IsAbs(cfg.DataDir) || strings.HasSuffix(cfg.DataDir, "/") {
|
||||
t.Errorf("data_dir = %q, want a cleaned absolute path", cfg.DataDir)
|
||||
}
|
||||
if !filepath.IsAbs(cfg.Webroot) {
|
||||
t.Errorf("webroot = %q, want an absolute path", cfg.Webroot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDirs(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
cfg := Default()
|
||||
cfg.DataDir = filepath.Join(base, "state")
|
||||
cfg.Webroot = filepath.Join(base, "www")
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.EnsureDirs(); err != nil {
|
||||
t.Fatalf("EnsureDirs: %v", err)
|
||||
}
|
||||
for _, d := range []string{cfg.DataDir, cfg.CASDir(), filepath.Join(cfg.CASDir(), "tmp"), cfg.DeploymentsDir(), cfg.Webroot} {
|
||||
fi, err := os.Stat(d)
|
||||
if err != nil {
|
||||
t.Errorf("missing %s: %v", d, err)
|
||||
continue
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
t.Errorf("%s is not a directory", d)
|
||||
}
|
||||
}
|
||||
// Idempotent: a restart must not fail on directories that already exist.
|
||||
if err := cfg.EnsureDirs(); err != nil {
|
||||
t.Fatalf("second EnsureDirs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectNamePattern(t *testing.T) {
|
||||
valid := []string{"a", "demo", "my-site", "my.site", "my_site", "a1", strings.Repeat("x", 63)}
|
||||
invalid := []string{
|
||||
"", "-lead", ".lead", "_lead", "UPPER", "has space", "has/slash", "..",
|
||||
"a/../b", "a\\b", "tilde~", strings.Repeat("x", 64), "naïve", "a\x00b",
|
||||
}
|
||||
for _, s := range valid {
|
||||
if !ProjectNamePattern.MatchString(s) {
|
||||
t.Errorf("%q should be a valid project name", s)
|
||||
}
|
||||
}
|
||||
for _, s := range invalid {
|
||||
if ProjectNamePattern.MatchString(s) {
|
||||
t.Errorf("%q must be rejected as a project name", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationRoundTrip(t *testing.T) {
|
||||
var d Duration
|
||||
if err := d.Set("15m30s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.D() != 15*time.Minute+30*time.Second {
|
||||
t.Errorf("d = %s", d)
|
||||
}
|
||||
b, err := d.MarshalText()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back Duration
|
||||
if err := back.UnmarshalText(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back != d {
|
||||
t.Errorf("round trip: %s != %s", back, d)
|
||||
}
|
||||
if err := d.Set("soon"); err == nil {
|
||||
t.Error("Set must reject a non-duration")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// Options is the result of parsing the server command line.
|
||||
type Options struct {
|
||||
Config Config
|
||||
ConfigPath string
|
||||
CheckOnly bool
|
||||
ShowVersion bool
|
||||
}
|
||||
|
||||
// Load resolves the server configuration with precedence
|
||||
//
|
||||
// flag > PAGES_* env > config file > built-in default
|
||||
//
|
||||
// args excludes the program name. It returns flag.ErrHelp when -h was passed.
|
||||
func Load(args []string, out io.Writer) (*Options, error) {
|
||||
fs := flag.NewFlagSet("pages-server", flag.ContinueOnError)
|
||||
fs.SetOutput(out)
|
||||
|
||||
// flagCfg receives whatever the user typed; we later copy across only the
|
||||
// fields whose flags were actually visited, so unset flags never shadow the
|
||||
// file or the environment.
|
||||
flagCfg := Default()
|
||||
opts := &Options{}
|
||||
|
||||
fs.StringVar(&opts.ConfigPath, "config", os.Getenv("PAGES_CONFIG"), "path to the TOML config file")
|
||||
fs.BoolVar(&opts.CheckOnly, "check-config", false, "validate configuration and exit without binding ports")
|
||||
fs.BoolVar(&opts.ShowVersion, "version", false, "print version and exit")
|
||||
|
||||
fs.StringVar(&flagCfg.DataDir, "data-dir", flagCfg.DataDir, "directory for the database, CAS blobs and deployment trees")
|
||||
fs.StringVar(&flagCfg.Webroot, "webroot", flagCfg.Webroot, "directory in which ~PROJECT symlinks are maintained")
|
||||
fs.StringVar(&flagCfg.Listen, "listen", flagCfg.Listen, "address for the public static-content listener")
|
||||
fs.StringVar(&flagCfg.APIListen, "api-listen", flagCfg.APIListen, "address for the management API listener")
|
||||
fs.StringVar(&flagCfg.SiteURL, "site-url", flagCfg.SiteURL, "public base URL of the static listener, e.g. https://pages.example.com (reported in API responses)")
|
||||
fs.StringVar((*string)(&flagCfg.AssembleMode), "assemble-mode", string(flagCfg.AssembleMode), "how deployment trees are built: auto|hardlink|copy|none")
|
||||
fs.StringVar(&flagCfg.LogLevel, "log-level", flagCfg.LogLevel, "debug|info|warn|error")
|
||||
fs.StringVar(&flagCfg.LogFormat, "log-format", flagCfg.LogFormat, "json|text")
|
||||
fs.Var(&flagCfg.GCInterval, "gc-interval", "how often the retention/blob sweep runs")
|
||||
fs.Var(&flagCfg.ShutdownGrace, "shutdown-grace", "how long in-flight requests may finish during shutdown")
|
||||
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(out, "Usage: pages-server [flags]\n\n"+
|
||||
"Serves static sites deployed through the pages CLI, switching each\n"+
|
||||
"project's content atomically.\n\nFlags:\n")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts.ShowVersion {
|
||||
return opts, nil
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
return nil, fmt.Errorf("unexpected argument %q", fs.Arg(0))
|
||||
}
|
||||
|
||||
set := make(map[string]bool, 16)
|
||||
fs.Visit(func(f *flag.Flag) { set[f.Name] = true })
|
||||
|
||||
cfg := Default()
|
||||
|
||||
if opts.ConfigPath != "" {
|
||||
if err := applyFile(&cfg, opts.ConfigPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := applyEnv(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Highest precedence: flags the user actually typed.
|
||||
overrides := map[string]func(){
|
||||
"data-dir": func() { cfg.DataDir = flagCfg.DataDir },
|
||||
"webroot": func() { cfg.Webroot = flagCfg.Webroot },
|
||||
"listen": func() { cfg.Listen = flagCfg.Listen },
|
||||
"api-listen": func() { cfg.APIListen = flagCfg.APIListen },
|
||||
"site-url": func() { cfg.SiteURL = flagCfg.SiteURL },
|
||||
"assemble-mode": func() { cfg.AssembleMode = flagCfg.AssembleMode },
|
||||
"log-level": func() { cfg.LogLevel = flagCfg.LogLevel },
|
||||
"log-format": func() { cfg.LogFormat = flagCfg.LogFormat },
|
||||
"gc-interval": func() { cfg.GCInterval = flagCfg.GCInterval },
|
||||
"shutdown-grace": func() { cfg.ShutdownGrace = flagCfg.ShutdownGrace },
|
||||
}
|
||||
for name, apply := range overrides {
|
||||
if set[name] {
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.Config = cfg
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// applyFile decodes the TOML file over cfg. Unknown keys are an error: a typo in
|
||||
// a config file should fail loudly rather than silently leave a default in place.
|
||||
func applyFile(cfg *Config, path string) error {
|
||||
md, err := toml.DecodeFile(path, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config %s: %w", path, err)
|
||||
}
|
||||
if undec := md.Undecoded(); len(undec) > 0 {
|
||||
keys := make([]string, len(undec))
|
||||
for i, k := range undec {
|
||||
keys[i] = k.String()
|
||||
}
|
||||
return fmt.Errorf("config %s: unknown key(s): %s", path, strings.Join(keys, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyEnv(cfg *Config) error {
|
||||
str := func(key string, dst *string) {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
*dst = v
|
||||
}
|
||||
}
|
||||
str("PAGES_DATA_DIR", &cfg.DataDir)
|
||||
str("PAGES_WEBROOT", &cfg.Webroot)
|
||||
str("PAGES_LISTEN", &cfg.Listen)
|
||||
str("PAGES_API_LISTEN", &cfg.APIListen)
|
||||
str("PAGES_SITE_URL", &cfg.SiteURL)
|
||||
str("PAGES_LOG_LEVEL", &cfg.LogLevel)
|
||||
str("PAGES_LOG_FORMAT", &cfg.LogFormat)
|
||||
str("PAGES_ASSEMBLE_MODE", (*string)(&cfg.AssembleMode))
|
||||
|
||||
dur := func(key string, dst *Duration) error {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := dst.UnmarshalText([]byte(v)); err != nil {
|
||||
return fmt.Errorf("%s=%q: %w", key, v, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := dur("PAGES_GC_INTERVAL", &cfg.GCInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dur("PAGES_RECONCILE_INTERVAL", &cfg.ReconcileInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dur("PAGES_SHUTDOWN_GRACE", &cfg.ShutdownGrace); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if v, ok := os.LookupEnv("PAGES_TRUSTED_PROXY_CIDRS"); ok {
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
cfg.TrustedProxyCIDRs = out
|
||||
}
|
||||
if v, ok := os.LookupEnv("PAGES_MAX_FILE_BYTES"); ok {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("PAGES_MAX_FILE_BYTES=%q: %w", v, err)
|
||||
}
|
||||
cfg.Limits.MaxFileBytes = n
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logger builds the structured logger described by the configuration.
|
||||
func (c *Config) Logger(w io.Writer) *slog.Logger {
|
||||
var level slog.Level
|
||||
switch c.LogLevel {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
default:
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
if c.LogFormat == "text" {
|
||||
return slog.New(slog.NewTextHandler(w, opts))
|
||||
}
|
||||
return slog.New(slog.NewJSONHandler(w, opts))
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package deploy owns the deployment lifecycle: creating one, negotiating its
|
||||
// manifest, accepting blob uploads, assembling the directory tree, and (from M3)
|
||||
// switching a project over to it.
|
||||
//
|
||||
// It is the only package that writes to $DATA_DIR/deployments. Everything it
|
||||
// writes lands in a staging directory first and becomes visible with a single
|
||||
// rename, which is the same discipline the CAS uses for blobs and the registry
|
||||
// uses for the active pointer.
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/pathutil"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
dirMode = 0o755
|
||||
// stagingSuffix marks a tree that is still being built. Recovery deletes
|
||||
// every directory carrying it, since by definition nothing references one.
|
||||
stagingSuffix = ".staging"
|
||||
)
|
||||
|
||||
// DeploymentDir is where a deployment's assembled tree lives.
|
||||
//
|
||||
// The project id rather than its name: a project that is renamed keeps its
|
||||
// deployments where they are, and no user-chosen string is ever a path segment
|
||||
// under $DATA_DIR.
|
||||
func DeploymentDir(root string, projectID int64, publicID string) string {
|
||||
return filepath.Join(root, strconv.FormatInt(projectID, 10), publicID)
|
||||
}
|
||||
|
||||
// Assemble builds destDir from the CAS.
|
||||
//
|
||||
// The tree is built under destDir+".staging" and moved into place with one
|
||||
// rename, so destDir either does not exist or is the complete deployment —
|
||||
// there is no state in which a reader could walk a half-built tree.
|
||||
//
|
||||
// It is idempotent in the way finalize needs: an existing destDir is a finished
|
||||
// tree (rename is atomic, so a partial one cannot survive a crash) and is left
|
||||
// alone.
|
||||
func Assemble(ctx context.Context, cs *cas.Store, files []store.FileRow, destDir string) error {
|
||||
staging := destDir + stagingSuffix
|
||||
if fi, err := os.Stat(destDir); err == nil {
|
||||
if !fi.IsDir() {
|
||||
return fmt.Errorf("deploy: %s exists and is not a directory", destDir)
|
||||
}
|
||||
return os.RemoveAll(staging)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Whatever an earlier attempt left behind is unreferenced by construction.
|
||||
if err := os.RemoveAll(staging); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(staging, dirMode); err != nil {
|
||||
return err
|
||||
}
|
||||
// One cleanup for every failure path: a staging tree that outlives its
|
||||
// attempt is wasted disk that only recovery would find.
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
os.RemoveAll(staging)
|
||||
}
|
||||
}()
|
||||
|
||||
// The set of directories that had to be created, so each can be fsynced
|
||||
// once. Recorded per ancestor because MkdirAll creates parents silently.
|
||||
dirs := map[string]bool{".": true}
|
||||
for _, f := range files {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
// The manifest was validated when it was accepted. Checking again here
|
||||
// costs a few hundred nanoseconds per file and means the one place that
|
||||
// turns stored strings into filesystem paths does not depend on a
|
||||
// promise made by a different package at a different time.
|
||||
if err := pathutil.Validate(f.Path); err != nil {
|
||||
return fmt.Errorf("deploy: manifest path %q: %w", f.Path, err)
|
||||
}
|
||||
if dir := path.Dir(f.Path); !dirs[dir] {
|
||||
if err := os.MkdirAll(filepath.Join(staging, filepath.FromSlash(dir)), dirMode); err != nil {
|
||||
return err
|
||||
}
|
||||
for d := dir; !dirs[d]; d = path.Dir(d) {
|
||||
dirs[d] = true
|
||||
}
|
||||
}
|
||||
if err := cs.LinkInto(f.Digest, staging, f.Path); err != nil {
|
||||
return fmt.Errorf("deploy: %s: %w", f.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Blob content is already durable — Put fsynced it, and a hardlink shares
|
||||
// that inode — but the directory entries pointing at it are not. Without
|
||||
// this, a crash could leave a renamed-into-place tree with missing files,
|
||||
// which is exactly the half-updated site the whole design exists to avoid.
|
||||
if err := syncDirs(staging, dirs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destDir), dirMode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(staging, destDir); err != nil {
|
||||
return err
|
||||
}
|
||||
ok = true
|
||||
return syncDir(filepath.Dir(destDir))
|
||||
}
|
||||
|
||||
// syncDirs fsyncs every directory of the staged tree, deepest first, so a
|
||||
// parent is only made durable once the entries it names are.
|
||||
func syncDirs(staging string, dirs map[string]bool) error {
|
||||
rel := make([]string, 0, len(dirs))
|
||||
for d := range dirs {
|
||||
rel = append(rel, d)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(rel)))
|
||||
for _, d := range rel {
|
||||
if err := syncDir(filepath.Join(staging, filepath.FromSlash(d))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDir(dir string) error {
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := f.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// fixture is a CAS holding some content plus the manifest that names it.
|
||||
type fixture struct {
|
||||
cas *cas.Store
|
||||
dir string // deployments root
|
||||
files []store.FileRow
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, contents map[string]string) *fixture {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
deployDir := filepath.Join(base, "deployments")
|
||||
cs, err := cas.Open(filepath.Join(base, "cas"), cas.Options{ProbeDir: deployDir})
|
||||
if err != nil {
|
||||
t.Fatalf("cas.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { cs.Close() })
|
||||
|
||||
f := &fixture{cas: cs, dir: deployDir}
|
||||
for path, content := range contents {
|
||||
d := cas.Sum([]byte(content))
|
||||
if _, err := cs.Put(t.Context(), d, int64(len(content)), 1<<20, strings.NewReader(content)); err != nil {
|
||||
t.Fatalf("put %s: %v", path, err)
|
||||
}
|
||||
f.files = append(f.files, store.FileRow{Path: path, Digest: d, Size: int64(len(content))})
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// walk reads back an assembled tree as path -> content.
|
||||
func walk(t *testing.T, dir string) map[string]string {
|
||||
t.Helper()
|
||||
out := map[string]string{}
|
||||
err := filepath.WalkDir(dir, func(p string, e fs.DirEntry, err error) error {
|
||||
if err != nil || e.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(dir, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[filepath.ToSlash(rel)] = string(b)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", dir, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAssembleBuildsTheTree(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"index.html": "<h1>hi</h1>",
|
||||
"assets/app.js": "console.log(1)",
|
||||
"assets/css/app.css": "body{}",
|
||||
"a/b/c/d/deep.txt": "deep",
|
||||
"copy.html": "<h1>hi</h1>", // shares a blob with index.html
|
||||
}
|
||||
f := newFixture(t, want)
|
||||
dest := DeploymentDir(f.dir, 7, "dpl_0123456789abcdef")
|
||||
|
||||
if err := Assemble(t.Context(), f.cas, f.files, dest); err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
got := walk(t, dest)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("assembled %d files, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for p, content := range want {
|
||||
if got[p] != content {
|
||||
t.Errorf("%s = %q, want %q", p, got[p], content)
|
||||
}
|
||||
}
|
||||
// The staging directory is gone: it became the tree by rename.
|
||||
if _, err := os.Stat(dest + stagingSuffix); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("staging directory survived: %v", err)
|
||||
}
|
||||
// The project id is a path segment, so the tree is where the registry will
|
||||
// later expect to find it.
|
||||
if !strings.HasSuffix(filepath.Dir(dest), string(filepath.Separator)+"7") {
|
||||
t.Errorf("deployment dir %q is not under its project id", dest)
|
||||
}
|
||||
|
||||
// Where the filesystem allows it, an assembled file is the blob rather than a
|
||||
// copy of it. This is what keeps a hundred deployments of one site costing
|
||||
// one site's worth of disk, so it is worth asserting rather than assuming.
|
||||
if f.cas.LinkMode() != cas.LinkHard {
|
||||
t.Skipf("link mode is %s on this filesystem; skipping the hardlink assertion", f.cas.LinkMode())
|
||||
}
|
||||
for _, e := range f.files {
|
||||
blob, err := os.Stat(f.cas.Path(e.Digest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placed, err := os.Stat(filepath.Join(dest, e.Path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !os.SameFile(blob, placed) {
|
||||
t.Errorf("%s is a copy of its blob, not a link to it", e.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize can be retried, so assembling onto a finished tree must be a no-op
|
||||
// rather than a rebuild — a rebuild would briefly unlink files that an
|
||||
// external consumer of $WEBROOT is reading.
|
||||
func TestAssembleIsIdempotent(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"index.html": "one"})
|
||||
dest := DeploymentDir(f.dir, 1, "dpl_a")
|
||||
if err := Assemble(t.Context(), f.cas, f.files, dest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.Stat(filepath.Join(dest, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Assemble(t.Context(), f.cas, f.files, dest); err != nil {
|
||||
t.Fatalf("second Assemble: %v", err)
|
||||
}
|
||||
after, err := os.Stat(filepath.Join(dest, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !os.SameFile(before, after) {
|
||||
t.Error("the second Assemble replaced a file that was already in place")
|
||||
}
|
||||
}
|
||||
|
||||
// A crash leaves a staging tree behind. The next attempt must clear it rather
|
||||
// than build on top of files it did not put there.
|
||||
func TestAssembleDiscardsALeftoverStagingTree(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"index.html": "real"})
|
||||
dest := DeploymentDir(f.dir, 1, "dpl_a")
|
||||
staging := dest + stagingSuffix
|
||||
if err := os.MkdirAll(staging, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Same path as a manifest entry, so a build that did not clear this would
|
||||
// fail on the exclusive create rather than silently serve the wrong bytes.
|
||||
if err := os.WriteFile(filepath.Join(staging, "index.html"), []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, "orphan.txt"), []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Assemble(t.Context(), f.cas, f.files, dest); err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
got := walk(t, dest)
|
||||
if len(got) != 1 || got["index.html"] != "real" {
|
||||
t.Errorf("tree = %v, want just the real index.html", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A failure must leave nothing behind: no partial tree at the destination, and
|
||||
// no staging directory quietly consuming disk until recovery notices it.
|
||||
func TestAssembleLeavesNothingBehindOnFailure(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"index.html": "real"})
|
||||
missing := store.FileRow{Path: "gone.txt", Digest: cas.Sum([]byte("never stored")), Size: 12}
|
||||
dest := DeploymentDir(f.dir, 1, "dpl_a")
|
||||
|
||||
err := Assemble(t.Context(), f.cas, append(f.files, missing), dest)
|
||||
if !errors.Is(err, cas.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want cas.ErrNotFound", err)
|
||||
}
|
||||
for _, p := range []string{dest, dest + stagingSuffix} {
|
||||
if _, err := os.Stat(p); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("%s still exists: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assembly is the one place that turns stored strings into filesystem paths, so
|
||||
// it re-checks them even though the API validated the manifest on the way in.
|
||||
// A row that got past the API — or into the table by some other route — must not
|
||||
// be able to place a file outside the tree being built.
|
||||
func TestAssembleRejectsAnEscapingPath(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"index.html": "real"})
|
||||
dest := DeploymentDir(f.dir, 1, "dpl_a")
|
||||
|
||||
for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "a/../../b", "a//b", `a\..\b`, "a/./b", ""} {
|
||||
row := store.FileRow{Path: bad, Digest: f.files[0].Digest, Size: f.files[0].Size}
|
||||
if err := Assemble(t.Context(), f.cas, []store.FileRow{row}, dest); err == nil {
|
||||
t.Errorf("Assemble accepted %q", bad)
|
||||
}
|
||||
// Nothing is left behind for the next attempt to inherit, and in
|
||||
// particular no directory was created on the way to the rejection.
|
||||
for _, p := range []string{dest, dest + stagingSuffix} {
|
||||
if _, err := os.Stat(p); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("%q left %s behind: %v", bad, p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleHonoursCancellation(t *testing.T) {
|
||||
f := newFixture(t, map[string]string{"index.html": "real"})
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
dest := DeploymentDir(f.dir, 1, "dpl_a")
|
||||
if err := Assemble(ctx, f.cas, f.files, dest); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, err := os.Stat(dest + stagingSuffix); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("staging directory survived cancellation: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/site"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
"github.com/iceBear67/simplepages/internal/webroot"
|
||||
)
|
||||
|
||||
// These are the tests the whole program exists for.
|
||||
//
|
||||
// The failure they are written against is the one rsync has: during a
|
||||
// deployment a visitor sees the new HTML with the old JavaScript, or an asset
|
||||
// that is not there yet. Everything else in this repository — the immutable
|
||||
// snapshot, the single pointer store, the database-before-memory ordering — is
|
||||
// a means to making that state unobservable, and unobservable is a claim about
|
||||
// concurrent behaviour that only a concurrent test can support.
|
||||
//
|
||||
// What is actually asserted, and why it is the strongest true statement:
|
||||
//
|
||||
// - Every single response is internally consistent. big.bin is half a
|
||||
// megabyte of one repeated byte, so it spans many writes and a switch
|
||||
// landing mid-body would show up as a seam. Every byte of it equal to the
|
||||
// same version is the claim "no request ever saw a half-updated site".
|
||||
// - A reader's successive responses never go backwards. Requests within one
|
||||
// reader are strictly ordered — the next is not sent until the previous has
|
||||
// been read to completion — so the version it observes may only rise.
|
||||
//
|
||||
// It would be tempting to also demand that three separate GETs issued around
|
||||
// the same time report the same version. That is not a property this or any
|
||||
// design has: they are three requests, an activation may legitimately land
|
||||
// between any two of them, and asserting otherwise would be asserting that the
|
||||
// switch never happens. The per-response guarantee above is what "atomic
|
||||
// deployment" means.
|
||||
|
||||
const (
|
||||
// Large enough that a response spans many socket writes, so a switch has
|
||||
// somewhere to land mid-body.
|
||||
stormFileSize = 512 << 10
|
||||
// The in-flight tests need the server to still be blocked writing when the
|
||||
// test does something underneath it, which means comfortably more than a
|
||||
// loopback socket will buffer for a client that has stopped reading.
|
||||
inflightFileSize = 8 << 20
|
||||
)
|
||||
|
||||
// switchEnv is an env with the serving layer attached: a registry, a webroot
|
||||
// and an HTTP server, which is the only configuration in which the switch is
|
||||
// observable from the outside.
|
||||
type switchEnv struct {
|
||||
*env
|
||||
reg *site.Registry
|
||||
wrDir string
|
||||
srv *httptest.Server
|
||||
cl *http.Client
|
||||
}
|
||||
|
||||
func newSwitchEnv(t *testing.T) *switchEnv {
|
||||
t.Helper()
|
||||
e := newEnv(t)
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
|
||||
reg := site.NewRegistry()
|
||||
wrDir := t.TempDir()
|
||||
wr, err := webroot.Open(wrDir, e.dir)
|
||||
if err != nil {
|
||||
t.Fatalf("webroot.Open: %v", err)
|
||||
}
|
||||
e.svc.Sites = reg
|
||||
e.svc.Webroot = wr
|
||||
reg.Put(e.p)
|
||||
|
||||
srv := httptest.NewServer(&site.Handler{Registry: reg, CAS: e.cas, Log: log})
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// The default transport keeps two idle connections per host, which would
|
||||
// turn 64 readers into a connection churn benchmark instead of a switching
|
||||
// one.
|
||||
cl := &http.Client{Transport: &http.Transport{MaxIdleConns: 512, MaxIdleConnsPerHost: 512}}
|
||||
t.Cleanup(cl.CloseIdleConnections)
|
||||
|
||||
return &switchEnv{env: e, reg: reg, wrDir: wrDir, srv: srv, cl: cl}
|
||||
}
|
||||
|
||||
// versionFiles is deployment n: three small files that name their version, and
|
||||
// one large one filled with the single byte n so that any part of it identifies
|
||||
// the whole.
|
||||
func versionFiles(n, size int) map[string]string {
|
||||
v := strconv.Itoa(n)
|
||||
return map[string]string{
|
||||
"marker.txt": v,
|
||||
"a.txt": v,
|
||||
"b.txt": v,
|
||||
"big.bin": string(bytes.Repeat([]byte{byte(n)}, size)),
|
||||
}
|
||||
}
|
||||
|
||||
// publish takes version n all the way to ready without activating it.
|
||||
func (e *switchEnv) publish(t *testing.T, n, size int) *store.Deployment {
|
||||
t.Helper()
|
||||
contents := versionFiles(n, size)
|
||||
dep := e.create(t)
|
||||
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
||||
t.Fatalf("SetManifest v%d: %v", n, err)
|
||||
}
|
||||
names := make([]string, 0, len(contents))
|
||||
for p := range contents {
|
||||
names = append(names, p)
|
||||
}
|
||||
e.upload(t, contents, names...)
|
||||
dep, err := e.svc.Finalize(t.Context(), e.p, dep)
|
||||
if err != nil {
|
||||
t.Fatalf("Finalize v%d: %v", n, err)
|
||||
}
|
||||
return dep
|
||||
}
|
||||
|
||||
func (e *switchEnv) activate(t *testing.T, dep *store.Deployment) *store.Deployment {
|
||||
t.Helper()
|
||||
out, err := e.svc.Activate(t.Context(), e.p, dep)
|
||||
if err != nil {
|
||||
t.Fatalf("Activate %s: %v", dep.PublicID, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// get fetches one file from the served site. It returns errors rather than
|
||||
// failing the test, because most of its callers are goroutines.
|
||||
func (e *switchEnv) get(name string) ([]byte, error) {
|
||||
resp, err := e.cl.Get(e.srv.URL + "/~demo/" + name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil, fmt.Errorf("GET %s: status %d", name, resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// bigVersion is the assertion that matters: it reports which version a big.bin
|
||||
// body came from, and fails if the body is not entirely from one version.
|
||||
func bigVersion(b []byte, size int) (int, error) {
|
||||
if len(b) != size {
|
||||
return 0, fmt.Errorf("big.bin is %d bytes, want %d", len(b), size)
|
||||
}
|
||||
first := b[0]
|
||||
for i, c := range b {
|
||||
if c != first {
|
||||
return 0, fmt.Errorf(
|
||||
"big.bin mixes two deployments: byte 0 is from version %d but byte %d is from version %d",
|
||||
first, i, c)
|
||||
}
|
||||
}
|
||||
return int(first), nil
|
||||
}
|
||||
|
||||
func smallVersion(b []byte) (int, error) {
|
||||
n, err := strconv.Atoi(string(b))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("file body %q is not a version number: %v", b, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func TestActivationAtomicity(t *testing.T) {
|
||||
const versions = 50
|
||||
const readers = 64
|
||||
|
||||
e := newSwitchEnv(t)
|
||||
deps := make([]*store.Deployment, versions)
|
||||
valid := make(map[string]bool, versions)
|
||||
for i := range deps {
|
||||
deps[i] = e.publish(t, i+1, stormFileSize)
|
||||
valid[deps[i].PublicID] = true
|
||||
}
|
||||
e.activate(t, deps[0])
|
||||
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Anti-vacuity: if every reader only ever saw the last version the
|
||||
// invariants above hold trivially and prove nothing.
|
||||
var mu sync.Mutex
|
||||
lowest, highest := versions+1, 0
|
||||
record := func(n int) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lowest = min(lowest, n)
|
||||
highest = max(highest, n)
|
||||
}
|
||||
|
||||
for range readers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
seen := 0
|
||||
for ctx.Err() == nil {
|
||||
for _, name := range []string{"a.txt", "b.txt", "big.bin"} {
|
||||
body, err := e.get(name)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var n int
|
||||
if name == "big.bin" {
|
||||
n, err = bigVersion(body, stormFileSize)
|
||||
} else {
|
||||
n, err = smallVersion(body)
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
return
|
||||
}
|
||||
if n < seen {
|
||||
t.Errorf("%s reported version %d after version %d had already been "+
|
||||
"served to this reader: the switch was observed running backwards",
|
||||
name, n, seen)
|
||||
return
|
||||
}
|
||||
seen = n
|
||||
record(n)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// The symlink is not what serves the site, but an external reader — a
|
||||
// reverse proxy, a backup job — follows it, and it must never be missing or
|
||||
// dangling while the switch runs.
|
||||
link := filepath.Join(e.wrDir, "~demo")
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for ctx.Err() == nil {
|
||||
target, err := os.Readlink(link)
|
||||
if err != nil {
|
||||
t.Errorf("$WEBROOT/~demo: %v", err)
|
||||
return
|
||||
}
|
||||
// Stat follows the link, so a dangling one fails here.
|
||||
fi, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Errorf("$WEBROOT/~demo -> %s: %v", target, err)
|
||||
return
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
t.Errorf("$WEBROOT/~demo -> %s is not a directory", target)
|
||||
return
|
||||
}
|
||||
if !valid[filepath.Base(target)] {
|
||||
t.Errorf("$WEBROOT/~demo -> %s, which is not a deployment of this project", target)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for _, dep := range deps[1:] {
|
||||
if _, err := e.svc.Activate(t.Context(), e.p, dep); err != nil {
|
||||
t.Errorf("Activate %s: %v", dep.PublicID, err)
|
||||
break
|
||||
}
|
||||
}
|
||||
stop()
|
||||
wg.Wait()
|
||||
|
||||
if t.Failed() {
|
||||
return
|
||||
}
|
||||
if lowest >= versions {
|
||||
t.Fatalf("every observation was of version %d or later: the readers never "+
|
||||
"overlapped the switching, so this test proved nothing", lowest)
|
||||
}
|
||||
if lowest == highest {
|
||||
t.Fatalf("every observation was of version %d: no switch was observed", lowest)
|
||||
}
|
||||
for _, name := range []string{"marker.txt", "a.txt", "b.txt"} {
|
||||
body, err := e.get(name)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if got, err := smallVersion(body); err != nil || got != versions {
|
||||
t.Errorf("after the storm %s = %q (%v), want version %d", name, body, err, versions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// openBig starts a request for big.bin and reads only its first kilobyte, so
|
||||
// the response is still open and the server is still blocked writing it.
|
||||
func (e *switchEnv) openBig(t *testing.T) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
resp, err := e.cl.Get(e.srv.URL + "/~demo/big.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("GET big.bin: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("GET big.bin: status %d", resp.StatusCode)
|
||||
}
|
||||
head := make([]byte, 1024)
|
||||
if _, err := io.ReadFull(resp.Body, head); err != nil {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("reading the start of big.bin: %v", err)
|
||||
}
|
||||
return resp, head
|
||||
}
|
||||
|
||||
// drain finishes a response opened by openBig and reports which version the
|
||||
// whole body came from.
|
||||
func drain(t *testing.T, resp *http.Response, head []byte) int {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
rest, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("reading the rest of big.bin: %v", err)
|
||||
}
|
||||
n, err := bigVersion(append(head, rest...), inflightFileSize)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestAnInFlightRequestKeepsReadingTheDeploymentItStartedOn(t *testing.T) {
|
||||
e := newSwitchEnv(t)
|
||||
v1 := e.publish(t, 1, inflightFileSize)
|
||||
v2 := e.publish(t, 2, inflightFileSize)
|
||||
e.activate(t, v1)
|
||||
|
||||
resp, head := e.openBig(t)
|
||||
e.activate(t, v2)
|
||||
|
||||
// This is the property a symlink rename cannot give you: the switch has
|
||||
// already happened, and this response is still the one it started as.
|
||||
if n := drain(t, resp, head); n != 1 {
|
||||
t.Errorf("a request that started before the switch finished on version %d, want 1", n)
|
||||
}
|
||||
body, err := e.get("marker.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if n, _ := smallVersion(body); n != 2 {
|
||||
t.Errorf("a request that started after the switch got version %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInFlightRequestSurvivesTheContentBeingCollected(t *testing.T) {
|
||||
e := newSwitchEnv(t)
|
||||
v1 := e.publish(t, 1, inflightFileSize)
|
||||
v2 := e.publish(t, 2, inflightFileSize)
|
||||
e.activate(t, v1)
|
||||
|
||||
resp, head := e.openBig(t)
|
||||
e.activate(t, v2)
|
||||
|
||||
// Everything a collector could possibly remove, with no grace period at
|
||||
// all: the assembled tree and the content it was hardlinked from. Both,
|
||||
// because removing only one of them leaves the inode alive through the
|
||||
// other and the test would prove nothing.
|
||||
if err := os.RemoveAll(DeploymentDir(e.dir, e.p.ID, v1.PublicID)); err != nil {
|
||||
t.Fatalf("removing the old tree: %v", err)
|
||||
}
|
||||
for _, c := range versionFiles(1, inflightFileSize) {
|
||||
if err := e.cas.Remove(cas.Sum([]byte(c))); err != nil {
|
||||
t.Fatalf("removing old content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The handler is holding an open descriptor, and POSIX keeps the inode
|
||||
// alive until it closes. The grace period in the collector exists so this
|
||||
// never has to be relied on, but relying on it has to work.
|
||||
if n := drain(t, resp, head); n != 1 {
|
||||
t.Errorf("a request whose content was deleted under it finished on version %d, want 1", n)
|
||||
}
|
||||
if _, err := e.get("marker.txt"); err != nil {
|
||||
t.Errorf("the live deployment stopped serving after the old one was collected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedActivationChangesNothing(t *testing.T) {
|
||||
e := newSwitchEnv(t)
|
||||
v1 := e.publish(t, 1, 64)
|
||||
v2 := e.publish(t, 2, 64)
|
||||
e.activate(t, v1)
|
||||
|
||||
link := filepath.Join(e.wrDir, "~demo")
|
||||
before, err := os.Readlink(link)
|
||||
if err != nil {
|
||||
t.Fatalf("$WEBROOT/~demo: %v", err)
|
||||
}
|
||||
|
||||
// Make v2's manifest unreadable, which fails Activate inside index() —
|
||||
// before the transaction, before the pointer store, before the symlink. The
|
||||
// bogus blobs row exists only to satisfy the foreign key; the point is the
|
||||
// one-byte digest, which cas.FromBytes refuses.
|
||||
err = e.db.Tx(t.Context(), func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(t.Context(), `
|
||||
INSERT INTO blobs (digest, size, present, created_at, last_ref_at)
|
||||
VALUES (x'00', 1, 1, 0, 0) ON CONFLICT(digest) DO NOTHING`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(t.Context(),
|
||||
`UPDATE deployment_files SET digest = x'00' WHERE deployment_id = ?`, v2.ID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("corrupting the manifest: %v", err)
|
||||
}
|
||||
|
||||
if _, err := e.svc.Activate(t.Context(), e.p, v2); err == nil {
|
||||
t.Fatal("Activate succeeded on a deployment whose manifest cannot be read")
|
||||
}
|
||||
|
||||
// In memory.
|
||||
body, err := e.get("marker.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if n, _ := smallVersion(body); n != 1 {
|
||||
t.Errorf("after the failed activation the site serves version %d, want 1", n)
|
||||
}
|
||||
// In the database, which is what a restart would come back to.
|
||||
active, err := e.db.ActiveDeployment(t.Context(), e.p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ActiveDeployment: %v", err)
|
||||
}
|
||||
if active.PublicID != v1.PublicID {
|
||||
t.Errorf("the database says %s is active, want %s", active.PublicID, v1.PublicID)
|
||||
}
|
||||
// And on disk.
|
||||
after, err := os.Readlink(link)
|
||||
if err != nil {
|
||||
t.Fatalf("$WEBROOT/~demo: %v", err)
|
||||
}
|
||||
if after != before {
|
||||
t.Errorf("$WEBROOT/~demo moved to %s, want it left at %s", after, before)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultBlobGrace is how long a blob must have been unreferenced before
|
||||
// its content is removed.
|
||||
//
|
||||
// It is what makes the read path safe without reference counting requests.
|
||||
// A handler resolves a digest from its snapshot and then opens it; between
|
||||
// those two instants the collector could in principle delete the file. An
|
||||
// hour is an enormous margin for a gap that is measured in microseconds,
|
||||
// and it costs only some disk that was going to be reclaimed anyway.
|
||||
defaultBlobGrace = time.Hour
|
||||
|
||||
// failedRetention is how long a failed deployment's row is kept. Its
|
||||
// manifest is already gone, so this is purely so that an operator
|
||||
// investigating a broken CI job can still see that it failed and why.
|
||||
failedRetention = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Collect runs one garbage collection pass: retention first, then the content
|
||||
// nothing references any more.
|
||||
//
|
||||
// Two passes in that order, and not one, because the first is what makes work
|
||||
// for the second. Deleting a deployment drops its manifest rows, the delete
|
||||
// trigger takes each blob's refcount down, and only then can a blob be seen to
|
||||
// be unreferenced. The second pass will not act on those blobs in this same
|
||||
// run — the trigger sets last_ref_at to now and the grace period has not
|
||||
// elapsed — which is deliberate: content that just became unreachable is
|
||||
// exactly the content some in-flight request is most likely to still be
|
||||
// reading.
|
||||
//
|
||||
// A dry run reports the deployments that would be deleted and the blobs that
|
||||
// are collectable right now. It cannot report the blobs the deletions would
|
||||
// free, because nothing has been deleted; the number is a floor, not an
|
||||
// estimate, and it is honest about being one.
|
||||
func (s *Service) Collect(ctx context.Context, dryRun bool) (api.GCStats, error) {
|
||||
stats := api.GCStats{DryRun: dryRun}
|
||||
|
||||
if !dryRun {
|
||||
// Abandoned uploads first, so that whatever only they referenced is
|
||||
// already unreferenced by the time the blob pass looks.
|
||||
n, err := s.DB.ExpireStaleDeployments(ctx, time.Now().Add(-staleUploadAge),
|
||||
"abandoned: no activity for "+staleUploadAge.String())
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
if n > 0 {
|
||||
s.Log.InfoContext(ctx, "expired unfinished deployments", "count", n)
|
||||
}
|
||||
}
|
||||
|
||||
projects, err := s.DB.AllProjects(ctx)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
for _, p := range projects {
|
||||
n, err := s.collectProject(ctx, p, dryRun)
|
||||
stats.DeploymentsDeleted += n
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
}
|
||||
|
||||
blobs, err := s.DB.UnreferencedBlobs(ctx, time.Now().Add(-s.blobGrace()), 0)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
for _, b := range blobs {
|
||||
if dryRun {
|
||||
stats.BlobsDeleted++
|
||||
stats.BytesFreed += b.Size
|
||||
continue
|
||||
}
|
||||
deleted, err := s.DB.DeleteBlob(ctx, b.Digest, func() error { return s.CAS.Remove(b.Digest) })
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
// Not deleted means the blob was referenced again between the listing
|
||||
// and the delete — a new deployment naming content that was about to be
|
||||
// collected. Leaving it alone is the whole point of rechecking the
|
||||
// refcount inside the transaction.
|
||||
if deleted {
|
||||
stats.BlobsDeleted++
|
||||
stats.BytesFreed += b.Size
|
||||
}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// collectProject applies one project's retention policy.
|
||||
//
|
||||
// The active deployment is not considered at all: it is excluded by the query,
|
||||
// so no counting mistake here can reach it.
|
||||
func (s *Service) collectProject(ctx context.Context, p *store.Project, dryRun bool) (int, error) {
|
||||
deps, err := s.DB.InactiveDeployments(ctx, p.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
grace := time.Duration(p.RetentionGraceS) * time.Second
|
||||
now := time.Now()
|
||||
|
||||
var deleted, kept int
|
||||
for _, dep := range deps {
|
||||
switch dep.State {
|
||||
case store.StateReady:
|
||||
// Newest first, so the first RetentionCount of them are the ones
|
||||
// worth keeping for a rollback.
|
||||
if kept < p.RetentionCount {
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
if now.Sub(retiredAt(dep)) < grace {
|
||||
continue
|
||||
}
|
||||
case store.StateFailed:
|
||||
if now.Sub(dep.CreatedAt) < failedRetention {
|
||||
continue
|
||||
}
|
||||
case store.StateDeleting:
|
||||
// Already claimed by a sweep that did not finish. No grace applies:
|
||||
// nothing may serve a tree that is half removed.
|
||||
default:
|
||||
// pending or uploading. Someone may still be uploading to it, and
|
||||
// ExpireStaleDeployments is what decides when they are not.
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
deleted++
|
||||
continue
|
||||
}
|
||||
if err := s.claim(ctx, p, dep); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
// Deleted by someone else between the listing and now.
|
||||
case errors.Is(err, store.ErrConflict):
|
||||
// Activated between the listing and now — a rollback landed on
|
||||
// a deployment retention had picked. Correct outcome: the claim
|
||||
// is refused and the deployment stays.
|
||||
s.Log.InfoContext(ctx, "skipped a deployment that was activated during collection",
|
||||
"project", p.Name, "deployment", dep.PublicID)
|
||||
default:
|
||||
return deleted, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.removeDeployment(ctx, dep); err != nil {
|
||||
// The row is in the deleting state and committed, so recovery or
|
||||
// the next sweep will finish it. Nothing serves it in the meantime.
|
||||
s.Log.WarnContext(ctx, "could not finish deleting a deployment",
|
||||
"project", p.Name, "deployment", dep.PublicID, "err", err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// retiredAt is when a deployment stopped being served, or when it was created
|
||||
// if it never was. It is what the retention grace is measured from.
|
||||
func retiredAt(dep *store.Deployment) time.Time {
|
||||
if dep.DeactivatedAt != nil {
|
||||
return *dep.DeactivatedAt
|
||||
}
|
||||
return dep.CreatedAt
|
||||
}
|
||||
|
||||
// claim marks a deployment as being deleted, which is what makes it safe to
|
||||
// start removing files.
|
||||
//
|
||||
// The project lock is held for exactly this statement. Activation takes the
|
||||
// same lock and re-reads the row under it, so the two orderings are the only
|
||||
// possible ones: either activation commits first and this fails on the
|
||||
// active = 0 condition, or this commits first and activation finds a deployment
|
||||
// in the deleting state and refuses it. There is no interleaving in which a
|
||||
// tree is removed from underneath a deployment that has just become active.
|
||||
func (s *Service) claim(ctx context.Context, p *store.Project, dep *store.Deployment) error {
|
||||
unlock, err := s.locks.lock(ctx, p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
return s.DB.MarkDeploymentDeleting(ctx, dep.ID)
|
||||
}
|
||||
|
||||
// Delete removes one deployment on request. The active one cannot be deleted:
|
||||
// that is a conflict, not a permission problem, and the client is expected to
|
||||
// activate something else first.
|
||||
func (s *Service) Delete(ctx context.Context, p *store.Project, dep *store.Deployment) error {
|
||||
if err := s.claim(ctx, p, dep); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
return api.Errorf(api.CodeNotFound, "no such deployment")
|
||||
case errors.Is(err, store.ErrConflict):
|
||||
return api.Errorf(api.CodeDeploymentActive,
|
||||
"this deployment is the one the project is serving; activate another one first")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := s.removeDeployment(ctx, dep); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Log.InfoContext(ctx, "deployment deleted", "project", p.Name, "deployment", dep.PublicID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProjectTrees deletes what a project left on disk once its rows are
|
||||
// gone. Its blobs are freed by the same cascade and collected on the next pass.
|
||||
func (s *Service) RemoveProjectTrees(projectID int64) error {
|
||||
if s.Dir == "" {
|
||||
return nil
|
||||
}
|
||||
return os.RemoveAll(filepath.Join(s.Dir, strconv.FormatInt(projectID, 10)))
|
||||
}
|
||||
|
||||
// RunCollector collects on a timer until ctx is done. A failed pass is logged
|
||||
// and the next one runs as scheduled: everything the collector does is
|
||||
// idempotent, so there is nothing to unwind and no reason to stop.
|
||||
func (s *Service) RunCollector(ctx context.Context, every time.Duration) {
|
||||
if every <= 0 {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
stats, err := s.Collect(ctx, false)
|
||||
if err != nil {
|
||||
s.Log.WarnContext(ctx, "garbage collection did not finish", "err", err)
|
||||
}
|
||||
if stats.DeploymentsDeleted > 0 || stats.BlobsDeleted > 0 {
|
||||
s.Log.InfoContext(ctx, "collected",
|
||||
"deployments", stats.DeploymentsDeleted,
|
||||
"blobs", stats.BlobsDeleted, "bytes", stats.BytesFreed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) blobGrace() time.Duration {
|
||||
if s.BlobGrace == 0 {
|
||||
return defaultBlobGrace
|
||||
}
|
||||
return s.BlobGrace
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// These tests set BlobGrace negative. The grace exists so that a request which
|
||||
// has resolved a digest and is about to open it cannot lose the file underneath
|
||||
// it, and the default hour is far longer than any test wants to wait. Timestamps
|
||||
// are whole seconds, so a grace of zero would not collect a blob dereferenced in
|
||||
// the same second either — negative is the only value that means "now".
|
||||
const collectNow = -time.Minute
|
||||
|
||||
// deployReady publishes a finished, inactive deployment holding contents.
|
||||
func (e *env) deployReady(t *testing.T, contents map[string]string) *store.Deployment {
|
||||
t.Helper()
|
||||
dep := e.create(t)
|
||||
files := manifest(contents)
|
||||
if _, _, err := e.svc.SetManifest(t.Context(), dep, files); err != nil {
|
||||
t.Fatalf("SetManifest: %v", err)
|
||||
}
|
||||
paths := make([]string, 0, len(contents))
|
||||
for p := range contents {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
e.upload(t, contents, paths...)
|
||||
dep, err := e.svc.Finalize(t.Context(), e.p, dep)
|
||||
if err != nil {
|
||||
t.Fatalf("Finalize: %v", err)
|
||||
}
|
||||
return dep
|
||||
}
|
||||
|
||||
// version publishes a deployment whose single file identifies it, which is
|
||||
// enough for retention tests: what matters is how many survive and which.
|
||||
func (e *env) version(t *testing.T, n int) *store.Deployment {
|
||||
t.Helper()
|
||||
return e.deployReady(t, map[string]string{"index.html": "v" + strconv.Itoa(n)})
|
||||
}
|
||||
|
||||
func (e *env) activate(t *testing.T, dep *store.Deployment) {
|
||||
t.Helper()
|
||||
if _, err := e.svc.Activate(t.Context(), e.p, dep); err != nil {
|
||||
t.Fatalf("Activate %s: %v", dep.PublicID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// exec runs a statement the service has no method for. Retention tests need to
|
||||
// backdate rows, because the alternative is a test that sleeps for an hour.
|
||||
func (e *env) exec(t *testing.T, query string, args ...any) {
|
||||
t.Helper()
|
||||
err := e.db.Tx(t.Context(), func(tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(t.Context(), query, args...)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", query, err)
|
||||
}
|
||||
}
|
||||
|
||||
// setRetention rewrites the project's policy and re-reads it, because the
|
||||
// collector reads the row and not the struct the test is holding.
|
||||
func (e *env) setRetention(t *testing.T, count int, graceS int64) {
|
||||
t.Helper()
|
||||
e.exec(t, `UPDATE projects SET retention_count = ?, retention_grace_s = ? WHERE id = ?`,
|
||||
count, graceS, e.p.ID)
|
||||
p, err := e.db.ProjectByID(t.Context(), e.p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.p = p
|
||||
}
|
||||
|
||||
// alive reports whether a deployment still has a row and a tree.
|
||||
func (e *env) alive(t *testing.T, dep *store.Deployment) (row, tree bool) {
|
||||
t.Helper()
|
||||
_, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID)
|
||||
switch {
|
||||
case err == nil:
|
||||
row = true
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
default:
|
||||
t.Fatalf("DeploymentByPublicID: %v", err)
|
||||
}
|
||||
_, err = os.Stat(DeploymentDir(e.dir, e.p.ID, dep.PublicID))
|
||||
switch {
|
||||
case err == nil:
|
||||
tree = true
|
||||
case os.IsNotExist(err):
|
||||
default:
|
||||
t.Fatalf("stat deployment tree: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (e *env) mustSurvive(t *testing.T, dep *store.Deployment, why string) {
|
||||
t.Helper()
|
||||
row, tree := e.alive(t, dep)
|
||||
if !row || !tree {
|
||||
t.Errorf("%s (%s) was collected: row=%v tree=%v", why, dep.PublicID, row, tree)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *env) mustBeGone(t *testing.T, dep *store.Deployment, why string) {
|
||||
t.Helper()
|
||||
row, tree := e.alive(t, dep)
|
||||
if row || tree {
|
||||
t.Errorf("%s (%s) survived: row=%v tree=%v", why, dep.PublicID, row, tree)
|
||||
}
|
||||
}
|
||||
|
||||
// blobCount is how many blobs the database knows about, which is the number the
|
||||
// milestone's manual check watches drop.
|
||||
func blobCount(t *testing.T, e *env) int64 {
|
||||
t.Helper()
|
||||
counts, err := e.db.Counts(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return counts.Blobs
|
||||
}
|
||||
|
||||
func hasContent(t *testing.T, e *env, content string) bool {
|
||||
t.Helper()
|
||||
ok, err := e.cas.Has(cas.Sum([]byte(content)))
|
||||
if err != nil {
|
||||
t.Fatalf("cas.Has: %v", err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// The headline retention rule from the milestone: deploy repeatedly, keep
|
||||
// retention_count of them plus whichever one is being served, and watch the
|
||||
// content of the rest go away.
|
||||
func TestCollectKeepsRetentionCountPlusTheActiveOne(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.svc.BlobGrace = collectNow
|
||||
e.setRetention(t, 10, 0)
|
||||
|
||||
var deps []*store.Deployment
|
||||
for i := 1; i <= 15; i++ {
|
||||
dep := e.version(t, i)
|
||||
deps = append(deps, dep)
|
||||
e.activate(t, dep)
|
||||
}
|
||||
// Roll back to the oldest one, so the deployment being served is also the
|
||||
// one retention would otherwise drop first. Nothing may collect it.
|
||||
e.activate(t, deps[0])
|
||||
|
||||
before := blobCount(t, e)
|
||||
stats, err := e.svc.Collect(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 15 deployments, 10 kept by retention plus the active one: 4 collected.
|
||||
if stats.DeploymentsDeleted != 4 {
|
||||
t.Errorf("deleted %d deployments, want 4", stats.DeploymentsDeleted)
|
||||
}
|
||||
e.mustSurvive(t, deps[0], "the active deployment")
|
||||
for _, dep := range deps[5:] {
|
||||
e.mustSurvive(t, dep, "a deployment inside the retention window")
|
||||
}
|
||||
for _, dep := range deps[1:5] {
|
||||
e.mustBeGone(t, dep, "a deployment past the retention window")
|
||||
}
|
||||
|
||||
if after := blobCount(t, e); after != before-4 {
|
||||
t.Errorf("blob count went from %d to %d, want %d", before, after, before-4)
|
||||
}
|
||||
if stats.BlobsDeleted != 4 {
|
||||
t.Errorf("collected %d blobs, want the 4 the deleted deployments held", stats.BlobsDeleted)
|
||||
}
|
||||
if stats.BytesFreed <= 0 {
|
||||
t.Errorf("BytesFreed = %d, want the size of what was removed", stats.BytesFreed)
|
||||
}
|
||||
for i := 2; i <= 5; i++ {
|
||||
if hasContent(t, e, "v"+strconv.Itoa(i)) {
|
||||
t.Errorf("content of the collected deployment v%d is still in the CAS", i)
|
||||
}
|
||||
}
|
||||
// What the survivors reference is untouched, which is what makes a rollback
|
||||
// to any of them still work.
|
||||
if !hasContent(t, e, "v1") || !hasContent(t, e, "v15") {
|
||||
t.Error("content a surviving deployment references was collected")
|
||||
}
|
||||
|
||||
// Nothing left to do on a second pass.
|
||||
stats, err = e.svc.Collect(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.DeploymentsDeleted != 0 || stats.BlobsDeleted != 0 {
|
||||
t.Errorf("a second pass collected %+v, want nothing", stats)
|
||||
}
|
||||
}
|
||||
|
||||
// The grace period is measured from when a deployment stopped being served, so
|
||||
// a rollback that was a mistake can be undone for a while afterwards.
|
||||
func TestCollectHonoursTheRetentionGrace(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.svc.BlobGrace = collectNow
|
||||
e.setRetention(t, 0, 3600)
|
||||
|
||||
old := e.version(t, 1)
|
||||
e.activate(t, old)
|
||||
current := e.version(t, 2)
|
||||
e.activate(t, current)
|
||||
|
||||
// retention_count is zero, so only the grace is protecting it.
|
||||
stats, err := e.svc.Collect(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.DeploymentsDeleted != 0 {
|
||||
t.Errorf("deleted %d deployments inside the grace period, want 0", stats.DeploymentsDeleted)
|
||||
}
|
||||
e.mustSurvive(t, old, "a deployment retired seconds ago")
|
||||
|
||||
// Backdate the deactivation past the grace and it becomes collectable.
|
||||
e.exec(t, `UPDATE deployments SET deactivated_at = ? WHERE id = ?`,
|
||||
time.Now().Add(-2*time.Hour).Unix(), old.ID)
|
||||
if _, err := e.svc.Collect(t.Context(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.mustBeGone(t, old, "a deployment retired before the grace period")
|
||||
e.mustSurvive(t, current, "the active deployment")
|
||||
}
|
||||
|
||||
// A deployment that never finished uploading is not retention's business: it
|
||||
// has no deactivated_at to measure from and someone may still be pushing to it.
|
||||
func TestCollectLeavesUnfinishedUploadsToTheExpiry(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.svc.BlobGrace = collectNow
|
||||
e.setRetention(t, 0, 0)
|
||||
|
||||
contents := map[string]string{"index.html": "in progress"}
|
||||
dep := e.create(t)
|
||||
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.upload(t, contents, "index.html")
|
||||
|
||||
if _, err := e.svc.Collect(t.Context(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID)
|
||||
if err != nil {
|
||||
t.Fatalf("an in-progress upload was collected: %v", err)
|
||||
}
|
||||
if got.State != store.StateUploading {
|
||||
t.Errorf("state = %q, want it left alone as %q", got.State, store.StateUploading)
|
||||
}
|
||||
// Its content is protected too, by the manifest rows that already reference
|
||||
// it — which is the whole reason the manifest is written before the upload.
|
||||
if !hasContent(t, e, "in progress") {
|
||||
t.Error("the content of an in-progress upload was collected")
|
||||
}
|
||||
|
||||
// Once it is old enough, one pass does the whole job: the expiry fails it
|
||||
// and drops its manifest, retention finds a failed deployment older than it
|
||||
// keeps failures for, and the blob pass then reaches what only it
|
||||
// referenced. That the three run in that order is why it takes one pass and
|
||||
// not three.
|
||||
e.exec(t, `UPDATE deployments SET created_at = ? WHERE id = ?`,
|
||||
time.Now().Add(-48*time.Hour).Unix(), dep.ID)
|
||||
stats, err := e.svc.Collect(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.DeploymentsDeleted != 1 || stats.BlobsDeleted != 1 {
|
||||
t.Errorf("collected %+v, want the abandoned upload and its content", stats)
|
||||
}
|
||||
if _, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("the abandoned upload survived: %v", err)
|
||||
}
|
||||
if hasContent(t, e, "in progress") {
|
||||
t.Error("content nothing references any more survived the sweep")
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment that failed while being finalized keeps its row for a day, so an
|
||||
// operator looking into a broken CI job can still see that it failed and why.
|
||||
func TestCollectKeepsRecentFailuresForInspection(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.svc.BlobGrace = collectNow
|
||||
e.setRetention(t, 0, 0)
|
||||
|
||||
dep := e.create(t)
|
||||
if err := e.db.MarkDeploymentFailed(t.Context(), dep.ID, "assembly failed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := e.svc.Collect(t.Context(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID)
|
||||
if err != nil {
|
||||
t.Fatalf("a deployment that failed moments ago was collected: %v", err)
|
||||
}
|
||||
if got.Error != "assembly failed" {
|
||||
t.Errorf("error = %q, want the reason still readable", got.Error)
|
||||
}
|
||||
|
||||
e.exec(t, `UPDATE deployments SET created_at = ? WHERE id = ?`,
|
||||
time.Now().Add(-48*time.Hour).Unix(), dep.ID)
|
||||
if _, err := e.svc.Collect(t.Context(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Errorf("a failed row older than the retention survived: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Content two deployments share outlives the first of them. This is what makes
|
||||
// cross-deployment deduplication safe to rely on.
|
||||
func TestCollectKeepsSharedContent(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.svc.BlobGrace = collectNow
|
||||
e.setRetention(t, 0, 0)
|
||||
|
||||
shared := "console.log(1)"
|
||||
old := e.deployReady(t, map[string]string{"index.html": "v1", "app.js": shared})
|
||||
e.activate(t, old)
|
||||
current := e.deployReady(t, map[string]string{"index.html": "v2", "app.js": shared})
|
||||
e.activate(t, current)
|
||||
|
||||
if _, err := e.svc.Collect(t.Context(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.mustBeGone(t, old, "the superseded deployment")
|
||||
if hasContent(t, e, "v1") {
|
||||
t.Error("content only the collected deployment referenced survived")
|
||||
}
|
||||
if !hasContent(t, e, shared) {
|
||||
t.Fatal("content the active deployment still references was collected")
|
||||
}
|
||||
// And it is still readable through the deployment that survived, which is
|
||||
// the property the assembled tree shares an inode for.
|
||||
body, err := os.ReadFile(filepath.Join(DeploymentDir(e.dir, e.p.ID, current.PublicID), "app.js"))
|
||||
if err != nil || string(body) != shared {
|
||||
t.Errorf("reading shared content from the surviving tree = %q, %v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The grace is what makes the read path safe without per-request reference
|
||||
// counting, so it has to actually hold content back.
|
||||
func TestCollectHoldsRecentlyDereferencedBlobs(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.setRetention(t, 0, 0) // BlobGrace left at its default hour.
|
||||
|
||||
old := e.version(t, 1)
|
||||
e.activate(t, old)
|
||||
e.activate(t, e.version(t, 2))
|
||||
|
||||
stats, err := e.svc.Collect(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.mustBeGone(t, old, "the superseded deployment")
|
||||
if stats.BlobsDeleted != 0 {
|
||||
t.Errorf("collected %d blobs, want them held by the grace period", stats.BlobsDeleted)
|
||||
}
|
||||
if !hasContent(t, e, "v1") {
|
||||
t.Error("content dereferenced moments ago was removed inside the grace period")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDryRunChangesNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.svc.BlobGrace = collectNow
|
||||
e.setRetention(t, 0, 0)
|
||||
|
||||
old := e.version(t, 1)
|
||||
e.activate(t, old)
|
||||
current := e.version(t, 2)
|
||||
e.activate(t, current)
|
||||
|
||||
stats, err := e.svc.Collect(t.Context(), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !stats.DryRun {
|
||||
t.Error("the report does not say it was a dry run")
|
||||
}
|
||||
if stats.DeploymentsDeleted != 1 {
|
||||
t.Errorf("reported %d deployments, want the 1 that would be deleted", stats.DeploymentsDeleted)
|
||||
}
|
||||
e.mustSurvive(t, old, "a deployment a dry run only reported on")
|
||||
e.mustSurvive(t, current, "the active deployment")
|
||||
if !hasContent(t, e, "v1") {
|
||||
t.Error("a dry run removed content")
|
||||
}
|
||||
|
||||
// Blobs the reported deletions would free are not counted: nothing was
|
||||
// deleted, so they are all still referenced. The number is a floor.
|
||||
if stats.BlobsDeleted != 0 {
|
||||
t.Errorf("a dry run reported %d collectable blobs, want 0 while everything is referenced",
|
||||
stats.BlobsDeleted)
|
||||
}
|
||||
|
||||
// The real pass then does what the dry run said it would.
|
||||
stats, err = e.svc.Collect(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.DryRun {
|
||||
t.Error("a real pass reported itself as a dry run")
|
||||
}
|
||||
if stats.DeploymentsDeleted != 1 {
|
||||
t.Errorf("deleted %d deployments, want 1", stats.DeploymentsDeleted)
|
||||
}
|
||||
e.mustBeGone(t, old, "the superseded deployment")
|
||||
}
|
||||
|
||||
func TestDeleteRemovesRowAndTree(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.svc.BlobGrace = collectNow
|
||||
|
||||
dep := e.version(t, 1)
|
||||
current := e.version(t, 2)
|
||||
e.activate(t, current)
|
||||
|
||||
if err := e.svc.Delete(t.Context(), e.p, dep); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
e.mustBeGone(t, dep, "the deleted deployment")
|
||||
|
||||
// Deleting it dropped the manifest, so the next collection reaches what only
|
||||
// it referenced.
|
||||
if _, err := e.svc.Collect(t.Context(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hasContent(t, e, "v1") {
|
||||
t.Error("content the deleted deployment held survived collection")
|
||||
}
|
||||
|
||||
if err := e.svc.Delete(t.Context(), e.p, dep); apiCode(err) != api.CodeNotFound {
|
||||
t.Errorf("deleting it again = %v, want %q", err, api.CodeNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting what a project is serving is a conflict, not a permission problem:
|
||||
// the client is told to activate something else first.
|
||||
func TestDeleteRefusesTheActiveDeployment(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
dep := e.version(t, 1)
|
||||
e.activate(t, dep)
|
||||
|
||||
err := e.svc.Delete(t.Context(), e.p, dep)
|
||||
wantCode(t, err, api.CodeDeploymentActive)
|
||||
e.mustSurvive(t, dep, "the active deployment")
|
||||
|
||||
// It becomes deletable the moment something else is being served, which is
|
||||
// the sequence the error message describes.
|
||||
e.activate(t, e.version(t, 2))
|
||||
if err := e.svc.Delete(t.Context(), e.p, dep); err != nil {
|
||||
t.Fatalf("Delete after activating another deployment: %v", err)
|
||||
}
|
||||
e.mustBeGone(t, dep, "the deployment that was superseded and then deleted")
|
||||
}
|
||||
|
||||
func TestRemoveProjectTrees(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
dep := e.version(t, 1)
|
||||
e.activate(t, dep)
|
||||
|
||||
other := store.DefaultProject("other")
|
||||
if err := e.db.CreateProject(t.Context(), other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherDir := DeploymentDir(e.dir, other.ID, "dpl_0000000000000000")
|
||||
if err := os.MkdirAll(otherDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := e.svc.RemoveProjectTrees(e.p.ID); err != nil {
|
||||
t.Fatalf("RemoveProjectTrees: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(e.dir, strconv.FormatInt(e.p.ID, 10))); !os.IsNotExist(err) {
|
||||
t.Errorf("the project's directory survived: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(otherDir); err != nil {
|
||||
t.Errorf("another project's directory was removed: %v", err)
|
||||
}
|
||||
|
||||
// Idempotent: recovery may run it again after a crash partway through.
|
||||
if err := e.svc.RemoveProjectTrees(e.p.ID); err != nil {
|
||||
t.Errorf("a second removal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting the project takes its deployments with it, and the collector then
|
||||
// reclaims everything they referenced.
|
||||
func TestCollectAfterProjectDeletion(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.svc.BlobGrace = collectNow
|
||||
|
||||
dep := e.version(t, 1)
|
||||
e.activate(t, dep)
|
||||
|
||||
if err := e.db.DeleteProject(t.Context(), e.p.ID); err != nil {
|
||||
t.Fatalf("DeleteProject: %v", err)
|
||||
}
|
||||
if err := e.svc.RemoveProjectTrees(e.p.ID); err != nil {
|
||||
t.Fatalf("RemoveProjectTrees: %v", err)
|
||||
}
|
||||
|
||||
stats, err := e.svc.Collect(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.BlobsDeleted != 1 {
|
||||
t.Errorf("collected %d blobs, want the 1 the deleted project held", stats.BlobsDeleted)
|
||||
}
|
||||
if hasContent(t, e, "v1") {
|
||||
t.Error("content of a deleted project survived collection")
|
||||
}
|
||||
if n := blobCount(t, e); n != 0 {
|
||||
t.Errorf("%d blob rows left after the project was deleted", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment claimed by a sweep that was interrupted is finished by the next
|
||||
// one, whatever its retention would otherwise have said.
|
||||
func TestCollectResumesAnInterruptedDeletion(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.svc.BlobGrace = collectNow
|
||||
e.setRetention(t, 10, 3600) // Generous: retention alone would keep it.
|
||||
|
||||
dep := e.version(t, 1)
|
||||
e.activate(t, e.version(t, 2))
|
||||
if err := e.db.MarkDeploymentDeleting(t.Context(), dep.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stats, err := e.svc.Collect(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.DeploymentsDeleted != 1 {
|
||||
t.Errorf("deleted %d deployments, want the claimed one", stats.DeploymentsDeleted)
|
||||
}
|
||||
e.mustBeGone(t, dep, "a deployment a previous sweep had claimed")
|
||||
}
|
||||
|
||||
// The activation path and the collector both take the project lock, and the
|
||||
// claim rechecks active = 0 under it. A deployment that becomes active between
|
||||
// being listed and being claimed is therefore refused rather than deleted.
|
||||
func TestCollectSkipsADeploymentActivatedUnderIt(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.svc.BlobGrace = collectNow
|
||||
e.setRetention(t, 0, 0)
|
||||
|
||||
old := e.version(t, 1)
|
||||
e.activate(t, e.version(t, 2))
|
||||
|
||||
// Stand in for the interleaving: retention has decided to drop `old`, and a
|
||||
// rollback activates it before the claim runs.
|
||||
e.activate(t, old)
|
||||
if err := e.svc.claim(t.Context(), e.p, old); !errors.Is(err, store.ErrConflict) {
|
||||
t.Fatalf("claiming a deployment that became active = %v, want ErrConflict", err)
|
||||
}
|
||||
e.mustSurvive(t, old, "a deployment activated during collection")
|
||||
|
||||
stats, err := e.svc.Collect(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.DeploymentsDeleted != 1 {
|
||||
t.Errorf("deleted %d deployments, want only the one that is no longer served", stats.DeploymentsDeleted)
|
||||
}
|
||||
e.mustSurvive(t, old, "the deployment the rollback made active")
|
||||
}
|
||||
|
||||
// Collection walks every project, not just the one a request happened to name.
|
||||
func TestCollectSpansProjects(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.svc.BlobGrace = collectNow
|
||||
e.setRetention(t, 0, 0)
|
||||
|
||||
first := e.p
|
||||
firstOld := e.version(t, 1)
|
||||
e.activate(t, e.version(t, 2))
|
||||
|
||||
second := store.DefaultProject("second")
|
||||
second.RetentionCount = 0
|
||||
second.RetentionGraceS = 0
|
||||
if err := e.db.CreateProject(t.Context(), second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.p = second
|
||||
secondOld := e.version(t, 3)
|
||||
e.activate(t, e.version(t, 4))
|
||||
e.p = first
|
||||
|
||||
stats, err := e.svc.Collect(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.DeploymentsDeleted != 2 {
|
||||
t.Errorf("deleted %d deployments, want one from each project", stats.DeploymentsDeleted)
|
||||
}
|
||||
e.mustBeGone(t, firstOld, "the first project's superseded deployment")
|
||||
e.p = second
|
||||
e.mustBeGone(t, secondOld, "the second project's superseded deployment")
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// staleUploadAge is how long a deployment may sit unfinished before recovery
|
||||
// gives up on it. Generous on purpose: the only thing separating a CI job that
|
||||
// died from one that is uploading a large site over a slow link is how long it
|
||||
// has been, and expiring the second kind turns a slow deploy into a failed one.
|
||||
const staleUploadAge = 24 * time.Hour
|
||||
|
||||
// Recover makes the filesystem and the database agree again after a crash or an
|
||||
// unclean shutdown.
|
||||
//
|
||||
// It runs once at startup, before any listener exists and before the registry is
|
||||
// built, so it can assume it is the only thing touching either. Everything it
|
||||
// does is idempotent: being killed halfway through only means the next start
|
||||
// finds a little more to do.
|
||||
//
|
||||
// Nothing here is allowed to be fatal. A server that refuses to start because
|
||||
// one directory could not be swept is worse than one that starts and logs it —
|
||||
// the deployments themselves are already durable, and every inconsistency this
|
||||
// looks for is one the running system tolerates.
|
||||
func (s *Service) Recover(ctx context.Context) error {
|
||||
// An upload that was in progress is unreferenced by construction: a blob only
|
||||
// becomes reachable by being renamed out of the temp directory.
|
||||
if n, err := s.CAS.PurgeTemp(); err != nil {
|
||||
s.Log.Warn("could not clear interrupted uploads", "err", err)
|
||||
} else if n > 0 {
|
||||
s.Log.Info("cleared interrupted uploads", "count", n)
|
||||
}
|
||||
|
||||
if err := s.recoverBlobs(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Then the deployments that were still being written when the process went
|
||||
// away, so their manifest rows are gone before the blob collector next runs
|
||||
// and can reclaim whatever only they referenced.
|
||||
n, err := s.DB.ExpireStaleDeployments(ctx, time.Now().Add(-staleUploadAge),
|
||||
"abandoned: no activity for "+staleUploadAge.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
s.Log.Info("expired unfinished deployments", "count", n)
|
||||
}
|
||||
|
||||
if err := s.resumeDeletions(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sweepTrees(ctx)
|
||||
}
|
||||
|
||||
// recoverBlobs finds content the database believes is on disk and is not.
|
||||
//
|
||||
// This is the "restored the database, lost the disk" case, and also what a
|
||||
// half-finished collector sweep leaves behind. Marking the rows absent is
|
||||
// enough to fix it: the next deploy naming one of these digests is asked to
|
||||
// upload it, and every deployment that referenced it becomes deployable again as
|
||||
// soon as one does.
|
||||
func (s *Service) recoverBlobs(ctx context.Context) error {
|
||||
var absent []cas.Digest
|
||||
err := s.DB.EachPresentBlob(ctx, func(d cas.Digest) error {
|
||||
has, err := s.CAS.Has(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has {
|
||||
absent = append(absent, d)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(absent) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.DB.MarkBlobsAbsent(ctx, absent); err != nil {
|
||||
return err
|
||||
}
|
||||
// Loud, because on a healthy server this number is zero. Anything else means
|
||||
// the content store lost data, and an operator wants to hear about it before
|
||||
// a deploy fails for a reason that looks like the client's fault.
|
||||
s.Log.Warn("content is missing from the store and was marked for re-upload",
|
||||
"blobs", len(absent))
|
||||
return nil
|
||||
}
|
||||
|
||||
// resumeDeletions finishes what a collector sweep was doing when it stopped.
|
||||
//
|
||||
// A deployment enters the deleting state before anything of it is removed, so a
|
||||
// row still in that state is a tree that may be half gone. Half a tree is
|
||||
// exactly what nothing may serve, which is why the state is committed first: it
|
||||
// makes an interrupted deletion recognisable rather than indistinguishable from
|
||||
// a healthy deployment.
|
||||
func (s *Service) resumeDeletions(ctx context.Context) error {
|
||||
deps, err := s.DB.DeploymentsInState(ctx, store.StateDeleting, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, dep := range deps {
|
||||
if err := s.removeDeployment(ctx, dep); err != nil {
|
||||
s.Log.Warn("could not finish deleting a deployment",
|
||||
"deployment", dep.PublicID, "err", err)
|
||||
continue
|
||||
}
|
||||
s.Log.Info("finished deleting a deployment", "deployment", dep.PublicID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeDeployment takes a claimed deployment the rest of the way: its tree
|
||||
// first, then its rows. Tree before rows, because a row without a tree is a
|
||||
// deployment that simply cannot be activated, whereas a tree without a row is
|
||||
// disk nobody will ever account for again.
|
||||
func (s *Service) removeDeployment(ctx context.Context, dep *store.Deployment) error {
|
||||
if s.Dir != "" {
|
||||
dir := DeploymentDir(s.Dir, dep.ProjectID, dep.PublicID)
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(dir + stagingSuffix); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.DB.DeleteDeployment(ctx, dep.ID)
|
||||
}
|
||||
|
||||
// sweepTrees removes assembled trees that nothing refers to.
|
||||
//
|
||||
// Two kinds: staging directories, which are by definition a build that never
|
||||
// finished, and directories whose deployment row is gone — the reverse of the
|
||||
// blob audit above, and the residue of a deletion that removed rows before it
|
||||
// removed files, or of a database restored from an older backup than the disk.
|
||||
func (s *Service) sweepTrees(ctx context.Context) error {
|
||||
if s.Dir == "" {
|
||||
return nil
|
||||
}
|
||||
refs, err := s.DB.AllDeploymentRefs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
known := make(map[store.DeploymentRef]struct{}, len(refs))
|
||||
for _, r := range refs {
|
||||
known[r] = struct{}{}
|
||||
}
|
||||
|
||||
projects, err := os.ReadDir(s.Dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var staging, orphans int
|
||||
for _, pe := range projects {
|
||||
// Only the layout this package writes is ever considered for removal:
|
||||
// one directory per project id, named by the id. Anything else under
|
||||
// $DATA_DIR/deployments was put there by someone else and is left alone.
|
||||
projectID, err := strconv.ParseInt(pe.Name(), 10, 64)
|
||||
if err != nil || !pe.IsDir() {
|
||||
continue
|
||||
}
|
||||
projectDir := filepath.Join(s.Dir, pe.Name())
|
||||
entries, err := os.ReadDir(projectDir)
|
||||
if err != nil {
|
||||
s.Log.Warn("could not read a project's deployment directory", "dir", projectDir, "err", err)
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
target := filepath.Join(projectDir, name)
|
||||
switch {
|
||||
case strings.HasSuffix(name, stagingSuffix):
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
s.Log.Warn("could not remove a staging directory", "dir", target, "err", err)
|
||||
continue
|
||||
}
|
||||
staging++
|
||||
default:
|
||||
if _, ok := known[store.DeploymentRef{ProjectID: projectID, PublicID: name}]; ok {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
s.Log.Warn("could not remove an orphaned deployment tree", "dir", target, "err", err)
|
||||
continue
|
||||
}
|
||||
orphans++
|
||||
}
|
||||
}
|
||||
}
|
||||
if staging > 0 || orphans > 0 {
|
||||
s.Log.Info("swept unreferenced deployment trees", "staging", staging, "orphaned", orphans)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/site"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
"github.com/iceBear67/simplepages/internal/webroot"
|
||||
)
|
||||
|
||||
// Service runs the deployment lifecycle.
|
||||
//
|
||||
// Client-visible failures are returned as *api.Error so the HTTP layer stays a
|
||||
// translation of shapes rather than a second copy of the rules; anything else
|
||||
// is an internal error and is rendered as an opaque 500 by httpx.WriteError.
|
||||
type Service struct {
|
||||
DB *store.DB
|
||||
CAS *cas.Store
|
||||
Log *slog.Logger
|
||||
|
||||
// Dir is the root of the assembled deployment trees. Empty means the
|
||||
// operator chose assemble_mode=none: content is served straight from the
|
||||
// CAS and nothing is built on disk.
|
||||
Dir string
|
||||
|
||||
// Sites is the in-memory state the HTTP site handler reads. Activation
|
||||
// publishes into it; nil leaves the service usable without a serving layer,
|
||||
// which is what the store-level tests want.
|
||||
Sites *site.Registry
|
||||
|
||||
// Webroot maintains the $WEBROOT/~project symlinks. Nil when the operator
|
||||
// configured no webroot. Nothing here depends on it succeeding.
|
||||
Webroot *webroot.Webroot
|
||||
|
||||
// BlobGrace is how long content must have been unreferenced before the
|
||||
// collector removes it. Zero means defaultBlobGrace. Negative collects
|
||||
// immediately, which is what the tests want and what an operator reclaiming
|
||||
// space on a server they know is idle might ask for.
|
||||
BlobGrace time.Duration
|
||||
|
||||
locks projectLocks
|
||||
}
|
||||
|
||||
// Create starts a deployment. Nothing touches the filesystem until a manifest
|
||||
// arrives, so an abandoned create costs one row.
|
||||
func (s *Service) Create(ctx context.Context, p *store.Project, keyID string, meta map[string]string) (*store.Deployment, error) {
|
||||
dep := &store.Deployment{ProjectID: p.ID, CreatedByKey: keyID, Meta: meta}
|
||||
if err := s.DB.CreateDeployment(ctx, dep); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dep, nil
|
||||
}
|
||||
|
||||
// SetManifest records the file list and reports which blobs still have to be
|
||||
// uploaded. files must already be validated: paths through pathutil and sizes
|
||||
// against the project's limits.
|
||||
func (s *Service) SetManifest(ctx context.Context, dep *store.Deployment, files []store.FileRow) (missing []cas.Digest, missingBytes int64, err error) {
|
||||
missing, missingBytes, err = s.DB.SetManifest(ctx, dep.ID, files)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrConflict):
|
||||
return nil, 0, api.Errorf(api.CodeConflict,
|
||||
"this deployment can no longer accept a manifest; create a new one")
|
||||
case errors.Is(err, cas.ErrSizeMismatch):
|
||||
return nil, 0, api.Errorf(api.CodeSizeMismatch, "%s", err)
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
return missing, missingBytes, nil
|
||||
}
|
||||
|
||||
// Upload stores one blob's content.
|
||||
//
|
||||
// The digest must already be named by some manifest. That check is what keeps
|
||||
// the endpoint from being general-purpose storage: content nobody declared can
|
||||
// never be written, and the length it must have is the one the manifest agreed
|
||||
// on rather than whatever Content-Length claims.
|
||||
//
|
||||
// Reports whether the content was newly stored; a blob that is already present
|
||||
// is a success without reading the body, which is what makes a retried deploy
|
||||
// cheap.
|
||||
func (s *Service) Upload(ctx context.Context, digest cas.Digest, body io.Reader) (size int64, stored bool, err error) {
|
||||
b, err := s.DB.Blob(ctx, digest)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return 0, false, api.Errorf(api.CodeNotFound,
|
||||
"no manifest references this digest; send the manifest first")
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
if b.Present {
|
||||
return b.Size, false, nil
|
||||
}
|
||||
|
||||
// The blob's declared length is both the expectation and the ceiling: Put
|
||||
// reads one byte past it and rejects anything longer, so a client cannot
|
||||
// spend more of the disk than its manifest was accepted for. Put requires a
|
||||
// positive ceiling, hence the floor of one byte for an empty blob.
|
||||
limit := b.Size
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
n, err := s.CAS.Put(ctx, digest, b.Size, limit, body)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, cas.ErrDigestMismatch):
|
||||
return 0, false, api.Errorf(api.CodeDigestMismatch, "%s", err)
|
||||
case errors.Is(err, cas.ErrSizeMismatch):
|
||||
return 0, false, api.Errorf(api.CodeSizeMismatch, "%s", err)
|
||||
case errors.Is(err, cas.ErrTooLarge):
|
||||
return 0, false, api.Errorf(api.CodeLimitExceeded, "%s", err)
|
||||
}
|
||||
return 0, false, err
|
||||
}
|
||||
if err := s.DB.MarkBlobPresent(ctx, digest, n); err != nil {
|
||||
// The content is on disk and verified; only the row disagrees. A retry
|
||||
// finds the blob already stored and updates the row then.
|
||||
return 0, false, err
|
||||
}
|
||||
return n, true, nil
|
||||
}
|
||||
|
||||
// Finalize checks that every blob arrived, assembles the tree, and marks the
|
||||
// deployment ready. It does not activate it: a ready deployment is one that
|
||||
// could be served, and choosing when to serve it is a separate decision.
|
||||
//
|
||||
// Retrying is safe. An assembled tree is left as it is, and a deployment that
|
||||
// is already ready simply stays ready.
|
||||
func (s *Service) Finalize(ctx context.Context, p *store.Project, dep *store.Deployment) (*store.Deployment, error) {
|
||||
// One finalize per project at a time. Two concurrent CI jobs for one
|
||||
// project are ordered rather than racing over the same directory.
|
||||
unlock, err := s.locks.lock(ctx, p.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
// Re-read under the lock: the state may have moved since the handler
|
||||
// resolved it.
|
||||
dep, err = s.DB.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
|
||||
if err != nil {
|
||||
return nil, mapNotFound(err)
|
||||
}
|
||||
switch dep.State {
|
||||
case store.StateUploading, store.StateReady:
|
||||
default:
|
||||
return nil, api.Errorf(api.CodeConflict, "cannot finalize a deployment that is %s", dep.State)
|
||||
}
|
||||
|
||||
missing, err := s.DB.MissingBlobs(ctx, dep.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
hex := make([]string, len(missing))
|
||||
for i, d := range missing {
|
||||
hex[i] = d.String()
|
||||
}
|
||||
return nil, api.Errorf(api.CodeBlobsMissing,
|
||||
"%d blobs have not been uploaded", len(missing)).WithDetail("missing", hex)
|
||||
}
|
||||
|
||||
if s.Dir != "" {
|
||||
files, err := s.DB.DeploymentFiles(ctx, dep.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := Assemble(ctx, s.CAS, files, DeploymentDir(s.Dir, p.ID, dep.PublicID)); err != nil {
|
||||
// A cancelled request is not a broken deployment: leave it uploading
|
||||
// so the client can simply try again.
|
||||
if ctx.Err() != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Log.ErrorContext(ctx, "assembling deployment tree failed",
|
||||
"project", p.Name, "deployment", dep.PublicID, "err", err)
|
||||
if ferr := s.DB.MarkDeploymentFailed(context.WithoutCancel(ctx), dep.ID, err.Error()); ferr != nil {
|
||||
s.Log.ErrorContext(ctx, "recording the failure failed too",
|
||||
"deployment", dep.PublicID, "err", ferr)
|
||||
}
|
||||
return nil, api.Errorf(api.CodeInternal, "could not assemble the deployment tree")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.DB.MarkDeploymentReady(ctx, dep.ID); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return nil, api.Errorf(api.CodeConflict, "%s", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.DB.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
|
||||
}
|
||||
|
||||
// Activate makes a ready deployment the one the project serves. Activating an
|
||||
// older deployment is how a rollback works, and costs exactly the same.
|
||||
//
|
||||
// The order of the steps is the entire correctness argument:
|
||||
//
|
||||
// 1. Take the project lock, so two activations of one project are ordered.
|
||||
// 2. Re-read the deployment under it and require that it is ready.
|
||||
// 3. Build the snapshot's index — the one step that reads the manifest and can
|
||||
// fail — *before* anything has changed. A failure here leaves the currently
|
||||
// served deployment exactly as it was.
|
||||
// 4. Commit the database transaction. From here on SQLite is the truth.
|
||||
// 5. Store the pointer. This single store is the switch: requests that started
|
||||
// earlier finish on the old snapshot, later ones see the new one, and no
|
||||
// request can ever observe a mixture of the two.
|
||||
// 6. Repoint the symlink, best effort.
|
||||
//
|
||||
// Database before memory matters: a crash between 4 and 5 restarts into a
|
||||
// process that serves what the database says. The reverse order would leave a
|
||||
// process serving something the database disagrees with.
|
||||
func (s *Service) Activate(ctx context.Context, p *store.Project, dep *store.Deployment) (*store.Deployment, error) {
|
||||
unlock, err := s.locks.lock(ctx, p.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
dep, err = s.DB.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
|
||||
if err != nil {
|
||||
return nil, mapNotFound(err)
|
||||
}
|
||||
if dep.State != store.StateReady {
|
||||
return nil, api.Errorf(api.CodeDeploymentNotReady,
|
||||
"cannot activate a deployment that is %s", dep.State)
|
||||
}
|
||||
|
||||
idx, err := s.index(ctx, dep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.DB.ActivateDeployment(ctx, p.ID, dep.ID); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
return nil, api.Errorf(api.CodeNotFound, "no such deployment")
|
||||
case errors.Is(err, store.ErrConflict):
|
||||
return nil, api.Errorf(api.CodeConflict, "%s", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Re-read so the snapshot and the response carry the timestamps the
|
||||
// transaction actually wrote.
|
||||
dep, err = s.DB.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
|
||||
if err != nil {
|
||||
return nil, mapNotFound(err)
|
||||
}
|
||||
|
||||
dir := s.deploymentDir(p, dep)
|
||||
if s.Sites != nil {
|
||||
s.Sites.Put(p).Activate(site.NewDeployment(dep, idx, dir))
|
||||
}
|
||||
if s.Webroot != nil && dir != "" {
|
||||
if err := s.Webroot.Point(p.Name, dir); err != nil {
|
||||
// The site is already being served from memory; the symlink is for
|
||||
// everything else and the reconciler will fix it.
|
||||
s.Log.ErrorContext(ctx, "could not repoint the webroot symlink",
|
||||
"project", p.Name, "deployment", dep.PublicID, "err", err)
|
||||
}
|
||||
}
|
||||
s.Log.InfoContext(ctx, "deployment activated",
|
||||
"project", p.Name, "deployment", dep.PublicID,
|
||||
"files", dep.FileCount, "bytes", dep.TotalBytes)
|
||||
return dep, nil
|
||||
}
|
||||
|
||||
func mapNotFound(err error) error {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return api.Errorf(api.CodeNotFound, "no such deployment")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// projectLocks serialises the mutating operations of one project against each
|
||||
// other. Entries are keyed by row id and are never evicted: there is one per
|
||||
// project that has ever been written to, which is bounded by the number of
|
||||
// projects.
|
||||
type projectLocks struct {
|
||||
mu sync.Mutex
|
||||
m map[int64]chan struct{}
|
||||
}
|
||||
|
||||
// lock acquires the project's lock, or gives up if ctx is done first — a
|
||||
// client that has already hung up should not keep a slow assembly waiting.
|
||||
func (l *projectLocks) lock(ctx context.Context, id int64) (func(), error) {
|
||||
l.mu.Lock()
|
||||
if l.m == nil {
|
||||
l.m = make(map[int64]chan struct{})
|
||||
}
|
||||
ch, ok := l.m[id]
|
||||
if !ok {
|
||||
ch = make(chan struct{}, 1)
|
||||
l.m[id] = ch
|
||||
}
|
||||
l.mu.Unlock()
|
||||
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
return func() { <-ch }, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// env is a service on a real database and a real CAS, which is what these tests
|
||||
// are about: every interesting rule here is enforced by a trigger, a unique
|
||||
// index or the filesystem, and a fake would only assert that the fake agrees
|
||||
// with itself.
|
||||
type env struct {
|
||||
svc *Service
|
||||
db *store.DB
|
||||
cas *cas.Store
|
||||
p *store.Project
|
||||
dir string // deployments root
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
|
||||
db, err := store.Open(t.Context(), filepath.Join(base, "pages.db"), log)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
deployDir := filepath.Join(base, "deployments")
|
||||
cs, err := cas.Open(filepath.Join(base, "cas"), cas.Options{ProbeDir: deployDir, Log: log})
|
||||
if err != nil {
|
||||
t.Fatalf("cas.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { cs.Close() })
|
||||
|
||||
p := store.DefaultProject("demo")
|
||||
if err := db.CreateProject(t.Context(), p); err != nil {
|
||||
t.Fatalf("CreateProject: %v", err)
|
||||
}
|
||||
return &env{
|
||||
svc: &Service{DB: db, CAS: cs, Log: log, Dir: deployDir},
|
||||
db: db, cas: cs, p: p, dir: deployDir,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *env) create(t *testing.T) *store.Deployment {
|
||||
t.Helper()
|
||||
dep, err := e.svc.Create(t.Context(), e.p, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
return dep
|
||||
}
|
||||
|
||||
// manifest turns path->content into the rows a client would have sent.
|
||||
func manifest(contents map[string]string) []store.FileRow {
|
||||
files := make([]store.FileRow, 0, len(contents))
|
||||
for p, c := range contents {
|
||||
files = append(files, store.FileRow{Path: p, Digest: cas.Sum([]byte(c)), Size: int64(len(c))})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// upload pushes every named blob through the service, as a client would.
|
||||
func (e *env) upload(t *testing.T, contents map[string]string, want ...string) {
|
||||
t.Helper()
|
||||
for _, p := range want {
|
||||
c := contents[p]
|
||||
if _, _, err := e.svc.Upload(t.Context(), cas.Sum([]byte(c)), strings.NewReader(c)); err != nil {
|
||||
t.Fatalf("Upload %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apiCode returns the wire code of err, or "" if it is not a client error.
|
||||
func apiCode(err error) api.Code {
|
||||
var e *api.Error
|
||||
if errors.As(err, &e) {
|
||||
return e.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func wantCode(t *testing.T, err error, want api.Code) {
|
||||
t.Helper()
|
||||
if got := apiCode(err); got != want {
|
||||
t.Fatalf("error = %v (code %q), want code %q", err, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullDeploymentRoundTrip(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
contents := map[string]string{
|
||||
"index.html": "<h1>hello</h1>",
|
||||
"assets/app.js": "console.log(1)",
|
||||
"copy.html": "<h1>hello</h1>", // same blob as index.html
|
||||
}
|
||||
dep := e.create(t)
|
||||
if dep.State != store.StatePending {
|
||||
t.Fatalf("state = %s, want pending", dep.State)
|
||||
}
|
||||
|
||||
files := manifest(contents)
|
||||
missing, missingBytes, err := e.svc.SetManifest(t.Context(), dep, files)
|
||||
if err != nil {
|
||||
t.Fatalf("SetManifest: %v", err)
|
||||
}
|
||||
// Two unique blobs for three files: the shared one is only asked for once.
|
||||
if len(missing) != 2 {
|
||||
t.Fatalf("missing = %d digests, want 2", len(missing))
|
||||
}
|
||||
if want := int64(len("<h1>hello</h1>") + len("console.log(1)")); missingBytes != want {
|
||||
t.Errorf("missingBytes = %d, want %d", missingBytes, want)
|
||||
}
|
||||
|
||||
// Finalize before the content arrives names what is still outstanding rather
|
||||
// than failing opaquely, because that list is what the client retries.
|
||||
_, err = e.svc.Finalize(t.Context(), e.p, dep)
|
||||
wantCode(t, err, api.CodeBlobsMissing)
|
||||
var apiErr *api.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
list, _ := apiErr.Details["missing"].([]string)
|
||||
if len(list) != 2 {
|
||||
t.Errorf("details.missing = %v, want 2 digests", apiErr.Details["missing"])
|
||||
}
|
||||
}
|
||||
|
||||
e.upload(t, contents, "index.html", "assets/app.js")
|
||||
|
||||
dep, err = e.svc.Finalize(t.Context(), e.p, dep)
|
||||
if err != nil {
|
||||
t.Fatalf("Finalize: %v", err)
|
||||
}
|
||||
if dep.State != store.StateReady {
|
||||
t.Fatalf("state = %s, want ready", dep.State)
|
||||
}
|
||||
if dep.FileCount != 3 || dep.TotalBytes != int64(len(contents["index.html"])*2+len(contents["assets/app.js"])) {
|
||||
t.Errorf("file_count = %d, total_bytes = %d", dep.FileCount, dep.TotalBytes)
|
||||
}
|
||||
if dep.FinalizedAt == nil {
|
||||
t.Error("finalized_at was not recorded")
|
||||
}
|
||||
if dep.Active {
|
||||
t.Error("finalize activated the deployment; that is a separate decision")
|
||||
}
|
||||
|
||||
// The tree on disk is the deployment, byte for byte.
|
||||
got := walk(t, DeploymentDir(e.dir, e.p.ID, dep.PublicID))
|
||||
if len(got) != len(contents) {
|
||||
t.Fatalf("assembled %v", got)
|
||||
}
|
||||
for p, c := range contents {
|
||||
if got[p] != c {
|
||||
t.Errorf("%s = %q, want %q", p, got[p], c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A second deployment of a mostly-unchanged site is the case the whole
|
||||
// content-addressed protocol exists for: only what actually changed is asked
|
||||
// for, across deployments and across projects.
|
||||
func TestASecondDeploymentOnlyAsksForWhatChanged(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
first := map[string]string{"index.html": "v1", "assets/app.js": "shared"}
|
||||
dep := e.create(t)
|
||||
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(first)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.upload(t, first, "index.html", "assets/app.js")
|
||||
if _, err := e.svc.Finalize(t.Context(), e.p, dep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second := map[string]string{"index.html": "v2", "assets/app.js": "shared"}
|
||||
dep2 := e.create(t)
|
||||
missing, missingBytes, err := e.svc.SetManifest(t.Context(), dep2, manifest(second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(missing) != 1 || missing[0] != cas.Sum([]byte("v2")) {
|
||||
t.Fatalf("missing = %v, want just the changed index.html", digests(missing))
|
||||
}
|
||||
if missingBytes != 2 {
|
||||
t.Errorf("missingBytes = %d, want 2", missingBytes)
|
||||
}
|
||||
|
||||
e.upload(t, second, "index.html")
|
||||
if _, err := e.svc.Finalize(t.Context(), e.p, dep2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Both trees exist and disagree, which is what makes a rollback a rollback.
|
||||
if got := walk(t, DeploymentDir(e.dir, e.p.ID, dep.PublicID)); got["index.html"] != "v1" {
|
||||
t.Errorf("first deployment = %v, want v1 intact", got)
|
||||
}
|
||||
if got := walk(t, DeploymentDir(e.dir, e.p.ID, dep2.PublicID)); got["index.html"] != "v2" {
|
||||
t.Errorf("second deployment = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func digests(ds []cas.Digest) []string {
|
||||
out := make([]string, len(ds))
|
||||
for i, d := range ds {
|
||||
out[i] = d.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The upload endpoint is not general-purpose storage. Content nobody declared
|
||||
// has no size the server agreed to and no deployment that would ever reference
|
||||
// it, so it is refused before a byte is read.
|
||||
func TestUploadRejectsContentNoManifestAskedFor(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
body := &countingReader{r: strings.NewReader("unsolicited")}
|
||||
|
||||
_, _, err := e.svc.Upload(t.Context(), cas.Sum([]byte("unsolicited")), body)
|
||||
wantCode(t, err, api.CodeNotFound)
|
||||
if body.n != 0 {
|
||||
t.Errorf("read %d bytes of a body it had already decided to refuse", body.n)
|
||||
}
|
||||
if has, _ := e.cas.Has(cas.Sum([]byte("unsolicited"))); has {
|
||||
t.Error("the content was stored anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// The claimed digest is only ever a claim. Without this check a client could
|
||||
// declare another project's digest and poison every project sharing that blob.
|
||||
func TestUploadRejectsContentThatDoesNotHashToItsDigest(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
contents := map[string]string{"index.html": "honest"}
|
||||
dep := e.create(t)
|
||||
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
claimed := cas.Sum([]byte("honest"))
|
||||
_, _, err := e.svc.Upload(t.Context(), claimed, strings.NewReader("forged"))
|
||||
wantCode(t, err, api.CodeDigestMismatch)
|
||||
if has, _ := e.cas.Has(claimed); has {
|
||||
t.Fatal("the forged content was stored under the honest digest")
|
||||
}
|
||||
|
||||
// And the deployment is still deployable once the real bytes arrive.
|
||||
if _, _, err := e.svc.Upload(t.Context(), claimed, strings.NewReader("honest")); err != nil {
|
||||
t.Fatalf("honest upload after a forged one: %v", err)
|
||||
}
|
||||
if _, err := e.svc.Finalize(t.Context(), e.p, dep); err != nil {
|
||||
t.Fatalf("Finalize: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest's size is the ceiling, so a client cannot spend more disk than
|
||||
// the manifest it got accepted for. Content-Length is never consulted.
|
||||
func TestUploadRejectsMoreBytesThanTheManifestDeclared(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
dep := e.create(t)
|
||||
body := strings.Repeat("x", 4096)
|
||||
// Declare a small file, then send a large one under the same digest.
|
||||
files := []store.FileRow{{Path: "a.txt", Digest: cas.Sum([]byte(body)), Size: 4}}
|
||||
if _, _, err := e.svc.SetManifest(t.Context(), dep, files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err := e.svc.Upload(t.Context(), files[0].Digest, strings.NewReader(body))
|
||||
if code := apiCode(err); code != api.CodeSizeMismatch && code != api.CodeLimitExceeded {
|
||||
t.Fatalf("error = %v (code %q), want a size rejection", err, code)
|
||||
}
|
||||
if has, _ := e.cas.Has(files[0].Digest); has {
|
||||
t.Error("the oversized content was stored")
|
||||
}
|
||||
}
|
||||
|
||||
// Re-uploading a blob the server already has is the fast path a retried deploy
|
||||
// depends on: no body is read and nothing is rewritten.
|
||||
func TestUploadOfAPresentBlobDoesNotReadTheBody(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
contents := map[string]string{"index.html": "hello"}
|
||||
dep := e.create(t)
|
||||
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := cas.Sum([]byte("hello"))
|
||||
|
||||
size, stored, err := e.svc.Upload(t.Context(), d, strings.NewReader("hello"))
|
||||
if err != nil || !stored || size != 5 {
|
||||
t.Fatalf("first upload: size=%d stored=%v err=%v", size, stored, err)
|
||||
}
|
||||
body := &countingReader{r: strings.NewReader("hello")}
|
||||
size, stored, err = e.svc.Upload(t.Context(), d, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored {
|
||||
t.Error("the second upload claimed to have stored content the server already had")
|
||||
}
|
||||
if size != 5 {
|
||||
t.Errorf("size = %d, want 5", size)
|
||||
}
|
||||
if body.n != 0 {
|
||||
t.Errorf("read %d bytes of a blob it already had", body.n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadStoresAnEmptyBlob(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
dep := e.create(t)
|
||||
files := []store.FileRow{{Path: "empty", Digest: cas.Sum(nil), Size: 0}}
|
||||
if _, _, err := e.svc.SetManifest(t.Context(), dep, files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Put insists on a positive ceiling; the service floors it at one byte so a
|
||||
// legitimately empty file is still uploadable.
|
||||
size, stored, err := e.svc.Upload(t.Context(), files[0].Digest, strings.NewReader(""))
|
||||
if err != nil || !stored || size != 0 {
|
||||
t.Fatalf("size=%d stored=%v err=%v", size, stored, err)
|
||||
}
|
||||
if _, err := e.svc.Finalize(t.Context(), e.p, dep); err != nil {
|
||||
t.Fatalf("Finalize: %v", err)
|
||||
}
|
||||
got := walk(t, DeploymentDir(e.dir, e.p.ID, dep.PublicID))
|
||||
if c, ok := got["empty"]; !ok || c != "" {
|
||||
t.Errorf("tree = %v, want one empty file", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Finalizing twice must be a no-op rather than a second assembly: the client
|
||||
// that lost its response to a timeout retries, and the tree may already be live.
|
||||
func TestFinalizeIsIdempotent(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
contents := map[string]string{"index.html": "hello"}
|
||||
dep := e.create(t)
|
||||
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.upload(t, contents, "index.html")
|
||||
|
||||
first, err := e.svc.Finalize(t.Context(), e.p, dep)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := e.svc.Finalize(t.Context(), e.p, dep)
|
||||
if err != nil {
|
||||
t.Fatalf("second Finalize: %v", err)
|
||||
}
|
||||
if !first.FinalizedAt.Equal(*second.FinalizedAt) {
|
||||
t.Errorf("finalized_at moved from %v to %v on a retry", first.FinalizedAt, second.FinalizedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeRejectsADeploymentWithNoManifest(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
dep := e.create(t) // still pending
|
||||
|
||||
_, err := e.svc.Finalize(t.Context(), e.p, dep)
|
||||
wantCode(t, err, api.CodeConflict)
|
||||
if _, err := os.Stat(DeploymentDir(e.dir, e.p.ID, dep.PublicID)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("a tree was built for a deployment that never had a manifest")
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment belongs to exactly one project. Finalizing another project's
|
||||
// deployment must not work even when the caller knows its id.
|
||||
func TestFinalizeIsProjectScoped(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
other := store.DefaultProject("other")
|
||||
if err := e.db.CreateProject(t.Context(), other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents := map[string]string{"index.html": "hello"}
|
||||
dep := e.create(t)
|
||||
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.upload(t, contents, "index.html")
|
||||
|
||||
_, err := e.svc.Finalize(t.Context(), other, dep)
|
||||
wantCode(t, err, api.CodeNotFound)
|
||||
}
|
||||
|
||||
// assemble_mode=none: content is served straight from the CAS, so finalize must
|
||||
// still succeed and must not build anything on disk.
|
||||
func TestFinalizeWithoutAssemblyBuildsNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.svc.Dir = ""
|
||||
contents := map[string]string{"index.html": "hello"}
|
||||
dep := e.create(t)
|
||||
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.upload(t, contents, "index.html")
|
||||
|
||||
dep, err := e.svc.Finalize(t.Context(), e.p, dep)
|
||||
if err != nil {
|
||||
t.Fatalf("Finalize: %v", err)
|
||||
}
|
||||
if dep.State != store.StateReady {
|
||||
t.Fatalf("state = %s, want ready", dep.State)
|
||||
}
|
||||
if _, err := os.Stat(DeploymentDir(e.dir, e.p.ID, dep.PublicID)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("a tree was assembled despite assemble_mode=none")
|
||||
}
|
||||
}
|
||||
|
||||
// If assembly fails the deployment must end up failed rather than ready — a
|
||||
// ready deployment is one that could be served, and this one could not be.
|
||||
func TestFinalizeMarksTheDeploymentFailedWhenAssemblyCannotProceed(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
contents := map[string]string{"index.html": "hello"}
|
||||
dep := e.create(t)
|
||||
if _, _, err := e.svc.SetManifest(t.Context(), dep, manifest(contents)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.upload(t, contents, "index.html")
|
||||
|
||||
// A plain file where the project's directory has to go: MkdirAll cannot get
|
||||
// past it, so assembly fails for a reason that is not the client's fault.
|
||||
if err := os.MkdirAll(e.dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Dir(DeploymentDir(e.dir, e.p.ID, dep.PublicID)), []byte("in the way"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := e.svc.Finalize(t.Context(), e.p, dep)
|
||||
wantCode(t, err, api.CodeInternal)
|
||||
// The reason stays server-side: the client is told nothing about the layout
|
||||
// of the server's disk.
|
||||
if strings.Contains(err.Error(), e.dir) {
|
||||
t.Errorf("the error exposes a server path: %v", err)
|
||||
}
|
||||
|
||||
after, err := e.db.DeploymentByPublicID(t.Context(), e.p.ID, dep.PublicID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.State != store.StateFailed {
|
||||
t.Fatalf("state = %s, want failed", after.State)
|
||||
}
|
||||
if after.Error == "" {
|
||||
t.Error("no reason was recorded for the failure")
|
||||
}
|
||||
// And it stays failed: a failed deployment is never resurrected.
|
||||
_, err = e.svc.Finalize(t.Context(), e.p, dep)
|
||||
wantCode(t, err, api.CodeConflict)
|
||||
}
|
||||
|
||||
// countingReader reports whether a body was read at all, which is how the tests
|
||||
// tell "refused up front" apart from "read and then discarded".
|
||||
type countingReader struct {
|
||||
r io.Reader
|
||||
n int
|
||||
}
|
||||
|
||||
func (c *countingReader) Read(p []byte) (int, error) {
|
||||
n, err := c.r.Read(p)
|
||||
c.n += n
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/site"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// index reads a deployment's manifest and turns it into the lookup structures
|
||||
// the serving path needs. This is the expensive, failure-prone half of building
|
||||
// a snapshot, which is why it is separable: an activation runs it before it
|
||||
// changes anything.
|
||||
func (s *Service) index(ctx context.Context, dep *store.Deployment) (*site.Index, error) {
|
||||
files, err := s.DB.DeploymentFiles(ctx, dep.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return site.NewIndex(files), nil
|
||||
}
|
||||
|
||||
// deploymentDir is where a deployment's tree was assembled, or "" under
|
||||
// assemble_mode=none, where nothing was.
|
||||
func (s *Service) deploymentDir(p *store.Project, dep *store.Deployment) string {
|
||||
if s.Dir == "" {
|
||||
return ""
|
||||
}
|
||||
return DeploymentDir(s.Dir, p.ID, dep.PublicID)
|
||||
}
|
||||
|
||||
// LoadSites builds the registry from the database and makes the webroot agree
|
||||
// with it. It runs once, before the listeners start.
|
||||
//
|
||||
// Without this a restart would serve 404 for every project until each one was
|
||||
// deployed again. It also has to complete before the registry can stand in as
|
||||
// the API's project resolver: a half-built registry would report a caller's own
|
||||
// project as one that does not exist.
|
||||
func (s *Service) LoadSites(ctx context.Context) error {
|
||||
projects, err := s.DB.AllProjects(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries := make([]*site.Project, 0, len(projects))
|
||||
var active int
|
||||
for _, p := range projects {
|
||||
sp := site.NewProject(p)
|
||||
entries = append(entries, sp)
|
||||
|
||||
dep, err := s.DB.ActiveDeployment(ctx, p.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// A project that has never been deployed to. It exists, it
|
||||
// resolves, and it answers 503 until something is activated.
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
idx, err := s.index(ctx, dep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sp.Activate(site.NewDeployment(dep, idx, s.deploymentDir(p, dep)))
|
||||
active++
|
||||
}
|
||||
s.Sites.Replace(entries)
|
||||
s.Log.Info("site registry loaded", "projects", len(entries), "active", active)
|
||||
|
||||
// Best effort, like every other webroot operation: the server serves its own
|
||||
// content and a stale symlink is not a reason to refuse to start.
|
||||
if err := s.Reconcile(); err != nil {
|
||||
s.Log.Warn("could not fully reconcile the webroot", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reconcile makes $WEBROOT agree with what this process is serving: one symlink
|
||||
// per project with an active deployment, and nothing of ours left over for
|
||||
// projects that no longer have one.
|
||||
//
|
||||
// The wanted set comes from the registry rather than from the database, because
|
||||
// the registry is what requests are actually being answered from. The symlinks
|
||||
// exist so that an external reader — a reverse proxy, a backup job — sees the
|
||||
// same version this process does, and taking them from anywhere else would let
|
||||
// the two disagree.
|
||||
func (s *Service) Reconcile() error {
|
||||
if s.Webroot == nil {
|
||||
return nil
|
||||
}
|
||||
want := make(map[string]string)
|
||||
for _, p := range s.Sites.Projects() {
|
||||
// One load per project, and the value is used for both the name and the
|
||||
// directory: reading Active() twice could straddle an activation and
|
||||
// produce a link to a directory the other half of the pair disagrees with.
|
||||
if d := p.Active(); d != nil && d.Dir != "" {
|
||||
want[p.Name] = d.Dir
|
||||
}
|
||||
}
|
||||
return s.Webroot.Reconcile(want)
|
||||
}
|
||||
|
||||
// RunReconciler repairs the webroot on a timer until ctx is done.
|
||||
//
|
||||
// Activation repoints a symlink itself and logs when it cannot, so this is for
|
||||
// the cases where nothing was there to notice: a link an operator deleted or
|
||||
// edited, one whose repoint failed on a full disk, or one left pointing at a
|
||||
// deployment that has since been collected. Serving does not depend on any of
|
||||
// it, which is why a failure here is a warning and never stops the loop.
|
||||
func (s *Service) RunReconciler(ctx context.Context, every time.Duration) {
|
||||
if s.Webroot == nil || every <= 0 {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := s.Reconcile(); err != nil {
|
||||
s.Log.Warn("could not fully reconcile the webroot", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
)
|
||||
|
||||
func prefixes(t *testing.T, ss ...string) []netip.Prefix {
|
||||
t.Helper()
|
||||
out := make([]netip.Prefix, 0, len(ss))
|
||||
for _, s := range ss {
|
||||
p, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrefix(%q): %v", s, err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestLogNeverContainsCredentials is the load-bearing test of this package:
|
||||
// an access log that leaks a bearer token turns every log shipper, backup and
|
||||
// support ticket into a credential store.
|
||||
func TestLogNeverContainsCredentials(t *testing.T) {
|
||||
const token = "pgs_k7m2q4x9v0zt3b8w_S3cr3tVa1ueThatMustNeverBeLogged00000000"
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// A handler that legitimately annotates the log must still not be able to
|
||||
// smuggle the secret in: it logs the key id, which is public.
|
||||
LogAttr(r.Context(), "key_id", "k7m2q4x9v0zt3b8w")
|
||||
LogAttr(r.Context(), "project", "demo")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}), WithRequestID(nil), AccessLog(log, nil), Recover(log))
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/whoami?access_token="+token, nil)
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("Cookie", "session="+token)
|
||||
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||
|
||||
out := buf.String()
|
||||
if out == "" {
|
||||
t.Fatal("no log output produced")
|
||||
}
|
||||
for _, needle := range []string{token, "S3cr3tVa1ue", "Bearer", "Authorization", "session="} {
|
||||
if strings.Contains(out, needle) {
|
||||
t.Errorf("log contains %q\nlog: %s", needle, out)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`"key_id":"k7m2q4x9v0zt3b8w"`, `"project":"demo"`, `"status":204`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("log missing %s\nlog: %s", want, out)
|
||||
}
|
||||
}
|
||||
// The raw query string is not logged either: tokens end up there when someone
|
||||
// ignores the docs, and we would rather drop the field than log the secret.
|
||||
if strings.Contains(out, "access_token") {
|
||||
t.Errorf("log contains the query string\nlog: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLogRecordsStatusAndBytes(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
fmt.Fprint(w, "hello")
|
||||
}), AccessLog(log, nil))
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/x", nil))
|
||||
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &rec); err != nil {
|
||||
t.Fatalf("log line is not JSON: %v (%s)", err, buf.String())
|
||||
}
|
||||
if got := rec["status"]; got != float64(http.StatusTeapot) {
|
||||
t.Errorf("status = %v, want 418", got)
|
||||
}
|
||||
if got := rec["bytes"]; got != float64(5) {
|
||||
t.Errorf("bytes = %v, want 5", got)
|
||||
}
|
||||
if rec["level"] != "WARN" {
|
||||
t.Errorf("level = %v, want WARN for a 4xx", rec["level"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverReturnsOpaque500(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic("database password is hunter2")
|
||||
}), WithRequestID(nil), AccessLog(log, nil), Recover(log))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/boom", nil))
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", w.Code)
|
||||
}
|
||||
// The panicking request must still produce an access-log line, which is only
|
||||
// true while Recover runs inside AccessLog.
|
||||
if !strings.Contains(buf.String(), `"msg":"request"`) {
|
||||
t.Errorf("no access-log line for the panicking request\nlog: %s", buf.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "hunter2") {
|
||||
t.Errorf("panic value leaked to the client: %s", w.Body.String())
|
||||
}
|
||||
var env api.ErrorEnvelope
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("body is not an error envelope: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
if env.Error.Code != api.CodeInternal {
|
||||
t.Errorf("code = %q, want %q", env.Error.Code, api.CodeInternal)
|
||||
}
|
||||
if env.Error.Details["request_id"] == nil {
|
||||
t.Error("500 body carries no request_id, so the log line cannot be found")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "hunter2") {
|
||||
t.Error("panic value was not logged; it must reach the operator even though it must not reach the client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
trusted := prefixes(t, "127.0.0.1/32", "10.0.0.0/8")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
remote string
|
||||
xff []string
|
||||
want string
|
||||
}{
|
||||
{"untrusted peer, header ignored", "203.0.113.9:1234", []string{"9.9.9.9"}, "203.0.113.9"},
|
||||
{"trusted peer, single hop", "127.0.0.1:1234", []string{"198.51.100.7"}, "198.51.100.7"},
|
||||
{"trusted peer, chain", "10.1.2.3:1234", []string{"198.51.100.7, 10.4.5.6"}, "198.51.100.7"},
|
||||
{"forged prefix ignored", "127.0.0.1:1234", []string{"1.2.3.4, 198.51.100.7"}, "198.51.100.7"},
|
||||
{"multiple headers", "127.0.0.1:1234", []string{"1.1.1.1", "198.51.100.7"}, "198.51.100.7"},
|
||||
{"garbage entries skipped", "127.0.0.1:1234", []string{"not-an-ip, 198.51.100.7, 10.0.0.1"}, "198.51.100.7"},
|
||||
{"all trusted, falls back to peer", "127.0.0.1:1234", []string{"10.0.0.1"}, "127.0.0.1"},
|
||||
{"no header", "127.0.0.1:1234", nil, "127.0.0.1"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.RemoteAddr = tc.remote
|
||||
for _, v := range tc.xff {
|
||||
r.Header.Add("X-Forwarded-For", v)
|
||||
}
|
||||
got, ok := ClientIP(r, trusted)
|
||||
if !ok {
|
||||
t.Fatal("ClientIP reported no address")
|
||||
}
|
||||
if got.String() != tc.want {
|
||||
t.Errorf("ClientIP = %s, want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDAdoption(t *testing.T) {
|
||||
trusted := prefixes(t, "127.0.0.1/32")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
remote string
|
||||
header string
|
||||
wantSet bool // true = the inbound value is echoed back verbatim
|
||||
}{
|
||||
{"trusted peer, sane id", "127.0.0.1:1", "deadbeef-42", true},
|
||||
{"untrusted peer", "203.0.113.9:1", "deadbeef-42", false},
|
||||
{"trusted peer, space injected", "127.0.0.1:1", "id with space", false},
|
||||
{"trusted peer, newline injected", "127.0.0.1:1", "id\nlevel=INFO", false},
|
||||
{"trusted peer, over-long", "127.0.0.1:1", strings.Repeat("a", 65), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var seen string
|
||||
h := WithRequestID(trusted)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = RequestIDFrom(r.Context())
|
||||
}))
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.RemoteAddr = tc.remote
|
||||
r.Header.Set("X-Request-Id", tc.header)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if tc.wantSet {
|
||||
if seen != tc.header {
|
||||
t.Errorf("request id = %q, want the inbound %q", seen, tc.header)
|
||||
}
|
||||
} else if seen == tc.header {
|
||||
t.Errorf("adopted an untrustworthy inbound id %q", tc.header)
|
||||
}
|
||||
if seen == "" {
|
||||
t.Error("no request id assigned")
|
||||
}
|
||||
if got := w.Header().Get("X-Request-Id"); got != seen {
|
||||
t.Errorf("echoed %q but handler saw %q", got, seen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorMapsCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{api.Errorf(api.CodeNotFound, "nope"), http.StatusNotFound},
|
||||
{api.Errorf(api.CodeDeploymentActive, "still active"), http.StatusConflict},
|
||||
{api.Errorf(api.CodeInvalidPath, "bad"), http.StatusBadRequest},
|
||||
{api.Errorf(api.CodeLimitExceeded, "too big"), http.StatusRequestEntityTooLarge},
|
||||
{fmt.Errorf("wrapped: %w", api.Errorf(api.CodeForbidden, "no")), http.StatusForbidden},
|
||||
{fmt.Errorf("open /var/lib/pages-server/secret: permission denied"), http.StatusInternalServerError},
|
||||
}
|
||||
log := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil))
|
||||
for _, tc := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
WriteError(w, httptest.NewRequest(http.MethodGet, "/", nil), log, tc.err)
|
||||
if w.Code != tc.want {
|
||||
t.Errorf("WriteError(%v) = %d, want %d", tc.err, w.Code, tc.want)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type = %q", ct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A non-api error must not put internal detail in the response body.
|
||||
func TestWriteErrorHidesInternalDetail(t *testing.T) {
|
||||
log := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil))
|
||||
w := httptest.NewRecorder()
|
||||
WriteError(w, httptest.NewRequest(http.MethodGet, "/", nil), log,
|
||||
fmt.Errorf("sql: no rows in /var/lib/pages-server/pages.db"))
|
||||
if strings.Contains(w.Body.String(), "pages.db") {
|
||||
t.Errorf("internal detail leaked: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSON(t *testing.T) {
|
||||
type payload struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
max int64
|
||||
want api.Code
|
||||
}{
|
||||
{"ok", `{"name":"demo"}`, 1024, ""},
|
||||
{"unknown field", `{"name":"demo","nmae":"typo"}`, 1024, api.CodeBadRequest},
|
||||
{"wrong type", `{"name":42}`, 1024, api.CodeBadRequest},
|
||||
{"malformed", `{"name":`, 1024, api.CodeBadRequest},
|
||||
{"trailing content", `{"name":"a"} {"name":"b"}`, 1024, api.CodeBadRequest},
|
||||
{"too large", `{"name":"` + strings.Repeat("x", 200) + `"}`, 32, api.CodePayloadTooLarge},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tc.body))
|
||||
var v payload
|
||||
err := DecodeJSON(httptest.NewRecorder(), r, tc.max, &v)
|
||||
if tc.want == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if got := api.CodeOf(err); got != tc.want {
|
||||
t.Errorf("code = %q, want %q (%v)", got, tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package httpx holds the HTTP plumbing shared by both listeners: the JSON
|
||||
// error envelope, the middleware chain, and server lifecycle management.
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
)
|
||||
|
||||
// StatusFor maps a machine-readable code to its HTTP status.
|
||||
func StatusFor(code api.Code) int {
|
||||
switch code {
|
||||
case api.CodeBadRequest, api.CodeInvalidProjectName, api.CodeInvalidPath,
|
||||
api.CodeDigestMismatch, api.CodeSizeMismatch:
|
||||
return http.StatusBadRequest
|
||||
case api.CodeUnauthorized:
|
||||
return http.StatusUnauthorized
|
||||
case api.CodeForbidden:
|
||||
return http.StatusForbidden
|
||||
case api.CodeNotFound:
|
||||
return http.StatusNotFound
|
||||
case api.CodeMethodNotAllowed:
|
||||
return http.StatusMethodNotAllowed
|
||||
case api.CodeConflict, api.CodeProjectExists, api.CodeDeploymentNotReady,
|
||||
api.CodeDeploymentActive, api.CodeBlobsMissing:
|
||||
return http.StatusConflict
|
||||
case api.CodePayloadTooLarge, api.CodeLimitExceeded:
|
||||
return http.StatusRequestEntityTooLarge
|
||||
case api.CodeRateLimited:
|
||||
return http.StatusTooManyRequests
|
||||
case api.CodeUnavailable:
|
||||
return http.StatusServiceUnavailable
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// WriteJSON writes v as JSON with the given status.
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
buf, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
// Marshalling our own response types should never fail; if it does the
|
||||
// handler already wrote nothing, so a bare 500 is the honest answer.
|
||||
http.Error(w, `{"error":{"code":"internal","message":"response encoding failed"}}`,
|
||||
http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf)
|
||||
_, _ = w.Write([]byte("\n"))
|
||||
}
|
||||
|
||||
// WriteError renders err as the standard error envelope. Non-api errors become
|
||||
// an opaque 500: internal failure text may name paths or SQL and must not reach
|
||||
// the client. The full error is logged instead.
|
||||
func WriteError(w http.ResponseWriter, r *http.Request, log *slog.Logger, err error) {
|
||||
var apiErr *api.Error
|
||||
if !errors.As(err, &apiErr) {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
apiErr = api.Errorf(api.CodePayloadTooLarge, "request body exceeds %d bytes", maxErr.Limit)
|
||||
} else {
|
||||
if log != nil {
|
||||
log.ErrorContext(r.Context(), "unhandled error", "err", err,
|
||||
"method", r.Method, "path", r.URL.Path)
|
||||
}
|
||||
apiErr = api.Errorf(api.CodeInternal, "internal error")
|
||||
}
|
||||
}
|
||||
status := StatusFor(apiErr.Code)
|
||||
if status >= 500 && log != nil {
|
||||
log.ErrorContext(r.Context(), "request failed", "err", err,
|
||||
"code", string(apiErr.Code), "method", r.Method, "path", r.URL.Path)
|
||||
}
|
||||
WriteJSON(w, status, api.ErrorEnvelope{Error: *apiErr})
|
||||
}
|
||||
|
||||
// DecodeJSON reads a JSON body into v, capped at maxBytes. It rejects unknown
|
||||
// fields (a misspelled key in a deploy script should fail, not be ignored) and
|
||||
// trailing content after the top-level value.
|
||||
func DecodeJSON(w http.ResponseWriter, r *http.Request, maxBytes int64, v any) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
return api.Errorf(api.CodePayloadTooLarge, "request body exceeds %d bytes", maxErr.Limit)
|
||||
}
|
||||
var syn *json.SyntaxError
|
||||
if errors.As(err, &syn) {
|
||||
return api.Errorf(api.CodeBadRequest, "malformed JSON at byte %d", syn.Offset)
|
||||
}
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
return api.Errorf(api.CodeBadRequest, "field %q: want %s", typeErr.Field, typeErr.Type)
|
||||
}
|
||||
return api.Errorf(api.CodeBadRequest, "%s", err)
|
||||
}
|
||||
if dec.More() {
|
||||
return api.Errorf(api.CodeBadRequest, "unexpected content after JSON value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newInternalEnvelope builds the opaque 500 body. The request id is included so
|
||||
// an operator can find the corresponding log line, which holds the real cause.
|
||||
func newInternalEnvelope(reqID string) api.ErrorEnvelope {
|
||||
e := api.Errorf(api.CodeInternal, "internal error")
|
||||
if reqID != "" {
|
||||
e = e.WithDetail("request_id", reqID)
|
||||
}
|
||||
return api.ErrorEnvelope{Error: *e}
|
||||
}
|
||||
|
||||
// NoBody rejects requests that carry a body where none is expected.
|
||||
func NoBody(r *http.Request) error {
|
||||
if r.ContentLength > 0 {
|
||||
return api.Errorf(api.CodeBadRequest, "unexpected request body")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Middleware wraps a handler. Chain applies them so that the first listed runs
|
||||
// outermost.
|
||||
type Middleware func(http.Handler) http.Handler
|
||||
|
||||
// Chain wraps h with mw, outermost first.
|
||||
func Chain(h http.Handler, mw ...Middleware) http.Handler {
|
||||
for i := len(mw) - 1; i >= 0; i-- {
|
||||
h = mw[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxKeyRequestID ctxKey = iota
|
||||
ctxKeyLogState
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- request id
|
||||
|
||||
// RequestIDFrom returns the request id assigned by WithRequestID.
|
||||
func RequestIDFrom(ctx context.Context) string {
|
||||
id, _ := ctx.Value(ctxKeyRequestID).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// WithRequestID assigns each request an id, echoes it in X-Request-Id, and makes
|
||||
// it available to handlers and the access log.
|
||||
//
|
||||
// An inbound X-Request-Id is adopted only when the peer is a trusted proxy and
|
||||
// the value is short and printable: it ends up in log records, and an arbitrary
|
||||
// client-controlled string there is a log-forging primitive.
|
||||
func WithRequestID(trusted []netip.Prefix) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := ""
|
||||
if in := r.Header.Get("X-Request-Id"); in != "" && sanitaryID(in) {
|
||||
if addr, ok := peerAddr(r); ok && inAny(addr, trusted) {
|
||||
id = in
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
id = newID()
|
||||
}
|
||||
w.Header().Set("X-Request-Id", id)
|
||||
ctx := context.WithValue(r.Context(), ctxKeyRequestID, id)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand cannot fail on any supported platform; if it somehow does,
|
||||
// an empty id is better than taking down the request.
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func sanitaryID(s string) bool {
|
||||
if len(s) == 0 || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c < 0x21 || c > 0x7e {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- log enrichment
|
||||
|
||||
// logState collects attributes that handlers discover mid-request (project,
|
||||
// deployment, key id) so the single access-log line can carry them.
|
||||
type logState struct {
|
||||
mu sync.Mutex
|
||||
attrs []slog.Attr
|
||||
}
|
||||
|
||||
// LogAttr attaches a key/value pair to this request's access-log line. It is a
|
||||
// no-op outside the middleware chain, so handlers may call it unconditionally.
|
||||
//
|
||||
// Never pass a token, an Authorization header, or any part of either.
|
||||
func LogAttr(ctx context.Context, key string, value any) {
|
||||
st, ok := ctx.Value(ctxKeyLogState).(*logState)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.attrs = append(st.attrs, slog.Any(key, value))
|
||||
st.mu.Unlock()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- access logger
|
||||
|
||||
// AccessLog emits exactly one record per request.
|
||||
//
|
||||
// It logs the method, the URL path, and nothing else from the request: headers
|
||||
// are never logged (Authorization carries a bearer token) and neither is the raw
|
||||
// query string. Handlers add their own context with LogAttr.
|
||||
func AccessLog(log *slog.Logger, trusted []netip.Prefix) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
st := &logState{}
|
||||
ctx := context.WithValue(r.Context(), ctxKeyLogState, st)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
|
||||
start := time.Now()
|
||||
next.ServeHTTP(rec, r)
|
||||
dur := time.Since(start)
|
||||
|
||||
level := slog.LevelInfo
|
||||
switch {
|
||||
case rec.status >= 500:
|
||||
level = slog.LevelError
|
||||
case rec.status == http.StatusNotFound || rec.status == http.StatusMethodNotAllowed:
|
||||
// Ordinary outcomes on a public listener: a crawler, a stale link,
|
||||
// a missing favicon. Logging them as warnings would make warnings
|
||||
// the bulk of the file and hide the ones that mean something.
|
||||
level = slog.LevelInfo
|
||||
case rec.status >= 400:
|
||||
level = slog.LevelWarn
|
||||
case r.URL.Path == "/healthz" || r.URL.Path == "/readyz":
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
if !log.Enabled(ctx, level) {
|
||||
return
|
||||
}
|
||||
|
||||
st.mu.Lock()
|
||||
extra := st.attrs
|
||||
st.mu.Unlock()
|
||||
|
||||
attrs := make([]slog.Attr, 0, 8+len(extra))
|
||||
attrs = append(attrs,
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", rec.status),
|
||||
slog.Int64("bytes", rec.written),
|
||||
slog.Float64("dur_ms", float64(dur.Microseconds())/1000),
|
||||
)
|
||||
if ip, ok := ClientIP(r, trusted); ok {
|
||||
attrs = append(attrs, slog.String("ip", ip.String()))
|
||||
}
|
||||
if id := RequestIDFrom(ctx); id != "" {
|
||||
attrs = append(attrs, slog.String("req_id", id))
|
||||
}
|
||||
attrs = append(attrs, extra...)
|
||||
log.LogAttrs(ctx, level, "request", attrs...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ recovery
|
||||
|
||||
// Recover turns a handler panic into a 500 instead of tearing down the process,
|
||||
// logging the stack. The panic value itself never reaches the client.
|
||||
func Recover(log *slog.Logger) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
v := recover()
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
// ErrAbortHandler is the documented way to abort a response;
|
||||
// net/http expects to handle it and logs nothing.
|
||||
if err, ok := v.(error); ok && errors.Is(err, http.ErrAbortHandler) {
|
||||
panic(v)
|
||||
}
|
||||
log.ErrorContext(r.Context(), "handler panic",
|
||||
"panic", v,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"req_id", RequestIDFrom(r.Context()),
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
if rec, ok := w.(*recorder); ok && rec.wroteHeader {
|
||||
return // response already begun; nothing safe left to send
|
||||
}
|
||||
WriteJSON(w, http.StatusInternalServerError,
|
||||
newInternalEnvelope(RequestIDFrom(r.Context())))
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- client IP
|
||||
|
||||
// ClientIP returns the address to attribute the request to. X-Forwarded-For is
|
||||
// honoured only when the direct peer is itself a trusted proxy; otherwise any
|
||||
// client could forge its own source address and defeat per-IP rate limiting.
|
||||
func ClientIP(r *http.Request, trusted []netip.Prefix) (netip.Addr, bool) {
|
||||
peer, ok := peerAddr(r)
|
||||
if !ok {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
if !inAny(peer, trusted) {
|
||||
return peer, true
|
||||
}
|
||||
// Walk right to left and take the first address that is not itself trusted:
|
||||
// everything to its right was appended by infrastructure we control, and
|
||||
// everything to its left may have been forged by the client.
|
||||
xff := r.Header.Values("X-Forwarded-For")
|
||||
for i := len(xff) - 1; i >= 0; i-- {
|
||||
parts := strings.Split(xff[i], ",")
|
||||
for j := len(parts) - 1; j >= 0; j-- {
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(parts[j]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if !inAny(addr, trusted) {
|
||||
return addr, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return peer, true
|
||||
}
|
||||
|
||||
func peerAddr(r *http.Request) (netip.Addr, bool) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
return addr.Unmap(), true
|
||||
}
|
||||
|
||||
func inAny(addr netip.Addr, prefixes []netip.Prefix) bool {
|
||||
for _, p := range prefixes {
|
||||
if p.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ response record
|
||||
|
||||
// recorder observes the status and byte count without altering behaviour.
|
||||
//
|
||||
// It implements Unwrap, ReadFrom, Flush and Hijack so that wrapping costs
|
||||
// nothing: without ReadFrom the static handler would lose the sendfile fast
|
||||
// path that io.Copy takes when the destination is the raw *http.response.
|
||||
type recorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
written int64
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (r *recorder) WriteHeader(status int) {
|
||||
if r.wroteHeader {
|
||||
return
|
||||
}
|
||||
r.status = status
|
||||
r.wroteHeader = true
|
||||
r.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (r *recorder) Write(b []byte) (int, error) {
|
||||
if !r.wroteHeader {
|
||||
r.WriteHeader(http.StatusOK)
|
||||
}
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.written += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *recorder) ReadFrom(src io.Reader) (int64, error) {
|
||||
if !r.wroteHeader {
|
||||
r.WriteHeader(http.StatusOK)
|
||||
}
|
||||
rf, ok := r.ResponseWriter.(io.ReaderFrom)
|
||||
if !ok {
|
||||
n, err := io.Copy(r.ResponseWriter, src)
|
||||
r.written += n
|
||||
return n, err
|
||||
}
|
||||
n, err := rf.ReadFrom(src)
|
||||
r.written += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
||||
|
||||
func (r *recorder) Flush() {
|
||||
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
||||
if !r.wroteHeader {
|
||||
r.WriteHeader(http.StatusOK)
|
||||
}
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *recorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
h, ok := r.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("httpx: ResponseWriter does not support hijacking")
|
||||
}
|
||||
return h.Hijack()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Timeouts configures a listener's deadlines.
|
||||
//
|
||||
// WriteTimeout is deliberately optional and left at zero for the static
|
||||
// listener: a global write deadline covers the whole response, so a large file
|
||||
// over a slow link gets its connection torn down mid-download even though
|
||||
// nothing is wrong. Per-response deadlines belong to the handler, via
|
||||
// http.ResponseController.
|
||||
type Timeouts struct {
|
||||
ReadHeader time.Duration
|
||||
Read time.Duration
|
||||
Idle time.Duration
|
||||
Write time.Duration // 0 = none
|
||||
}
|
||||
|
||||
// Server is an http.Server whose listener is already bound.
|
||||
//
|
||||
// Binding at construction time means a port conflict is reported before any
|
||||
// background work starts, and it lets a caller pass ":0" and read back the
|
||||
// chosen address — which is what the integration tests do.
|
||||
type Server struct {
|
||||
Name string
|
||||
|
||||
srv *http.Server
|
||||
ln net.Listener
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Listen binds addr and prepares a server for h.
|
||||
func Listen(name, addr string, h http.Handler, t Timeouts, log *slog.Logger) (*Server, error) {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{
|
||||
Name: name,
|
||||
ln: ln,
|
||||
log: log,
|
||||
srv: &http.Server{
|
||||
Handler: h,
|
||||
ReadHeaderTimeout: t.ReadHeader,
|
||||
ReadTimeout: t.Read,
|
||||
IdleTimeout: t.Idle,
|
||||
WriteTimeout: t.Write,
|
||||
// Route net/http's own errors (malformed requests, TLS handshake
|
||||
// failures) into the structured log rather than bare stderr.
|
||||
ErrorLog: slog.NewLogLogger(log.With("listener", name).Handler(), slog.LevelWarn),
|
||||
},
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Addr is the address actually bound, which differs from the requested one when
|
||||
// port 0 was asked for.
|
||||
func (s *Server) Addr() string { return s.ln.Addr().String() }
|
||||
|
||||
// Serve blocks until the server stops. It returns nil on a graceful shutdown.
|
||||
func (s *Server) Serve() error {
|
||||
err := s.srv.Serve(s.ln)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Shutdown stops accepting connections and waits for in-flight requests, up to
|
||||
// ctx's deadline. Past the deadline the remaining connections are closed.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
err := s.srv.Shutdown(ctx)
|
||||
if err != nil {
|
||||
// Shutdown only fails by running out of time; Close is then the only way
|
||||
// to release the port.
|
||||
s.log.Warn("graceful shutdown timed out, closing connections",
|
||||
"listener", s.Name, "err", err)
|
||||
return s.srv.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Group runs several servers with a shared lifetime: if one fails, all stop.
|
||||
type Group struct {
|
||||
Servers []*Server
|
||||
Grace time.Duration
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// Run serves until ctx is cancelled or a server fails, then shuts every server
|
||||
// down within Grace. It returns the first non-nil error.
|
||||
func (g *Group) Run(ctx context.Context) error {
|
||||
errs := make(chan error, len(g.Servers))
|
||||
for _, s := range g.Servers {
|
||||
go func() {
|
||||
g.Log.Info("listening", "listener", s.Name, "addr", s.Addr())
|
||||
errs <- s.Serve()
|
||||
}()
|
||||
}
|
||||
|
||||
var first error
|
||||
done := 0
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
g.Log.Info("shutting down", "grace", g.Grace)
|
||||
case err := <-errs:
|
||||
done++
|
||||
first = err
|
||||
if err != nil {
|
||||
g.Log.Error("listener failed, stopping", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The shutdown deadline must survive the cancellation that triggered it,
|
||||
// otherwise ctx.Done() would make Shutdown return immediately.
|
||||
shutCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), g.Grace)
|
||||
defer cancel()
|
||||
for _, s := range g.Servers {
|
||||
if err := s.Shutdown(shutCtx); err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the remaining Serve results so no goroutine is left blocked on send.
|
||||
for ; done < len(g.Servers); done++ {
|
||||
select {
|
||||
case err := <-errs:
|
||||
if err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
return first
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Package pathutil validates the site-relative paths a deployment manifest may
|
||||
// declare.
|
||||
//
|
||||
// It depends on the standard library only. Both sides run these checks: the CLI
|
||||
// so a bad path is caught before anything is uploaded, and the server because a
|
||||
// client is never a trust boundary. cmd/pages/deps_test.go fails the build if
|
||||
// anything server-side reaches the CLI through this package.
|
||||
package pathutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxPathBytes caps a whole path. Linux's PATH_MAX is 4096 including the
|
||||
// deployment directory this gets joined onto, so anything approaching it is
|
||||
// already not a real build artifact.
|
||||
MaxPathBytes = 4096
|
||||
// MaxSegmentBytes caps one component, matching the NAME_MAX of every
|
||||
// filesystem a deployment tree is plausibly assembled on.
|
||||
MaxSegmentBytes = 255
|
||||
)
|
||||
|
||||
// Why a path can be refused. Callers match on these with errors.Is; the message
|
||||
// carries the specifics.
|
||||
var (
|
||||
ErrEmpty = errors.New("path is empty")
|
||||
ErrTooLong = errors.New("path is too long")
|
||||
ErrSegmentTooLong = errors.New("path component is too long")
|
||||
ErrNotUTF8 = errors.New("path is not valid UTF-8")
|
||||
ErrControlChar = errors.New("path contains a control character")
|
||||
ErrBackslash = errors.New("path contains a backslash")
|
||||
ErrNotRelative = errors.New("path must be relative and slash-separated, with no empty, \".\" or \"..\" component")
|
||||
ErrNotLocal = errors.New("path is not usable as a local filename")
|
||||
|
||||
ErrDuplicate = errors.New("path appears twice in the manifest")
|
||||
ErrCaseCollision = errors.New("path differs from another only by letter case")
|
||||
ErrPathConflict = errors.New("path is used as both a file and a directory")
|
||||
)
|
||||
|
||||
// Validate reports whether p may appear in a manifest.
|
||||
//
|
||||
// The checks overlap on purpose. fs.ValidPath states the semantic rule —
|
||||
// relative, slash-separated, no empty or "." or ".." component, no trailing
|
||||
// slash — and filepath.Localize answers the question that actually matters when
|
||||
// the tree is assembled: can this become a filename that filepath.Join is
|
||||
// unable to walk out of the destination directory. On Windows Localize also
|
||||
// rejects the reserved device names. Running both means that a future change to
|
||||
// either one cannot quietly widen what is accepted.
|
||||
func Validate(p string) error {
|
||||
if p == "" {
|
||||
return ErrEmpty
|
||||
}
|
||||
if len(p) > MaxPathBytes {
|
||||
return fmt.Errorf("%w: %d bytes, limit is %d", ErrTooLong, len(p), MaxPathBytes)
|
||||
}
|
||||
if !utf8.ValidString(p) {
|
||||
// These bytes would be a perfectly legal filename on Linux, but the path
|
||||
// is also a value in a TEXT column, and SQLite's comparison and
|
||||
// collation behaviour on non-UTF-8 is undefined. The database is the
|
||||
// stricter of the two, so it sets the rule.
|
||||
return ErrNotUTF8
|
||||
}
|
||||
for i := 0; i < len(p); i++ {
|
||||
// A newline would forge a second line in the access log; an ESC would
|
||||
// let a manifest repaint the operator's terminal during `pages
|
||||
// deployment show --files`. NUL would truncate the path at the syscall
|
||||
// boundary while the database kept the whole thing.
|
||||
if c := p[i]; c < 0x20 || c == 0x7f {
|
||||
return ErrControlChar
|
||||
}
|
||||
}
|
||||
if strings.ContainsRune(p, '\\') {
|
||||
// An ordinary filename character on Linux and a separator on Windows.
|
||||
// Refusing it outright is what makes a manifest mean the same thing
|
||||
// wherever the tree is assembled.
|
||||
return ErrBackslash
|
||||
}
|
||||
if !fs.ValidPath(p) || p == "." {
|
||||
return ErrNotRelative
|
||||
}
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if len(seg) > MaxSegmentBytes {
|
||||
return fmt.Errorf("%w: %d bytes, limit is %d", ErrSegmentTooLong, len(seg), MaxSegmentBytes)
|
||||
}
|
||||
}
|
||||
if _, err := filepath.Localize(p); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrNotLocal, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set accumulates one manifest's paths and rejects the pairs that cannot
|
||||
// coexist in a single directory tree.
|
||||
//
|
||||
// Three kinds, each of which would otherwise surface far from its cause:
|
||||
//
|
||||
// - An exact duplicate declares two digests for one path. Assembly would
|
||||
// write whichever arrived last and the site would be subtly wrong.
|
||||
// - A case-only collision ("App.js" and "app.js") assembles fine on ext4 and
|
||||
// silently loses a file on APFS or NTFS — a failure that reproduces on the
|
||||
// server and on nobody's laptop.
|
||||
// - A file that another entry needs as a directory ("a" and "a/b") fails
|
||||
// mid-assembly with a bare ENOTDIR, after part of the tree already exists.
|
||||
//
|
||||
// Catching all three at manifest time turns each into one clear error naming
|
||||
// both paths, before a single byte is uploaded.
|
||||
type Set struct {
|
||||
files map[string]string // folded path -> the path that claimed it
|
||||
dirs map[string]string // folded directory prefix -> a path that needs it
|
||||
}
|
||||
|
||||
// NewSet returns a Set sized for an expected number of files.
|
||||
func NewSet(size int) *Set {
|
||||
return &Set{
|
||||
files: make(map[string]string, size),
|
||||
dirs: make(map[string]string, size/4+1),
|
||||
}
|
||||
}
|
||||
|
||||
// Add validates p and records it. The error names the path it conflicts with,
|
||||
// which is the only detail that makes a collision fixable.
|
||||
func (s *Set) Add(p string) error {
|
||||
if err := Validate(p); err != nil {
|
||||
return err
|
||||
}
|
||||
folded := fold(p)
|
||||
if prev, ok := s.files[folded]; ok {
|
||||
if prev == p {
|
||||
return ErrDuplicate
|
||||
}
|
||||
return fmt.Errorf("%w: %q", ErrCaseCollision, prev)
|
||||
}
|
||||
if prev, ok := s.dirs[folded]; ok {
|
||||
return fmt.Errorf("%w: %q needs it as a directory", ErrPathConflict, prev)
|
||||
}
|
||||
|
||||
// Check every ancestor before recording anything, so a rejected path leaves
|
||||
// the set exactly as it was.
|
||||
for dir := path.Dir(p); dir != "."; dir = path.Dir(dir) {
|
||||
if prev, ok := s.files[fold(dir)]; ok {
|
||||
return fmt.Errorf("%w: %q is a file", ErrPathConflict, prev)
|
||||
}
|
||||
}
|
||||
s.files[folded] = p
|
||||
for dir := path.Dir(p); dir != "."; dir = path.Dir(dir) {
|
||||
fd := fold(dir)
|
||||
if _, ok := s.dirs[fd]; ok {
|
||||
// Every shallower ancestor is already recorded too.
|
||||
break
|
||||
}
|
||||
s.dirs[fd] = p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Len is how many paths have been accepted.
|
||||
func (s *Set) Len() int { return len(s.files) }
|
||||
|
||||
// fold is the key under which two names collide on a case-insensitive
|
||||
// filesystem. strings.ToLower is not byte-for-byte what APFS or NTFS do — they
|
||||
// each pin a particular Unicode version's table — but it agrees with both on
|
||||
// everything a build tool emits, and disagreeing by being too strict is the
|
||||
// safe direction.
|
||||
func fold(p string) string { return strings.ToLower(p) }
|
||||
@@ -0,0 +1,180 @@
|
||||
package pathutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateAccepts(t *testing.T) {
|
||||
ok := []string{
|
||||
"index.html",
|
||||
"assets/app.js",
|
||||
"a/b/c/d/e/f/g.txt",
|
||||
".well-known/acme-challenge/token",
|
||||
"_next/static/chunks/main-abc123.js",
|
||||
"文档/说明.html",
|
||||
"a file with spaces.html",
|
||||
"weird!@#$%^&()+=[]{};'\",<>?.html",
|
||||
"..hidden", // only an exact ".." component is a traversal
|
||||
"a/..b/c", //
|
||||
"...", // not "." and not ".."
|
||||
"~tilde.html", // no special meaning below a project prefix
|
||||
strings.Repeat("a", MaxSegmentBytes),
|
||||
}
|
||||
for _, p := range ok {
|
||||
if err := Validate(p); err != nil {
|
||||
t.Errorf("Validate(%q) = %v, want nil", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejects(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want error
|
||||
}{
|
||||
{"", ErrEmpty},
|
||||
{".", ErrNotRelative},
|
||||
{"..", ErrNotRelative},
|
||||
{"../etc/passwd", ErrNotRelative},
|
||||
{"a/../../etc/passwd", ErrNotRelative},
|
||||
{"a/./b", ErrNotRelative},
|
||||
{"/etc/passwd", ErrNotRelative},
|
||||
{"a//b", ErrNotRelative},
|
||||
{"a/", ErrNotRelative},
|
||||
{"/", ErrNotRelative},
|
||||
{"a/b/..", ErrNotRelative},
|
||||
{"a\x00b", ErrControlChar},
|
||||
{"a\nb", ErrControlChar},
|
||||
{"a\tb", ErrControlChar},
|
||||
{"a\x1b[31m", ErrControlChar},
|
||||
{"a\x7fb", ErrControlChar},
|
||||
{"a\\b", ErrBackslash},
|
||||
{"..\\..\\windows", ErrBackslash},
|
||||
{"a/\xff\xfe/b", ErrNotUTF8},
|
||||
{strings.Repeat("a", MaxSegmentBytes+1), ErrSegmentTooLong},
|
||||
{"ok/" + strings.Repeat("b", MaxSegmentBytes+1), ErrSegmentTooLong},
|
||||
{strings.Repeat("a/", MaxPathBytes/2) + "b", ErrTooLong},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := Validate(tc.path)
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("Validate(%q) = %v, want %v", tc.path, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The traversal cases are the ones that matter most, so state them again as an
|
||||
// executable claim about what a manifest can never make Join produce.
|
||||
func TestValidateBlocksEscape(t *testing.T) {
|
||||
for _, p := range []string{
|
||||
"../x", "a/../../x", "./../x", "/x", "a/b/../../../x",
|
||||
"..", "a/..", "\\..\\x", "a\\..\\..\\x",
|
||||
} {
|
||||
if err := Validate(p); err == nil {
|
||||
t.Errorf("Validate(%q) accepted a path that can escape its directory", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDetectsCollisions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
paths []string
|
||||
want error
|
||||
}{
|
||||
{"duplicate", []string{"a.html", "a.html"}, ErrDuplicate},
|
||||
{"case only", []string{"App.js", "app.js"}, ErrCaseCollision},
|
||||
{"case in a directory", []string{"Assets/x.js", "assets/y.js"}, nil},
|
||||
{"case collision under a folded directory", []string{"Assets/x.js", "assets/X.js"}, ErrCaseCollision},
|
||||
{"file then directory", []string{"a", "a/b"}, ErrPathConflict},
|
||||
{"directory then file", []string{"a/b", "a"}, ErrPathConflict},
|
||||
{"deep file then directory", []string{"a/b/c", "a/b"}, ErrPathConflict},
|
||||
{"file then deep directory", []string{"a/b", "a/b/c/d"}, ErrPathConflict},
|
||||
{"case-folded file vs directory", []string{"A", "a/b"}, ErrPathConflict},
|
||||
{"siblings are fine", []string{"a/b", "a/c", "a/d/e"}, nil},
|
||||
{"unrelated", []string{"index.html", "assets/app.js", "assets/app.css"}, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := NewSet(len(tc.paths))
|
||||
var err error
|
||||
for _, p := range tc.paths {
|
||||
if err = s.Add(p); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if tc.want == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if s.Len() != len(tc.paths) {
|
||||
t.Errorf("Len = %d, want %d", s.Len(), len(tc.paths))
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("err = %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A rejected path must not leave a mark: the caller may report the error and
|
||||
// carry on validating the rest of the manifest.
|
||||
func TestSetRejectionLeavesNoTrace(t *testing.T) {
|
||||
s := NewSet(4)
|
||||
if err := s.Add("a/b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Add("a"); !errors.Is(err, ErrPathConflict) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if err := s.Add("bad\x00path"); !errors.Is(err, ErrControlChar) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if s.Len() != 1 {
|
||||
t.Errorf("Len = %d, want 1", s.Len())
|
||||
}
|
||||
if err := s.Add("a/c"); err != nil {
|
||||
t.Errorf("a sibling must still be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The message has to name the other path, or a 50,000-file manifest reports a
|
||||
// collision the operator cannot locate.
|
||||
func TestCollisionErrorNamesTheOtherPath(t *testing.T) {
|
||||
s := NewSet(2)
|
||||
if err := s.Add("Assets/App.js"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := s.Add("assets/app.js")
|
||||
if err == nil {
|
||||
t.Fatal("want a collision")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Assets/App.js") {
|
||||
t.Errorf("error %q does not name the conflicting path", err)
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzValidate(f *testing.F) {
|
||||
for _, s := range []string{"index.html", "a/b", "../x", "a\\b", "", ".", "a\x00b"} {
|
||||
f.Add(s)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, p string) {
|
||||
if Validate(p) != nil {
|
||||
return
|
||||
}
|
||||
// Anything accepted must be safe to join onto a directory. Localize is
|
||||
// the standard library's own statement of that property.
|
||||
if strings.HasPrefix(p, "/") || strings.Contains(p, "\\") {
|
||||
t.Fatalf("Validate accepted %q", p)
|
||||
}
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if seg == "" || seg == "." || seg == ".." {
|
||||
t.Fatalf("Validate accepted %q with segment %q", p, seg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package site turns an activated deployment into HTTP responses.
|
||||
//
|
||||
// Everything here is built around one property: a request must never observe a
|
||||
// mixture of two deployments. That is achieved by making the served state an
|
||||
// immutable snapshot behind an atomic pointer — a handler loads it once, at the
|
||||
// top, and every subsequent decision in that request comes from the value it
|
||||
// loaded. Switching versions is one pointer store, so an in-flight request keeps
|
||||
// reading the deployment it started on until it finishes.
|
||||
package site
|
||||
|
||||
import (
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// FileEntry is what a request path resolves to: enough to serve the bytes and
|
||||
// to answer a conditional request, and nothing else.
|
||||
type FileEntry struct {
|
||||
Digest cas.Digest
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Index is the part of a snapshot that costs something to build.
|
||||
//
|
||||
// It is separate from Deployment so activation can pay for it *before* it
|
||||
// changes anything: if the manifest cannot be read or turned into these maps,
|
||||
// the failure happens while the old deployment is still the active one.
|
||||
type Index struct {
|
||||
files map[string]FileEntry
|
||||
dirs map[string]struct{}
|
||||
totalBytes int64
|
||||
}
|
||||
|
||||
// NewIndex builds the lookup structures for one manifest.
|
||||
//
|
||||
// Dirs holds every directory prefix, which is what lets Resolve tell "no such
|
||||
// path" apart from "that is a directory, redirect to it with a trailing slash"
|
||||
// without scanning the file map for prefixes on every miss.
|
||||
func NewIndex(files []store.FileRow) *Index {
|
||||
idx := &Index{
|
||||
files: make(map[string]FileEntry, len(files)),
|
||||
dirs: make(map[string]struct{}),
|
||||
}
|
||||
for _, f := range files {
|
||||
idx.files[f.Path] = FileEntry{Digest: f.Digest, Size: f.Size}
|
||||
idx.totalBytes += f.Size
|
||||
for dir := path.Dir(f.Path); dir != "." && dir != "/"; dir = path.Dir(dir) {
|
||||
if _, ok := idx.dirs[dir]; ok {
|
||||
// Every shorter prefix was added with this one, so there is
|
||||
// nothing left to walk.
|
||||
break
|
||||
}
|
||||
idx.dirs[dir] = struct{}{}
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// Deployment is an immutable snapshot of what a project serves.
|
||||
//
|
||||
// Nothing mutates one after NewDeployment returns. That is the whole reason a
|
||||
// version switch can be a bare pointer store: readers need no synchronisation
|
||||
// beyond the atomic load that handed them the snapshot.
|
||||
type Deployment struct {
|
||||
ID string
|
||||
ProjectID int64
|
||||
// Dir is the assembled tree, kept for the webroot symlink and for
|
||||
// operators. It is empty under assemble_mode=none and is never used to
|
||||
// serve a request — content comes from the CAS by digest.
|
||||
Dir string
|
||||
// CreatedAt is the Last-Modified of every file in the deployment. The CAS
|
||||
// file's own mtime would be wrong: blobs are shared across projects and
|
||||
// versions, so their mtime means nothing here and would leak when some
|
||||
// other project first uploaded the same bytes.
|
||||
CreatedAt time.Time
|
||||
ActivatedAt time.Time
|
||||
FileCount int
|
||||
TotalBytes int64
|
||||
|
||||
idx *Index
|
||||
}
|
||||
|
||||
// NewDeployment pairs a row with an already-built index. It allocates one
|
||||
// struct and copies no maps, so activation can call it after the database
|
||||
// commit without any risk of failing there.
|
||||
func NewDeployment(dep *store.Deployment, idx *Index, dir string) *Deployment {
|
||||
d := &Deployment{
|
||||
ID: dep.PublicID,
|
||||
ProjectID: dep.ProjectID,
|
||||
Dir: dir,
|
||||
CreatedAt: dep.CreatedAt,
|
||||
FileCount: len(idx.files),
|
||||
TotalBytes: idx.totalBytes,
|
||||
idx: idx,
|
||||
}
|
||||
if dep.ActivatedAt != nil {
|
||||
d.ActivatedAt = *dep.ActivatedAt
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Lookup finds one file by its slash-separated path within the deployment.
|
||||
func (d *Deployment) Lookup(rel string) (FileEntry, bool) {
|
||||
e, ok := d.idx.files[rel]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// IsDir reports whether rel is a directory prefix of some file.
|
||||
func (d *Deployment) IsDir(rel string) bool {
|
||||
_, ok := d.idx.dirs[rel]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// ProjectConfig is the serving-time part of a project row, copied out so the
|
||||
// read path never touches the database. It is replaced wholesale, never edited
|
||||
// in place, so a reader always sees one consistent set of settings.
|
||||
type ProjectConfig struct {
|
||||
IndexFile string
|
||||
NotFoundFile string
|
||||
SPAFallback bool
|
||||
CacheControl string
|
||||
}
|
||||
|
||||
// ConfigOf extracts what serving needs from a project row.
|
||||
func ConfigOf(p *store.Project) *ProjectConfig {
|
||||
return &ProjectConfig{
|
||||
IndexFile: p.IndexFile,
|
||||
NotFoundFile: p.NotFoundFile,
|
||||
SPAFallback: p.SPAFallback,
|
||||
CacheControl: p.CacheControl,
|
||||
}
|
||||
}
|
||||
|
||||
// Project is one project's live serving state.
|
||||
//
|
||||
// Both fields are atomic pointers to immutable values, so every read on the
|
||||
// serving path is one atomic load and nothing else. Writers are already
|
||||
// serialised per project by the deploy service's lock; the atomics are here for
|
||||
// the readers, not for mutual exclusion.
|
||||
type Project struct {
|
||||
ID int64
|
||||
Name string
|
||||
|
||||
cfg atomic.Pointer[ProjectConfig]
|
||||
active atomic.Pointer[Deployment]
|
||||
}
|
||||
|
||||
// NewProject builds a registry entry with no deployment activated yet.
|
||||
func NewProject(p *store.Project) *Project {
|
||||
sp := &Project{ID: p.ID, Name: p.Name}
|
||||
sp.cfg.Store(ConfigOf(p))
|
||||
return sp
|
||||
}
|
||||
|
||||
// Active is the deployment this project is serving, or nil if it has never
|
||||
// activated one.
|
||||
//
|
||||
// A request handler calls this exactly once, at the top, and uses the value it
|
||||
// gets for the rest of the request. Calling it a second time within one request
|
||||
// is a bug: the two loads could straddle an activation and produce a response
|
||||
// assembled from two different versions, which is the exact failure this whole
|
||||
// program exists to prevent.
|
||||
func (p *Project) Active() *Deployment { return p.active.Load() }
|
||||
|
||||
// Config is the project's serving settings.
|
||||
func (p *Project) Config() *ProjectConfig { return p.cfg.Load() }
|
||||
|
||||
// Activate publishes a snapshot. This single store is the version switch.
|
||||
func (p *Project) Activate(d *Deployment) { p.active.Store(d) }
|
||||
|
||||
// SetConfig swaps in new serving settings.
|
||||
func (p *Project) SetConfig(c *ProjectConfig) { p.cfg.Store(c) }
|
||||
|
||||
// Registry maps project names to their live state.
|
||||
//
|
||||
// Reads are overwhelmingly more common than writes — every static request does
|
||||
// one, while the set of projects changes on the order of minutes to days — so
|
||||
// the map is copy-on-write behind an atomic pointer: readers get a lock-free,
|
||||
// allocation-free lookup against a consistent snapshot of the whole set, and
|
||||
// writers pay O(n) to clone it. A RWMutex would put one shared cache line in
|
||||
// every request's path for no benefit at this write rate.
|
||||
//
|
||||
// Note the layering: activating a deployment does *not* rebuild this map. It
|
||||
// stores into the Project the map already points at, so the copy is reserved for
|
||||
// changes to the project set itself.
|
||||
type Registry struct {
|
||||
mu sync.Mutex // serialises writers only; readers never take it
|
||||
byName atomic.Pointer[map[string]*Project]
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
r := &Registry{}
|
||||
r.byName.Store(&map[string]*Project{})
|
||||
return r
|
||||
}
|
||||
|
||||
// Lookup finds a project by the name in the URL.
|
||||
func (r *Registry) Lookup(name string) (*Project, bool) {
|
||||
p, ok := (*r.byName.Load())[name]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Len is the number of projects the registry knows about.
|
||||
func (r *Registry) Len() int { return len(*r.byName.Load()) }
|
||||
|
||||
// Projects returns the current entries in no particular order.
|
||||
func (r *Registry) Projects() []*Project {
|
||||
m := *r.byName.Load()
|
||||
out := make([]*Project, 0, len(m))
|
||||
for _, p := range m {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveProject satisfies auth.ProjectResolver, which lets the ownership guard
|
||||
// on the hot deployment endpoints answer from memory instead of querying.
|
||||
//
|
||||
// It is only safe in that role because the registry is fully built from the
|
||||
// database before the listeners start: a partially populated registry would
|
||||
// report someone's own project as unknown.
|
||||
func (r *Registry) ResolveProject(_ context.Context, name string) (int64, error) {
|
||||
p, ok := r.Lookup(name)
|
||||
if !ok {
|
||||
return 0, store.ErrNotFound
|
||||
}
|
||||
return p.ID, nil
|
||||
}
|
||||
|
||||
// Replace swaps in a whole new project set, which is how startup publishes the
|
||||
// registry it built from the database.
|
||||
func (r *Registry) Replace(ps []*Project) {
|
||||
m := make(map[string]*Project, len(ps))
|
||||
for _, p := range ps {
|
||||
m[p.Name] = p
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.byName.Store(&m)
|
||||
}
|
||||
|
||||
// Put adds or updates a project and returns its live entry.
|
||||
//
|
||||
// An entry that is already present is kept rather than rebuilt, so a settings
|
||||
// change does not disturb the deployment the project is currently serving.
|
||||
func (r *Registry) Put(p *store.Project) *Project {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
old := *r.byName.Load()
|
||||
if sp, ok := old[p.Name]; ok && sp.ID == p.ID {
|
||||
sp.SetConfig(ConfigOf(p))
|
||||
return sp
|
||||
}
|
||||
sp := NewProject(p)
|
||||
next := maps.Clone(old)
|
||||
if next == nil {
|
||||
next = make(map[string]*Project, 1)
|
||||
}
|
||||
next[p.Name] = sp
|
||||
r.byName.Store(&next)
|
||||
return sp
|
||||
}
|
||||
|
||||
// Delete drops a project. Requests for it start 404ing as soon as the new map
|
||||
// is stored; requests already reading a snapshot of it finish normally.
|
||||
func (r *Registry) Delete(name string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
old := *r.byName.Load()
|
||||
if _, ok := old[name]; !ok {
|
||||
return
|
||||
}
|
||||
next := maps.Clone(old)
|
||||
delete(next, name)
|
||||
r.byName.Store(&next)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
func TestRegistryLookupAndDelete(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if _, ok := r.Lookup("demo"); ok {
|
||||
t.Fatal("an empty registry answered a lookup")
|
||||
}
|
||||
if r.Len() != 0 {
|
||||
t.Fatalf("Len = %d on an empty registry", r.Len())
|
||||
}
|
||||
|
||||
sp := r.Put(&store.Project{ID: 7, Name: "demo", IndexFile: "index.html"})
|
||||
got, ok := r.Lookup("demo")
|
||||
if !ok || got != sp {
|
||||
t.Fatal("Put did not publish the project it returned")
|
||||
}
|
||||
if got.ID != 7 || got.Name != "demo" {
|
||||
t.Errorf("entry = %+v", got)
|
||||
}
|
||||
if r.Len() != 1 {
|
||||
t.Errorf("Len = %d, want 1", r.Len())
|
||||
}
|
||||
|
||||
r.Delete("demo")
|
||||
if _, ok := r.Lookup("demo"); ok {
|
||||
t.Error("a deleted project still resolves")
|
||||
}
|
||||
r.Delete("demo") // deleting twice is not an error
|
||||
r.Delete("never-existed")
|
||||
}
|
||||
|
||||
// A settings change must not disturb what the project is currently serving: the
|
||||
// entry is updated in place rather than rebuilt, so the active pointer survives.
|
||||
func TestRegistryPutKeepsTheActiveDeployment(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
p := &store.Project{ID: 1, Name: "demo", IndexFile: "index.html"}
|
||||
sp := r.Put(p)
|
||||
d := fixture("index.html")
|
||||
sp.Activate(d)
|
||||
|
||||
p2 := &store.Project{ID: 1, Name: "demo", IndexFile: "main.html", SPAFallback: true}
|
||||
again := r.Put(p2)
|
||||
if again != sp {
|
||||
t.Fatal("Put replaced the live entry instead of updating it")
|
||||
}
|
||||
if again.Active() != d {
|
||||
t.Fatal("updating the settings dropped the active deployment")
|
||||
}
|
||||
if cfg := again.Config(); cfg.IndexFile != "main.html" || !cfg.SPAFallback {
|
||||
t.Errorf("config = %+v, want the new settings", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// A project recreated under the same name is a different project, so its entry
|
||||
// must start empty rather than inherit the old one's deployment.
|
||||
func TestRegistryPutReplacesAnEntryWithADifferentID(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
sp := r.Put(&store.Project{ID: 1, Name: "demo"})
|
||||
sp.Activate(fixture("index.html"))
|
||||
|
||||
again := r.Put(&store.Project{ID: 2, Name: "demo"})
|
||||
if again == sp {
|
||||
t.Fatal("a different project id reused the old entry")
|
||||
}
|
||||
if again.Active() != nil {
|
||||
t.Error("the new project inherited the old project's deployment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryReplace(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Put(&store.Project{ID: 1, Name: "gone"})
|
||||
r.Replace([]*Project{
|
||||
NewProject(&store.Project{ID: 2, Name: "a"}),
|
||||
NewProject(&store.Project{ID: 3, Name: "b"}),
|
||||
})
|
||||
if _, ok := r.Lookup("gone"); ok {
|
||||
t.Error("Replace kept a project that is not in the new set")
|
||||
}
|
||||
if r.Len() != 2 {
|
||||
t.Fatalf("Len = %d, want 2", r.Len())
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, p := range r.Projects() {
|
||||
names[p.Name] = true
|
||||
}
|
||||
if !names["a"] || !names["b"] {
|
||||
t.Errorf("Projects = %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// The API's ownership guard compares resolved ids, so this is the lookup that
|
||||
// decides whether one project's key can touch another's deployment.
|
||||
func TestRegistryResolveProject(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Put(&store.Project{ID: 42, Name: "demo"})
|
||||
|
||||
id, err := r.ResolveProject(context.Background(), "demo")
|
||||
if err != nil || id != 42 {
|
||||
t.Fatalf("ResolveProject = (%d, %v), want (42, nil)", id, err)
|
||||
}
|
||||
if _, err := r.ResolveProject(context.Background(), "other"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("ResolveProject on an unknown name = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Readers take no lock at all, so this is worth running under -race: a writer
|
||||
// mutating the map in place instead of cloning it would show up here.
|
||||
func TestRegistryConcurrentReadersAndWriters(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Put(&store.Project{ID: 1, Name: "stable"})
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if p, ok := r.Lookup("stable"); !ok || p.ID != 1 {
|
||||
t.Error("the stable project vanished while other projects changed")
|
||||
return
|
||||
}
|
||||
r.Lookup("churn")
|
||||
r.Len()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := range 500 {
|
||||
r.Put(&store.Project{ID: int64(i + 2), Name: "churn"})
|
||||
r.Delete("churn")
|
||||
}
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/pathutil"
|
||||
)
|
||||
|
||||
// Result is what a request path resolved to. It is a value, not a response:
|
||||
// Resolve performs no I/O and touches nothing, which is what makes the routing
|
||||
// rules exhaustively testable as a table.
|
||||
type Result struct {
|
||||
Entry FileEntry
|
||||
// Name is the logical path within the deployment, which decides the
|
||||
// Content-Type. It is never a filesystem path.
|
||||
Name string
|
||||
// Status is 200, 301, 400 or 404. A 404 may still carry an Entry, which is
|
||||
// the project's custom not-found document.
|
||||
Status int
|
||||
// Location is the site-absolute path to redirect to, set when Status is 301.
|
||||
// It is unescaped; the caller is responsible for building the header value.
|
||||
Location string
|
||||
}
|
||||
|
||||
// Resolve maps a request onto a file in the deployment.
|
||||
//
|
||||
// project and rest are what splitTilde produced, so rest keeps its leading
|
||||
// slash and is empty for "/~proj". accept is the request's Accept header, which
|
||||
// only matters for the SPA fallback.
|
||||
func Resolve(d *Deployment, cfg *ProjectConfig, project, rest, accept string) Result {
|
||||
// "/~proj" must become "/~proj/" before anything else: without the trailing
|
||||
// slash every relative link in the page would resolve one level too high.
|
||||
if rest == "" {
|
||||
return Result{Status: http.StatusMovedPermanently, Location: "/~" + project + "/"}
|
||||
}
|
||||
// splitTilde always leaves the leading slash on, so this cannot happen from
|
||||
// the serving path. Enforcing it anyway is what keeps every "/~" + project +
|
||||
// … below a path *inside* the project: without it a rest of "x" would build
|
||||
// "/~demox" and send the client to a different project entirely.
|
||||
if rest[0] != '/' {
|
||||
return Result{Status: http.StatusBadRequest}
|
||||
}
|
||||
|
||||
// Canonicalise. The trailing slash survives Clean deliberately — it is the
|
||||
// difference between asking for a directory and asking for a file, and
|
||||
// dropping it here would redirect "/dir/" to "/dir" only for step 6 to
|
||||
// redirect it back.
|
||||
trailing := strings.HasSuffix(rest, "/")
|
||||
clean := path.Clean(rest)
|
||||
canon := clean
|
||||
if trailing && clean != "/" {
|
||||
canon += "/"
|
||||
}
|
||||
if canon != rest {
|
||||
return Result{Status: http.StatusMovedPermanently, Location: "/~" + project + canon}
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(clean, "/")
|
||||
if rel == "" {
|
||||
if cfg.IndexFile == "" {
|
||||
return Result{Status: http.StatusNotFound}
|
||||
}
|
||||
rel = cfg.IndexFile
|
||||
}
|
||||
// Clean has already removed any "..", so this is defence in depth rather
|
||||
// than the primary guard — but it is also what rejects NUL and control
|
||||
// bytes, and a path that cannot be a manifest entry cannot be a hit.
|
||||
if err := pathutil.Validate(rel); err != nil {
|
||||
return Result{Status: http.StatusBadRequest}
|
||||
}
|
||||
|
||||
if e, ok := d.Lookup(rel); ok {
|
||||
if trailing && clean != "/" {
|
||||
// "/page.html/" names a file with a directory's URL. Serving it there
|
||||
// would make every relative link inside resolve one level too deep,
|
||||
// and would cache the same bytes under two URLs.
|
||||
return Result{Status: http.StatusMovedPermanently, Location: "/~" + project + clean}
|
||||
}
|
||||
return Result{Entry: e, Name: rel, Status: http.StatusOK}
|
||||
}
|
||||
|
||||
if d.IsDir(rel) {
|
||||
if !trailing {
|
||||
// Redirect rather than serve the index directly, again so relative
|
||||
// links inside the page resolve against the directory.
|
||||
return Result{Status: http.StatusMovedPermanently, Location: "/~" + project + clean + "/"}
|
||||
}
|
||||
if idx := path.Join(rel, cfg.IndexFile); pathutil.Validate(idx) == nil {
|
||||
if e, ok := d.Lookup(idx); ok {
|
||||
return Result{Entry: e, Name: idx, Status: http.StatusOK}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SPA fallback, gated on Accept. Without that gate a missing
|
||||
// /assets/app.js would come back as HTML with status 200, and the failure
|
||||
// surfaces later as "Unexpected token '<'" somewhere entirely unrelated.
|
||||
if cfg.SPAFallback && acceptsHTML(accept) {
|
||||
if e, ok := d.Lookup(cfg.IndexFile); ok {
|
||||
return Result{Entry: e, Name: cfg.IndexFile, Status: http.StatusOK}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.NotFoundFile != "" {
|
||||
if e, ok := d.Lookup(cfg.NotFoundFile); ok {
|
||||
return Result{Entry: e, Name: cfg.NotFoundFile, Status: http.StatusNotFound}
|
||||
}
|
||||
}
|
||||
return Result{Status: http.StatusNotFound}
|
||||
}
|
||||
|
||||
// acceptsHTML reports whether the client asked for HTML specifically.
|
||||
//
|
||||
// "*/*" does not count. A browser navigating to a page sends text/html; a
|
||||
// fetch() for a script or a JSON document sends */* or something narrower, and
|
||||
// those are exactly the requests that must keep getting a 404.
|
||||
func acceptsHTML(accept string) bool {
|
||||
for len(accept) > 0 {
|
||||
var field string
|
||||
field, accept, _ = strings.Cut(accept, ",")
|
||||
media, _, _ := strings.Cut(field, ";")
|
||||
if strings.EqualFold(strings.TrimSpace(media), "text/html") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// siteFiles is one deployment covering every shape Resolve has to tell apart: a
|
||||
// root index, a directory that has an index, a directory that does not, a custom
|
||||
// error document, and a non-ASCII name.
|
||||
var siteFiles = []string{
|
||||
"index.html",
|
||||
"404.html",
|
||||
"assets/app.js",
|
||||
"docs/index.html",
|
||||
"docs/deep/page.html",
|
||||
"noindex/data.json",
|
||||
"文档/说明.html",
|
||||
}
|
||||
|
||||
func fixture(paths ...string) *Deployment {
|
||||
rows := make([]store.FileRow, len(paths))
|
||||
for i, p := range paths {
|
||||
rows[i] = store.FileRow{Path: p, Digest: cas.Sum([]byte(p)), Size: int64(len(p))}
|
||||
}
|
||||
dep := &store.Deployment{PublicID: "dpl_test", ProjectID: 1, CreatedAt: time.Unix(1700000000, 0).UTC()}
|
||||
return NewDeployment(dep, NewIndex(rows), "")
|
||||
}
|
||||
|
||||
// plain is the default project: an index, no error document, no fallback.
|
||||
func plain() *ProjectConfig { return &ProjectConfig{IndexFile: "index.html"} }
|
||||
|
||||
func withNotFound() *ProjectConfig {
|
||||
c := plain()
|
||||
c.NotFoundFile = "404.html"
|
||||
return c
|
||||
}
|
||||
|
||||
func withSPA() *ProjectConfig {
|
||||
c := plain()
|
||||
c.SPAFallback = true
|
||||
return c
|
||||
}
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
d := fixture(siteFiles...)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *ProjectConfig
|
||||
rest string
|
||||
accept string
|
||||
status int
|
||||
file string // Result.Name
|
||||
location string
|
||||
}{
|
||||
// ------------------------------------------------ canonicalisation
|
||||
{
|
||||
name: "bare project name gets a trailing slash",
|
||||
cfg: plain(),
|
||||
rest: "",
|
||||
status: http.StatusMovedPermanently,
|
||||
location: "/~demo/",
|
||||
},
|
||||
{
|
||||
name: "root serves the index",
|
||||
cfg: plain(),
|
||||
rest: "/",
|
||||
status: http.StatusOK,
|
||||
file: "index.html",
|
||||
},
|
||||
{
|
||||
name: "exact hit",
|
||||
cfg: plain(),
|
||||
rest: "/assets/app.js",
|
||||
status: http.StatusOK,
|
||||
file: "assets/app.js",
|
||||
},
|
||||
{
|
||||
name: "unicode path",
|
||||
cfg: plain(),
|
||||
rest: "/文档/说明.html",
|
||||
status: http.StatusOK,
|
||||
file: "文档/说明.html",
|
||||
},
|
||||
{
|
||||
name: "double slash is collapsed",
|
||||
cfg: plain(),
|
||||
rest: "//assets//app.js",
|
||||
status: http.StatusMovedPermanently,
|
||||
location: "/~demo/assets/app.js",
|
||||
},
|
||||
{
|
||||
name: "dot segment is removed",
|
||||
cfg: plain(),
|
||||
rest: "/./assets/app.js",
|
||||
status: http.StatusMovedPermanently,
|
||||
location: "/~demo/assets/app.js",
|
||||
},
|
||||
{
|
||||
// The decoded form of %2e%2e%2f: it arrives here unredirected by the
|
||||
// mux, and Clean resolves it before anything is looked up. It cannot
|
||||
// reach outside the deployment because the result is only ever a key
|
||||
// into a map.
|
||||
name: "parent segments are resolved, not followed",
|
||||
cfg: plain(),
|
||||
rest: "/assets/../index.html",
|
||||
status: http.StatusMovedPermanently,
|
||||
location: "/~demo/index.html",
|
||||
},
|
||||
{
|
||||
name: "parent segments above the root land at the root",
|
||||
cfg: plain(),
|
||||
rest: "/../../etc/passwd",
|
||||
status: http.StatusMovedPermanently,
|
||||
location: "/~demo/etc/passwd",
|
||||
},
|
||||
{
|
||||
name: "a file asked for with a directory's URL",
|
||||
cfg: plain(),
|
||||
rest: "/index.html/",
|
||||
status: http.StatusMovedPermanently,
|
||||
location: "/~demo/index.html",
|
||||
},
|
||||
|
||||
// ------------------------------------------------------ directories
|
||||
{
|
||||
name: "directory without a trailing slash redirects",
|
||||
cfg: plain(),
|
||||
rest: "/docs",
|
||||
status: http.StatusMovedPermanently,
|
||||
location: "/~demo/docs/",
|
||||
},
|
||||
{
|
||||
name: "directory with a trailing slash serves its index",
|
||||
cfg: plain(),
|
||||
rest: "/docs/",
|
||||
status: http.StatusOK,
|
||||
file: "docs/index.html",
|
||||
},
|
||||
{
|
||||
name: "intermediate directory redirects too",
|
||||
cfg: plain(),
|
||||
rest: "/docs/deep",
|
||||
status: http.StatusMovedPermanently,
|
||||
location: "/~demo/docs/deep/",
|
||||
},
|
||||
{
|
||||
name: "directory with no index is a 404, not a listing",
|
||||
cfg: plain(),
|
||||
rest: "/noindex/",
|
||||
status: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "nothing there at all",
|
||||
cfg: plain(),
|
||||
rest: "/nope.txt",
|
||||
status: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "an empty index file setting leaves the root a 404",
|
||||
cfg: &ProjectConfig{},
|
||||
rest: "/",
|
||||
status: http.StatusNotFound,
|
||||
},
|
||||
|
||||
// --------------------------------------------------- invalid paths
|
||||
{
|
||||
name: "NUL byte",
|
||||
cfg: plain(),
|
||||
rest: "/index\x00.html",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "control character",
|
||||
cfg: plain(),
|
||||
rest: "/index\n.html",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "backslash",
|
||||
cfg: plain(),
|
||||
rest: "/assets\\app.js",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid UTF-8",
|
||||
cfg: plain(),
|
||||
rest: "/\xff\xfe.html",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "path longer than the limit",
|
||||
cfg: plain(),
|
||||
rest: "/" + strings.Repeat("a", 4097),
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "segment longer than the limit",
|
||||
cfg: plain(),
|
||||
rest: "/" + strings.Repeat("b", 256) + "/x",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
|
||||
// ------------------------------------------------- custom 404 page
|
||||
{
|
||||
name: "the error document is served with a 404 status",
|
||||
cfg: withNotFound(),
|
||||
rest: "/nope.txt",
|
||||
status: http.StatusNotFound,
|
||||
file: "404.html",
|
||||
},
|
||||
{
|
||||
name: "an error document that is not in the manifest is skipped",
|
||||
cfg: &ProjectConfig{IndexFile: "index.html", NotFoundFile: "missing.html"},
|
||||
rest: "/nope.txt",
|
||||
status: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "a directory with no index falls to the error document",
|
||||
cfg: withNotFound(),
|
||||
rest: "/noindex/",
|
||||
status: http.StatusNotFound,
|
||||
file: "404.html",
|
||||
},
|
||||
|
||||
// ---------------------------------------------------- SPA fallback
|
||||
{
|
||||
name: "a navigation gets the app shell",
|
||||
cfg: withSPA(),
|
||||
rest: "/some/client/route",
|
||||
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
status: http.StatusOK,
|
||||
file: "index.html",
|
||||
},
|
||||
{
|
||||
// The whole point of the Accept gate: without it a missing script
|
||||
// comes back as HTML with a 200 and fails as a syntax error in some
|
||||
// unrelated place.
|
||||
name: "a missing script still 404s",
|
||||
cfg: withSPA(),
|
||||
rest: "/assets/missing.js",
|
||||
accept: "*/*",
|
||||
status: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "no Accept header at all is not a navigation",
|
||||
cfg: withSPA(),
|
||||
rest: "/some/client/route",
|
||||
status: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "html anywhere in the list counts",
|
||||
cfg: withSPA(),
|
||||
rest: "/some/client/route",
|
||||
accept: "application/json;q=0.9, text/html;q=0.8",
|
||||
status: http.StatusOK,
|
||||
file: "index.html",
|
||||
},
|
||||
{
|
||||
name: "with the fallback off a navigation 404s",
|
||||
cfg: plain(),
|
||||
rest: "/some/client/route",
|
||||
accept: "text/html",
|
||||
status: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "the fallback does not rescue an invalid path",
|
||||
cfg: withSPA(),
|
||||
rest: "/bad\x00path",
|
||||
accept: "text/html",
|
||||
status: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
// A directory redirect outranks the fallback: the URL is real, it just
|
||||
// needs its slash.
|
||||
name: "the fallback does not swallow a directory redirect",
|
||||
cfg: withSPA(),
|
||||
rest: "/docs",
|
||||
accept: "text/html",
|
||||
status: http.StatusMovedPermanently,
|
||||
location: "/~demo/docs/",
|
||||
},
|
||||
{
|
||||
name: "the error document wins over nothing when the shell is missing",
|
||||
cfg: &ProjectConfig{IndexFile: "absent.html", NotFoundFile: "404.html", SPAFallback: true},
|
||||
rest: "/some/client/route",
|
||||
accept: "text/html",
|
||||
status: http.StatusNotFound,
|
||||
file: "404.html",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := Resolve(d, tc.cfg, "demo", tc.rest, tc.accept)
|
||||
if got.Status != tc.status {
|
||||
t.Errorf("status = %d, want %d", got.Status, tc.status)
|
||||
}
|
||||
if got.Name != tc.file {
|
||||
t.Errorf("name = %q, want %q", got.Name, tc.file)
|
||||
}
|
||||
if got.Location != tc.location {
|
||||
t.Errorf("location = %q, want %q", got.Location, tc.location)
|
||||
}
|
||||
if tc.file != "" && got.Entry.Digest != cas.Sum([]byte(tc.file)) {
|
||||
t.Errorf("entry does not belong to %s", tc.file)
|
||||
}
|
||||
if tc.file == "" && got.Entry != (FileEntry{}) {
|
||||
t.Errorf("a response with no file carries an entry: %+v", got.Entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A redirect must always be a step towards a terminal answer. A pair that
|
||||
// bounced between two locations would be an infinite loop in a browser.
|
||||
func TestResolveRedirectsConverge(t *testing.T) {
|
||||
d := fixture(siteFiles...)
|
||||
cfgs := map[string]*ProjectConfig{"plain": plain(), "spa": withSPA(), "404": withNotFound()}
|
||||
paths := []string{
|
||||
"", "/", "//", "/.", "/..", "/docs", "/docs/", "/docs//deep", "/docs/deep",
|
||||
"/index.html", "/index.html/", "/./docs/../docs/", "/noindex", "/nope",
|
||||
"/文档", "/文档/",
|
||||
}
|
||||
for name, cfg := range cfgs {
|
||||
for _, p := range paths {
|
||||
rest := p
|
||||
for hop := 0; ; hop++ {
|
||||
if hop > 4 {
|
||||
t.Errorf("%s %q: still redirecting after %d hops", name, p, hop)
|
||||
break
|
||||
}
|
||||
res := Resolve(d, cfg, "demo", rest, "text/html")
|
||||
if res.Status != http.StatusMovedPermanently {
|
||||
break
|
||||
}
|
||||
next, ok := strings.CutPrefix(res.Location, "/~demo")
|
||||
if !ok {
|
||||
t.Fatalf("%s %q: redirect escaped the project: %q", name, p, res.Location)
|
||||
break
|
||||
}
|
||||
if next == rest {
|
||||
t.Fatalf("%s %q: redirects to itself", name, p)
|
||||
}
|
||||
rest = next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever a request asks for, the answer either names a path that could have
|
||||
// been a manifest entry or names nothing at all. That is what keeps the serving
|
||||
// path from ever deriving a filesystem name from user input.
|
||||
func FuzzResolve(f *testing.F) {
|
||||
d := fixture(siteFiles...)
|
||||
cfg := &ProjectConfig{IndexFile: "index.html", NotFoundFile: "404.html", SPAFallback: true}
|
||||
|
||||
for _, s := range []string{
|
||||
"", "/", "/index.html", "/../../etc/passwd", "//", "/docs/", "/\x00",
|
||||
"/文档/说明.html", "/a/b/c/../../..", "/.git/config",
|
||||
} {
|
||||
f.Add(s, "text/html")
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, rest, accept string) {
|
||||
res := Resolve(d, cfg, "demo", rest, accept)
|
||||
switch res.Status {
|
||||
case http.StatusOK, http.StatusNotFound:
|
||||
if res.Name == "" {
|
||||
return
|
||||
}
|
||||
if !fs.ValidPath(res.Name) {
|
||||
t.Fatalf("resolved %q to the invalid name %q", rest, res.Name)
|
||||
}
|
||||
if _, ok := d.Lookup(res.Name); !ok {
|
||||
t.Fatalf("resolved %q to %q, which is not in the manifest", rest, res.Name)
|
||||
}
|
||||
case http.StatusMovedPermanently:
|
||||
if !strings.HasPrefix(res.Location, "/~demo/") && res.Location != "/~demo" {
|
||||
t.Fatalf("resolved %q to a location outside the project: %q", rest, res.Location)
|
||||
}
|
||||
if res.Name != "" {
|
||||
t.Fatalf("a redirect for %q also named a file: %q", rest, res.Name)
|
||||
}
|
||||
case http.StatusBadRequest:
|
||||
if res.Name != "" {
|
||||
t.Fatalf("a rejection for %q also named a file: %q", rest, res.Name)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("resolved %q to the unexpected status %d", rest, res.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAcceptsHTML(t *testing.T) {
|
||||
tests := []struct {
|
||||
accept string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"*/*", false}, // a fetch() with no opinion is not a navigation
|
||||
{"text/*", false}, // and neither is a wildcard subtype
|
||||
{"application/json", false},
|
||||
{"text/plain", false},
|
||||
{"text/htmlx", false},
|
||||
{"text/html", true},
|
||||
{"TEXT/HTML", true},
|
||||
{"text/html;charset=utf-8", true},
|
||||
{"text/html, */*", true},
|
||||
{"application/json, text/html;q=0.1", true},
|
||||
{" text/html ", true},
|
||||
{"application/xhtml+xml,text/html", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := acceptsHTML(tc.accept); got != tc.want {
|
||||
t.Errorf("acceptsHTML(%q) = %v, want %v", tc.accept, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexRecordsEveryDirectoryPrefix(t *testing.T) {
|
||||
d := fixture("a/b/c/d.txt", "top.txt")
|
||||
for _, dir := range []string{"a", "a/b", "a/b/c"} {
|
||||
if !d.IsDir(dir) {
|
||||
t.Errorf("%q is not recorded as a directory", dir)
|
||||
}
|
||||
}
|
||||
for _, notDir := range []string{"", ".", "/", "a/b/c/d.txt", "top.txt", "a/b/c/d"} {
|
||||
if d.IsDir(notDir) {
|
||||
t.Errorf("%q is recorded as a directory", notDir)
|
||||
}
|
||||
}
|
||||
if d.FileCount != 2 {
|
||||
t.Errorf("FileCount = %d, want 2", d.FileCount)
|
||||
}
|
||||
if want := int64(len("a/b/c/d.txt") + len("top.txt")); d.TotalBytes != want {
|
||||
t.Errorf("TotalBytes = %d, want %d", d.TotalBytes, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package site
|
||||
|
||||
import "strings"
|
||||
|
||||
// splitTilde splits a site URL path into its project and the rest.
|
||||
//
|
||||
// "/~proj" -> ("proj", "", true)
|
||||
// "/~proj/" -> ("proj", "/", true)
|
||||
// "/~proj/a/b" -> ("proj", "/a/b", true)
|
||||
// "/", "/x", "/~" -> ("", "", false)
|
||||
//
|
||||
// The remainder keeps its leading slash, because its absence is what
|
||||
// distinguishes "/~proj" — which has to be redirected before relative links
|
||||
// inside the page can resolve — from "/~proj/".
|
||||
//
|
||||
// This is hand-parsed rather than expressed as a ServeMux pattern because
|
||||
// net/http rejects "/~{project}/{path...}": its wildcards must start a path
|
||||
// segment. Registering "/" instead also keeps the mux's built-in ".." and "//"
|
||||
// redirects, though those are a convenience and not a defence — a
|
||||
// percent-encoded traversal arrives here already decoded and unredirected, so
|
||||
// Resolve does its own normalisation.
|
||||
func splitTilde(urlPath string) (project, rest string, ok bool) {
|
||||
if !strings.HasPrefix(urlPath, "/~") {
|
||||
return "", "", false
|
||||
}
|
||||
rest = urlPath[len("/~"):]
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
project, rest = rest[:i], rest[i:]
|
||||
} else {
|
||||
project, rest = rest, ""
|
||||
}
|
||||
if project == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return project, rest, true
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package site
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSplitTilde(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
project string
|
||||
rest string
|
||||
ok bool
|
||||
}{
|
||||
{"/~proj", "proj", "", true},
|
||||
{"/~proj/", "proj", "/", true},
|
||||
{"/~proj/a/b", "proj", "/a/b", true},
|
||||
{"/~proj/a/b/", "proj", "/a/b/", true},
|
||||
{"/~proj//a", "proj", "//a", true},
|
||||
{"/~p", "p", "", true},
|
||||
{"/~proj/~other/x", "proj", "/~other/x", true},
|
||||
// A percent-encoded traversal arrives here already decoded; the project
|
||||
// still ends at the first slash, and Resolve cleans the remainder.
|
||||
{"/~proj/../../etc/passwd", "proj", "/../../etc/passwd", true},
|
||||
|
||||
// Not a site request at all.
|
||||
{"", "", "", false},
|
||||
{"/", "", "", false},
|
||||
{"/x", "", "", false},
|
||||
{"/~", "", "", false},
|
||||
{"/~/", "", "", false},
|
||||
{"~proj/", "", "", false},
|
||||
{"//~proj/", "", "", false},
|
||||
{"/favicon.ico", "", "", false},
|
||||
{"/api/v1/projects", "", "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
project, rest, ok := splitTilde(tc.path)
|
||||
if project != tc.project || rest != tc.rest || ok != tc.ok {
|
||||
t.Errorf("splitTilde(%q) = (%q, %q, %v), want (%q, %q, %v)",
|
||||
tc.path, project, rest, ok, tc.project, tc.rest, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The project name never contains a slash, which is what makes "~" + name a
|
||||
// single entry inside the webroot and keeps a redirect built from it inside the
|
||||
// project's own prefix.
|
||||
func TestSplitTildeProjectIsOneSegment(t *testing.T) {
|
||||
for _, p := range []string{"/~a/b", "/~a//b", "/~a/../b", "/~a/b/c/d"} {
|
||||
project, _, ok := splitTilde(p)
|
||||
if !ok {
|
||||
t.Fatalf("splitTilde(%q) refused a site path", p)
|
||||
}
|
||||
if project != "a" {
|
||||
t.Errorf("splitTilde(%q) project = %q, want %q", p, project, "a")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/httpx"
|
||||
)
|
||||
|
||||
// Handler serves the active deployment of every project.
|
||||
//
|
||||
// Content comes from the content store by digest, never from the assembled
|
||||
// directory. That is a security decision as much as a performance one: the only
|
||||
// filesystem path this path ever builds is cas/<2>/<2>/<64 hex>, derived from a
|
||||
// [32]byte that came out of a map lookup. No user-controlled string reaches the
|
||||
// filesystem at all, so traversal on the read path is not defended against —
|
||||
// it is structurally impossible.
|
||||
type Handler struct {
|
||||
Registry *Registry
|
||||
CAS *cas.Store
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
h.fail(w, http.StatusMethodNotAllowed, "method not allowed\n")
|
||||
return
|
||||
}
|
||||
|
||||
project, rest, ok := splitTilde(r.URL.Path)
|
||||
if !ok {
|
||||
h.fail(w, http.StatusNotFound, "no site is served at this path; sites live under /~project/\n")
|
||||
return
|
||||
}
|
||||
p, ok := h.Registry.Lookup(project)
|
||||
if !ok {
|
||||
h.fail(w, http.StatusNotFound, "no such project\n")
|
||||
return
|
||||
}
|
||||
httpx.LogAttr(r.Context(), "project", project)
|
||||
|
||||
// Loaded exactly once. Every decision below uses this snapshot, so an
|
||||
// activation that lands mid-request cannot split the response across two
|
||||
// versions. Calling p.Active() again anywhere in this function would
|
||||
// reintroduce precisely the failure this program exists to prevent.
|
||||
d := p.Active()
|
||||
if d == nil {
|
||||
h.fail(w, http.StatusServiceUnavailable, "this project has no active deployment\n")
|
||||
return
|
||||
}
|
||||
httpx.LogAttr(r.Context(), "deployment", d.ID)
|
||||
|
||||
cfg := p.Config()
|
||||
res := Resolve(d, cfg, project, rest, r.Header.Get("Accept"))
|
||||
switch {
|
||||
case res.Status == http.StatusMovedPermanently:
|
||||
// url.URL re-escapes the path, so a project or file name that needed
|
||||
// escaping in the request does not arrive raw in the Location header.
|
||||
loc := url.URL{Path: res.Location, RawQuery: r.URL.RawQuery}
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.Redirect(w, r, loc.String(), http.StatusMovedPermanently)
|
||||
case res.Status == http.StatusBadRequest:
|
||||
h.fail(w, http.StatusBadRequest, "bad request path\n")
|
||||
case res.Name == "":
|
||||
h.fail(w, res.Status, "404 page not found\n")
|
||||
default:
|
||||
h.serveEntry(w, r, d, cfg, res)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) serveEntry(w http.ResponseWriter, r *http.Request, d *Deployment, cfg *ProjectConfig, res Result) {
|
||||
f, err := h.CAS.Open(res.Entry.Digest)
|
||||
if err != nil {
|
||||
if errors.Is(err, cas.ErrNotFound) {
|
||||
// The manifest says this blob exists and the store disagrees. GC's
|
||||
// grace period is supposed to make this unreachable, so it means
|
||||
// either a bug or an operator who cleared the store by hand.
|
||||
h.Log.ErrorContext(r.Context(), "blob missing from the content store",
|
||||
"digest", res.Entry.Digest, "deployment", d.ID, "path", res.Name)
|
||||
h.fail(w, http.StatusNotFound, "404 page not found\n")
|
||||
return
|
||||
}
|
||||
h.Log.ErrorContext(r.Context(), "opening blob", "err", err, "deployment", d.ID, "path", res.Name)
|
||||
h.fail(w, http.StatusInternalServerError, "internal server error\n")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
head := w.Header()
|
||||
head.Set("X-Content-Type-Options", "nosniff")
|
||||
// The type comes from the logical name, not the CAS path, which has no
|
||||
// extension at all.
|
||||
ctype := mime.TypeByExtension(path.Ext(res.Name))
|
||||
if ctype == "" {
|
||||
ctype = "application/octet-stream"
|
||||
}
|
||||
head.Set("Content-Type", ctype)
|
||||
if cfg.CacheControl != "" {
|
||||
head.Set("Cache-Control", cfg.CacheControl)
|
||||
}
|
||||
if cfg.SPAFallback {
|
||||
// With the fallback on, one URL can answer with the app shell or with a
|
||||
// 404 depending on Accept, so a shared cache must key on it.
|
||||
head.Set("Vary", "Accept")
|
||||
}
|
||||
|
||||
if res.Status != http.StatusOK {
|
||||
// The custom 404 document. ServeContent always writes 200, so this one
|
||||
// is written by hand; range requests for an error page are not worth
|
||||
// the machinery.
|
||||
head.Set("Content-Length", strconv.FormatInt(res.Entry.Size, 10))
|
||||
w.WriteHeader(res.Status)
|
||||
if r.Method != http.MethodHead {
|
||||
io.Copy(w, f)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A strong validator: the digest *is* the content, so a matching ETag
|
||||
// cannot be a lie. With it set, ServeContent handles If-None-Match,
|
||||
// If-Modified-Since, If-Range, Range and multipart ranges by itself.
|
||||
head.Set("ETag", `"sha256:`+res.Entry.Digest.String()+`"`)
|
||||
// The empty name keeps ServeContent from sniffing: the type is already set.
|
||||
// The modtime is the deployment's, never the blob's — blobs are shared
|
||||
// across projects, so their mtime says when some unrelated project first
|
||||
// uploaded the same bytes.
|
||||
http.ServeContent(w, r, "", d.CreatedAt, f)
|
||||
}
|
||||
|
||||
// fail writes a plain-text response. Site errors are never JSON: whatever is on
|
||||
// the other end of a static request is a browser or a curl, not an API client.
|
||||
// http.Error sets the text/plain type and the nosniff header for us.
|
||||
func (h *Handler) fail(w http.ResponseWriter, status int, msg string) {
|
||||
http.Error(w, msg, status)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
"github.com/iceBear67/simplepages/internal/store"
|
||||
)
|
||||
|
||||
// depTime is the deployment's creation time, which is the Last-Modified of
|
||||
// every file it serves.
|
||||
var depTime = time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
type serveEnv struct {
|
||||
h *Handler
|
||||
cs *cas.Store
|
||||
reg *Registry
|
||||
p *Project
|
||||
log *bytes.Buffer
|
||||
}
|
||||
|
||||
// newServeEnv puts contents into a real content store and publishes a
|
||||
// deployment of them. The store is real because the read path's whole shape —
|
||||
// open by digest, hand the file to ServeContent — only means anything against
|
||||
// actual files.
|
||||
func newServeEnv(t *testing.T, cfg *ProjectConfig, contents map[string]string) *serveEnv {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
buf := &bytes.Buffer{}
|
||||
log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
cs, err := cas.Open(base+"/cas", cas.Options{ProbeDir: base + "/deployments", Log: log})
|
||||
if err != nil {
|
||||
t.Fatalf("cas.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { cs.Close() })
|
||||
|
||||
rows := make([]store.FileRow, 0, len(contents))
|
||||
for p, c := range contents {
|
||||
d := cas.Sum([]byte(c))
|
||||
limit := int64(len(c))
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if _, err := cs.Put(t.Context(), d, int64(len(c)), limit, strings.NewReader(c)); err != nil {
|
||||
t.Fatalf("cas.Put %s: %v", p, err)
|
||||
}
|
||||
rows = append(rows, store.FileRow{Path: p, Digest: d, Size: int64(len(c))})
|
||||
}
|
||||
|
||||
sp := NewProject(&store.Project{ID: 1, Name: "demo"})
|
||||
sp.SetConfig(cfg)
|
||||
sp.Activate(NewDeployment(
|
||||
&store.Deployment{PublicID: "dpl_0123456789abcdef", ProjectID: 1, CreatedAt: depTime},
|
||||
NewIndex(rows), ""))
|
||||
|
||||
reg := NewRegistry()
|
||||
reg.Replace([]*Project{sp})
|
||||
return &serveEnv{
|
||||
h: &Handler{Registry: reg, CAS: cs, Log: log},
|
||||
cs: cs, reg: reg, p: sp, log: buf,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *serveEnv) do(method, target string, header http.Header) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
for k, vs := range header {
|
||||
req.Header[k] = vs
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (e *serveEnv) get(target string) *httptest.ResponseRecorder {
|
||||
return e.do(http.MethodGet, target, nil)
|
||||
}
|
||||
|
||||
func demoSite() map[string]string {
|
||||
return map[string]string{
|
||||
"index.html": "<h1>hello</h1>",
|
||||
"assets/app.js": "console.log(1)",
|
||||
"docs/index.html": "<p>docs</p>",
|
||||
"404.html": "<p>gone</p>",
|
||||
"data.bin": strings.Repeat("x", 4096),
|
||||
"noext": "plain",
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeFile(t *testing.T) {
|
||||
e := newServeEnv(t, plain(), demoSite())
|
||||
|
||||
rec := e.get("/~demo/assets/app.js")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != "console.log(1)" {
|
||||
t.Errorf("body = %q", got)
|
||||
}
|
||||
head := rec.Header()
|
||||
if ct := head.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") &&
|
||||
!strings.HasPrefix(ct, "application/javascript") {
|
||||
t.Errorf("Content-Type = %q, want a javascript type", ct)
|
||||
}
|
||||
if want := `"sha256:` + cas.Sum([]byte("console.log(1)")).String() + `"`; head.Get("Etag") != want {
|
||||
t.Errorf("ETag = %q, want %q", head.Get("Etag"), want)
|
||||
}
|
||||
if head.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Error("nosniff is missing")
|
||||
}
|
||||
if head.Get("Cache-Control") != plain().CacheControl {
|
||||
t.Errorf("Cache-Control = %q", head.Get("Cache-Control"))
|
||||
}
|
||||
// The deployment's time, never the blob's: a shared blob's mtime says when
|
||||
// some unrelated project first uploaded the same bytes.
|
||||
if got := head.Get("Last-Modified"); got != depTime.Format(http.TimeFormat) {
|
||||
t.Errorf("Last-Modified = %q, want %q", got, depTime.Format(http.TimeFormat))
|
||||
}
|
||||
if head.Get("Vary") != "" {
|
||||
t.Errorf("Vary = %q with the SPA fallback off", head.Get("Vary"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIndexAndRedirects(t *testing.T) {
|
||||
e := newServeEnv(t, plain(), demoSite())
|
||||
|
||||
if rec := e.get("/~demo/"); rec.Code != http.StatusOK || rec.Body.String() != "<h1>hello</h1>" {
|
||||
t.Errorf("root: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := e.get("/~demo"); rec.Code != http.StatusMovedPermanently ||
|
||||
rec.Header().Get("Location") != "/~demo/" {
|
||||
t.Errorf("bare name: %d %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if rec := e.get("/~demo/docs"); rec.Code != http.StatusMovedPermanently ||
|
||||
rec.Header().Get("Location") != "/~demo/docs/" {
|
||||
t.Errorf("directory: %d %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
// The query survives the redirect, or a link with parameters loses them.
|
||||
if rec := e.get("/~demo/docs?a=1&b=2"); rec.Header().Get("Location") != "/~demo/docs/?a=1&b=2" {
|
||||
t.Errorf("query dropped: %q", rec.Header().Get("Location"))
|
||||
}
|
||||
if rec := e.get("/~demo/docs/"); rec.Body.String() != "<p>docs</p>" {
|
||||
t.Errorf("directory index: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A file name that needs escaping must not arrive raw in the Location header.
|
||||
func TestServeRedirectEscapesTheLocation(t *testing.T) {
|
||||
e := newServeEnv(t, plain(), map[string]string{"a b/index.html": "spaced"})
|
||||
|
||||
rec := e.do(http.MethodGet, "/~demo/a%20b", nil)
|
||||
if rec.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("status = %d, want 301", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "/~demo/a%20b/" {
|
||||
t.Errorf("Location = %q, want the space escaped", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeMethodGate(t *testing.T) {
|
||||
e := newServeEnv(t, plain(), demoSite())
|
||||
|
||||
rec := e.do(http.MethodHead, "/~demo/index.html", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("HEAD status = %d", rec.Code)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Errorf("HEAD returned %d bytes of body", rec.Body.Len())
|
||||
}
|
||||
|
||||
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
|
||||
rec := e.do(m, "/~demo/index.html", nil)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s status = %d, want 405", m, rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Allow"); got != "GET, HEAD" {
|
||||
t.Errorf("%s Allow = %q", m, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeUnknownProjectAndPath(t *testing.T) {
|
||||
e := newServeEnv(t, plain(), demoSite())
|
||||
|
||||
for _, target := range []string{"/~nosuch/", "/~nosuch/index.html"} {
|
||||
if rec := e.get(target); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s = %d, want 404", target, rec.Code)
|
||||
}
|
||||
}
|
||||
for _, target := range []string{"/", "/index.html", "/api/v1/projects", "/~"} {
|
||||
rec := e.get(target)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s = %d, want 404", target, rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "/~project/") {
|
||||
t.Errorf("%s: the 404 does not say where sites live: %q", target, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// A project with no deployment exists but has nothing to serve, which is a
|
||||
// different answer from one that does not exist.
|
||||
e.reg.Put(&store.Project{ID: 2, Name: "empty"})
|
||||
if rec := e.get("/~empty/"); rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("undeployed project = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeConditionalRequest(t *testing.T) {
|
||||
e := newServeEnv(t, plain(), demoSite())
|
||||
etag := e.get("/~demo/index.html").Header().Get("Etag")
|
||||
if etag == "" {
|
||||
t.Fatal("no ETag to revalidate with")
|
||||
}
|
||||
|
||||
rec := e.do(http.MethodGet, "/~demo/index.html", http.Header{"If-None-Match": {etag}})
|
||||
if rec.Code != http.StatusNotModified {
|
||||
t.Errorf("status = %d, want 304", rec.Code)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Errorf("304 carried %d bytes", rec.Body.Len())
|
||||
}
|
||||
// A stale validator must still transfer the content.
|
||||
rec = e.do(http.MethodGet, "/~demo/index.html", http.Header{"If-None-Match": {`"sha256:stale"`}})
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "<h1>hello</h1>" {
|
||||
t.Errorf("stale validator: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = e.do(http.MethodGet, "/~demo/index.html",
|
||||
http.Header{"If-Modified-Since": {depTime.Add(time.Hour).Format(http.TimeFormat)}})
|
||||
if rec.Code != http.StatusNotModified {
|
||||
t.Errorf("If-Modified-Since: status = %d, want 304", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRange(t *testing.T) {
|
||||
e := newServeEnv(t, plain(), demoSite())
|
||||
|
||||
rec := e.do(http.MethodGet, "/~demo/data.bin", http.Header{"Range": {"bytes=10-19"}})
|
||||
if rec.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, want 206", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != strings.Repeat("x", 10) {
|
||||
t.Errorf("body = %q (%d bytes)", got, len(got))
|
||||
}
|
||||
if got := rec.Header().Get("Content-Range"); got != "bytes 10-19/4096" {
|
||||
t.Errorf("Content-Range = %q", got)
|
||||
}
|
||||
if rec.Header().Get("Accept-Ranges") != "bytes" {
|
||||
t.Error("ranges are not advertised")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeCustom404(t *testing.T) {
|
||||
e := newServeEnv(t, withNotFound(), demoSite())
|
||||
|
||||
rec := e.get("/~demo/nope.txt")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != "<p>gone</p>" {
|
||||
t.Errorf("body = %q, want the project's error document", rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Length"); got != "11" {
|
||||
t.Errorf("Content-Length = %q, want 11", got)
|
||||
}
|
||||
// An error document is not a cacheable validator target; it must not claim
|
||||
// to be the requested resource.
|
||||
if rec.Header().Get("Etag") != "" {
|
||||
t.Error("the error document was served with an ETag")
|
||||
}
|
||||
|
||||
// HEAD gets the headers and no body.
|
||||
rec = e.do(http.MethodHead, "/~demo/nope.txt", nil)
|
||||
if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 {
|
||||
t.Errorf("HEAD of a 404: %d, %d bytes", rec.Code, rec.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeSPAVariesOnAccept(t *testing.T) {
|
||||
e := newServeEnv(t, withSPA(), demoSite())
|
||||
|
||||
rec := e.do(http.MethodGet, "/~demo/client/route", http.Header{"Accept": {"text/html"}})
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "<h1>hello</h1>" {
|
||||
t.Fatalf("navigation: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("Vary") != "Accept" {
|
||||
t.Error("a response that depends on Accept did not say so")
|
||||
}
|
||||
rec = e.do(http.MethodGet, "/~demo/client/route", http.Header{"Accept": {"*/*"}})
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("fetch: %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeUnknownExtensionIsNotSniffed(t *testing.T) {
|
||||
e := newServeEnv(t, plain(), demoSite())
|
||||
|
||||
rec := e.get("/~demo/noext")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "application/octet-stream" {
|
||||
t.Errorf("Content-Type = %q, want application/octet-stream", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeBadRequestPath(t *testing.T) {
|
||||
e := newServeEnv(t, plain(), demoSite())
|
||||
|
||||
rec := e.do(http.MethodGet, "/~demo/bad%00path", nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The grace period is supposed to make this unreachable, so it means a bug or
|
||||
// an operator who cleared the store by hand. The request must fail cleanly and
|
||||
// the reason must reach the log.
|
||||
func TestServeMissingBlobIs404AndLogged(t *testing.T) {
|
||||
e := newServeEnv(t, plain(), demoSite())
|
||||
if err := os.Remove(e.cs.Path(cas.Sum([]byte("<h1>hello</h1>")))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := e.get("/~demo/index.html")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if !strings.Contains(e.log.String(), "blob missing from the content store") {
|
||||
t.Errorf("nothing was logged about the missing blob: %s", e.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
// An in-flight response keeps reading the deployment it started on. This is the
|
||||
// property a symlink swap cannot give, and the reason the switch is a pointer
|
||||
// store.
|
||||
func TestServeInFlightResponseSurvivesAnActivation(t *testing.T) {
|
||||
e := newServeEnv(t, plain(), map[string]string{"index.html": "v1"})
|
||||
|
||||
// Open the blob the way the handler does, then activate over the top of it.
|
||||
first := e.p.Active()
|
||||
entry, ok := first.Lookup("index.html")
|
||||
if !ok {
|
||||
t.Fatal("no index.html in the first deployment")
|
||||
}
|
||||
f, err := e.cs.Open(entry.Digest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
d2 := cas.Sum([]byte("v2"))
|
||||
if _, err := e.cs.Put(t.Context(), d2, 2, 2, strings.NewReader("v2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.p.Activate(NewDeployment(
|
||||
&store.Deployment{PublicID: "dpl_second", ProjectID: 1, CreatedAt: depTime},
|
||||
NewIndex([]store.FileRow{{Path: "index.html", Digest: d2, Size: 2}}), ""))
|
||||
|
||||
buf := make([]byte, 8)
|
||||
n, _ := f.Read(buf)
|
||||
if string(buf[:n]) != "v1" {
|
||||
t.Errorf("the open file returned %q, want the deployment it was opened on", buf[:n])
|
||||
}
|
||||
if rec := e.get("/~demo/"); rec.Body.String() != "v2" {
|
||||
t.Errorf("a new request got %q, want v2", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
go test fuzz v1
|
||||
string("0/.")
|
||||
string("0")
|
||||
@@ -0,0 +1,94 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
)
|
||||
|
||||
// Blob is what the database believes about one piece of content: how long it
|
||||
// is, and whether its bytes have been verified onto disk yet.
|
||||
type Blob struct {
|
||||
Digest cas.Digest
|
||||
Size int64
|
||||
Present bool
|
||||
}
|
||||
|
||||
// Blob looks up one blob. ErrNotFound means no manifest has ever declared it,
|
||||
// which is what lets the upload endpoint refuse content nobody asked for.
|
||||
func (d *DB) Blob(ctx context.Context, digest cas.Digest) (*Blob, error) {
|
||||
b := Blob{Digest: digest}
|
||||
err := d.r.QueryRowContext(ctx,
|
||||
`SELECT size, present FROM blobs WHERE digest = ?`, digest.Bytes()).
|
||||
Scan(&b.Size, &b.Present)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// MarkBlobPresent records that a blob's content has been verified onto disk.
|
||||
//
|
||||
// Idempotent, because a retried `pages deploy` re-uploads blobs that in the
|
||||
// meantime became present, and because the row may already say so if a previous
|
||||
// attempt was interrupted between the rename and this update.
|
||||
//
|
||||
// The size is part of the WHERE clause rather than something to overwrite: a
|
||||
// digest determines its content and therefore its length, so a row that
|
||||
// disagrees means the manifest and the upload cannot both be describing the same
|
||||
// bytes, and the safe move is to change nothing.
|
||||
func (d *DB) MarkBlobPresent(ctx context.Context, digest cas.Digest, size int64) error {
|
||||
return d.Tx(ctx, func(tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE blobs SET present = 1 WHERE digest = ? AND size = ?`, digest.Bytes(), size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Nothing was updated. Distinguish "no such blob" from "the row says a
|
||||
// different size" so the caller can report something actionable.
|
||||
var have int64
|
||||
err = tx.QueryRowContext(ctx, `SELECT size FROM blobs WHERE digest = ?`, digest.Bytes()).Scan(&have)
|
||||
if err != nil {
|
||||
return mapErr(err)
|
||||
}
|
||||
return fmt.Errorf("%w: blob %s is %d bytes here, upload declared %d",
|
||||
cas.ErrSizeMismatch, digest, have, size)
|
||||
})
|
||||
}
|
||||
|
||||
// MissingBlobs lists the distinct digests a deployment needs whose content has
|
||||
// not been uploaded yet. It is both the manifest response and the check
|
||||
// finalize runs before assembling anything.
|
||||
func (d *DB) MissingBlobs(ctx context.Context, deploymentID int64) ([]cas.Digest, error) {
|
||||
rows, err := d.r.QueryContext(ctx, `
|
||||
SELECT DISTINCT f.digest
|
||||
FROM deployment_files f JOIN blobs b ON b.digest = f.digest
|
||||
WHERE f.deployment_id = ? AND b.present = 0
|
||||
ORDER BY f.digest`, deploymentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var missing []cas.Digest
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dg, err := cas.FromBytes(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
missing = append(missing, dg)
|
||||
}
|
||||
return missing, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Package store owns the SQLite database: connection management, migrations and
|
||||
// every query the server runs. It is server-side only — the CLI must never reach
|
||||
// it, and cmd/pages/deps_test.go enforces that.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"modernc.org/sqlite"
|
||||
sqlite3 "modernc.org/sqlite/lib"
|
||||
)
|
||||
|
||||
const driverName = "sqlite"
|
||||
|
||||
// maxTxAttempts bounds the BEGIN-level retry loop. busy_timeout already handles
|
||||
// contention inside a transaction; these retries exist for the cases it cannot
|
||||
// cover (see Tx).
|
||||
const maxTxAttempts = 5
|
||||
|
||||
// DB holds the two connection pools SQLite wants under concurrent load.
|
||||
//
|
||||
// SQLite permits exactly one writer at a time. Handing database/sql a single
|
||||
// pool means readers and the writer compete for the same connections and every
|
||||
// static request can end up queued behind a deployment commit. Two pools make
|
||||
// the rule explicit instead: W is capped at one connection so writes serialise
|
||||
// in Go (where waiting is cheap and fair) rather than in SQLite (where it
|
||||
// surfaces as SQLITE_BUSY), and R holds the concurrent readers that WAL mode
|
||||
// lets run undisturbed alongside the writer.
|
||||
type DB struct {
|
||||
w *sql.DB
|
||||
r *sql.DB
|
||||
path string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Open connects to the database at path, applying the pragmas both pools need,
|
||||
// and runs any outstanding migrations.
|
||||
func Open(ctx context.Context, path string, log *slog.Logger) (*DB, error) {
|
||||
w, err := sql.Open(driverName, dsn(path, false))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
// One writer, by construction. Idle == open and no lifetime cap: rebuilding a
|
||||
// SQLite connection means replaying every pragma, and a server-side
|
||||
// connection has nothing to go stale against.
|
||||
w.SetMaxOpenConns(1)
|
||||
w.SetMaxIdleConns(1)
|
||||
w.SetConnMaxLifetime(0)
|
||||
w.SetConnMaxIdleTime(0)
|
||||
|
||||
// The writer opens first so the database file, and its WAL, exist before any
|
||||
// read-only connection tries to attach to them.
|
||||
if err := w.PingContext(ctx); err != nil {
|
||||
w.Close()
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
|
||||
readers := max(4, runtime.NumCPU())
|
||||
r, err := sql.Open(driverName, dsn(path, true))
|
||||
if err != nil {
|
||||
w.Close()
|
||||
return nil, fmt.Errorf("open %s (read pool): %w", path, err)
|
||||
}
|
||||
r.SetMaxOpenConns(readers)
|
||||
r.SetMaxIdleConns(readers)
|
||||
r.SetConnMaxLifetime(0)
|
||||
r.SetConnMaxIdleTime(0)
|
||||
if err := r.PingContext(ctx); err != nil {
|
||||
w.Close()
|
||||
r.Close()
|
||||
return nil, fmt.Errorf("open %s (read pool): %w", path, err)
|
||||
}
|
||||
|
||||
d := &DB{w: w, r: r, path: path, log: log}
|
||||
if err := d.migrate(ctx); err != nil {
|
||||
d.Close()
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// dsn builds the connection string. The pragmas are per-connection state, so
|
||||
// every connection in both pools must carry them.
|
||||
func dsn(path string, readOnly bool) string {
|
||||
q := url.Values{}
|
||||
// Ordering inside the driver is fixed (busy_timeout first), so the list here
|
||||
// is grouped by intent rather than by application order.
|
||||
q.Add("_pragma", "busy_timeout(10000)") // wait for a lock instead of failing
|
||||
q.Add("_pragma", "journal_mode(WAL)") // readers do not block the writer
|
||||
q.Add("_pragma", "synchronous(NORMAL)") // the correct pairing with WAL
|
||||
q.Add("_pragma", "foreign_keys(ON)") // off by default in SQLite
|
||||
q.Add("_pragma", "recursive_triggers(ON)")
|
||||
q.Add("_pragma", "temp_store(MEMORY)")
|
||||
q.Add("_pragma", "wal_autocheckpoint(1000)")
|
||||
|
||||
if readOnly {
|
||||
// A guard rail, not a security boundary: it turns "this query was meant to
|
||||
// be a read" from a silent lock contention bug into an immediate error.
|
||||
q.Set("_query_only", "true")
|
||||
} else {
|
||||
// Every transaction takes the write lock at BEGIN. Without this a
|
||||
// transaction that starts with a SELECT and later writes must upgrade its
|
||||
// lock, and an upgrade that finds another writer fails with SQLITE_BUSY
|
||||
// immediately — busy_timeout does not apply to it, because waiting could
|
||||
// only ever deadlock.
|
||||
q.Set("_txlock", "immediate")
|
||||
}
|
||||
return "file:" + path + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// Reader returns the read-only pool. Queries that must observe uncommitted
|
||||
// changes belong inside the writing transaction instead.
|
||||
func (d *DB) Reader() *sql.DB { return d.r }
|
||||
|
||||
// Path is the database file's location.
|
||||
func (d *DB) Path() string { return d.path }
|
||||
|
||||
// Tx runs fn inside a single write transaction and commits it, retrying from
|
||||
// the top when SQLite reports contention.
|
||||
//
|
||||
// fn may be called more than once, so it must not have side effects outside the
|
||||
// transaction — no file writes, no atomic pointer stores, no channel sends.
|
||||
// Publishing a change to the rest of the process is the caller's job, after Tx
|
||||
// returns nil.
|
||||
func (d *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
var err error
|
||||
for attempt := range maxTxAttempts {
|
||||
if attempt > 0 {
|
||||
// Exponential backoff with jitter, so two contending writers do not
|
||||
// retry in lockstep.
|
||||
backoff := time.Duration(1<<attempt) * time.Millisecond
|
||||
select {
|
||||
case <-time.After(backoff/2 + time.Duration(rand.Int64N(int64(backoff)))):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
err = d.tx(ctx, fn)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !IsBusy(err) || ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
if d.log != nil {
|
||||
d.log.Debug("write transaction contended, retrying",
|
||||
"attempt", attempt+1, "err", err)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("write transaction still contended after %d attempts: %w", maxTxAttempts, err)
|
||||
}
|
||||
|
||||
func (d *DB) tx(ctx context.Context, fn func(*sql.Tx) error) (err error) {
|
||||
tx, err := d.w.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
tx.Rollback()
|
||||
panic(p)
|
||||
}
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
if err = fn(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Close checkpoints the WAL and shuts both pools down.
|
||||
func (d *DB) Close() error {
|
||||
var first error
|
||||
if d.r != nil {
|
||||
// Readers go first: a TRUNCATE checkpoint needs the WAL to itself.
|
||||
if err := d.r.Close(); err != nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
if d.w != nil {
|
||||
// Fold the WAL back into the database file so a restart — or an operator
|
||||
// copying the directory — sees one self-contained file.
|
||||
if _, err := d.w.ExecContext(context.Background(), "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil && d.log != nil {
|
||||
d.log.Warn("wal checkpoint on close failed", "err", err)
|
||||
}
|
||||
if err := d.w.Close(); err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
// IsBusy reports whether err is SQLite telling us the database was locked.
|
||||
func IsBusy(err error) bool {
|
||||
var serr *sqlite.Error
|
||||
if !errors.As(err, &serr) {
|
||||
return false
|
||||
}
|
||||
switch serr.Code() {
|
||||
case sqlite3.SQLITE_BUSY, sqlite3.SQLITE_BUSY_SNAPSHOT,
|
||||
sqlite3.SQLITE_LOCKED, sqlite3.SQLITE_LOCKED_SHAREDCACHE:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsConstraint reports whether err is a constraint violation, optionally of a
|
||||
// specific extended kind (sqlite3.SQLITE_CONSTRAINT_UNIQUE, ...).
|
||||
func IsConstraint(err error, extended ...int) bool {
|
||||
var serr *sqlite.Error
|
||||
if !errors.As(err, &serr) {
|
||||
return false
|
||||
}
|
||||
code := serr.Code()
|
||||
if code&0xff != sqlite3.SQLITE_CONSTRAINT {
|
||||
return false
|
||||
}
|
||||
if len(extended) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, e := range extended {
|
||||
if code == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// nowFunc is the clock the store stamps rows with. Tests replace it.
|
||||
var nowFunc = time.Now
|
||||
|
||||
// unixNow is the timestamp format every *_at column uses: whole seconds, which
|
||||
// is what the SQL triggers' unixepoch() produces.
|
||||
func unixNow() int64 { return nowFunc().Unix() }
|
||||
@@ -0,0 +1,453 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
)
|
||||
|
||||
// State is where a deployment sits in its lifecycle. The values mirror the
|
||||
// CHECK constraint in the schema; api carries the same set for the wire and
|
||||
// TestStateConstantsMatchTheWire asserts the two never drift.
|
||||
type State string
|
||||
|
||||
const (
|
||||
// StatePending: the row exists and nothing else does.
|
||||
StatePending State = "pending"
|
||||
// StateUploading: a manifest has been accepted, so every blob it names is
|
||||
// already refcounted and safe from GC while its content is still arriving.
|
||||
StateUploading State = "uploading"
|
||||
// StateReady: every blob is present and the directory tree is assembled.
|
||||
// Only a ready deployment may be activated.
|
||||
StateReady State = "ready"
|
||||
// StateFailed: abandoned. Its manifest rows get dropped so refcounts fall
|
||||
// and GC can reclaim whatever only it referenced.
|
||||
StateFailed State = "failed"
|
||||
// StateDeleting: GC has claimed it and is removing its tree.
|
||||
StateDeleting State = "deleting"
|
||||
)
|
||||
|
||||
// Deployment is a row of the deployments table.
|
||||
//
|
||||
// ID is internal and never leaves the server; PublicID is what the API and the
|
||||
// CLI use. Keeping them separate means a URL cannot be walked to a neighbouring
|
||||
// project's deployment by incrementing an integer.
|
||||
type Deployment struct {
|
||||
ID int64
|
||||
PublicID string
|
||||
ProjectID int64
|
||||
State State
|
||||
Active bool
|
||||
|
||||
FileCount int
|
||||
TotalBytes int64
|
||||
// CreatedByKey is empty once that key has been deleted; the schema sets it
|
||||
// to NULL rather than losing the deployment.
|
||||
CreatedByKey string
|
||||
Meta map[string]string
|
||||
Error string
|
||||
|
||||
CreatedAt time.Time
|
||||
FinalizedAt *time.Time
|
||||
ActivatedAt *time.Time
|
||||
DeactivatedAt *time.Time
|
||||
}
|
||||
|
||||
// FileRow is one manifest entry as stored.
|
||||
//
|
||||
// There is deliberately no mode: a client-supplied permission bit must never
|
||||
// reach the filesystem, which is why the schema has no column for one either.
|
||||
// The encoding column is likewise not represented — v1 only ever writes ” —
|
||||
// but it stays in the primary key so pre-compressed variants can be added later
|
||||
// without a painful migration.
|
||||
type FileRow struct {
|
||||
Path string
|
||||
Digest cas.Digest
|
||||
Size int64
|
||||
}
|
||||
|
||||
const deploymentColumns = `id, public_id, project_id, state, active, file_count, total_bytes,
|
||||
created_by_key, meta, error, created_at, finalized_at, activated_at, deactivated_at`
|
||||
|
||||
func scanDeployment(row rowScanner) (*Deployment, error) {
|
||||
var d Deployment
|
||||
var createdBy, meta, errText sql.NullString
|
||||
var created int64
|
||||
var finalized, activated, deactivated sql.NullInt64
|
||||
err := row.Scan(&d.ID, &d.PublicID, &d.ProjectID, &d.State, &d.Active, &d.FileCount,
|
||||
&d.TotalBytes, &createdBy, &meta, &errText, &created, &finalized, &activated, &deactivated)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
d.CreatedByKey = createdBy.String
|
||||
d.Error = errText.String
|
||||
d.CreatedAt = time.Unix(created, 0).UTC()
|
||||
d.FinalizedAt = timePtr(finalized)
|
||||
d.ActivatedAt = timePtr(activated)
|
||||
d.DeactivatedAt = timePtr(deactivated)
|
||||
if meta.String != "" && meta.String != "{}" {
|
||||
// Metadata is whatever the CI job attached. A row that will not parse is
|
||||
// not worth failing a rollback over, so it is dropped rather than
|
||||
// returned as an error.
|
||||
_ = json.Unmarshal([]byte(meta.String), &d.Meta)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// NewDeploymentID mints a deployment's public identifier.
|
||||
//
|
||||
// 64 bits from crypto/rand, which is not a capability and is not treated as
|
||||
// one: every endpoint taking a deployment id sits behind RequireProject, and
|
||||
// DeploymentByPublicID is always scoped by project, so knowing an id grants
|
||||
// nothing. The randomness is here so two concurrent CI jobs never collide and
|
||||
// so ids carry no information about how many deployments the server has seen.
|
||||
func NewDeploymentID() (string, error) {
|
||||
var buf [8]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "dpl_" + hex.EncodeToString(buf[:]), nil
|
||||
}
|
||||
|
||||
// CreateDeployment inserts a pending deployment and fills in its ID, PublicID
|
||||
// and CreatedAt. Nothing touches the filesystem until a manifest arrives.
|
||||
func (d *DB) CreateDeployment(ctx context.Context, dep *Deployment) error {
|
||||
if dep.PublicID == "" {
|
||||
id, err := NewDeploymentID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dep.PublicID = id
|
||||
}
|
||||
meta := "{}"
|
||||
if len(dep.Meta) > 0 {
|
||||
b, err := json.Marshal(dep.Meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta = string(b)
|
||||
}
|
||||
now := unixNow()
|
||||
return d.Tx(ctx, func(tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO deployments (public_id, project_id, state, created_by_key, meta, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
dep.PublicID, dep.ProjectID, StatePending, nullString(dep.CreatedByKey), meta, now)
|
||||
if err != nil {
|
||||
return mapErr(err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dep.ID = id
|
||||
dep.State = StatePending
|
||||
dep.CreatedAt = time.Unix(now, 0).UTC()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeploymentByPublicID looks up one deployment within one project.
|
||||
//
|
||||
// The project is part of the lookup rather than something to check afterwards.
|
||||
// A handler holding a project-scoped identity therefore cannot name another
|
||||
// project's deployment at all: the wrong project simply yields ErrNotFound,
|
||||
// which is also the right answer to give — it reveals nothing about whether the
|
||||
// id exists elsewhere.
|
||||
func (d *DB) DeploymentByPublicID(ctx context.Context, projectID int64, publicID string) (*Deployment, error) {
|
||||
return scanDeployment(d.r.QueryRowContext(ctx,
|
||||
`SELECT `+deploymentColumns+` FROM deployments WHERE project_id = ? AND public_id = ?`,
|
||||
projectID, publicID))
|
||||
}
|
||||
|
||||
// ActiveDeployment returns the deployment a project is currently serving, or
|
||||
// ErrNotFound when it has never activated one. The partial unique index makes
|
||||
// "at most one" a property of the database rather than of this query.
|
||||
func (d *DB) ActiveDeployment(ctx context.Context, projectID int64) (*Deployment, error) {
|
||||
return scanDeployment(d.r.QueryRowContext(ctx,
|
||||
`SELECT `+deploymentColumns+` FROM deployments WHERE project_id = ? AND active = 1`,
|
||||
projectID))
|
||||
}
|
||||
|
||||
// ListDeployments pages one project's deployments, newest first.
|
||||
//
|
||||
// The cursor is the public id of the last row of the previous page. An id that
|
||||
// no longer exists — GC ran between pages — yields an empty page rather than an
|
||||
// error, which is the behaviour a paging client can actually handle.
|
||||
func (d *DB) ListDeployments(ctx context.Context, projectID int64, state State, limit int, cursor string) (deployments []*Deployment, next string, err error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := d.r.QueryContext(ctx, `
|
||||
SELECT `+deploymentColumns+` FROM deployments
|
||||
WHERE project_id = ?
|
||||
AND (? = '' OR state = ?)
|
||||
AND (? = '' OR id < (SELECT id FROM deployments WHERE public_id = ?))
|
||||
ORDER BY id DESC LIMIT ?`,
|
||||
projectID, string(state), string(state), cursor, cursor, limit+1)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
dep, err := scanDeployment(rows)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
deployments = append(deployments, dep)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(deployments) > limit {
|
||||
deployments = deployments[:limit]
|
||||
next = deployments[len(deployments)-1].PublicID
|
||||
}
|
||||
return deployments, next, nil
|
||||
}
|
||||
|
||||
// SetManifest replaces a deployment's file list and reports which blobs the
|
||||
// server does not have content for yet.
|
||||
//
|
||||
// The ordering here is the point of the whole upload protocol. The manifest
|
||||
// rows land *before* any blob is uploaded, and the insert triggers bump each
|
||||
// blob's refcount as they do, so from this transaction's commit onwards every
|
||||
// blob the deployment needs is protected from GC. That closes the race the
|
||||
// obvious design has: "the server told me it already had this blob, then
|
||||
// deleted it while I was uploading the others".
|
||||
//
|
||||
// Re-sending a manifest is allowed while the deployment is pending or
|
||||
// uploading, because a CLI that lost its connection mid-negotiation should be
|
||||
// able to start over without creating a second deployment.
|
||||
func (d *DB) SetManifest(ctx context.Context, deploymentID int64, files []FileRow) (missing []cas.Digest, missingBytes int64, err error) {
|
||||
// One entry per distinct digest, in first-appearance order, so the missing
|
||||
// list — and therefore the client's upload order — is deterministic.
|
||||
type need struct {
|
||||
digest cas.Digest
|
||||
size int64
|
||||
}
|
||||
var needs []need
|
||||
seen := make(map[cas.Digest]int64, len(files))
|
||||
var totalBytes int64
|
||||
for _, f := range files {
|
||||
totalBytes += f.Size
|
||||
if prev, ok := seen[f.Digest]; ok {
|
||||
if prev != f.Size {
|
||||
return nil, 0, fmt.Errorf("%w: the manifest gives digest %s both %d and %d bytes",
|
||||
cas.ErrSizeMismatch, f.Digest, prev, f.Size)
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen[f.Digest] = f.Size
|
||||
needs = append(needs, need{f.Digest, f.Size})
|
||||
}
|
||||
|
||||
now := unixNow()
|
||||
err = d.Tx(ctx, func(tx *sql.Tx) error {
|
||||
// Tx re-runs this function on SQLITE_BUSY, so the results are rebuilt
|
||||
// from scratch each attempt rather than appended to.
|
||||
missing, missingBytes = nil, 0
|
||||
|
||||
var state State
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT state FROM deployments WHERE id = ?`, deploymentID).Scan(&state); err != nil {
|
||||
return mapErr(err)
|
||||
}
|
||||
if state != StatePending && state != StateUploading {
|
||||
return fmt.Errorf("%w: deployment is %s; a manifest may only be set while pending or uploading",
|
||||
ErrConflict, state)
|
||||
}
|
||||
|
||||
// Discard any earlier attempt. A refcount that goes 1 -> 0 -> 1 inside
|
||||
// this transaction is safe because GC needs the same single write
|
||||
// connection and so can never observe the zero.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM deployment_files WHERE deployment_id = ?`, deploymentID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prepared statements rather than batched multi-row VALUES: a 50,000
|
||||
// file manifest is a few hundred milliseconds of Exec either way, and
|
||||
// this version has no chunk arithmetic to get wrong and no bound on how
|
||||
// many parameters one statement may carry.
|
||||
insertBlob, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO blobs (digest, size, present, created_at, last_ref_at)
|
||||
VALUES (?, ?, 0, ?, ?) ON CONFLICT(digest) DO NOTHING`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer insertBlob.Close()
|
||||
readBlob, err := tx.PrepareContext(ctx, `SELECT size, present FROM blobs WHERE digest = ?`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer readBlob.Close()
|
||||
|
||||
for _, n := range needs {
|
||||
if _, err := insertBlob.ExecContext(ctx, n.digest.Bytes(), n.size, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
var have int64
|
||||
var present bool
|
||||
if err := readBlob.QueryRowContext(ctx, n.digest.Bytes()).Scan(&have, &present); err != nil {
|
||||
return mapErr(err)
|
||||
}
|
||||
if have != n.size {
|
||||
// A digest fixes its content and therefore its length, so this
|
||||
// is a client that computed one of the two wrong. Surfacing it
|
||||
// here beats accepting a manifest whose byte totals are fiction.
|
||||
return fmt.Errorf("%w: digest %s is %d bytes on this server, the manifest declares %d",
|
||||
cas.ErrSizeMismatch, n.digest, have, n.size)
|
||||
}
|
||||
if !present {
|
||||
missing = append(missing, n.digest)
|
||||
missingBytes += n.size
|
||||
}
|
||||
}
|
||||
|
||||
insertFile, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO deployment_files (deployment_id, path, digest, size) VALUES (?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer insertFile.Close()
|
||||
for _, f := range files {
|
||||
if _, err := insertFile.ExecContext(ctx, deploymentID, f.Path, f.Digest.Bytes(), f.Size); err != nil {
|
||||
return mapErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE deployments SET state = ?, file_count = ?, total_bytes = ? WHERE id = ?`,
|
||||
StateUploading, len(files), totalBytes, deploymentID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return missing, missingBytes, nil
|
||||
}
|
||||
|
||||
// DeploymentFiles returns the manifest, ordered by path so assembly creates
|
||||
// each directory once and in a predictable order.
|
||||
func (d *DB) DeploymentFiles(ctx context.Context, deploymentID int64) ([]FileRow, error) {
|
||||
rows, err := d.r.QueryContext(ctx,
|
||||
`SELECT path, digest, size FROM deployment_files WHERE deployment_id = ? ORDER BY path`,
|
||||
deploymentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var files []FileRow
|
||||
for rows.Next() {
|
||||
var f FileRow
|
||||
var raw []byte
|
||||
if err := rows.Scan(&f.Path, &raw, &f.Size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.Digest, err = cas.FromBytes(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
return files, rows.Err()
|
||||
}
|
||||
|
||||
// MarkDeploymentReady records that assembly succeeded.
|
||||
//
|
||||
// Idempotent, and deliberately so: the CLI retries finalize after re-uploading
|
||||
// blobs that went missing, and a retry must not turn a good deployment into an
|
||||
// error. finalized_at keeps its original value so the timestamp reflects the
|
||||
// first success rather than the last attempt.
|
||||
func (d *DB) MarkDeploymentReady(ctx context.Context, id int64) error {
|
||||
now := unixNow()
|
||||
return d.Tx(ctx, func(tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
UPDATE deployments SET state = ?, finalized_at = COALESCE(finalized_at, ?), error = NULL
|
||||
WHERE id = ? AND state IN (?, ?)`,
|
||||
StateReady, now, id, StateUploading, StateReady)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneRow(ctx, tx, res, id, "finalize")
|
||||
})
|
||||
}
|
||||
|
||||
// ActivateDeployment makes one ready deployment the project's active one and
|
||||
// demotes whatever held that role before.
|
||||
//
|
||||
// Demotion comes first because deployments_one_active is a partial unique index,
|
||||
// checked per statement: promoting before demoting would collide with the row
|
||||
// still holding active = 1. The demotion deliberately excludes the target, so
|
||||
// re-activating the deployment that is already active is a no-op that refreshes
|
||||
// its timestamp rather than a transaction that briefly leaves the project with
|
||||
// nothing active.
|
||||
//
|
||||
// The caller is expected to hold the project lock and to have already built
|
||||
// whatever in-memory snapshot it means to publish: once this commits, SQLite is
|
||||
// the truth and a crash before the pointer store is recovered from here.
|
||||
func (d *DB) ActivateDeployment(ctx context.Context, projectID, deploymentID int64) error {
|
||||
now := unixNow()
|
||||
return d.Tx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE deployments SET active = 0, deactivated_at = ?
|
||||
WHERE project_id = ? AND active = 1 AND id <> ?`,
|
||||
now, projectID, deploymentID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
UPDATE deployments SET active = 1, activated_at = ?, deactivated_at = NULL
|
||||
WHERE id = ? AND project_id = ? AND state = ?`,
|
||||
now, deploymentID, projectID, StateReady)
|
||||
if err != nil {
|
||||
return mapErr(err)
|
||||
}
|
||||
return requireOneRow(ctx, tx, res, deploymentID, "activate")
|
||||
})
|
||||
}
|
||||
|
||||
// MarkDeploymentFailed records why a deployment was abandoned. The manifest
|
||||
// rows stay for now; GC drops them, which is what lets its blobs be reclaimed.
|
||||
func (d *DB) MarkDeploymentFailed(ctx context.Context, id int64, reason string) error {
|
||||
return d.Tx(ctx, func(tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
UPDATE deployments SET state = ?, error = ? WHERE id = ? AND state IN (?, ?, ?)`,
|
||||
StateFailed, truncate(reason, 1024), id, StatePending, StateUploading, StateFailed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneRow(ctx, tx, res, id, "fail")
|
||||
})
|
||||
}
|
||||
|
||||
// requireOneRow turns "the UPDATE matched nothing" into the reason it matched
|
||||
// nothing, which is either a deployment that is gone or one in a state the
|
||||
// operation does not apply to.
|
||||
func requireOneRow(ctx context.Context, tx *sql.Tx, res sql.Result, id int64, op string) error {
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
var state State
|
||||
if err := tx.QueryRowContext(ctx, `SELECT state FROM deployments WHERE id = ?`, id).Scan(&state); err != nil {
|
||||
return mapErr(err)
|
||||
}
|
||||
return fmt.Errorf("%w: cannot %s a deployment that is %s", ErrConflict, op, state)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/iceBear67/simplepages/api"
|
||||
"github.com/iceBear67/simplepages/internal/cas"
|
||||
)
|
||||
|
||||
// The lifecycle is spelled out in three places that cannot import each other:
|
||||
// this package, the CHECK constraint in the schema, and api for the wire.
|
||||
// Renaming a state in one of them without the others would surface as a
|
||||
// constraint violation in production rather than at compile time, so it is
|
||||
// asserted here instead.
|
||||
func TestStateConstantsMatchTheWire(t *testing.T) {
|
||||
states := []struct {
|
||||
store State
|
||||
wire string
|
||||
}{
|
||||
{StatePending, api.StatePending},
|
||||
{StateUploading, api.StateUploading},
|
||||
{StateReady, api.StateReady},
|
||||
{StateFailed, api.StateFailed},
|
||||
{StateDeleting, api.StateDeleting},
|
||||
}
|
||||
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
for _, s := range states {
|
||||
if string(s.store) != s.wire {
|
||||
t.Errorf("store %q and wire %q disagree", s.store, s.wire)
|
||||
}
|
||||
// And the schema accepts it: a state this package can set but the CHECK
|
||||
// constraint rejects would only fail once something reached that state.
|
||||
dep := testDeployment(t, db, p.ID)
|
||||
if _, err := db.w.ExecContext(context.Background(),
|
||||
`UPDATE deployments SET state = ? WHERE id = ?`, s.store, dep.ID); err != nil {
|
||||
t.Errorf("the schema rejects state %q: %v", s.store, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testProject(t *testing.T, db *DB, name string) *Project {
|
||||
t.Helper()
|
||||
p := DefaultProject(name)
|
||||
if err := db.CreateProject(context.Background(), p); err != nil {
|
||||
t.Fatalf("CreateProject(%q): %v", name, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func testDeployment(t *testing.T, db *DB, projectID int64) *Deployment {
|
||||
t.Helper()
|
||||
dep := &Deployment{ProjectID: projectID}
|
||||
if err := db.CreateDeployment(context.Background(), dep); err != nil {
|
||||
t.Fatalf("CreateDeployment: %v", err)
|
||||
}
|
||||
return dep
|
||||
}
|
||||
|
||||
// file builds a manifest entry whose digest really is the digest of content, so
|
||||
// tests never accidentally assert on an impossible pairing.
|
||||
func file(path, content string) FileRow {
|
||||
return FileRow{Path: path, Digest: cas.Sum([]byte(content)), Size: int64(len(content))}
|
||||
}
|
||||
|
||||
func refcount(t *testing.T, db *DB, d cas.Digest) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := db.Reader().QueryRow(`SELECT refcount FROM blobs WHERE digest = ?`, d.Bytes()).Scan(&n); err != nil {
|
||||
t.Fatalf("refcount(%s): %v", d, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func digests(ds []cas.Digest) []string {
|
||||
out := make([]string, len(ds))
|
||||
for i, d := range ds {
|
||||
out[i] = d.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCreateDeployment(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
|
||||
dep := &Deployment{ProjectID: p.ID, CreatedByKey: "k7m2qabcdefghijk", Meta: map[string]string{"git_sha": "abc"}}
|
||||
// The key must exist: created_by_key is a foreign key.
|
||||
if _, err := db.w.ExecContext(ctx,
|
||||
`INSERT INTO api_keys (id, secret_hash, scope, project_id, created_at) VALUES (?, x'00', 'project', ?, 1)`,
|
||||
dep.CreatedByKey, p.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateDeployment(ctx, dep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dep.ID == 0 || dep.PublicID == "" || dep.State != StatePending {
|
||||
t.Fatalf("CreateDeployment left %+v", dep)
|
||||
}
|
||||
|
||||
got, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Meta["git_sha"] != "abc" {
|
||||
t.Errorf("meta = %v, want git_sha=abc", got.Meta)
|
||||
}
|
||||
if got.CreatedByKey != dep.CreatedByKey || got.Active || got.CreatedAt.IsZero() {
|
||||
t.Errorf("round-tripped as %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeploymentIDsAreDistinct(t *testing.T) {
|
||||
seen := make(map[string]bool, 256)
|
||||
for range 256 {
|
||||
id, err := NewDeploymentID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(id) != len("dpl_")+16 {
|
||||
t.Fatalf("id %q has the wrong shape", id)
|
||||
}
|
||||
if seen[id] {
|
||||
t.Fatalf("NewDeploymentID repeated %q", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Security requirement: a deployment id is only ever resolvable inside its own
|
||||
// project, so a project-scoped caller cannot name a neighbour's deployment even
|
||||
// knowing its id exactly.
|
||||
func TestDeploymentByPublicIDIsProjectScoped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
mine := testProject(t, db, "mine")
|
||||
theirs := testProject(t, db, "theirs")
|
||||
dep := testDeployment(t, db, theirs.ID)
|
||||
|
||||
if _, err := db.DeploymentByPublicID(ctx, theirs.ID, dep.PublicID); err != nil {
|
||||
t.Fatalf("the owning project cannot see its own deployment: %v", err)
|
||||
}
|
||||
_, err := db.DeploymentByPublicID(ctx, mine.ID, dep.PublicID)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("cross-project lookup = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordering claim the upload protocol rests on: once SetManifest commits,
|
||||
// every blob the deployment needs is already refcounted, so nothing GC does
|
||||
// while the content is still being uploaded can take one away.
|
||||
func TestSetManifestRefcountsBeforeUpload(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
dep := testDeployment(t, db, p.ID)
|
||||
|
||||
files := []FileRow{
|
||||
file("index.html", "<h1>hi</h1>"),
|
||||
file("assets/app.js", "console.log(1)"),
|
||||
file("copy.html", "<h1>hi</h1>"), // same content as index.html
|
||||
}
|
||||
missing, missingBytes, err := db.SetManifest(ctx, dep.ID, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Two distinct digests, each reported once, in first-appearance order.
|
||||
want := []string{files[0].Digest.String(), files[1].Digest.String()}
|
||||
if got := digests(missing); len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Errorf("missing = %v, want %v", got, want)
|
||||
}
|
||||
if wantBytes := files[0].Size + files[1].Size; missingBytes != wantBytes {
|
||||
t.Errorf("missingBytes = %d, want %d", missingBytes, wantBytes)
|
||||
}
|
||||
|
||||
if got := refcount(t, db, files[0].Digest); got != 2 {
|
||||
t.Errorf("shared blob refcount = %d, want 2 (two paths reference it)", got)
|
||||
}
|
||||
if got := refcount(t, db, files[1].Digest); got != 1 {
|
||||
t.Errorf("refcount = %d, want 1", got)
|
||||
}
|
||||
|
||||
got, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != StateUploading {
|
||||
t.Errorf("state = %s, want uploading", got.State)
|
||||
}
|
||||
if got.FileCount != 3 {
|
||||
t.Errorf("file_count = %d, want 3", got.FileCount)
|
||||
}
|
||||
if wantTotal := files[0].Size + files[1].Size + files[2].Size; got.TotalBytes != wantTotal {
|
||||
t.Errorf("total_bytes = %d, want %d", got.TotalBytes, wantTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// A CLI that lost its connection mid-negotiation re-sends the manifest. The
|
||||
// second one must replace the first outright, refcounts included.
|
||||
func TestSetManifestReplacesTheEarlierAttempt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
dep := testDeployment(t, db, p.ID)
|
||||
|
||||
dropped := file("old.html", "version one")
|
||||
kept := file("index.html", "shared")
|
||||
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{dropped, kept}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
added := file("assets/app.js", "version two")
|
||||
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{kept, added}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := refcount(t, db, dropped.Digest); got != 0 {
|
||||
t.Errorf("dropped blob refcount = %d, want 0", got)
|
||||
}
|
||||
if got := refcount(t, db, kept.Digest); got != 1 {
|
||||
t.Errorf("kept blob refcount = %d, want 1", got)
|
||||
}
|
||||
if got := refcount(t, db, added.Digest); got != 1 {
|
||||
t.Errorf("added blob refcount = %d, want 1", got)
|
||||
}
|
||||
|
||||
files, err := db.DeploymentFiles(ctx, dep.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) != 2 || files[0].Path != "assets/app.js" || files[1].Path != "index.html" {
|
||||
t.Errorf("manifest = %+v, want the second attempt ordered by path", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetManifestRejectsASizeDisagreement(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
|
||||
f := file("index.html", "content")
|
||||
first := testDeployment(t, db, p.ID)
|
||||
if _, _, err := db.SetManifest(ctx, first.ID, []FileRow{f}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Same digest, a different declared length: one of the two numbers is a lie
|
||||
// and the manifest cannot be accepted either way.
|
||||
lying := f
|
||||
lying.Size = f.Size + 1
|
||||
second := testDeployment(t, db, p.ID)
|
||||
_, _, err := db.SetManifest(ctx, second.ID, []FileRow{lying})
|
||||
if !errors.Is(err, cas.ErrSizeMismatch) {
|
||||
t.Errorf("err = %v, want ErrSizeMismatch", err)
|
||||
}
|
||||
|
||||
// The same disagreement inside one manifest is caught before any write.
|
||||
third := testDeployment(t, db, p.ID)
|
||||
_, _, err = db.SetManifest(ctx, third.ID, []FileRow{f, {Path: "other.html", Digest: f.Digest, Size: 99}})
|
||||
if !errors.Is(err, cas.ErrSizeMismatch) {
|
||||
t.Errorf("err = %v, want ErrSizeMismatch", err)
|
||||
}
|
||||
if got := refcount(t, db, f.Digest); got != 1 {
|
||||
t.Errorf("refcount = %d after two rejected manifests, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetManifestRejectsAFinishedDeployment(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
dep := testDeployment(t, db, p.ID)
|
||||
|
||||
f := file("index.html", "content")
|
||||
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{f}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.MarkBlobPresent(ctx, f.Digest, f.Size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.MarkDeploymentReady(ctx, dep.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err := db.SetManifest(ctx, dep.ID, []FileRow{file("other.html", "x")})
|
||||
if !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("err = %v, want ErrConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetManifestOnAMissingDeployment(t *testing.T) {
|
||||
_, _, err := testDB(t).SetManifest(context.Background(), 424242, []FileRow{file("a", "b")})
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingBlobsShrinksAsContentArrives(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
dep := testDeployment(t, db, p.ID)
|
||||
|
||||
a, b := file("a.html", "aaa"), file("b.html", "bbb")
|
||||
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{a, b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := db.MissingBlobs(ctx, dep.ID); err != nil || len(got) != 2 {
|
||||
t.Fatalf("MissingBlobs = %v, %v; want 2 digests", digests(got), err)
|
||||
}
|
||||
|
||||
if err := db.MarkBlobPresent(ctx, a.Digest, a.Size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := db.MissingBlobs(ctx, dep.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != b.Digest {
|
||||
t.Fatalf("MissingBlobs = %v, want just %s", digests(got), b.Digest)
|
||||
}
|
||||
|
||||
// A second deployment of overlapping content sees only what is genuinely
|
||||
// new — this is the deduplication the CLI reports as its headline number.
|
||||
next := testDeployment(t, db, p.ID)
|
||||
c := file("c.html", "ccc")
|
||||
missing, _, err := db.SetManifest(ctx, next.ID, []FileRow{a, c})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(missing) != 1 || missing[0] != c.Digest {
|
||||
t.Errorf("missing = %v, want just the new blob", digests(missing))
|
||||
}
|
||||
|
||||
if err := db.MarkBlobPresent(ctx, b.Digest, b.Size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := db.MissingBlobs(ctx, dep.ID); err != nil || len(got) != 0 {
|
||||
t.Errorf("MissingBlobs = %v, %v; want none", digests(got), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkBlobPresent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
dep := testDeployment(t, db, p.ID)
|
||||
f := file("index.html", "content")
|
||||
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{f}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := db.MarkBlobPresent(ctx, f.Digest, f.Size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Idempotent: a retried upload of a blob that arrived meanwhile is a no-op.
|
||||
if err := db.MarkBlobPresent(ctx, f.Digest, f.Size); err != nil {
|
||||
t.Errorf("second MarkBlobPresent: %v", err)
|
||||
}
|
||||
b, err := db.Blob(ctx, f.Digest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !b.Present || b.Size != f.Size {
|
||||
t.Errorf("blob = %+v", b)
|
||||
}
|
||||
|
||||
if err := db.MarkBlobPresent(ctx, f.Digest, f.Size+1); !errors.Is(err, cas.ErrSizeMismatch) {
|
||||
t.Errorf("err = %v, want ErrSizeMismatch", err)
|
||||
}
|
||||
// Unknown digests are refused, which is what stops the upload endpoint from
|
||||
// being used as arbitrary storage.
|
||||
if _, err := db.Blob(ctx, cas.Sum([]byte("never declared"))); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Blob = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := db.MarkBlobPresent(ctx, cas.Sum([]byte("never declared")), 1); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("MarkBlobPresent = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkDeploymentReadyIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
dep := testDeployment(t, db, p.ID)
|
||||
|
||||
// A pending deployment has no manifest, so there is nothing to finalize.
|
||||
if err := db.MarkDeploymentReady(ctx, dep.ID); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("finalize while pending = %v, want ErrConflict", err)
|
||||
}
|
||||
|
||||
f := file("index.html", "content")
|
||||
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{f}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.MarkDeploymentReady(ctx, dep.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.State != StateReady || first.FinalizedAt == nil {
|
||||
t.Fatalf("deployment = %+v", first)
|
||||
}
|
||||
|
||||
if err := db.MarkDeploymentReady(ctx, dep.ID); err != nil {
|
||||
t.Errorf("retried finalize: %v", err)
|
||||
}
|
||||
again, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !again.FinalizedAt.Equal(*first.FinalizedAt) {
|
||||
t.Errorf("finalized_at moved from %v to %v", first.FinalizedAt, again.FinalizedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkDeploymentFailed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
dep := testDeployment(t, db, p.ID)
|
||||
|
||||
if err := db.MarkDeploymentFailed(ctx, dep.ID, "upload timed out"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := db.DeploymentByPublicID(ctx, p.ID, dep.PublicID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != StateFailed || got.Error != "upload timed out" {
|
||||
t.Errorf("deployment = %+v", got)
|
||||
}
|
||||
|
||||
// A failed deployment cannot be resurrected by a late manifest or finalize.
|
||||
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{file("a", "b")}); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("SetManifest = %v, want ErrConflict", err)
|
||||
}
|
||||
if err := db.MarkDeploymentReady(ctx, dep.ID); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("MarkDeploymentReady = %v, want ErrConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDeployments(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
other := testProject(t, db, "other")
|
||||
testDeployment(t, db, other.ID)
|
||||
|
||||
var ids []string
|
||||
for range 5 {
|
||||
ids = append(ids, testDeployment(t, db, p.ID).PublicID)
|
||||
}
|
||||
// Newest first, so reverse creation order.
|
||||
var want []string
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
want = append(want, ids[i])
|
||||
}
|
||||
|
||||
var seen []string
|
||||
cursor := ""
|
||||
for {
|
||||
page, next, err := db.ListDeployments(ctx, p.ID, "", 2, cursor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range page {
|
||||
seen = append(seen, d.PublicID)
|
||||
}
|
||||
if next == "" {
|
||||
break
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
if len(seen) != len(want) {
|
||||
t.Fatalf("paged %v, want %v", seen, want)
|
||||
}
|
||||
for i := range want {
|
||||
if seen[i] != want[i] {
|
||||
t.Fatalf("paged %v, want %v", seen, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Filtering by state.
|
||||
fifth, err := db.DeploymentByPublicID(ctx, p.ID, ids[4])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.MarkDeploymentFailed(ctx, fifth.ID, "abandoned"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failed, _, err := db.ListDeployments(ctx, p.ID, StateFailed, 10, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(failed) != 1 || failed[0].PublicID != ids[4] {
|
||||
t.Errorf("failed page = %v, want just %s", failed, ids[4])
|
||||
}
|
||||
|
||||
// A cursor GC removed between pages yields an empty page, not an error.
|
||||
gone, _, err := db.ListDeployments(ctx, p.ID, "", 10, "dpl_ffffffffffffffff")
|
||||
if err != nil {
|
||||
t.Fatalf("stale cursor: %v", err)
|
||||
}
|
||||
if len(gone) != 0 {
|
||||
t.Errorf("stale cursor returned %d rows", len(gone))
|
||||
}
|
||||
}
|
||||
|
||||
// ready builds a deployment that has a manifest and has been finalized, which
|
||||
// is the only state activation accepts.
|
||||
func ready(t *testing.T, db *DB, projectID int64, content string) *Deployment {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
dep := testDeployment(t, db, projectID)
|
||||
if _, _, err := db.SetManifest(ctx, dep.ID, []FileRow{file("index.html", content)}); err != nil {
|
||||
t.Fatalf("SetManifest: %v", err)
|
||||
}
|
||||
if err := db.MarkDeploymentReady(ctx, dep.ID); err != nil {
|
||||
t.Fatalf("MarkDeploymentReady: %v", err)
|
||||
}
|
||||
return dep
|
||||
}
|
||||
|
||||
func TestActivateDeployment(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
|
||||
if _, err := db.ActiveDeployment(ctx, p.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("ActiveDeployment on a fresh project = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
first := ready(t, db, p.ID, "v1")
|
||||
if err := db.ActivateDeployment(ctx, p.ID, first.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := db.ActiveDeployment(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != first.ID || !got.Active || got.ActivatedAt == nil || got.DeactivatedAt != nil {
|
||||
t.Fatalf("after activation the row is %+v", got)
|
||||
}
|
||||
|
||||
second := ready(t, db, p.ID, "v2")
|
||||
if err := db.ActivateDeployment(ctx, p.ID, second.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = db.ActiveDeployment(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != second.ID {
|
||||
t.Fatalf("active = %d, want the second deployment %d", got.ID, second.ID)
|
||||
}
|
||||
|
||||
// The superseded one stays ready and on disk — that is what makes rollback
|
||||
// a single activation rather than a redeploy — but it is stamped so GC's
|
||||
// grace period can start counting.
|
||||
old, err := db.DeploymentByPublicID(ctx, p.ID, first.PublicID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if old.Active || old.State != StateReady || old.DeactivatedAt == nil {
|
||||
t.Errorf("the superseded deployment is %+v", old)
|
||||
}
|
||||
if old.ActivatedAt == nil {
|
||||
t.Error("deactivation cleared activated_at")
|
||||
}
|
||||
|
||||
// Rollback.
|
||||
if err := db.ActivateDeployment(ctx, p.ID, first.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = db.ActiveDeployment(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != first.ID || got.DeactivatedAt != nil {
|
||||
t.Errorf("after rollback the active row is %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-activating what is already active must not leave the project with nothing
|
||||
// active in between, which is why the demotion excludes the target row.
|
||||
func TestActivateDeploymentIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
dep := ready(t, db, p.ID, "v1")
|
||||
|
||||
for range 3 {
|
||||
if err := db.ActivateDeployment(ctx, p.ID, dep.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := db.ActiveDeployment(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("nothing is active after re-activating: %v", err)
|
||||
}
|
||||
if got.ID != dep.ID || got.DeactivatedAt != nil {
|
||||
t.Fatalf("row = %+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateDeploymentRequiresReady(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
|
||||
pending := testDeployment(t, db, p.ID)
|
||||
if err := db.ActivateDeployment(ctx, p.ID, pending.ID); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("activating a pending deployment = %v, want ErrConflict", err)
|
||||
}
|
||||
|
||||
failed := testDeployment(t, db, p.ID)
|
||||
if err := db.MarkDeploymentFailed(ctx, failed.ID, "assembly failed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ActivateDeployment(ctx, p.ID, failed.ID); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("activating a failed deployment = %v, want ErrConflict", err)
|
||||
}
|
||||
|
||||
if err := db.ActivateDeployment(ctx, p.ID, 9999); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("activating a deployment that does not exist = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := db.ActiveDeployment(ctx, p.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Error("a refused activation left something active")
|
||||
}
|
||||
}
|
||||
|
||||
// The ownership check the API's authorization rests on lives here too: naming
|
||||
// another project's deployment id must not activate it, and must not disturb
|
||||
// either project.
|
||||
func TestActivateDeploymentIsProjectScoped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
victim := testProject(t, db, "victim")
|
||||
attacker := testProject(t, db, "attacker")
|
||||
|
||||
target := ready(t, db, victim.ID, "secret")
|
||||
if err := db.ActivateDeployment(ctx, victim.ID, target.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine := ready(t, db, attacker.ID, "mine")
|
||||
if err := db.ActivateDeployment(ctx, attacker.ID, mine.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := db.ActivateDeployment(ctx, attacker.ID, target.ID); err == nil {
|
||||
t.Fatal("a project activated another project's deployment")
|
||||
}
|
||||
got, err := db.ActiveDeployment(ctx, attacker.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != mine.ID {
|
||||
t.Errorf("the cross-project attempt changed the attacker's active deployment to %d", got.ID)
|
||||
}
|
||||
got, err = db.ActiveDeployment(ctx, victim.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != target.ID || !got.Active {
|
||||
t.Errorf("the cross-project attempt disturbed the victim: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// "At most one active deployment per project" is enforced by the database, not
|
||||
// by the code above it. Writing the second active row by hand is the only way
|
||||
// to check that: if this ever stops failing, every guarantee that rests on the
|
||||
// invariant has quietly lost its foundation.
|
||||
func TestOneActiveDeploymentPerProjectIsEnforcedBySchema(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
p := testProject(t, db, "demo")
|
||||
other := testProject(t, db, "other")
|
||||
|
||||
first := ready(t, db, p.ID, "v1")
|
||||
second := ready(t, db, p.ID, "v2")
|
||||
if err := db.ActivateDeployment(ctx, p.ID, first.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := db.w.ExecContext(ctx,
|
||||
`UPDATE deployments SET active = 1 WHERE id = ?`, second.ID); err == nil {
|
||||
t.Fatal("the schema allowed a project to have two active deployments")
|
||||
}
|
||||
|
||||
// The index is partial, so it constrains only active rows: any number of
|
||||
// inactive ones per project, and one active row per *other* project.
|
||||
elsewhere := ready(t, db, other.ID, "v1")
|
||||
if err := db.ActivateDeployment(ctx, other.ID, elsewhere.ID); err != nil {
|
||||
t.Fatalf("the index leaked across projects: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sentinels the HTTP layer maps to error codes. Callers use errors.Is; the
|
||||
// store never constructs api.Error values itself, so that the mapping from
|
||||
// storage failure to wire response lives in exactly one place (internal/adminapi).
|
||||
var (
|
||||
// ErrNotFound is returned instead of sql.ErrNoRows so callers do not have to
|
||||
// know that the store is backed by database/sql.
|
||||
ErrNotFound = errors.New("store: not found")
|
||||
// ErrExists means a uniqueness constraint rejected the write.
|
||||
ErrExists = errors.New("store: already exists")
|
||||
// ErrConflict means the row was not in the state the operation required.
|
||||
ErrConflict = errors.New("store: conflicting state")
|
||||
)
|
||||
|
||||
// mapErr normalises the errors callers are expected to branch on.
|
||||
func mapErr(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return ErrNotFound
|
||||
case IsConstraint(err):
|
||||
return errors.Join(ErrExists, err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- null conversions
|
||||
|
||||
func nullTime(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func timePtr(n sql.NullInt64) *time.Time {
|
||||
if !n.Valid {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(n.Int64, 0).UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
func nullInt(p *int64) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func intPtr(n sql.NullInt64) *int64 {
|
||||
if !n.Valid {
|
||||
return nil
|
||||
}
|
||||
v := n.Int64
|
||||
return &v
|
||||
}
|
||||
|
||||
func nullString(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user