Skip to content

Commit 2f858d8

Browse files
Merge pull request #12 from crydensync/feat/tier7-distribution
Feat/tier7 distribution
2 parents 1ddabf1 + 607535e commit 2f858d8

13 files changed

Lines changed: 1261 additions & 52 deletions

File tree

.dockerignore

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Everything the build stage does not need. The image copies the whole
2+
# source tree (it has to — Go builds from source), so this is what keeps a
3+
# developer's local state out of it.
4+
.git
5+
.github
6+
*.md
7+
docs
8+
.env
9+
.env.*
10+
# A local SQLite database is the one thing that must never end up baked
11+
# into an image: it is a deployment's live data, and a stale copy in a
12+
# layer is both a leak and a confusing bug.
13+
*.db
14+
*.db-wal
15+
*.db-shm
16+
api
17+
api_server

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ PORT=8080
1010
ACCESS_TOKEN_TTL_MINUTES=15
1111
BASE_URL=http://localhost:8080
1212

13+
# Migrations run automatically on boot, so there is no migrate step in the
14+
# setup instructions. Set this to true if you would rather schema changes
15+
# be a reviewed step of their own — then `api migrate` is that step, and
16+
# the server starts without touching the schema. See README's "Migrations".
17+
# If your database already has this schema but no schema_migrations table
18+
# (i.e. you applied the SQL by hand), run `api migrate --baseline` once
19+
# before your first start with this setting either way.
20+
SKIP_AUTO_MIGRATE=false
21+
1322
# OAuth providers are optional; one missing its ID or secret is simply
1423
# unavailable (404 oauth_provider_not_configured), not a startup error.
1524
GOOGLE_CLIENT_ID=

.github/workflows/ci.yml

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,20 @@ jobs:
4646
exit 1
4747
fi
4848
49-
- name: Install postgresql-client
50-
run: sudo apt-get update && sudo apt-get install -y postgresql-client
49+
- name: Run tests
50+
run: go test ./...
5151

