Skip to content

Latest commit

 

History

History
388 lines (319 loc) · 17.6 KB

STARTER.md

File metadata and controls

388 lines (319 loc) · 17.6 KB

The starter application

Accounts and notes on PostgreSQL: configuration read from the environment, migrations, JWT access tokens with rotating refresh tokens, cursor paging, OpenAPI, health and readiness, tests, and a deployment recipe.

It is meant to be copied. The other examples each show one thing; this one shows the shape of a whole application, so the structure is the point as much as the code.

cd Examples
createdb starter
export DATABASE_URL='postgres://garuda:[email protected]:5432/starter?sslmode=disable'

swift run starter env              # what the environment says
swift run starter migrate          # apply migrations and exit
swift run starter serve -- --port 8080   # serve
curl -s localhost:8080/auth/signup -d '{"email":"[email protected]","password":"correct horse"}'
TOKENS=$(curl -s localhost:8080/auth/login -d '{"email":"[email protected]","password":"correct horse"}')
ACCESS=$(printf '%s' "$TOKENS" | sed 's/.*"access_token":"\([^"]*\)".*/\1/')
REFRESH=$(printf '%s' "$TOKENS" | sed 's/.*"refresh_token":"\([^"]*\)".*/\1/')

curl -s localhost:8080/notes -H "authorization: Bearer $ACCESS" -d '{"title":"First","body":"hello"}'
curl -s "localhost:8080/notes?limit=20" -H "authorization: Bearer $ACCESS"
curl -s localhost:8080/auth/refresh -d "{\"refresh_token\":\"$REFRESH\"}"
curl -s localhost:8080/auth/logout  -d "{\"refresh_token\":\"$REFRESH\"}" -o /dev/null -w '%{http_code}\n'

Open http://localhost:8080/docs for Swagger UI over the generated document.

The routes

Method Path Needs Answers
POST /auth/signup 201 the account, 409 taken, 422 invalid, 403 closed
POST /auth/login 200 a token pair, 401
POST /auth/refresh 200 a new pair, 400 invalid_grant
POST /auth/logout 204, and safe to repeat
POST /auth/logout-all access token 204
GET /auth/me access token 200 the account, its role included
GET /notes?limit=&before= access token 200 a page, newest first
POST /notes access token 201 the note, 422
GET /notes/:id access token 200, 404
PATCH /notes/:id access token 200, 404, 422
DELETE /notes/:id access token 204, 404
GET /admin/accounts?limit=&before= an administrator 200 a page, 401, 403
PUT /admin/accounts/:id/role an administrator 200 the account, 401, 403, 404, 409, 422
GET /health 200 always, while the process runs
GET /ready 200, or 503 when the database does not answer
GET /docs, /docs/openapi.json the API, documented

How it is laid out

File What lives there
Configuration.swift every environment variable, read and checked once
Services.swift what a shard holds: pool, keys, token issuer; withServices for work outside the server
Schema.swift migrations, append-only
Accounts.swift sign-up, login, refresh, logout
Notes.swift what an account owns
Admin.swift the routes only an administrator reaches
StarterApp.swift the application: state, start-up, middleware, routes
starter/main.swift serve, migrate and env
StarterTests.swift the whole thing through app.test

starterApp(configuration) returns the Application, and main.swift only chooses what to do with it. That is what makes the tests exercise the same routes the executable serves.

One file per feature, as it grows

Two features fit in two files. Twenty need a rule, and this is the one to copy: a feature is a function, and what it contributes is registered in one place.

// Notes.swift: routes only, so the feature is a value.
func noteRoutes() -> Router {
    let app = Router()
    app.get("") { … }
    return app
}

// StarterApp.swift
app.nest("/notes", noteRoutes())

A feature that needs more than routes takes the application and registers it all together -- there is nothing else to declare it to:

// Billing.swift
func addBilling(_ app: Application, _ configuration: StarterConfiguration) {
    app.state { _ in try StripeClient(configuration.stripeKey) }   // per shard
    app.onShardStart { start in try await start.state(StripeClient.self).warmUp() }
    app.every(.seconds(3600), onShard: 0) { _ in … }               // one shard's job
    app.nest("/billing", billingRoutes())
}
  • Routes as a Router when the feature only serves requests: it can be mounted under any prefix, mounted twice, and tested on an application of its own. Notes.swift is this shape.
  • A function on Application when the feature also needs per-shard state, start-up work, a timer or middleware, because those belong to the application. Accounts.swift is this shape, since it needs the configuration.
  • Migrations stay in one list (Schema.swift), not one per feature: the order they ran in is what the version counts, and two lists cannot agree on an order. A feature's tables go at the end of the one list.
  • Shared services in one value (Services.swift), built once per shard. Features reach it with State<Services> rather than each holding a pool of its own -- a pool per feature is connections multiplied by shards multiplied by features.
  • Tests per feature, through app.test on the whole application, so what is tested is what is served. A Router feature can also be tested alone: let app = Application(); app.merge(noteRoutes()).

