Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions .github/workflows/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,64 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build (and on main, push) the image
# Every PR and every merge: build the image into the runner's own Docker, so it can be
# tested before anything is pushed.
- name: Build the image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: api
platforms: linux/amd64 # the VPS is amd64; GitHub's runners build that natively
build-args: GIT_SHA=${{ github.sha }}
tags: ghcr.io/${{ github.repository_owner }}/relay-api:${{ github.sha }}
push: ${{ github.event_name == 'push' }}
tags: relay-api:smoke
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
# Run the migrations with the image just built, the way the migrate Job runs them
# (uid 1000, read-only root, no capabilities, its 128 MiB limit), against a throwaway
# Postgres made by our own first-start script. A dbmate that can't start in the real
# image is found here, on the PR, instead of halfway through a deploy.
- name: Smoke-test the migrations inside the image
shell: bash # GitHub then adds -e and pipefail
run: |
docker network create smoke-net
docker run -d --name smoke-pg --network smoke-net \
-e POSTGRES_PASSWORD=test -e RELAY_DB_PASSWORD=test \
-v "$PWD/k8s/data/initdb:/docker-entrypoint-initdb.d:ro" \
postgres:18.6@sha256:979bccfde35445db618c65107d27d08c6c21339d8dc3ad4c830c6633947be0e4
for _ in $(seq 1 60); do
docker exec smoke-pg pg_isready -q -h 127.0.0.1 -U postgres && break
sleep 1
done
migrate() {
docker run --rm --network smoke-net --read-only --user 1000:1000 --cap-drop ALL --memory 128m \
-e DATABASE_URL='postgres://relay:test@smoke-pg:5432/relay?sslmode=disable' \
relay-api:smoke bun run --bun db:migrate
}
echo "--- first run: applies every migration"
migrate
echo "--- second run: must find nothing to do"
migrate
tables=$(docker exec smoke-pg psql -X -U postgres -d relay -Atc \
"select count(*) from pg_tables where schemaname = 'public' and tablename in
('owners', 'api_keys', 'endpoints', 'events', 'deliveries', 'attempts')")
if [ "$tables" != 6 ]; then
echo "::error::expected the 6 tables of schema v1, found $tables"
exit 1
fi
docker exec smoke-pg psql -X -U postgres -d relay -Atc \
"select version from schema_migrations order by version" | sed 's/^/applied: /'
# Only after a merge to main: push the same image. Its layers come from the build above
# (the builder's cache), so this takes seconds and pushes what was just tested.
- name: Push the image (main only)
if: github.event_name == 'push'
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: api
platforms: linux/amd64
build-args: GIT_SHA=${{ github.sha }}
tags: ghcr.io/${{ github.repository_owner }}/relay-api:${{ github.sha }}
push: true
cache-from: type=gha

deploy-script:
name: deploy-script-test
Expand All @@ -94,10 +142,12 @@ jobs:
name: production
url: https://api.myrelay.tech
permissions: {} # needs no GitHub access at all: it only talks to our server
# Budget for two Deployments: 15 min lock + 6 min rollout + 9 min public check = 30 min.
# Failure path: 15 min lock + 6 min rollout + 6 min rollback = 27 min; no public check.
# Allow 5 extra minutes above the longer path for SSH, git, kubectl and job overhead.
timeout-minutes: 35
# Budget for two Deployments, each deploy starting with the migrate Job (about 3 min at most):
# success: 15 min lock + 3 migrate + 6 rollout + 9 public check = 33 min
# failure: 15 min lock + (3 + 6) deploy + (3 + 6) rollback, no check = 33 min
# Plus 5 minutes for SSH, git, kubectl and job overhead. When the worker (a third
# Deployment) arrives in Phase 4, its failure path is 39 + 5 minutes: raise this to 45.
timeout-minutes: 40
steps:
- name: Deploy the commit over SSH
# Values arrive as environment variables, never pasted into the command itself.
Expand Down
3 changes: 3 additions & 0 deletions api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
# The migrations travel in the same image as the code that needs them, so a commit's image
# always carries exactly that commit's schema. The migrate Job runs "bun run --bun db:migrate".
COPY db ./db

# The pipeline passes the commit; /version reports it, so we can see what's running.
ARG GIT_SHA=dev
Expand Down
17 changes: 17 additions & 0 deletions api/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 109 additions & 0 deletions api/db/migrations/20261031140445_init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
-- migrate:up
-- Migration 001: schema v1 (step 2.4). Run by dbmate in the migrate Job before every rollout.
-- Rules for every migration (docs/decisions/0006-migrations-expand-contract.md):
-- only ADD things old code can live with; never edit a merged migration; fix forward.
--
-- Every id is a UUIDv7 (built into Postgres 18): unique, ordered by time (so it also works as
-- the page cursor), and impossible to guess. Statuses are text + CHECK, not an enum: adding an
-- allowed value later is a small change, removing one from an enum is not.

-- Give up after 5 s of WAITING for a lock instead of queueing every API query behind us.
-- (An empty database has no locks to wait for; every migration starts the same way.)
SET LOCAL lock_timeout = '5s';

-- A customer of Relay.
CREATE TABLE owners (
id uuid PRIMARY KEY DEFAULT uuidv7(),
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 100),
created_at timestamptz NOT NULL DEFAULT now()
);

