Task 1.1 — Project scaffold

Phase 1 task 1.1 lands. Directus 11.17.4 boots locally end-to-end
against a TimescaleDB+PostGIS container; admin UI serves at :8055,
admin bootstrap from env vars works, named volumes preserve data
across down/up cycles.

Scaffold:
- Dockerfile — FROM directus/directus:11.17.4. Pre-installs
  postgresql16-client (ahead of task 1.2's db-init runner needing psql).
  Bakes in /directus/snapshots, /directus/db-init, /directus/scripts,
  /directus/extensions, /directus/entrypoint.sh.
- compose.dev.yaml — db (timescale/timescaledb-ha:pg16.6-ts2.17.2-all)
  + directus (local build), healthchecks, named volumes
  directus-pg-data + directus-uploads.
- entrypoint.sh — placeholder using upstream's actual flow
  (node cli.js bootstrap && pm2-runtime start ecosystem.config.cjs);
  the real db-init -> schema apply -> start wrapper lands in task 1.7.
- package.json — scripts-only (dev, dev:down, dev:reset,
  schema:snapshot, schema:apply, db:init), no runtime deps.
- .env.example — sectioned, fully documented, KEY/SECRET marked
  required with generation hints.
- .gitignore, .dockerignore — match the processor service conventions.
- snapshots/, db-init/, scripts/, extensions/ — empty with .gitkeep,
  filled by later Phase 1 tasks (1.3, 1.6) and Phase 5.

Lessons locked in (against the empirical pnpm dev boot):
- timescale/timescaledb-ha:pg16-latest does NOT exist on Docker Hub.
  Pin a concrete version (we used pg16.6-ts2.17.2-all).
- This image's data directory is /home/postgres/pgdata/data, not
  /pgdata or /var/lib/postgresql/data. PGDATA env var and the volume
  mount must both target it.
- The -all variant bundles PostGIS binaries but the extension is not
  auto-created on the directus database; CREATE EXTENSION lands in
  Phase 2 alongside the geofences/SLZs/waypoints collections.
- The upstream image's CMD is bootstrap + pm2-runtime, not a simple
  cli.js start. Bypassing pm2 would lose crash recovery.

These corrections folded into 01-project-scaffold.md (deliverable line
+ Done section), 08-gitea-ci-dryrun.md (CI service tag), and the
inline comments in compose.dev.yaml so future implementers don't
re-discover them.

Status: ROADMAP marks 1.1 done, Phase 1 in progress, 1.2 next.
This commit is contained in:
2026-05-01 21:29:00 +02:00
parent a8e808e71c
commit 387c3c4cfa
16 changed files with 389 additions and 8 deletions
+23
View File
@@ -0,0 +1,23 @@
# Files and directories excluded from the Docker build context.
# Keep this tight — a large context slows every `docker build`.
.git/
.planning/
node_modules/
directus-data/
# Secrets — never bake these into the image
.env
.env.local
.env.*
# Compose files are runtime config, not part of the image
compose.dev.yaml
# Markdown docs (README.md is excluded too — the image doesn't need it)
*.md
# pnpm lockfile and package.json are not needed inside the Directus image
# (the upstream image manages its own Node environment).
package.json
pnpm-lock.yaml
+98
View File
@@ -0,0 +1,98 @@
# Environment variables for the TRM directus service.
# Copy to .env and fill in values for local development.
# cp .env.example .env
#
# Required vars: DB_*, KEY, SECRET, ADMIN_EMAIL, ADMIN_PASSWORD, PUBLIC_URL.
# .env is gitignored — never commit real credentials.
# ---------------------------------------------------------------------------
# Database connection — Postgres 16 + TimescaleDB + PostGIS
# ---------------------------------------------------------------------------
# Directus DB driver. Always "pg" for this service.
DB_CLIENT=pg
# Hostname of the Postgres container (matches the compose service name when
# running via compose.dev.yaml; change to your host/IP for external Postgres).
DB_HOST=db
# Postgres port.
DB_PORT=5432
# Database name.
DB_DATABASE=directus
# Postgres user.
DB_USER=directus
# Postgres password.
DB_PASSWORD=directus
# ---------------------------------------------------------------------------
# Instance security — REQUIRED; generate fresh values for each environment.
#
# KEY: uuidgen (or openssl rand -hex 32)
# SECRET: openssl rand -hex 64
#
# IMPORTANT: two instances sharing the same KEY/SECRET will produce
# colliding JWT tokens. Use distinct values per environment.
# ---------------------------------------------------------------------------
KEY=replace-with-a-random-uuid
SECRET=replace-with-a-long-random-string
# ---------------------------------------------------------------------------
# Admin bootstrap
#
# Applied on first boot when the users table is empty. If the instance has
# already been initialised these values are ignored — change the password via
# the admin UI or Directus CLI instead.
# ---------------------------------------------------------------------------
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change-me-on-first-boot
# ---------------------------------------------------------------------------
# Public URL
#
# Used in password-reset emails, OAuth redirect URIs, and the Directus admin
# UI's "share" links. Set to the externally reachable URL in staging/prod.
# ---------------------------------------------------------------------------
PUBLIC_URL=http://localhost:8055
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
# Log level: fatal | error | warn | info | debug | trace
LOG_LEVEL=info
# Log format: pretty (human-readable) | json (structured, for log aggregators)
LOG_STYLE=pretty
# ---------------------------------------------------------------------------
# Cache (optional — disabled by default for local dev)
# ---------------------------------------------------------------------------
# Set to true to enable Directus's built-in response cache.
# Requires a cache store (Redis or memory) when enabled.
CACHE_ENABLED=false
# ---------------------------------------------------------------------------
# CORS (optional — disabled by default)
# ---------------------------------------------------------------------------
# Set to true to enable CORS headers.
CORS_ENABLED=false
# Allowed origin(s). Accepts a URL string, comma-separated list, or "true"
# to reflect any origin (development only — never use "true" in production).
# CORS_ORIGIN=http://localhost:3000
# ---------------------------------------------------------------------------
# WebSockets (enabled by default — required for the SPA live channel)
# ---------------------------------------------------------------------------
# Set to false only when the SPA is not connected (e.g. API-only deployments).
WEBSOCKETS_ENABLED=true
+9
View File
@@ -0,0 +1,9 @@
node_modules/
.env
.env.local
*.log
# Local Postgres data directory (only present if using a host bind mount
# instead of a named volume — the committed compose.dev.yaml uses named
# volumes, but a developer might override this locally).
directus-data/
+2 -2
View File
@@ -42,14 +42,14 @@ These rules govern every task. Any deviation must be discussed and documented as
### Phase 1 — Slice 1 schema + deploy pipeline
**Status:** ⬜ Not started
**Status:** 🟨 In progress (1.1 done; 1.2 next)
**Outcome:** A Directus instance with the org-level catalog (orgs, users, organization_users, vehicles, devices and their org junctions) and event-participation collections (events, classes, entries, entry_crew, entry_devices) live and snapshot-tracked. `db-init/` covers the TimescaleDB extension, the `positions` hypertable, and the `faulty` column. Image builds via Gitea Actions with a CI dry-run that catches snapshot drift before deploy. Rally Albania 2026 is registered as the first event in admin UI to dogfood the registration workflow. **This is what Rally Albania 2026 needs.**
[**See `phase-1-slice-1-schema/README.md`**](./phase-1-slice-1-schema/README.md)
| # | Task | Status | Landed in |
|---|------|--------|-----------|
| 1.1 | [Project scaffold](./phase-1-slice-1-schema/01-project-scaffold.md) | | |
| 1.1 | [Project scaffold](./phase-1-slice-1-schema/01-project-scaffold.md) | 🟩 | pending user commit |
| 1.2 | [db-init runner script](./phase-1-slice-1-schema/02-db-init-runner.md) | ⬜ | — |
| 1.3 | [Initial migrations (extensions, positions hypertable, faulty column)](./phase-1-slice-1-schema/03-initial-migrations.md) | ⬜ | — |
| 1.4 | [Org-level catalog collections](./phase-1-slice-1-schema/04-org-catalog-collections.md) | ⬜ | — |
@@ -1,7 +1,7 @@
# Task 1.1 — Project scaffold
**Phase:** 1 — Slice 1 schema + deploy pipeline
**Status:** ⬜ Not started
**Status:** 🟩 Done
**Depends on:** None
**Wiki refs:** `docs/wiki/entities/directus.md`, `docs/wiki/synthesis/directus-schema-draft.md`
@@ -13,7 +13,7 @@ Initialize the `directus/` service folder with the directory layout from the Pha
- `directus/Dockerfile``FROM directus/directus:11.x`, copies `snapshots/`, `db-init/`, `scripts/`, `entrypoint.sh`, `extensions/` into the image. Sets `ENTRYPOINT ["/directus/entrypoint.sh"]`. (Concrete entrypoint contents land in task 1.7; for now create a placeholder that just `exec`s the upstream entrypoint.)
- `directus/compose.dev.yaml` — two services:
- `db`: `timescale/timescaledb-ha:pg16-latest` (or equivalent), volume-mapped Postgres data dir, healthcheck.
- `db`: `timescale/timescaledb-ha:pg16.6-ts2.17.2-all` (the `-all` variant bundles PostGIS binaries; the `:pg16-latest` floating tag does NOT exist on Docker Hub — pin a concrete TS+PG version). Volume-mapped Postgres data dir, healthcheck.
- `directus`: built from local `Dockerfile`, depends on `db` healthy, env vars for DB connection + `KEY` + `SECRET` + admin bootstrap, port `8055` exposed.
- `directus/package.json` — minimal, only for npm scripts (no runtime deps). Scripts:
- `schema:snapshot``bash scripts/schema-snapshot.sh` (script body lands in 1.6)
@@ -58,4 +58,22 @@ Initialize the `directus/` service folder with the directory layout from the Pha
## Done
(Fill in commit SHA + one-line note when this lands.)
Pending commit by user. All deliverables created in the same working tree pass.
**Open question resolutions (corrected against live `pnpm dev` boot 2026-05-01):**
- **TimescaleDB-HA image tag.** `:pg16-latest` does **not** exist on Docker Hub (the agent's initial pin failed at pull time). The empirically-verified tag is `timescale/timescaledb-ha:pg16.6-ts2.17.2-all`. The `-all` suffix bundles PostGIS binaries.
- **PGDATA path.** Not `/pgdata` (the agent's first guess); the actual data directory baked into this image is `/home/postgres/pgdata/data`. `PGDATA: /pgdata` plus a volume mount to `/pgdata` produced "could not change permissions of directory" errors at initdb. Fixed by setting both `PGDATA` and the volume target to `/home/postgres/pgdata/data`.
- **PostGIS extension.** Binaries are bundled in the `-all` image but the extension is **not** auto-created on the `directus` database. Directus boot logs warn: `PostGIS isn't installed. Geometry type support will be limited.` Resolution: `CREATE EXTENSION IF NOT EXISTS postgis;` lands in db-init when geometry types are needed (Phase 2). Phase 1 has no geometry columns so the warning is benign.
- **Directus version pin**: `directus/directus:11.17.4` confirmed to exist on Docker Hub. Used as the image pin.
**Deviations from task spec:**
1. `entrypoint.sh` delegates to `node cli.js bootstrap && pm2-runtime start ecosystem.config.cjs` (the upstream image's actual CMD) rather than `exec /directus/cli.js start`. The upstream image uses pm2-runtime to manage the process; bypassing it would skip crash recovery and signal handling that pm2 provides. The `bootstrap` step is idempotent (safe to run every boot) and handles admin user creation.
2. `compose.dev.yaml` sets `PGDATA: /home/postgres/pgdata/data` and mounts the named volume to the same path. Required by the `timescaledb-ha:*-all` image; mounting elsewhere fails initdb.
3. `Dockerfile` installs `postgresql16-client` via apk so that `scripts/apply-db-init.sh` (task 1.2) can invoke `psql` without adding that dependency later.
4. `README.md` updated: pinned `11.x``11.17.4`; CI section notes workflow file is pending (task 1.8).
**Live boot acceptance (2026-05-01):**
`pnpm dev` against fresh volumes succeeded: db became healthy, Directus ran 60+ system migrations, PM2 cluster started, server bound at `http://0.0.0.0:8055`, GraphQL Subscriptions and WebSocket Server started. Admin bootstrap (creating first admin role + admin user) completed. One benign warning ("PostGIS isn't installed" — see Open question resolutions above). One harmless migration warning about a non-existent constraint on `directus_comments_collection_foreign` (a Directus internal migration's expected idempotent guard, not caused by this task). All Phase 1 task 1.1 acceptance criteria met.
@@ -33,7 +33,7 @@ Build a Gitea Actions workflow that on push to `main` (when relevant paths chang
runs-on: ubuntu-22.04
services:
postgres:
image: timescale/timescaledb-ha:pg16-latest
image: timescale/timescaledb-ha:pg16.6-ts2.17.2-all # match compose.dev.yaml; :pg16-latest does NOT exist on Docker Hub
env:
POSTGRES_USER: directus
POSTGRES_PASSWORD: directus
+45
View File
@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1.7
#
# TRM directus service image.
#
# Single-stage build for Phase 1. A multi-stage build (with a Node builder for
# extensions) lands in Phase 5 when TypeScript extensions are introduced.
#
# Artifacts baked into the image at build time:
# /directus/snapshots/ — schema.yaml (generated; empty placeholder in Phase 1)
# /directus/db-init/ — numbered SQL migration files (Phase 1 task 1.3 fills these)
# /directus/scripts/ — shell helpers (Phase 1 tasks 1.2, 1.6 fill these)
# /directus/extensions/ — TypeScript extensions (Phase 5)
# /directus/entrypoint.sh — boot wrapper (real flow lands in Phase 1 task 1.7)
#
# No bind mounts of these directories in compose.dev.yaml — the image is the
# source of truth. Reproducible across local, CI, and production environments.
FROM directus/directus:11.17.4
# Switch to root only for the setup steps; Directus's upstream image already
# drops to a non-root user — we preserve that for runtime.
USER root
# Install postgresql-client so scripts/apply-db-init.sh can use psql.
# Phase 1 task 1.2 writes the runner; we pre-install the dependency now so
# the image build never needs internet access at runtime.
RUN apk add --no-cache postgresql16-client
# ---- Copy baked-in artifacts ----
# Each COPY is conditional on the directory existing at build time.
# .gitkeep files ensure the directories always exist so COPY never fails.
COPY snapshots/ /directus/snapshots/
COPY db-init/ /directus/db-init/
COPY scripts/ /directus/scripts/
COPY extensions/ /directus/extensions/
COPY entrypoint.sh /directus/entrypoint.sh
# Ensure the entrypoint is executable inside the image regardless of the host
# filesystem's permission bits.
RUN chmod +x /directus/entrypoint.sh
# Drop back to the non-root user the upstream image uses.
USER node
ENTRYPOINT ["/directus/entrypoint.sh"]
+4 -2
View File
@@ -21,7 +21,7 @@ Apply order at boot: **db-init first, then `directus schema apply`, then `direct
## Quick start (local)
**Prerequisites:** Docker, the `directus/directus:11.x` image (pulled by compose), a running Postgres 16 + TimescaleDB + PostGIS instance.
**Prerequisites:** Docker, the `directus/directus:11.17.4` image (pulled automatically by compose), a running Postgres 16 + TimescaleDB + PostGIS instance (provided by `compose.dev.yaml`).
```bash
git clone <repo-url>
@@ -86,7 +86,9 @@ All other Directus envs (cache, logging, CORS, etc.) follow upstream defaults un
## CI behavior
Gitea Actions workflow is at `.gitea/workflows/build.yml`.
Gitea Actions workflow lands at `.gitea/workflows/build.yml` in Phase 1 task 1.8 — not yet present.
When the workflow exists:
- **Push to `main`** (only when `snapshots/`, `db-init/`, `extensions/`, `Dockerfile`, or the workflow file itself changes): builds the image, spins up a throwaway Postgres + TimescaleDB + PostGIS via `services:`, runs `apply-db-init.sh` and `directus schema apply --yes` against it as a **dry-run**, then publishes the image tagged `:main` if the dry-run exits 0. Auto-deploys to stage if a Portainer webhook is configured via `secrets.PORTAINER_WEBHOOK_URL`.
- **Manual trigger** (`workflow_dispatch`): same flow, run on demand.
+120
View File
@@ -0,0 +1,120 @@
# Local development compose — builds the Directus image from this repo's source
# tree and runs it alongside a TimescaleDB container.
#
# Use this file to verify Dockerfile changes, db-init migrations, and snapshot
# apply behaviour locally before pushing. For day-to-day admin UI work you can
# run `docker compose -f compose.dev.yaml up --build` and hit localhost:8055.
#
# For STAGE and PRODUCTION deployment, use the multi-service compose in the
# sibling `deploy/` repo (https://git.dev.microservices.al/trm/deploy), which
# references this service by its registry image tag instead of building locally.
#
# Usage:
# docker compose -f compose.dev.yaml up --build
# docker compose -f compose.dev.yaml down # preserves volumes
# docker compose -f compose.dev.yaml down -v # wipes volumes (clean slate)
#
# See also: pnpm dev | pnpm dev:down | pnpm dev:reset
name: directus-dev
services:
# ---------------------------------------------------------------------------
# db — PostgreSQL 16 + TimescaleDB + PostGIS (via timescaledb-ha)
#
# The "-all" suffix variant bundles all extensions (TimescaleDB, PostGIS,
# postgis_raster, etc.). Note: the binaries are present in the image, but
# extensions still need `CREATE EXTENSION` on each database that uses them
# — that's what db-init/*.sql does (Phase 1 task 1.3 + Phase 2).
#
# Pin a specific TS+PG version (not :pg16-latest, which doesn't exist on
# Docker Hub). Bump the pin explicitly when upgrading.
#
# PGDATA: this image initialises Postgres at /home/postgres/pgdata/data,
# not the upstream-standard /var/lib/postgresql/data and not /pgdata.
# The volume mount must match exactly or initdb fails with permission errors.
# ---------------------------------------------------------------------------
db:
image: timescale/timescaledb-ha:pg16.6-ts2.17.2-all
environment:
POSTGRES_USER: ${DB_USER:-directus}
POSTGRES_PASSWORD: ${DB_PASSWORD:-directus}
POSTGRES_DB: ${DB_DATABASE:-directus}
PGDATA: /home/postgres/pgdata/data
volumes:
- directus-pg-data:/home/postgres/pgdata/data
expose:
- '5432'
restart: unless-stopped
healthcheck:
# pg_isready reads PGUSER / PGDATABASE from env; compose sets those from
# the environment block above, so no hard-coding is needed here.
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
interval: 10s
timeout: 5s
start_period: 30s
retries: 5
# ---------------------------------------------------------------------------
# directus — built from local Dockerfile
#
# All env vars are read from .env (copy .env.example → .env to get started).
# The service waits for db to pass its healthcheck before starting.
# ---------------------------------------------------------------------------
directus:
build:
context: .
dockerfile: Dockerfile
depends_on:
db:
condition: service_healthy
ports:
- '8055:8055'
environment:
# Database connection
DB_CLIENT: ${DB_CLIENT:-pg}
DB_HOST: db
DB_PORT: ${DB_PORT:-5432}
DB_DATABASE: ${DB_DATABASE:-directus}
DB_USER: ${DB_USER:-directus}
DB_PASSWORD: ${DB_PASSWORD:-directus}
# Instance security — REQUIRED; set non-placeholder values in .env
KEY: ${KEY}
SECRET: ${SECRET}
# Admin bootstrap — only applied on first init when the users table is empty
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
# Public URL used in emails and OAuth redirects
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8055}
# Logging
LOG_LEVEL: ${LOG_LEVEL:-info}
LOG_STYLE: ${LOG_STYLE:-pretty}
# Optional: cache, CORS, WebSockets (inherit from .env if set)
CACHE_ENABLED: ${CACHE_ENABLED:-false}
CORS_ENABLED: ${CORS_ENABLED:-false}
CORS_ORIGIN: ${CORS_ORIGIN:-false}
WEBSOCKETS_ENABLED: ${WEBSOCKETS_ENABLED:-true}
volumes:
# Persist Directus file uploads across container restarts.
# No uploads use-case in Phase 1, but the volume is cheap to declare now.
- directus-uploads:/directus/uploads
restart: unless-stopped
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://localhost:8055/server/health || exit 1']
interval: 30s
timeout: 10s
start_period: 60s
retries: 3
volumes:
# Named volume so `down` preserves data and `down -v` wipes it.
directus-pg-data:
directus-uploads:
+6
View File
@@ -0,0 +1,6 @@
# Numbered SQL migration files land here in Phase 1 task 1.3:
# 001_extensions.sql — CREATE EXTENSION timescaledb
# 002_positions_hypertable.sql
# 003_faulty_column.sql
# PostGIS extension (Phase 2) will be added as 004_postgis.sql.
# Files are applied in numeric order by scripts/apply-db-init.sh.
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# TRM directus — image entrypoint (placeholder).
#
# Real flow (db-init runner → directus schema apply --yes → directus start)
# lands in Phase 1 task 1.7. Until then, this script replicates the upstream
# Directus image CMD so the container boots normally during tasks 1.4 and 1.5
# (admin UI schema work).
#
# Upstream CMD (from directus/directus:11.17.4 Dockerfile):
# node cli.js bootstrap && pm2-runtime start ecosystem.config.cjs
#
# bootstrap: idempotent — initialises the DB schema on first run, reads
# ADMIN_EMAIL / ADMIN_PASSWORD to create the initial admin user.
# pm2-runtime: starts the Directus process under PM2 so the container stays
# alive and restarts on crash without an outer supervisor.
#
# Exit codes are propagated: any non-zero exit causes the container to exit
# with that code, which compose reports as an error.
set -e
node /directus/cli.js bootstrap
exec pm2-runtime start /directus/ecosystem.config.cjs
+3
View File
@@ -0,0 +1,3 @@
# TypeScript extensions (hooks, endpoints, operations) land here in Phase 5.
# Each extension is a subdirectory with its own package.json and dist/ build output.
# The Dockerfile COPYs this directory into /directus/extensions/ inside the image.
+18
View File
@@ -0,0 +1,18 @@
{
"name": "directus",
"version": "0.1.0",
"description": "TRM business-plane: Directus 11 instance owning the relational schema, APIs, and admin UI",
"private": true,
"engines": {
"node": ">=22",
"pnpm": ">=9"
},
"scripts": {
"dev": "docker compose -f compose.dev.yaml up --build",
"dev:down": "docker compose -f compose.dev.yaml down",
"dev:reset": "docker compose -f compose.dev.yaml down -v && docker compose -f compose.dev.yaml up --build",
"schema:snapshot": "bash scripts/schema-snapshot.sh",
"schema:apply": "bash scripts/schema-apply.sh",
"db:init": "bash scripts/apply-db-init.sh"
}
}
+9
View File
@@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}
+4
View File
@@ -0,0 +1,4 @@
# Shell scripts land here across Phase 1:
# apply-db-init.sh — numeric-order, guard-table-protected runner (task 1.2)
# schema-snapshot.sh — wraps `directus schema snapshot --yes` (task 1.6)
# schema-apply.sh — wraps `directus schema apply --yes` (task 1.6)
+2
View File
@@ -0,0 +1,2 @@
# schema.yaml is generated here by `pnpm run schema:snapshot` (Phase 1 task 1.6).
# Do not hand-edit schema.yaml — always round-trip through the Directus admin UI.