Nothing here is a framework mechanism to learn: a module is a function, and Router is the value it can hand back. There is no container to register with and no scan at start-up, so what an application is made of is what its one file says it is.

Configuration

Garuda's own concerns stay on the command line, because the server parses them and CONFIG.md documents them: --port, --shards, --tls-*, --rate-limit, --access-log, --max-body. The application's concerns are environment variables, because a deployment sets them as secrets:

Variable Required Default
APP_ENV no development; production refuses guesses
DATABASE_URL in production postgres://garuda:[email protected]:5432/starter?sslmode=disable
JWT_PRIVATE_KEY in production a key made up for the run, and shared with every shard through the environment
JWT_PRIVATE_KEY_FILE no read in place of JWT_PRIVATE_KEY, for a mounted secret
ACCESS_TOKEN_SECONDS no 900
REFRESH_TOKEN_DAYS no 14
SESSION_DAYS no 90
SIGNUPS_OPEN no true
ADMIN_EMAILS no none; [email protected],[email protected] are made administrators at start-up
DATABASE_POOL_SIZE no 8, per shard
DOCS_PATH no /docs; off serves neither

AppEnvironment (Garuda) does the reading and collects the problems; Configuration.swift says what the variables are, what they default to, and which combinations make no sense. CONFIG.md has the readers.

Three things this pattern is built around:

  • Everything is read before anything is served. fromEnvironment returns a value or throws with every problem listed, so a missing secret is one restart, not five.
  • Production is refused defaults. No database URL, no signing key, and no sslmode=disable — each is an error rather than something guessed.
  • The checks are about sense, not just types. An access token that outlives its refresh token, or a refresh token that outlives its session, is refused.

Make a signing key with:

openssl ecparam -genkey -name prime256v1 -noout -out jwt.pem
export JWT_PRIVATE_KEY_FILE=$PWD/jwt.pem

Keep it. It signs every access token: a new key signs out everyone.

Migrations

Schema.swift is a list that only grows. Each entry is the statements of one migration, applied together in one transaction, and the database records how many have run.

public let starterMigrations: [[String]] = [
    ["create table users (...)"],
    ["create table notes (...)", "create index notes_user_id on notes (user_id, id desc)"],
    PostgresRefreshTokenStore.schema(),
]
  • Append; never edit. A migration that has run will not run again, so an edit reaches nobody's database but a new one's.
  • Two ways to run them, and both are safe:
    • swift run starter migrate — for a deployment that migrates before it rolls out new code.
    • Every shard, as it starts, from app.onShardStart. The first to take PostgreSQL's advisory lock migrates; the rest wait on that lock and find nothing to do.
  • A database ahead of the binary is an error, not a migration backwards. A rolled-back deployment does not drop columns.
  • Write migrations that suit a rolling deployment: add a column before the code that writes it, stop writing a column before dropping it. Old and new code serve at the same time while a deployment rolls out across machines.

Roles: who may do what

An account is a member or an admin, and Admin.swift is guarded in two lines:

app.authenticate(jwt: AccessClaims.self)       // whose request it is
app.authorize(jwt: AccessClaims.self, .admin)  // whether they may

No handler there checks anything about who is asking. A member's token is refused 403 {"error":"this route needs an administrator"}, a request with no token is 401 with the challenge, and both appear in the OpenAPI document for every route in the group without either route saying so. The rule itself is a value, named once and testable without a server:

extension Policy where Value == AccessClaims {
    static let admin = Policy(needs: "an administrator") { $0.role == "admin" }
}

The role is in the access token, so guarding a route costs no lookup. The price is staleness, and it is bounded twice: a token lives ACCESS_TOKEN_SECONDS (15 minutes by default), and a change of role also ends that account's sessions, so its next refresh is refused and signing in again mints a token that tells the truth. Put the role in the token when a route may act on a 15-minute-old answer; look it up per request when it may not.

The first administrator comes from the deployment. A new database has none, and a route that promotes whoever asks is not a route, so ADMIN_EMAILS names them and every shard applies it at start-up:

That list only ever promotes. A list that also demoted would undo an administrator's work at the next restart, so it is a floor rather than the whole truth — and an account in it cannot be demoted through the API, which answers 409 saying to take it out of the list first.

The last administrator cannot be demoted. That is the one rule a policy cannot hold: whether this demotion leaves any is a question for the database. It is asked inside the transaction, with the row locked (select … for update), so two administrators demoting each other at the same moment cannot both pass, and it answers 409 rather than 403 — nothing about who is asking is wrong.

The per-shard model