-- An owner's API keys. Only a SHA-256 hash of the key is stored: a stolen database (or backup)
-- doesn't contain usable keys. Lookup is by hash, so no secret is ever compared character by
-- character. No "last used" column: updating it on every request would make this a hot row.
CREATE TABLE api_keys (
id uuid PRIMARY KEY DEFAULT uuidv7(),
owner_id uuid NOT NULL REFERENCES owners ON DELETE CASCADE,
prefix text NOT NULL, -- e.g. 'relay_live_8f2k', safe to show in the dashboard
key_hash bytea NOT NULL UNIQUE, -- sha256(the full key)
created_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz
);

-- Where an owner wants events delivered. Deleting one is a soft delete (deleted_at), so old
-- deliveries keep showing its name and URL (docs/api.md, DELETE /v1/endpoints/:id).
-- The signing secret is stored in plain text on purpose: signing with HMAC needs the secret
-- itself, so it can't be hashed like an API key. Backups are protected instead (Phase 6).
CREATE TABLE endpoints (
id uuid PRIMARY KEY DEFAULT uuidv7(),
owner_id uuid NOT NULL REFERENCES owners ON DELETE CASCADE,
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 100),
url text NOT NULL CHECK (length(url) <= 2048),
signing_secret text NOT NULL, -- 'whsec_' + base64(32 random bytes)
event_types text[] NOT NULL DEFAULT '{*}' CHECK (cardinality(event_types) BETWEEN 1 AND 20),
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- An owner's endpoints that aren't deleted (the fan-out and the endpoint list read these).
CREATE INDEX endpoints_owner_live ON endpoints (owner_id) WHERE deleted_at IS NULL;

-- An event a producer sent (POST /v1/events). The same Idempotency-Key from the same owner
-- can't be stored twice; events without a key (NULL) never collide, because NULLs are never
-- equal in a UNIQUE constraint.
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT uuidv7(),
owner_id uuid NOT NULL REFERENCES owners ON DELETE CASCADE,
type text NOT NULL CHECK (type ~ '^[a-z0-9][a-z0-9_.-]{0,99}$'),
payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'),
idempotency_key text CHECK (length(idempotency_key) <= 255),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT events_owner_idempotency_key UNIQUE (owner_id, idempotency_key)
);

-- One event to one endpoint: the queue. Workers claim due rows with FOR UPDATE SKIP LOCKED and
-- a lease (Phase 4). While a row is leased, next_attempt_at is the lease's expiry and lease_id
-- is the fencing token: a result is only saved by the worker that still holds the lease.
CREATE TABLE deliveries (
id uuid PRIMARY KEY DEFAULT uuidv7(),
owner_id uuid NOT NULL REFERENCES owners ON DELETE CASCADE,
event_id uuid NOT NULL REFERENCES events ON DELETE CASCADE,
endpoint_id uuid NOT NULL REFERENCES endpoints ON DELETE CASCADE,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivered', 'dead')),
attempts int NOT NULL DEFAULT 0, -- tries in the current cycle (replay resets it)
next_attempt_at timestamptz NOT NULL DEFAULT now(),
lease_id uuid,
last_status_code int,
last_error text,
delivered_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT deliveries_event_endpoint UNIQUE (event_id, endpoint_id)
);
-- The worker's claim query: "pending rows that are due, oldest first". Partial: only pending
-- rows are in it, so it stays small while delivered and dead rows pile up.
CREATE INDEX deliveries_due ON deliveries (next_attempt_at) WHERE status = 'pending';
-- The delivery list (newest first, keyset paging on id), with and without the filters.
CREATE INDEX deliveries_owner_id ON deliveries (owner_id, id DESC);
CREATE INDEX deliveries_owner_status_id ON deliveries (owner_id, status, id DESC);
CREATE INDEX deliveries_endpoint_id ON deliveries (endpoint_id, id DESC);