52-
- name: Run migrations
53-
run: |
54-
for f in migrations/*.up.sql; do
55-
psql "$DATABASE_URL" -f "$f"
56-
done
57-
env:
58-
DATABASE_URL: postgres://api:api_test@localhost:5432/api_test?sslmode=disable
59-
60-
- name: Start server
52+
# The postgresql-client install and the psql loop that used to run
53+
# here are both gone on purpose. The server migrates itself on boot
54+
# now, so this step is the test: the database below is empty when
55+
# the server starts, and everything after this point depends on the
56+
# runner having applied all fourteen migrations correctly.
57+
#
58+
# That is a stronger check than the psql loop was. Piping files
59+
# through psql proved the SQL was valid; it could not have caught a
60+
# runner that applied them in the wrong order, twice, or not at all
61+
# — which is exactly what this repo had never verified.
62+
- name: Start server (which migrates the empty database on boot)
6163
run: |
6264
go build -o api_server .
6365
./api_server &
@@ -70,3 +72,13 @@ jobs:
7072

7173
- name: Run smoke test against the live server
7274
run: cd internal/smoketest && go run . http://localhost:8080
75+
76+
# `api migrate` on an already-migrated database is the no-op that
77+
# every boot of every existing deployment will perform, so it is
78+
# worth one step of its own rather than being implied by the above.
79+
- name: Migrate is a no-op on an up-to-date database
80+
run: ./api_server migrate
81+
env:
82+
DATABASE_URL: postgres://api:api_test@localhost:5432/api_test?sslmode=disable
83+
JWT_SECRET: ci-test-secret-not-for-prod
84+
CORS_ORIGINS: http://localhost:5173

.github/workflows/release.yml

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ on:
77

88
permissions:
99
contents: write
10+
# packages: write is what lets the image below be pushed to ghcr.io with
11+
# the workflow's own GITHUB_TOKEN. Without it the push is a 403, and
12+
# with it no extra repository secret is needed to publish an image —
13+
# which matters because a registry credential someone has to remember to
14+
# rotate is one more thing that can quietly expire.
15+
packages: write
1016

1117
jobs:
1218
release:
@@ -43,17 +49,12 @@ jobs:
4349
- name: Vet
4450
run: go vet ./...
4551

46-
- name: Install postgresql-client
47-
run: sudo apt-get update && sudo apt-get install -y postgresql-client
48-
49-
- name: Run migrations
50-
run: |
51-
for f in migrations/*.up.sql; do
52-
psql "$DATABASE_URL" -f "$f"
53-
done
54-
env:
55-
DATABASE_URL: postgres://api:api_test@localhost:5432/api_test?sslmode=disable
56-
52+
# The psql step that used to live here is gone on purpose. The
53+
# server now migrates itself on boot, so the smoke test below is
54+
# what proves the migration runner works — against a completely
55+
# empty database, which is the only way a fresh deployment ever
56+
# sees it. Keeping the psql step would have applied the schema by
57+
# hand and left the runner on the happy path untested.
5758
- name: Start server and run smoke test
5859
run: |
5960
go build -o api_server .
@@ -66,10 +67,44 @@ jobs:
6667
CORS_ORIGINS: http://localhost:5173
6768
PORT: 8080
6869

70+
# Five platforms, all CGO_ENABLED=0. windows/amd64 is built from
71+
# ubuntu because nothing here needs a host toolchain — see the
72+
# Dockerfile's note on why that is possible.
73+
- name: Cross-compile release binaries
74+
run: |
75+
set -eu
76+
mkdir -p dist
77+
for target in \
78+
linux/amd64 linux/arm64 \
79+
darwin/amd64 darwin/arm64 \
80+
windows/amd64
81+
do
82+
GOOS="${target%/*}"
83+
GOARCH="${target#*/}"
84+
name="api_${GOOS}_${GOARCH}"
85+
[ "$GOOS" = windows ] && name="${name}.exe"
86+
echo "building $name"
87+
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \
88+
go build -trimpath -ldflags="-s -w" -o "dist/$name" .
89+
done
90+
cd dist && sha256sum * > SHA256SUMS
91+
92+
- name: Build and push the container image
93+
run: |
94+
set -eu
95+
image="ghcr.io/${GITHUB_REPOSITORY,,}"
96+
echo "${{ secrets.GITHUB_TOKEN }}" \
97+
| docker login ghcr.io -u "${{ github.actor }}" --password-stdin
98+
docker build -t "$image:${GITHUB_REF_NAME}" -t "$image:latest" .
99+
docker push "$image:${GITHUB_REF_NAME}"
100+
docker push "$image:latest"
101+
69102
- name: Create Release
70103
uses: softprops/action-gh-release@v2
71104
with:
72105
generate_release_notes: true
73106
name: Release ${{ github.ref_name }}
107+
files: |
108+
dist/*
74109
env:
75110
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Dockerfile

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# The PostgreSQL migration runner and the SQLite driver this repo uses are
2+
# both pure Go, so the whole thing cross-compiles with CGO_ENABLED=0. That
3+
# is what makes the final image small and what makes the release binaries
4+
# below buildable for five platforms from one runner.
5+
FROM golang:1.25-alpine AS build
6+
7+
WORKDIR /src
8+
9+
# go.mod/go.sum first, so a source-only change does not re-download the
10+
# module graph. This is the whole reason the layer exists.
11+
COPY go.mod go.sum ./
12+
RUN go mod download
13+
14+
COPY . .
15+
16+
# -trimpath keeps the build directory out of the binary; -s -w drop the
17+
# symbol table and DWARF data. Together they are most of the size, and
18+
# nothing here debugs a stripped binary by hand.
19+
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /api .
20+
21+
# alpine rather than scratch or distroless, for one concrete reason: this
22+
# image has to be able to write a SQLite database to a mounted volume, and
23+
# a distroless nonroot image cannot be handed a writable directory without
24+
# a COPY --chown trick that is harder to read than it is worth. alpine
25+
# also brings ca-certificates, which the OAuth providers, the webhook
26+
# sender and the Anthropic API all need.
27+
FROM alpine:3.20
28+
29+
# The CA bundle, and nothing else. No shell, no package manager beyond
30+
# what alpine ships — anything that gets added here is attack surface in a
31+
# container that only ever runs one binary.
32+
RUN apk add --no-cache ca-certificates && \
33+
adduser -D -u 10001 -h /home/api api
34+
35+
COPY --from=build /api /usr/local/bin/api
36+
37+
# A SQLite deployment needs somewhere to put the database that is not the
38+
# container's writable layer, or every `docker compose up --build` is a
39+
# new database. /data is that place; a Postgres deployment ignores it.
40+
RUN mkdir -p /data && chown api:api /data
41+
VOLUME ["/data"]
42+
43+
USER api
44+
45+
# 8080 is config.Load's default for PORT, so an image run with no PORT set
46+
# listens where this line says it does.
47+
EXPOSE 8080
48+
49+
# Exec form, deliberately, and the reason is SIGTERM. Shell form
50+
# (`CMD /usr/local/bin/api`) runs the binary as a child of /bin/sh, which
51+
# means `docker stop` signals the shell and the binary — and its
52+
# in-flight requests — never see it. That would undo the whole point of
53+
# main.go's graceful shutdown, so this line is load-bearing rather than
54+
# stylistic.
55+
ENTRYPOINT ["/usr/local/bin/api"]

README.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,20 @@ cp .env.example .env # fill in DATABASE_URL, JWT_SECRET, CORS_ORIGINS
1818
go run .
1919
```
2020

21-
Run the migrations in `migrations/` against your database first, in order (copies of CrydenSync's own migrations, kept here so this repo is self-contained for local dev and CI — same as `typebook` keeps its own copy). `002_oauth_identities` is required even if you don't use OAuth yet — `NewOAuthStore` is wired into the engine config unconditionally. `004` through `008` are the TOTP, WebAuthn, recovery-code, login-attempt and API-key tables; run them even if you leave `ENCRYPTION_KEY` unset, since `007` is what the engine's credential-stuffing detection reads once Tier 2 wires it up and `008` is what the API-key work will use.
21+
That is the whole setup. Migrations are applied automatically on boot — see [Migrations](#migrations)so there is no separate step to run and nothing to remember after a `git pull`. Or with Docker, which needs no Go toolchain at all:
2222

23-
That paragraph is the Postgres path only. On SQLite there is nothing to run by hand — `main.go` calls cryden's own `sqlite.Migrate` at startup. See [The two backends](#the-two-backends).
23+
```bash
24+
docker run --env-file .env -p 8080:8080 ghcr.io/crydensync/api:latest
25+
```
26+
27+
A SQLite deployment can skip the database server entirely by setting `SQLITE_PATH` and mounting a volume for it:
28+
29+
```bash
30+
docker run --env-file .env -p 8080:8080 -v api-data:/data \
31+
-e SQLITE_PATH=/data/api.db ghcr.io/crydensync/api:latest
32+
```
33+
34+
On SQLite, note that the container runs as a non-root user (uid 10001); a host directory mounted into `/data` must be writable by it.
2435

2536
OAuth is optional. To enable a provider, set its client ID/secret plus `BASE_URL` (used to build the callback URL registered in that provider's console):
2637

@@ -90,6 +101,28 @@ The connection is opened with three pragmas, all of them load-bearing: `foreign_
90101

91102
A clean stop is what closes that window: on `SIGTERM` or `SIGINT` the server stops accepting connections, waits up to 30 seconds for the requests already in flight, stops the webhook worker and digest scheduler, and closes the database — which on SQLite is the checkpoint that folds the `-wal` file back into `api.db` and removes the sidecar files. So `systemctl stop`, `docker stop` and Ctrl-C all leave a `api.db` that is complete on its own. A `kill -9`, a crash or a power loss does not, which is why the paragraph above still stands.
92103

104+
## Migrations
105+
106+
There is no migrate step in the setup instructions because there does not need to be one. Migrations are compiled into the binary and applied on boot, so a `git pull` or a new container image brings its own schema change with it.
107+
108+
The two backends are migrated by different code, and that is deliberate rather than an inconsistency:
109+
110+
- **Postgres** — this repo's own runner (`migrate.go`) over this repo's own `migrations/*.sql`, embedding all fourteen into the binary. It records what it applied in a `schema_migrations` table it creates itself, so a second boot is a no-op.
111+
- **SQLite** — cryden's runner over cryden's embedded migrations, because cryden owns that schema. See [The two backends](#the-two-backends).
112+
113+
Both follow the same rules: `NNN_*.up.sql` in filename order, one transaction per file, and `.down.sql` files are never run automatically — an automatic rollback of a schema holding live credentials is not something a boot path should be able to do by accident.
114+
115+
```
116+
api migrate # apply pending migrations and exit; starts no server
117+
api migrate --baseline # record every embedded migration as already applied
118+
```
119+
120+
`api migrate` is for teams who would rather schema changes be a reviewed step than something that happens during a rolling deploy. Set `SKIP_AUTO_MIGRATE=true` and the server starts without touching the schema; `api migrate` is then the step, in your pipeline, before the new version goes out.
121+
122+
**If your database already has this schema but no `schema_migrations` table** — you applied the SQL by hand, or an earlier version of this repo had CI do it for you via `psql` — then run `api migrate --baseline` once before your first start. Without it, auto-migration starts at `001` and stops on `relation "users" already exists`: a deployment that cannot start, caused by the feature meant to make starting easier. Baseline records every embedded migration as applied and runs none of them.
123+
124+
It is an explicit command rather than something the boot path detects, because the alternative is guessing. "The tracking table is missing but `users` exists, so assume everything ran" is right for the case above and silently wrong for a database that is genuinely half-migrated — it would mark unrun migrations as applied, and the next deploy would look for columns that were never created.
125+
93126
## Second factors
94127

95128
TOTP and passkeys are optional and all-or-nothing on `ENCRYPTION_KEY`: cryden refuses to construct an engine with a TOTP or WebAuthn store set and no encryption key (a TOTP secret must be recoverable in plaintext to check a code, so it is encrypted rather than hashed). With the key unset, both methods answer `404 totp_not_configured` / `404 passkeys_not_configured` per request, the same shape an unconfigured OAuth provider uses, rather than the server refusing to start.

config/config.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type Config struct {
1818
// exclusive — Load refuses both or neither. See UsesSQLite.
1919
DatabaseURL string
2020
SQLitePath string
21+
SkipAutoMigrate bool
2122
JWTSecret string
2223
Port string
2324
CORSOrigins []string
@@ -351,6 +352,7 @@ func Load() (Config, error) {
351352
if cfg.DatabaseURL != "" && cfg.SQLitePath != "" {
352353
return cfg, fmt.Errorf("DATABASE_URL and SQLITE_PATH are mutually exclusive — set one; the admin console's tables are Postgres-only, so a SQLite deployment serves core auth and nothing under /v1/admin")
353354
}
355+
354356
if cfg.JWTSecret == "" {
355357
return cfg, fmt.Errorf("JWT_SECRET is required")
356358
}
@@ -415,12 +417,24 @@ func Load() (Config, error) {
415417
// separate secrets with separate lifetimes.
416418
cfg.SettingsEncryptionKey = os.Getenv("SETTINGS_ENCRYPTION_KEY")
417419

420+
// Declared here rather than just above its first use because the
421+
// auto-migration switch below needs it too, and that one belongs next
422+
// to the rest of what a deployment says about its database.
423+
var err error
424+
425+
// Auto-migration. Defaults to false, so a deployment migrates on boot
426+
// unless it says otherwise; the opt-out is for teams who want schema
427+
// changes to be a reviewed, separate step rather than something that
428+
// happens silently during a rolling deploy. `api migrate` is the step.
429+
if cfg.SkipAutoMigrate, err = envBool("SKIP_AUTO_MIGRATE", false); err != nil {
430+
return cfg, err
431+
}
432+
418433
// Anomaly detection and credential-stuffing detection — one switch,
419434
// because they are one store. Both threshold sets begin as the
420435
// engine's defaults and every knob below only replaces the one it
421436
// names (see the field comments for why that ordering is not
422437
// cosmetic).
423-
var err error
424438
if cfg.AnomalyDetection, err = envBool("ANOMALY_DETECTION", false); err != nil {
425439
return cfg, err
426440
}

0 commit comments

Comments
 (0)