Garuda runs a process per shard, and each shard serves on several threads of its own, Vega's work-stealing workers. What follows from that, and what this application does about it:

  • State is built as each shard starts. app.state runs in each shard, so the pool, the keys and the issuer exist once per process. Nothing here is shared between shards. Within a shard every thread reads it, so it is Sendable, and nothing in it changes once built, so it needs no lock.
  • Connections multiply. DATABASE_POOL_SIZE is per shard: 4 shards × 8 is 32 connections. Size it against PostgreSQL's max_connections, not against one number in your head.
  • State that takes work to build is built in app.state, which may await. The hash a login for an unknown email is checked against is made there.
  • Start-up work goes in app.onShardStart. It may await, it runs before the shard accepts anything, and a throw stops that shard rather than serving half-ready. Migrating and naming the administrators happen there.
  • Shutdown goes in app.state's shutdown:, which closes the pool when the shard has drained.
  • What every shard must see lives outside the process: the database, and Topic for anything to be heard across shards (the chat example). The signing key is the same in every shard because starter serve makes it before the shards start, and puts it in the environment.
  • Work on a timer is app.every. This application clears ended refresh tokens hourly with onShard: 0, so four shards do not each run the delete. Across machines, take a lock in the database instead; EXAMPLES.md shows how.
  • Work repeated in every shard is work done N times. A cache warmed in onShardStart is warmed per shard; a scheduled job started in every shard runs N times. Use start.shard.index == 0 to do something once, and remember that shard 0 is replaced on a reload.
  • Work outside the server -- starter migrate, and the tests' set-up -- goes through withServices, which builds the same Services on a Vega runtime of its own and closes them after.

Deploying

Build

swift build -c release --product starter

The binary needs the Swift runtime libraries and libssl, libz and libstdc++ (INSTALLATION.md lists them and how to ship them).

A container

deploy/Dockerfile builds from the repository root, because Examples/ depends on the Garuda checkout beside it:

docker build -f Examples/deploy/Dockerfile -t starter .
docker run --rm -p 8080:8080 \
    -e APP_ENV=production \
    -e DATABASE_URL='postgres://starter:secret@db:5432/starter?sslmode=require' \
    -e JWT_PRIVATE_KEY="$(cat jwt.pem)" \
    starter

It is a two-stage build: swift:6.3-noble compiles, ubuntu:noble runs with the Swift runtime libraries copied in, as a non-root user, with a health check on /health. --shards auto lets Vega choose the shard processes from the machine's CPUs; under a CPU limit, give --shards and --threads that fit it. docker run ... starter migrate applies the schema without serving.

systemd

deploy/starter.service and deploy/env.example:

install -m 755 .build/release/starter /usr/local/bin/starter
install -m 644 Examples/deploy/starter.service /etc/systemd/system/
install -m 640 -o root -g starter Examples/deploy/env.example /etc/starter/env
systemctl daemon-reload && systemctl enable --now starter
  • ExecStartPre=starter migrate migrates before any shard serves, so a failed migration fails the unit instead of the first request.
  • systemctl restart puts out a new binary: the health check fails for --drain-delay, then the shards finish what they are serving. systemctl reload replaces the shards one at a time without dropping a connection, but they are forks of the running process, so it runs the code already running.
  • TimeoutStopSec is above --drain-delay plus --graceful-timeout.
  • The unit takes away what the application does not need: no new privileges, a read-only system, no home, restricted address families.

In front of it

Bind to loopback behind a reverse proxy, and trust its headers with --forwarded-allow-ips, or serve TLS directly with --tls-cert and --tls-key, and --tls-reload to pick up a renewed certificate. INSTALLATION.md covers both, and CONFIG.md every flag.

Health, readiness and metrics

  • /health answers while the process runs and touches no database: a supervisor should not restart a shard for a database outage it cannot fix. The deployment serves it with --health-check-path /health, which answers before routing and fails during --drain-delay.
  • /ready takes a connection from the pool: a load balancer should stop sending traffic to a shard that cannot reach the database.
  • --metrics-port 9090 serves Prometheus metrics. Keep that port off the internet.
  • --access-log writes one line per request, and --request-id adds an id to carry into your own logs.

Before it is really production

This is a starter, not a finished service. What it deliberately leaves out:

  • Email: confirming an address, and resetting a password.
  • Rate limits per account. --rate-limit is per address, which is the floor.
  • Roles, and anything an administrator does.
  • Deleting an account, and exporting what it holds.
  • Backups, and a tested restore.

Testing

cd Examples
GARUDA_POSTGRES=127.0.0.1:46432:vega:vega-secret:vega swift test

GARUDA_POSTGRES is the server Garuda's own database tests use, as host:port:user:password:database (vega-db's scripts/postgres-test-server.sh starts one). The tests make a database of their own on it, garuda_starter_example, and drop and make tables there.

The tests drive the real engine through app.test, against a real PostgreSQL: sign-up and its refusals, login timing, the token rotation and reuse detection, ownership (another account gets 404, not 403), paging, health and the generated document. The configuration tests need no database. Without GARUDA_POSTGRES the database tests are skipped, so the suite still runs anywhere.