-- Every try at a delivery, kept forever (the dashboard's attempt timeline). The attempt number
-- is worked out when read (row_number()), so no counter has to be kept in step.
CREATE TABLE attempts (
id uuid PRIMARY KEY DEFAULT uuidv7(),
delivery_id uuid NOT NULL REFERENCES deliveries ON DELETE CASCADE,
started_at timestamptz NOT NULL,
duration_ms int NOT NULL,
status_code int,
error text, -- timeout | connect_failed | blocked_address | redirect | tls
response_excerpt text -- the first 1 KB of the receiver's answer
);
CREATE INDEX attempts_delivery_id ON attempts (delivery_id, id);

-- migrate:down
-- Forward-only, on purpose (ADR 0006). "dbmate rollback" would run this section and then delete
-- this migration's row in schema_migrations; an empty section would leave every table in place
-- while marking it "not applied". Raising an error rolls the whole rollback back instead.
-- To start over on a laptop, throw the throwaway database away.
DO $$ BEGIN RAISE EXCEPTION 'forward-only: migrations are never rolled back; to reset, recreate the throwaway database'; END $$;
4 changes: 3 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
"dev": "bun --watch src/server.ts",
"start": "bun src/server.ts",
"test": "bun test",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"db:migrate": "dbmate --wait --wait-timeout 60s --no-dump-schema --migration-lock --migration-lock-timeout 30s migrate"
},
"dependencies": {
"dbmate": "2.37.0",
"fastify": "5.12.7",
"zod": "4.6.7"
},
Expand Down
4 changes: 2 additions & 2 deletions docs/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Chas Academy's "Avancerad fullstackutveckling, test och DevOps".
**Data model v1** (Postgres 18.6, every id is a UUIDv7):
`owners` (id, name) · `api_keys` (owner_id, prefix, key_hash = SHA-256 UNIQUE, revoked_at) ·
`endpoints` (owner_id, name, url, signing_secret `whsec_…`, event_types[] default `{*}`,
disabled_at) · `events` (owner_id, type, payload jsonb object, idempotency_key; UNIQUE (owner_id,
deleted_at = soft delete) · `events` (owner_id, type, payload jsonb object, idempotency_key; UNIQUE (owner_id,
idempotency_key)) · `deliveries` (owner_id, event_id, endpoint_id, status pending|delivered|dead,
attempts, next_attempt_at, lease_id, last_status_code, last_error, delivered_at; UNIQUE (event_id,
endpoint_id); partial INDEX (next_attempt_at) WHERE status = 'pending') · `attempts`
Expand All @@ -96,7 +96,7 @@ Both the API and the dashboard follow it; changes to it go through a PR first.
| Web framework | **Fastify 5** ([decision 0001](decisions/0001-web-framework-fastify.md)): `buildApp()` + `listen({ host: "0.0.0.0" })`, pino logs, Zod validation, `@fastify/cors`, `inject()` tests | Hono (native on Bun, but new to us), Express, Elysia |
| Database library | postgres.js behind one file `src/db.ts` | `Bun.sql` (ties the data layer to the new runtime), Drizzle |
| API code structure | **Zod 4** for bodies, queries and env vars ([decision 0003](decisions/0003-api-code-structure.md)); layers routes → services → repositories; one Fastify error handler with one error shape; small files, one job each | Fastify JSON Schema, `fastify-type-provider-zod`, logic in route handlers |
| Migrations | dbmate (plain SQL), run by a Kubernetes Job before each rollout. Expand/contract rule | drizzle-kit, an initContainer (would rerun on every scale-up) |
| Migrations | dbmate (plain SQL, `api/db/migrations/`, inside the API image), run by the migrate Job (`k8s/migrate/`) that `relay-deploy` creates before each rollout. Forward-only, expand/contract ([decision 0006](decisions/0006-migrations-expand-contract.md)) | drizzle-kit, an initContainer (would rerun on every scale-up), `down` migrations on rollback |
| Unit + integration tests | `bun test`; integration against a real Postgres (Docker locally, service container in GitHub Actions) | Testcontainers (no official Bun support), mocked DB |
| Dashboard | **Vite + React + TypeScript, Tailwind CSS v4, React Router, axios** (one client in `src/api.ts`), plain React state with a small refresh loop while a delivery is pending, served by nginx ([decision 0002](decisions/0002-dashboard-stack.md)) | Next.js, plain CSS, `fetch`, TanStack Query |
| E2E tests | Playwright, runs on Node in the dashboard folder | Cypress |
Expand Down
Loading