diff --git a/CLAUDE.md b/CLAUDE.md index 757f4cd..8d01b8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,12 +40,18 @@ cli/ │ │ ├── services/ # Core business logic (database, pgschema, dbmate) │ │ ├── utils/ # DB-specific utilities (session, db-config, remotes) │ │ └── types/ # DB module types -│ └── auth/ # Keycloak auth module -│ ├── index.ts # Module registration (registerAuthModule) -│ ├── commands/ # export, import, sync -│ ├── services/ # Keycloak API, Docker importer -│ └── utils/ # Auth-specific config -├── vendor/ # Bundled binaries (pgschema for all platforms) +│ ├── auth/ # Keycloak auth module +│ │ ├── index.ts # Module registration (registerAuthModule) +│ │ ├── commands/ # export, import, sync +│ │ ├── services/ # Keycloak API, Docker importer +│ │ └── utils/ # Auth-specific config +│ └── stack/ # Local backend stack module +│ ├── index.ts # Module registration (registerStackModule) +│ ├── commands/ # up, down, status, logs, restart, keys, realm +│ ├── services/ # compose, db-init, health, keycloak-keys, realm-init, scaffold, sync-providers +│ ├── utils/ # stack-config, stack-state +│ └── types/ # Stack config types +├── vendor/ # Bundled binaries (pgschema for all platforms) + providers/ (Keycloak JARs) ├── dist/ # Build output (generated) ├── package.json └── tsup.config.ts # Build configuration @@ -112,9 +118,12 @@ PostKit files are split between committed (shared with team) and gitignored (use │ ├── session/ # GITIGNORED — temporary in-progress migrations │ ├── committed.json # COMMITTED — migration tracking index (shared) │ └── migrations/ # COMMITTED — committed SQL migrations for deploy (shared) -└── auth/ - ├── raw/ # COMMITTED — auth raw config (shared) - └── realm/ # COMMITTED — auth realm config (shared) +├── auth/ +│ ├── raw/ # COMMITTED — auth raw config (shared) +│ ├── realm/ # COMMITTED — auth realm config (shared) +│ └── providers/ # GITIGNORED — Keycloak JAR providers (copied from vendor + project) +└── stack/ + └── docker-compose.yml # GITIGNORED — generated compose file (ephemeral) ``` **Key paths** (from `modules/db/utils/db-config.ts`): @@ -136,21 +145,86 @@ PostKit files are split between committed (shared with team) and gitignored (use - `resolveApplyTarget(target?)` (`utils/apply-target.ts`) - Resolves `"local"` or `"remote"` apply target; used by infra and seed commands - `readJsonFile(path)` / `writeJsonFile(path, data)` (`utils/json-file.ts`) - Typed JSON helpers used by remotes and committed migration tracking +### Stack Module Architecture + +The `stack` module manages a local backend service stack (Postgres, Keycloak, PostgREST, Traefik) using Docker Compose. + +**Services:** + +| Service | Image | Port | Purpose | +|---------|-------|------|---------| +| `postgres` | `postgres:16-alpine` | 25432 | Database | +| `keycloak` | `quay.io/keycloak/keycloak:26.6` | via Traefik | Auth server | +| `postgrest` | `postgrest/postgrest:latest` | via Traefik | REST API | +| `traefik` | `traefik:v3.3` | 80 / 8080 | Reverse proxy + dashboard | + +**`stack up` startup sequence:** +1. Start `postgres` + `traefik` (Phase 1 infrastructure) +2. Wait for health checks on infrastructure services +3. `applyStackDeploy` — creates `postkit` schema, applies `db/infra/`, committed migrations, seeds (hard failure) +4. Start `keycloak` + `postgrest` (Phase 2 — only after DB is initialized) +5. Wait for health checks on all services +6. If `is_initial=true`: import realm template → fetch JWKs → update PostgREST → mark `is_initial=false` + +**`is_initial` flag** — stored in `postkit.stack_config` table in `postkit` schema: +- `true` (default / missing row) → runs realm import + JWKs fetch on next `stack up` +- `false` → skips realm/JWKs on subsequent starts +- Automatically resets when DB volumes are wiped (`stack down --volumes`) +- Manual reset: `postkit stack realm` or `postkit stack keys` + +**Keycloak providers** (`services/sync-providers.ts`): +- Bundled JARs from `vendor/providers/` are copied to `.postkit/auth/providers/` on `postkit init` +- Project-specific JARs from `auth/providers//target/*.jar` are also synced +- The providers directory is mounted into Keycloak at `/opt/keycloak/providers` + +**Realm template + JWT Role Mapper** (`services/realm-init.ts`): +- Default template scaffolded at `.postkit/auth/realm/postkit.json` +- `cleanRealmTemplate()` strips builtin clients, strips IDs/secrets, injects `JWT_ROLE_MAPPER` (`script-primary-role.js`) into every non-builtin client +- Import uses `keycloak-config-cli` via `docker run --network postkit-net` + +**Key paths** (from `modules/stack/utils/stack-config.ts`): +- `getStackDir()` — `.postkit/stack/` +- `getComposeFilePath()` — `.postkit/stack/docker-compose.yml` +- `getProvidersDir()` — `.postkit/auth/providers/` (from `sync-providers.ts`) + +**Key functions:** +- `getStackConfig()` (`utils/stack-config.ts`) — Loads + validates stack config, resolves defaults, reads JWKs/client secrets from secrets file +- `ensureStackSecrets(config)` (`utils/stack-config.ts`) — Auto-generates missing passwords/JWKs and writes to `postkit.secrets.json` +- `writeComposeFile(config, services)` (`services/compose.ts`) — Generates `.postkit/stack/docker-compose.yml` using project `name` as Docker Compose project name +- `applyStackDeploy(config, spinner)` (`services/db-init.ts`) — Creates `postkit` schema, applies infra/migrations/seeds via connection retry +- `readStackIsInitial(config)` / `setStackInitialized(config)` (`utils/stack-state.ts`) — Read/write `is_initial` flag in `postkit.stack_config` +- `syncKeycloakProviders(spinner?)` (`services/sync-providers.ts`) — Copies JARs from vendor + project into `.postkit/auth/providers/` +- `importRealmTemplate(config, spinner?)` (`services/realm-init.ts`) — Cleans realm JSON and imports via `keycloak-config-cli` container +- `cleanRealmTemplate(raw, realmName)` (`services/realm-init.ts`) — Strips builtins, injects JWT Role Mapper + +**`postkit init` scaffold additions:** +- Prompts for project name → generates `_<8hexchars>`, stored as `name` in `postkit.config.json` +- Creates `db/infra/001_roles.sql` (anon, authenticated, service_role, app_user, authenticator roles) +- Creates `db/infra/002_schemas.sql` (public, auth, storage schemas) +- Copies vendor provider JARs to `.postkit/auth/providers/` +- Scaffolds realm template at `.postkit/auth/realm/postkit.json` + ### Configuration System Config is loaded by `loadPostkitConfig()` from `common/config.ts`, which deep-merges two files: | File | Committed | Purpose | |------|-----------|---------| -| `postkit.config.json` | Yes | Non-sensitive project settings (schema paths, flags) | -| `postkit.secrets.json` | No (gitignored) | Credentials + all remote config (URLs, names, defaults) | +| `postkit.config.json` | Yes | Non-sensitive project settings (schema paths, flags, stack service config) | +| `postkit.secrets.json` | No (gitignored) | Credentials + all remote config (URLs, names, defaults) + stack secrets | **`postkit.config.json` (committed):** ```json { + "name": "myapp_a3f2b1c0", "db": { "schemaPath": "db/schema", - "schema": "public" + "schemas": ["public"], + "infraPath": "db/infra" + }, + "auth": { "configCliImage": "adorsys/keycloak-config-cli:latest-26" }, + "stack": { + "keycloak": { "realmTemplate": ".postkit/auth/realm/postkit.json" } } } ``` @@ -164,6 +238,10 @@ Config is loaded by `loadPostkitConfig()` from `common/config.ts`, which deep-me "dev": { "url": "postgres://...", "default": true, "addedAt": "2024-12-31T10:00:00.000Z" }, "staging": { "url": "postgres://..." } } + }, + "stack": { + "postgres": { "user": "postgres", "password": "" }, + "keycloak": { "adminUser": "admin", "adminPassword": "" } } } ``` @@ -216,6 +294,25 @@ Remotes are managed via utilities in `modules/db/utils/remotes.ts`: | `postkit db seed [--apply]` | Apply seed data | | `postkit db schema add ` | Scaffold schema dirs + update infra + register in config | +## Stack Module Commands Reference + +| Command | Purpose | +|---------|---------| +| `postkit stack up [services...]` | Start full stack (two-phase: infra first, then keycloak+postgrest) | +| `postkit stack up --no-wait` | Start without waiting for health checks | +| `postkit stack up --no-keys` | Start without auto-fetching Keycloak JWKs | +| `postkit stack down` | Stop all services and remove containers | +| `postkit stack down --volumes` | Stop all services and remove containers + volumes | +| `postkit stack status` | Show running service health | +| `postkit stack logs [service]` | Tail logs for all or a specific service | +| `postkit stack logs [service] -f` | Follow log output (default behavior) | +| `postkit stack logs [service] -n ` | Show last N lines (default 100) | +| `postkit stack restart [services...]` | Restart one or more services (validates names) | +| `postkit stack keys` | Fetch Keycloak JWKs + client secrets, update PostgREST | +| `postkit stack keys --restart` | Fetch keys then restart PostgREST | +| `postkit stack keys --clients ` | Fetch keys for specific comma-separated client names | +| `postkit stack realm` | Re-import the Keycloak realm template | + ## Common Patterns ### Command Handler Structure @@ -384,6 +481,7 @@ Skills are invoked via `/` in Claude Code. Agents are sub-processes | `cli/docs/architecture.md` | System architecture, module system, dependency direction | | `cli/docs/db.md` | Database module workflow and commands | | `cli/docs/auth.md` | Auth module workflow and commands | +| `cli/docs/stack.md` | Stack module — services, startup sequence, config, commands | | `cli/docs/e2e-testing.md` | E2E testing guide and infrastructure | ### Skills Registry diff --git a/cli/docs/architecture.md b/cli/docs/architecture.md index 8cfce12..5f9f0db 100644 --- a/cli/docs/architecture.md +++ b/cli/docs/architecture.md @@ -9,16 +9,16 @@ System architecture and design decisions for the PostKit modular CLI toolkit. PostKit is a modular CLI toolkit built with **TypeScript** and **Node.js** that provides developer tools for database migrations and Keycloak auth management. It uses a **plugin module architecture** where each feature is self-contained. ``` -┌─────────────────────────────────────────────────────────┐ -│ postkit (CLI) │ -│ cli/src/index.ts │ -├──────────┬──────────────────────────┬───────────────────┤ -│ init │ db module │ auth module │ -│ command │ (migrations, import) │ (Keycloak sync) │ -├──────────┴──────────────────────────┴───────────────────┤ -│ common layer │ -│ config · logger · shell · types · init-check │ -└─────────────────────────────────────────────────────────┘ +┌───────────────────────────────────────────────────────────────────────┐ +│ postkit (CLI) │ +│ cli/src/index.ts │ +├──────────┬────────────────────┬──────────────────┬────────────────────┤ +│ init │ db module │ auth module │ stack module │ +│ command │ (migrations,import)│ (Keycloak sync) │ (local dev stack) │ +├──────────┴────────────────────┴──────────────────┴────────────────────┤ +│ common layer │ +│ config · logger · shell · types · init-check │ +└───────────────────────────────────────────────────────────────────────┘ ``` --- @@ -94,6 +94,38 @@ Keycloak realm configuration management: `export → clean → import` └──────────┘ └───────┘ └────────┘ ``` +### Stack Module (`postkit stack`) + +**Registration**: `registerStackModule()` in `cli/src/modules/stack/index.ts` +**Docs**: `cli/docs/stack.md` + +Local backend stack management via Docker Compose: `up → (init) → keys → down` + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ stack up (two-phase) │ +├────────────────────────────────────────────────────────────────────┤ +│ Phase 1: postgres + traefik │ +│ └─▶ waitForAllServices (health checks) │ +│ Phase 2: applyStackDeploy (infra SQL + migrations + seeds) │ +│ Phase 3: keycloak + postgrest │ +│ └─▶ waitForAllServices │ +│ Phase 4: if is_initial=true: │ +│ └─▶ importRealmTemplate → fetchAndMergeKeys │ +│ └─▶ writeComposeFile → composeUp (postgrest) │ +│ └─▶ setStackInitialized (is_initial=false) │ +└────────────────────────────────────────────────────────────────────┘ +``` + +**Architectural decisions:** + +- **DB-backed initialization state**: `is_initial` is stored in `postkit.stack_config` table (not a file) so it resets automatically when volumes are wiped with `stack down --volumes`. No manual cleanup needed. +- **Two-phase boot**: `keycloak` and `postgrest` depend on schema/migrations being applied first. Starting them before `applyStackDeploy` completes causes startup failures. The stack module enforces this ordering explicitly rather than relying on Docker Compose `depends_on` health checks alone. +- **Provider sync at init time**: Keycloak provider JARs are copied to `.postkit/auth/providers/` during `postkit init` (not at startup) so the mount path exists before the container starts. Two sources: `vendor/providers/` (bundled) and `auth/providers//target/` (project-specific). +- **Realm import via keycloak-config-cli**: Import runs `docker run --network postkit-net adorsys/keycloak-config-cli` against the internal container name (`keycloak:8080`), not the Traefik hostname. This allows realm import to complete without Traefik being the entry point. +- **JWT Role Mapper injection**: `cleanRealmTemplate()` injects the `script-primary-role.js` protocol mapper into every non-builtin client automatically, so every client in the realm gets consistent role claim behavior without manual configuration. +- **Project name scoping**: `postkit.config.json` `name` field is used as the Docker Compose project name, ensuring container names and network names are isolated per project on the same machine. + --- ## Multi-Schema Support @@ -203,14 +235,19 @@ Loaded via `loadPostkitConfig()`, which deep-merges two files: ```json // postkit.config.json (committed — no remotes) { + "name": "myapp_a3f2b1c0", "db": { "infraPath": "db/infra", "schemaPath": "db/schema", "schemas": ["public", "app"] + }, + "auth": { "configCliImage": "adorsys/keycloak-config-cli:latest-26" }, + "stack": { + "keycloak": { "realmTemplate": ".postkit/auth/realm/postkit.json" } } } -// postkit.secrets.json (gitignored — all remote data lives here) +// postkit.secrets.json (gitignored — all credentials live here) { "db": { "localDbUrl": "postgres://user:pass@localhost:5432/myapp_local", @@ -218,14 +255,22 @@ Loaded via `loadPostkitConfig()`, which deep-merges two files: "dev": { "url": "postgres://user:pass@dev-host:5432/myapp", "default": true, "addedAt": "2024-12-31T10:00:00.000Z" }, "staging": { "url": "postgres://user:pass@staging-host:5432/myapp" } } + }, + "stack": { + "postgres": { "user": "postgres", "password": "" }, + "keycloak": { "adminUser": "admin", "adminPassword": "" } } } ``` +**`name`** — Project identifier used as the Docker Compose project name. Generated as `_<8hexchars>` by `postkit init`. Ensures container and network names are scoped per project. + **`schemas`** — Ordered array of schema names (`["public"]` by default). Array position determines execution order; schemas that other schemas depend on must appear first. Backward compat: `"schemas": ["public"]` with a flat `db/schema/` layout (no `db/schema/public/` subdirectory) continues to work unchanged. **`infraPath`** — Path to the DB-level infra directory (roles, extensions, `CREATE SCHEMA`). Defaults to `"db/infra"`. +**`stack.*`** — Stack service configuration. All service images, ports, volumes, and realm template path. Service credentials (passwords, admin user) live in `postkit.secrets.json` under `stack.*`. + `localDbUrl` can be empty — PostKit will automatically start a Docker container (`postgres:{version}-alpine`) for the session. The container image version is detected from the remote database at runtime via `SHOW server_version_num`. --- @@ -267,11 +312,12 @@ cli/test/ ├── common/ # Unit tests for common utilities ├── modules/ # Unit tests for module services/utils │ ├── db/ -│ └── auth/ +│ ├── auth/ +│ └── stack/ # Stack module unit tests (compose, realm-init, scaffold, sync-providers, db-init, stack-config, stack-state, restart) ├── e2e/ # End-to-end tests -│ ├── smoke/ # Quick tests (no Docker) -│ ├── workflows/ # Full workflow tests -│ └── error-handling/ # Error scenario tests +│ ├── smoke/ # Quick tests (no Docker) — includes stack-commands.test.ts +│ ├── workflows/ # Full workflow tests — includes stack-init-workflow.test.ts +│ └── error-handling/ # Error scenario tests — includes stack-config-errors.test.ts └── helpers/ # Shared test utilities (mock-config, mock-shell, etc.) ``` @@ -292,9 +338,12 @@ PostKit files in `.postkit/` are split between gitignored (ephemeral/user-specif │ ├── session/ # GITIGNORED — temporary in-progress migrations │ ├── committed.json # COMMITTED — migration tracking index (shared) │ └── migrations/ # COMMITTED — committed SQL migrations for deploy (shared) -└── auth/ - ├── raw/ # COMMITTED — auth raw config (shared) - └── realm/ # COMMITTED — auth realm config (shared) +├── auth/ +│ ├── raw/ # COMMITTED — auth raw config (shared) +│ ├── realm/ # COMMITTED — auth realm config (shared) +│ └── providers/ # GITIGNORED — Keycloak JARs (vendor + project), mounted into container +└── stack/ + └── docker-compose.yml # GITIGNORED — generated compose file (ephemeral, regenerated on stack up) ``` `.gitignore` (written by `postkit init`) covers only the ephemeral paths: @@ -302,4 +351,6 @@ PostKit files in `.postkit/` are split between gitignored (ephemeral/user-specif - `.postkit/db/plan_*.sql` - `.postkit/db/schema_*.sql` - `.postkit/db/session/` +- `.postkit/auth/providers/` +- `.postkit/stack/` - `postkit.secrets.json` diff --git a/cli/docs/e2e-testing.md b/cli/docs/e2e-testing.md index 7284eb4..cbfb877 100644 --- a/cli/docs/e2e-testing.md +++ b/cli/docs/e2e-testing.md @@ -423,6 +423,89 @@ test/e2e/ --- +## Stack Module Tests + +The stack module has its own E2E test files. None of these tests require Docker to be running — they test CLI behavior and filesystem scaffolding only. + +### Test Files + +| File | Category | Docker | Description | +|------|----------|--------|-------------| +| `smoke/stack-commands.test.ts` | Smoke | No | `stack --help`, `stack up --help`, `stack restart --help`, init-check enforcement | +| `error-handling/stack-config-errors.test.ts` | Error handling | No | Invalid stack config, missing secrets validation | +| `workflows/stack-init-workflow.test.ts` | Workflow | No | `postkit init` scaffold verification | + +### `stack-commands.test.ts` — Smoke Tests + +Verifies subcommand registration and init-check without starting Docker: + +- `stack --help` lists all subcommands (`up`, `down`, `restart`, `status`, `logs`) +- `stack up --help` shows `--no-wait` and `--no-keys` flags +- `stack restart --help` shows `[services...]` variadic argument +- `stack up` / `stack status` / `stack restart` / `stack down` all fail with `not initialized` or `Config file not found` in an empty (uninitialized) directory + +### `stack-init-workflow.test.ts` — Init Workflow (No Docker) + +Tests that `postkit init --force` produces the correct scaffold for the stack module. All assertions are filesystem-based — no containers are started. + +Pattern: +```typescript +beforeAll(async () => { + rootDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "postkit-e2e-init-")); + await runCli(["init", "--force"], {cwd: rootDir}); +}); +``` + +Assertions verified: +- `.postkit/auth/providers/` directory exists +- `.postkit/stack/` directory exists +- `db/infra/001_roles.sql` exists and contains `IF NOT EXISTS` pattern +- `db/infra/002_schemas.sql` exists and contains `CREATE SCHEMA IF NOT EXISTS auth/public/storage` +- `postkit.config.json` has `stack.keycloak.realmTemplate` pointing to an existing file +- `postkit.config.json` has `name` field matching `[a-z0-9-]*_[0-9a-f]{8}` pattern +- `postkit.secrets.example.json` exists and has `db` + `auth` top-level keys +- `.gitignore` contains `postkit.secrets.json` +- Second `postkit init --force` succeeds (idempotency) + +### Running Stack Tests + +```bash +# Run all stack smoke tests (no Docker) +npm run test:e2e:file -- test/e2e/smoke/stack-commands.test.ts + +# Run stack init workflow test (no Docker) +npm run test:e2e:file -- test/e2e/workflows/stack-init-workflow.test.ts + +# Run stack config error tests (no Docker) +npm run test:e2e:file -- test/e2e/error-handling/stack-config-errors.test.ts + +# Run all stack E2E tests together +npx vitest run --config vitest.e2e.config.ts test/e2e/smoke/stack-commands.test.ts test/e2e/workflows/stack-init-workflow.test.ts test/e2e/error-handling/stack-config-errors.test.ts +``` + +### Stack Unit Tests + +Stack module unit tests live under `test/modules/stack/` and use Vitest with `vi.mock()`: + +| File | What It Tests | +|------|--------------| +| `services/compose.test.ts` | `generateComposeFile()` output, `getSelectedServices()` dependency resolution | +| `services/realm-init.test.ts` | `cleanRealmTemplate()` — strips builtins, injects JWT Role Mapper | +| `services/scaffold.test.ts` | `scaffoldRealmTemplate()` — creates file, skips if exists | +| `services/sync-providers.test.ts` | `syncKeycloakProviders()` — copies JARs from vendor + project dirs | +| `services/db-init.test.ts` | `applyStackDeploy()` — connection retry, schema init, infra/migrations/seeds | +| `utils/stack-config.test.ts` | `getStackConfig()` — defaults, Zod validation, secrets merging | +| `utils/stack-state.test.ts` | `readStackIsInitial()` / `setStackInitialized()` — DB flag read/write | +| `commands/restart.test.ts` | `restartCommand()` — service name validation, multiple targets | + +Run unit tests: +```bash +npm run test:unit # All unit tests +npx vitest run test/modules/stack/ # Stack unit tests only +``` + +--- + ## CI/CD Integration ```yaml diff --git a/cli/docs/stack.md b/cli/docs/stack.md new file mode 100644 index 0000000..4643f5c --- /dev/null +++ b/cli/docs/stack.md @@ -0,0 +1,418 @@ +# 📦 Stack Module (`postkit stack`) + +A local backend stack manager. Starts and manages Postgres, Keycloak, PostgREST, and Traefik as a Docker Compose project, applies DB migrations on startup, and handles Keycloak realm initialization automatically on the first run. + +--- + +## 🗂️ Services Overview + +| Service | Image | Port | Purpose | +|---------|-------|------|---------| +| `postgres` | `postgres:16-alpine` | 25432 (host) | Database | +| `keycloak` | `quay.io/keycloak/keycloak:26.6` | via Traefik | Auth server (`keycloak.localhost`) | +| `postgrest` | `postgrest/postgrest:latest` | via Traefik | REST API (`api.localhost`) | +| `traefik` | `traefik:v3.3` | 80 (HTTP) / 8080 (dashboard) | Reverse proxy | + +All services share a Docker network named `postkit-net`. The network name is explicit (no Docker Compose project prefix) so external containers like `keycloak-config-cli` can join it by name. + +**Dependency rule:** Selecting `keycloak` or `postgrest` automatically includes `postgres` and `traefik`. + +--- + +## 🚀 `stack up` — Two-Phase Startup + +`stack up` enforces an ordered startup sequence: the database must be initialized before auth/API services start. + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ stack up (two-phase) │ +├────────────────────────────────────────────────────────────────────┤ +│ │ +│ Phase 1 — Infrastructure │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Start: postgres + traefik │ │ +│ │ Wait: health checks (pg_isready, Traefik API) │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Phase 2 — DB Initialization (hard failure stops stack up) │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 1. connectWithRetry → CREATE SCHEMA IF NOT EXISTS postkit │ │ +│ │ + CREATE TABLE IF NOT EXISTS postkit.stack_config │ │ +│ │ 2. Apply db/infra/*.sql (roles, schemas, extensions) │ │ +│ │ 3. Run committed migrations (.postkit/db/migrations/) │ │ +│ │ 4. Apply seeds │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Phase 3 — Application Services │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Start: keycloak + postgrest │ │ +│ │ Wait: health checks (Keycloak /health, PostgREST /) │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Phase 4 — Initial Setup (first run only, skipped if initialized) │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ readStackIsInitial → true? │ │ +│ │ ├─▶ importRealmTemplate (keycloak-config-cli container) │ │ +│ │ ├─▶ fetchAndMergeKeys (JWKs + client secrets) │ │ +│ │ ├─▶ writeComposeFile + composeUp(postgrest) [update JWT] │ │ +│ │ └─▶ setStackInitialized (is_initial = 'false' in DB) │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 🔁 `is_initial` State Management + +Whether to run realm import and JWKs fetch is controlled by a flag stored in the database itself — not in a file — so it resets automatically when the database volume is wiped. + +| State | Location | Meaning | +|-------|----------|---------| +| Missing row (default) | `postkit.stack_config` table | First run — executes realm import + JWKs on next `stack up` | +| `value = 'false'` | `postkit.stack_config` table | Already initialized — Phase 4 is skipped | + +**Automatic reset:** `postkit stack down --volumes` wipes the Postgres volume, which drops `postkit.stack_config`. The next `stack up` finds no row and runs full initialization again. + +**Manual override:** +- `postkit stack realm` — re-import realm without wiping data +- `postkit stack keys` — re-fetch JWKs without wiping data + +--- + +## 🔑 Keycloak Providers + +Keycloak provider JARs are mounted at `/opt/keycloak/providers` inside the container. PostKit assembles the mount source directory at `postkit init` time from two sources: + +| Source | Path | Notes | +|--------|------|-------| +| Bundled JARs | `vendor/providers/*.jar` (CLI) | Copied on `postkit init` | +| Project-specific JARs | `auth/providers//target/*.jar` | Copied on `postkit init` | + +**Destination:** `.postkit/auth/providers/` — gitignored, rebuilt by `postkit init`. + +If you add or update a project provider, re-run `postkit init` to sync the new JAR, then restart the stack. + +--- + +## 🏰 Realm Template + JWT Role Mapper + +On the first `stack up` (when `is_initial=true`), PostKit imports a Keycloak realm template. + +The template path is configured via `stack.keycloak.realmTemplate` (default: `.postkit/auth/realm/postkit.json`). Scaffolded automatically by `postkit init`. + +Before importing, `cleanRealmTemplate()` transforms the raw template JSON: + +| Transform | Detail | +|-----------|--------| +| Set realm name | Sets `realm` to `config.keycloak.realm`, removes top-level `id` | +| Strip builtin clients | Removes `account`, `account-console`, `admin-cli`, `broker`, `realm-management`, `security-admin-console` | +| Strip generated fields | Removes `id`, `secret`, `registrationAccessToken`, `client.secret.creation.time` | +| Strip role IDs | Removes `id` from all realm roles | +| Ensure admin role | Adds `admin` realm role if absent | +| Inject JWT Role Mapper | Adds `script-primary-role.js` protocol mapper to every non-builtin client | + +The **JWT Role Mapper** (`protocolMapper: "script-primary-role.js"`) maps Keycloak realm roles into JWT claims in the format expected by PostgREST for role-based access control. + +Import runs via: +``` +docker run --rm --network postkit-net \ + adorsys/keycloak-config-cli:latest-26 +``` +targeting `http://keycloak:8080` (internal Docker DNS, bypasses Traefik). + +--- + +## ⚙️ Configuration + +### `postkit.config.json` (committed) + +```json +{ + "name": "myapp_a3f2b1c0", + "db": { + "schemaPath": "db/schema", + "schemas": ["public"], + "infraPath": "db/infra" + }, + "auth": { + "configCliImage": "adorsys/keycloak-config-cli:latest-26" + }, + "stack": { + "postgres": { + "port": 25432, + "database": "postkit", + "pgVersion": 16 + }, + "keycloak": { + "realm": "postkit", + "realmTemplate": ".postkit/auth/realm/postkit.json", + "clients": ["app"] + }, + "postgrest": { + "dbSchema": "public", + "dbAnonRole": "anon" + }, + "traefik": { + "httpPort": 80, + "dashboardPort": 8080 + }, + "network": "postkit-net" + } +} +``` + +All `stack.*` fields are optional — defaults are applied for anything omitted. + +### `postkit.secrets.json` (gitignored) + +Auto-generated by `postkit stack up` on first run. Missing passwords are generated as random 32-byte hex strings. + +```json +{ + "stack": { + "postgres": { + "user": "postgres", + "password": "" + }, + "keycloak": { + "adminUser": "admin", + "adminPassword": "" + }, + "jwks": { + "keys": [{ "kty": "oct", "kid": "storage-url-signing-key", "alg": "HS256", "k": "..." }], + "urlSigningKey": { "kty": "oct", "kid": "storage-url-signing-key", "alg": "HS256", "k": "..." } + } + } +} +``` + +> JWKs and client secrets are populated here by `postkit stack keys` after being fetched from Keycloak. + +### Config Properties Reference + +| Property | File | Default | Description | +|----------|------|---------|-------------| +| `name` | config | required | Docker Compose project name — scopes containers per project | +| `stack.postgres.port` | config | 25432 | Host port mapped to Postgres container | +| `stack.postgres.database` | config | `postkit` | Database name | +| `stack.postgres.pgVersion` | config | 16 | Postgres major version | +| `stack.postgres.volume` | config | `postkit-pgdata` | Docker volume name for Postgres data | +| `stack.keycloak.realm` | config | `postkit` | Keycloak realm name | +| `stack.keycloak.realmTemplate` | config | `.postkit/auth/realm/postkit.json` | Path to realm template | +| `stack.keycloak.clients` | config | `[]` | Client names to fetch secrets for via `stack keys` | +| `stack.keycloak.volume` | config | `postkit-keycloak-data` | Docker volume name for Keycloak data | +| `stack.postgrest.dbSchema` | config | `public` | PostgREST exposed DB schema | +| `stack.postgrest.dbAnonRole` | config | `anon` | PostgREST anonymous role | +| `stack.traefik.httpPort` | config | 80 | Traefik HTTP entry point (host) | +| `stack.traefik.dashboardPort` | config | 8080 | Traefik dashboard port (host) | +| `stack.network` | config | `postkit-net` | Docker network name | +| `stack.postgres.password` | secrets | auto-generated | Postgres password | +| `stack.keycloak.adminPassword` | secrets | auto-generated | Keycloak admin password | + +--- + +## 🚀 Commands + +### `postkit stack up [services...]` + +Start the full stack or selected services. + +```bash +postkit stack up # Start all services +postkit stack up postgres traefik # Start only postgres + traefik +postkit stack up postgres keycloak # Includes traefik automatically +postkit stack up --no-wait # Skip health check waiting +postkit stack up --no-keys # Skip auto-fetching JWKs on init +``` + +Available service names: `postgres`, `keycloak`, `postgrest`, `traefik` + +--- + +### `postkit stack down [--volumes]` + +Stop and remove all stack containers. + +```bash +postkit stack down # Stop containers, keep volumes (data preserved) +postkit stack down --volumes # Stop containers AND delete volumes (resets is_initial) +``` + +> Without `--volumes`, Postgres and Keycloak data survive in Docker named volumes. Use `--volumes` for a clean slate — this also resets the `is_initial` flag so the next `stack up` re-runs realm import and JWKs fetch. + +--- + +### `postkit stack status` + +Show running services, ports, and health status. + +```bash +postkit stack status +``` + +--- + +### `postkit stack logs [service] [-f] [-n ]` + +Tail logs for all services or a specific service. + +```bash +postkit stack logs # Follow all services (default) +postkit stack logs keycloak # Keycloak logs only +postkit stack logs postgres --no-follow # Print last 100 lines and exit +postkit stack logs postgrest -n 50 # Last 50 lines, then follow +``` + +**Flags:** + +| Flag | Default | Description | +|------|---------|-------------| +| `-f, --follow` | true | Stream logs continuously | +| `--no-follow` | — | Print last N lines and exit | +| `-n, --tail ` | 100 | Number of lines to show | + +--- + +### `postkit stack restart [services...]` + +Restart one or more services. Service names are validated before restarting. + +```bash +postkit stack restart # Restart all services +postkit stack restart keycloak # Restart keycloak only +postkit stack restart keycloak postgrest # Restart multiple services +``` + +Invalid service names produce an error listing valid options (`postgres`, `keycloak`, `postgrest`, `traefik`). + +--- + +### `postkit stack keys [--restart] [--clients ]` + +Fetch JWKs and client secrets from Keycloak and write them to `postkit.secrets.json`. Optionally restarts PostgREST with the updated JWT configuration. + +```bash +postkit stack keys # Fetch and write to secrets +postkit stack keys --restart # Fetch + restart PostgREST +postkit stack keys --clients "app,admin" # Fetch keys for specific clients only +``` + +--- + +### `postkit stack realm` + +Re-import the Keycloak realm template without restarting the stack. + +```bash +postkit stack realm +``` + +Runs `cleanRealmTemplate()` + `importRealmTemplate()` — the same steps as Phase 4 of `stack up`. Use this after editing the realm template or when Keycloak loses its configuration. + +--- + +## 📋 Workflow Guide + +### First Run + +```bash +# 1. Initialize the project (creates infra SQL, realm template, providers) +postkit init + +# 2. Start the full stack +# Phase 1: postgres + traefik start and become healthy +# Phase 2: infra SQL + migrations + seeds applied +# Phase 3: keycloak + postgrest start and become healthy +# Phase 4: realm imported, JWKs fetched, postgrest restarted +postkit stack up + +# Stack is running: +# Keycloak: http://keycloak.localhost +# API: http://api.localhost +# DB: postgres://postgres:***@localhost:25432/postkit +# Dashboard: http://localhost:8080/dashboard/ +``` + +### Subsequent Runs + +```bash +# Phase 4 is skipped (is_initial=false in DB) +postkit stack up + +# Check health +postkit stack status + +# Tail logs +postkit stack logs + +# Stop (keep data) +postkit stack down +``` + +### After Schema Changes + +Schema changes are applied automatically on the next `stack up` (Phase 2 runs committed migrations every time). If the stack is already running: + +```bash +# Deploy schema changes to the running stack DB +postkit db deploy +``` + +### Full Reset + +```bash +# Wipe all data + volumes, reset is_initial flag +postkit stack down --volumes + +# Next up runs full initialization again +postkit stack up +``` + +### Re-importing the Realm Only + +```bash +# Edit .postkit/auth/realm/postkit.json +# Then re-import without restarting services +postkit stack realm +``` + +--- + +## 🔧 PostKit Directory Structure + +``` +.postkit/ +├── auth/ +│ ├── realm/ +│ │ └── postkit.json # COMMITTED — realm template (scaffolded by init) +│ └── providers/ # GITIGNORED — Keycloak JARs (vendor + project) +│ └── *.jar +└── stack/ + └── docker-compose.yml # GITIGNORED — generated compose file (regenerated on stack up) +``` + +The compose file is regenerated every time `stack up` runs from the current config. Never edit it manually — changes will be overwritten. + +--- + +## 🐛 Troubleshooting + +| Issue | Solution | +|-------|----------| +| `Docker not found` | Install Docker Desktop; ensure `docker` is on your PATH | +| `docker compose` not available | Install Docker Compose V2 (bundled with Docker Desktop 4.x+) | +| `Config file not found` / `not initialized` | Run `postkit init` before any stack command | +| `Invalid stack configuration` | Check `stack.*` fields in `postkit.config.json` against the Config Properties table | +| Keycloak `Broken pipe` or connection refused during realm import | Keycloak is still starting. Run `postkit stack logs keycloak` and wait for the startup message, then run `postkit stack realm` | +| PostgREST returns 401 after `stack keys` | JWT secret mismatch — run `postkit stack keys --restart` to sync JWKs and restart PostgREST | +| `keycloak-config-cli import failed` | Check `postkit stack logs keycloak` for startup errors. Verify realm template JSON is valid | +| Stack starts but Keycloak has DB errors | Ensure `db/infra/002_schemas.sql` creates the `auth` schema (`CREATE SCHEMA IF NOT EXISTS auth`) — applied in Phase 2 before Keycloak starts | +| `Unknown service: ""` | Valid names are: `postgres`, `keycloak`, `postgrest`, `traefik` | +| Ports already in use (25432, 80, 8080) | Override ports in `postkit.config.json` under `stack.postgres.port`, `stack.traefik.httpPort`, `stack.traefik.dashboardPort` | +| Provider JARs not loaded by Keycloak | Re-run `postkit init` to copy JARs to `.postkit/auth/providers/`, then `postkit stack down && postkit stack up` | +| `stack up` hangs at health check | Run `postkit stack logs` — Keycloak takes 30–60s on first boot. Health check timeout is 120s | +| `postkit stack down --volumes` does not reset realm | The reset is automatic because the `postkit.stack_config` table lives in the Postgres volume. If Keycloak volume was wiped but not Postgres, run `postkit stack realm` manually | diff --git a/cli/package-lock.json b/cli/package-lock.json index f8527cb..47d0dcd 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@appritech/postkit", - "version": "1.2.1", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@appritech/postkit", - "version": "1.2.1", + "version": "1.3.0", "license": "Apache-2.0", "dependencies": { "chalk": "^5.3.0", diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 733a61a..4ea7bd1 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -1,8 +1,9 @@ +import crypto from "crypto"; import fs from "fs"; import path from "path"; import ora from "ora"; import {logger} from "../common/logger"; -import {promptConfirm} from "../common/prompt"; +import {promptConfirm, promptInput} from "../common/prompt"; import { projectRoot, POSTKIT_CONFIG_FILE, @@ -12,9 +13,13 @@ import { getSecretsFilePath, getPostkitDir, getPostkitAuthDir, + getStackDir, } from "../common/config"; import type {CommandOptions} from "../common/types"; import type {PostkitPublicConfig, PostkitSecrets} from "../common/config"; +import {scaffoldDbInfra} from "../modules/db/services/scaffold"; +import {scaffoldRealmTemplate, DEFAULT_REALM_TEMPLATE_PATH} from "../modules/stack/services/scaffold"; +import {syncKeycloakProviders} from "../modules/stack/services/sync-providers"; // Ephemeral/user-specific files are gitignored; committed migrations and auth state are tracked. // postkit.config.json is safe to commit. @@ -24,6 +29,7 @@ const GITIGNORE_ENTRIES = [ ".postkit/db/plan_*.sql", ".postkit/db/schema_*.sql", ".postkit/db/session/", + ".postkit/stack/", "postkit.secrets.json", ]; @@ -35,7 +41,12 @@ const SCAFFOLD_PUBLIC_CONFIG: PostkitPublicConfig = { infraPath: "db/infra", }, auth: { - configCliImage: "adorsys/keycloak-config-cli:6.4.0-24", + configCliImage: "adorsys/keycloak-config-cli:latest-26", + }, + stack: { + keycloak: { + realmTemplate: DEFAULT_REALM_TEMPLATE_PATH, + }, }, }; @@ -58,6 +69,7 @@ const SCAFFOLD_SECRETS: PostkitSecrets = { adminPass: "", }, }, + stack: {}, }; // Example secrets template committed alongside the public config @@ -83,11 +95,30 @@ const SCAFFOLD_SECRETS_EXAMPLE: PostkitSecrets = { adminPass: "changeme", }, }, + stack: { + postgres: { + user: "postgres", + password: "changeme", + }, + keycloak: { + adminUser: "admin", + adminPassword: "changeme", + }, + }, }; export async function initCommand(options: CommandOptions): Promise { logger.heading("Postkit Init"); + // Prompt for project name — required + const rawName = await promptInput("Project name:", { + required: true, + force: options.force, + }); + const randomId = crypto.randomBytes(4).toString("hex"); + const projectName = `${rawName.trim().toLowerCase().replace(/\s+/g, "-")}_${randomId}`; + logger.info(`Project ID: ${projectName}`); + const postkitDir = getPostkitDir(); const configFile = getConfigFilePath(); const alreadyInitialized = @@ -112,7 +143,7 @@ export async function initCommand(options: CommandOptions): Promise { } } - const totalSteps = 5; + const totalSteps = 8; // Step 1: Create .postkit/db/ directory logger.step(1, totalSteps, "Creating .postkit/db/ directory"); @@ -143,23 +174,37 @@ export async function initCommand(options: CommandOptions): Promise { } else { const spinner = ora("Creating .postkit/auth/ directory...").start(); const postkitAuthDir = getPostkitAuthDir(); - for (const subdir of ["raw", "realm"]) { + for (const subdir of ["raw", "realm", "providers"]) { const subPath = path.join(postkitAuthDir, subdir); if (!fs.existsSync(subPath)) { fs.mkdirSync(subPath, {recursive: true}); } } + // Copy bundled Keycloak provider JARs from cli/vendor/providers/ + syncKeycloakProviders(); spinner.succeed(".postkit/auth/ directory created"); } - // Step 3: Generate config and secrets files - logger.step(3, totalSteps, "Generating config and secrets files"); + // Step 3: Create .postkit/stack/ directory + logger.step(3, totalSteps, "Creating .postkit/stack/ directory"); + if (options.dryRun) { + logger.info(`Dry run: would create ${POSTKIT_DIR}/stack/`); + } else { + const spinner = ora("Creating .postkit/stack/ directory...").start(); + const stackDir = getStackDir(); + fs.mkdirSync(stackDir, {recursive: true}); + spinner.succeed(".postkit/stack/ directory created"); + } + + // Step 4: Generate config and secrets files + logger.step(4, totalSteps, "Generating config and secrets files"); if (options.dryRun) { logger.info(`Dry run: would create ${POSTKIT_CONFIG_FILE} (committed) and ${POSTKIT_SECRETS_FILE} (gitignored)`); } else { const spinner = ora("Writing config files...").start(); - fs.writeFileSync(configFile, JSON.stringify(SCAFFOLD_PUBLIC_CONFIG, null, 2) + "\n"); + const publicConfig: PostkitPublicConfig = {...SCAFFOLD_PUBLIC_CONFIG, name: projectName}; + fs.writeFileSync(configFile, JSON.stringify(publicConfig, null, 2) + "\n"); const secretsFile = getSecretsFilePath(); fs.writeFileSync(secretsFile, JSON.stringify(SCAFFOLD_SECRETS, null, 2) + "\n"); @@ -171,8 +216,28 @@ export async function initCommand(options: CommandOptions): Promise { spinner.succeed(`${POSTKIT_CONFIG_FILE}, ${POSTKIT_SECRETS_FILE}, and postkit.secrets.example.json created`); } - // Step 4: Update .gitignore - logger.step(4, totalSteps, "Updating .gitignore"); + // Step 5: Scaffold db/infra/roles.sql + logger.step(5, totalSteps, "Scaffolding db/infra/roles.sql"); + if (options.dryRun) { + logger.info("Dry run: would create db/infra/roles.sql with PostgREST roles"); + } else { + const spinner = ora("Creating db/infra/roles.sql...").start(); + const created = scaffoldDbInfra(); + spinner.succeed(created ? "db/infra/roles.sql created" : "db/infra/roles.sql already exists — skipped"); + } + + // Step 6: Scaffold realm template + logger.step(6, totalSteps, "Scaffolding realm template"); + if (options.dryRun) { + logger.info(`Dry run: would create ${DEFAULT_REALM_TEMPLATE_PATH}`); + } else { + const spinner = ora(`Creating ${DEFAULT_REALM_TEMPLATE_PATH}...`).start(); + const created = scaffoldRealmTemplate(); + spinner.succeed(created ? `${DEFAULT_REALM_TEMPLATE_PATH} created` : `${DEFAULT_REALM_TEMPLATE_PATH} already exists — skipped`); + } + + // Step 7: Update .gitignore + logger.step(7, totalSteps, "Updating .gitignore"); const gitignorePath = path.join(projectRoot, ".gitignore"); if (options.dryRun) { logger.info("Dry run: would update .gitignore with Postkit entries"); @@ -199,8 +264,8 @@ export async function initCommand(options: CommandOptions): Promise { } } - // Step 5: Summary - logger.step(5, totalSteps, "Done"); + // Step 8: Summary + logger.step(8, totalSteps, "Done"); logger.blank(); logger.success("Postkit project initialized!"); logger.blank(); @@ -222,5 +287,7 @@ export async function initCommand(options: CommandOptions): Promise { logger.info(` 1. Fill in ${POSTKIT_SECRETS_FILE} with your database credentials`); logger.info(" 2. Add remote databases:"); logger.info(" postkit db remote add staging \"postgres://...\""); - logger.info(" 3. Run postkit db start to begin a migration session"); + logger.info(" 3. Start the local backend stack:"); + logger.info(" postkit stack up"); + logger.info(" 4. Or run postkit db start to begin a migration session"); } diff --git a/cli/src/common/config.ts b/cli/src/common/config.ts index 399ef52..6e1c15d 100644 --- a/cli/src/common/config.ts +++ b/cli/src/common/config.ts @@ -36,6 +36,10 @@ export function getPostkitAuthDir(): string { return path.join(projectRoot, POSTKIT_DIR, "auth"); } +export function getStackDir(): string { + return path.join(projectRoot, POSTKIT_DIR, "stack"); +} + export function getVendorDir(): string { return path.join(cliRoot, "vendor"); } @@ -77,9 +81,46 @@ export interface AuthPublicConfig { configCliImage?: string; } +export interface StackPostgresPublicConfig { + enabled?: boolean; + port?: number; + pgVersion?: number; + image?: string; + database?: string; + volume?: string; +} + +export interface StackKeycloakPublicConfig { + enabled?: boolean; + port?: number; + image?: string; + realm?: string; + volume?: string; + clientRealm?: string; + clients?: string[]; + realmTemplate?: string; +} + +export interface StackPostgrestPublicConfig { + enabled?: boolean; + port?: number; + image?: string; + dbSchema?: string; + dbAnonRole?: string; +} + +export interface StackPublicConfig { + postgres?: StackPostgresPublicConfig; + keycloak?: StackKeycloakPublicConfig; + postgrest?: StackPostgrestPublicConfig; + network?: string; +} + export interface PostkitPublicConfig { + name?: string; db?: DbPublicConfig; auth?: AuthPublicConfig; + stack?: StackPublicConfig; } // ─── Secrets (gitignored) ───────────────────────────────────────────────────── @@ -102,17 +143,40 @@ export interface AuthSecretsConfig { target?: Partial; } +export interface StackPostgresSecrets { + user?: string; + password?: string; +} + +export interface StackKeycloakSecrets { + adminUser?: string; + adminPassword?: string; +} + +export interface StackPostgrestSecrets { + jwtSecret?: string; +} + +export interface StackSecretsConfig { + postgres?: StackPostgresSecrets; + keycloak?: StackKeycloakSecrets; + postgrest?: StackPostgrestSecrets; +} + export interface PostkitSecrets { db?: DbSecretsConfig; auth?: AuthSecretsConfig; + stack?: StackSecretsConfig; } // ─── Merged runtime config ──────────────────────────────────────────────────── // PostkitConfig interface matching the JSON structure export interface PostkitConfig { + name?: string; db: DbInputConfig; auth: AuthInputConfig; + stack?: Record; } let cachedConfig: PostkitConfig | null = null; diff --git a/cli/src/index.ts b/cli/src/index.ts index d12a61b..d7b5877 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -3,6 +3,7 @@ import {createRequire} from "module"; import {initCommand} from "./commands/init"; import {registerDbModule} from "./modules/db/index"; import {registerAuthModule} from "./modules/auth/index"; +import {registerStackModule} from "./modules/stack/index"; import {logger} from "./common/logger"; const require = createRequire(import.meta.url); @@ -41,6 +42,7 @@ program // Register modules registerDbModule(program); registerAuthModule(program); +registerStackModule(program); // Parse and run program.parse(); diff --git a/cli/src/modules/auth/commands/export.ts b/cli/src/modules/auth/commands/export.ts index d35c1f3..ed336ba 100644 --- a/cli/src/modules/auth/commands/export.ts +++ b/cli/src/modules/auth/commands/export.ts @@ -1,7 +1,7 @@ import ora from "ora"; import {logger} from "../../../common/logger"; import {promptConfirm} from "../../../common/prompt"; -import {getAuthConfig} from "../utils/auth-config"; +import {getExportConfig} from "../utils/auth-config"; import { getAdminToken, exportRealm, @@ -18,7 +18,7 @@ export async function exportCommand(options: CommandOptions): Promise { // Step 1: Load config logger.step(1, 4, "Loading configuration..."); - const config = getAuthConfig(); + const config = getExportConfig(); logger.info(`Source : ${config.sourceUrl}`); logger.info(`Realm : ${config.sourceRealm}`); diff --git a/cli/src/modules/auth/commands/import.ts b/cli/src/modules/auth/commands/import.ts index 1e9f8af..f1b4992 100644 --- a/cli/src/modules/auth/commands/import.ts +++ b/cli/src/modules/auth/commands/import.ts @@ -1,7 +1,7 @@ import ora from "ora"; import {logger} from "../../../common/logger"; import {promptConfirm} from "../../../common/prompt"; -import {getAuthConfig} from "../utils/auth-config"; +import {getImportConfig} from "../utils/auth-config"; import {importRealm} from "../services/importer"; import type {CommandOptions} from "../../../common/types"; @@ -13,7 +13,7 @@ export async function importCommand(options: CommandOptions): Promise { // Step 1: Load config logger.step(1, 3, "Loading configuration..."); - const config = getAuthConfig(); + const config = getImportConfig(); logger.info(`Target : ${config.targetUrl}`); logger.info(`Config : ${config.cleanFilePath}`); diff --git a/cli/src/modules/auth/utils/auth-config.ts b/cli/src/modules/auth/utils/auth-config.ts index 8670c0e..b94f17a 100644 --- a/cli/src/modules/auth/utils/auth-config.ts +++ b/cli/src/modules/auth/utils/auth-config.ts @@ -7,7 +7,7 @@ import type {AuthConfig} from "../types/config"; export type {AuthConfig, AuthInputConfig} from "../types/config"; // ============================================ -// Zod Schemas for Validation +// Zod Schemas // ============================================ const AuthSourceSchema = z.object({ @@ -23,7 +23,27 @@ const AuthTargetSchema = z.object({ adminPass: z.string().min(1, "Target admin password is required"), }); -const AuthConfigInputSchema = z.object({ +// export: source (full) only +const ExportConfigSchema = z.object({ + source: AuthSourceSchema, + configCliImage: z.string().optional(), +}); + +// import: source.realm to locate the file + target (empty strings treated as not configured) +const ImportConfigSchema = z.object({ + source: z.object({ + realm: z.string().min(1, "source.realm is required to locate the realm file"), + }), + target: z.object({ + url: z.string(), + adminUser: z.string(), + adminPass: z.string(), + }).optional(), + configCliImage: z.string().optional(), +}); + +// sync: full source + full target +const SyncConfigSchema = z.object({ source: AuthSourceSchema, target: AuthTargetSchema, configCliImage: z.string().optional(), @@ -33,45 +53,88 @@ const AuthConfigInputSchema = z.object({ // Error Formatting // ============================================ -/** - * Format Zod validation errors into user-friendly messages - */ function formatZodErrors(error: z.ZodError): string { const lines = ["Invalid auth configuration:"]; for (const issue of error.issues) { - const path = issue.path.join("."); - lines.push(` • ${path}: ${issue.message}`); + const p = issue.path.join("."); + lines.push(` • ${p}: ${issue.message}`); } return lines.join("\n"); } // ============================================ -// Config Loader +// Config Loaders // ============================================ -/** - * Get validated auth configuration - * @throws Error if configuration is invalid - */ -export function getAuthConfig(): AuthConfig { +const DEFAULT_CONFIG_CLI_IMAGE = "adorsys/keycloak-config-cli:latest-26"; + +/** Used by `auth export` — only source is required. */ +export function getExportConfig(): AuthConfig { const config = loadPostkitConfig(); + const result = ExportConfigSchema.safeParse(config.auth); + if (!result.success) throw new Error(formatZodErrors(result.error)); - // Validate with Zod - const result = AuthConfigInputSchema.safeParse(config.auth); + const auth = result.data; + const authDir = getPostkitAuthDir(); + const outputFilename = `${auth.source.realm}.json`; - if (!result.success) { - throw new Error(formatZodErrors(result.error)); - } + return { + sourceUrl: auth.source.url, + sourceAdminUser: auth.source.adminUser, + sourceAdminPass: auth.source.adminPass, + sourceRealm: auth.source.realm, + targetUrl: "", + targetAdminUser: "", + targetAdminPass: "", + configCliImage: auth.configCliImage ?? DEFAULT_CONFIG_CLI_IMAGE, + rawFilePath: path.join(authDir, "raw", outputFilename), + cleanFilePath: path.join(authDir, "realm", outputFilename), + }; +} + +/** Used by `auth import` — source.realm + target required (must be explicitly configured). */ +export function getImportConfig(): AuthConfig { + const config = loadPostkitConfig(); + const result = ImportConfigSchema.safeParse(config.auth); + if (!result.success) throw new Error(formatZodErrors(result.error)); const auth = result.data; + const t = auth.target; + + if (!t?.url?.trim() || !t?.adminUser?.trim() || !t?.adminPass?.trim()) { + throw new Error( + "Target Keycloak not configured.\n" + + "Add auth.target.url, auth.target.adminUser, and auth.target.adminPass to postkit.secrets.json.", + ); + } + + const authDir = getPostkitAuthDir(); + const outputFilename = `${auth.source.realm}.json`; + + return { + sourceUrl: "", + sourceAdminUser: "", + sourceAdminPass: "", + sourceRealm: auth.source.realm, + targetUrl: t.url, + targetAdminUser: t.adminUser, + targetAdminPass: t.adminPass, + configCliImage: auth.configCliImage ?? DEFAULT_CONFIG_CLI_IMAGE, + rawFilePath: path.join(authDir, "raw", outputFilename), + cleanFilePath: path.join(authDir, "realm", outputFilename), + }; +} - // Use .postkit/auth/ as default locations with realm name as filename +/** Used by `auth sync` — both source and target required. */ +export function getAuthConfig(): AuthConfig { + const config = loadPostkitConfig(); + const result = SyncConfigSchema.safeParse(config.auth); + if (!result.success) throw new Error(formatZodErrors(result.error)); + + const auth = result.data; const authDir = getPostkitAuthDir(); const outputFilename = `${auth.source.realm}.json`; - const configCliImage = - auth.configCliImage || "adorsys/keycloak-config-cli:6.4.0-24"; - // Return flattened structure for easier use in commands return { sourceUrl: auth.source.url, sourceAdminUser: auth.source.adminUser, @@ -80,7 +143,7 @@ export function getAuthConfig(): AuthConfig { targetUrl: auth.target.url, targetAdminUser: auth.target.adminUser, targetAdminPass: auth.target.adminPass, - configCliImage, + configCliImage: auth.configCliImage ?? DEFAULT_CONFIG_CLI_IMAGE, rawFilePath: path.join(authDir, "raw", outputFilename), cleanFilePath: path.join(authDir, "realm", outputFilename), }; diff --git a/cli/src/modules/db/services/scaffold.ts b/cli/src/modules/db/services/scaffold.ts new file mode 100644 index 0000000..29e39a4 --- /dev/null +++ b/cli/src/modules/db/services/scaffold.ts @@ -0,0 +1,77 @@ +import fs from "fs"; +import path from "path"; +import {projectRoot} from "../../../common/config"; + +const ROLES_SQL = `DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN NOSUPERUSER INHERIT NOCREATEDB NOCREATEROLE NOREPLICATION; + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN NOSUPERUSER INHERIT NOCREATEDB NOCREATEROLE NOREPLICATION; + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'service_role') THEN + CREATE ROLE service_role NOLOGIN NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION; + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'app_user') THEN + CREATE ROLE app_user NOLOGIN NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION; + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'authenticator') THEN + CREATE ROLE authenticator LOGIN NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION; + END IF; +END +$$; +`; + +const SCHEMAS_SQL = `CREATE SCHEMA IF NOT EXISTS public; + +CREATE SCHEMA IF NOT EXISTS auth; + +CREATE SCHEMA IF NOT EXISTS storage; +`; + +/** + * Scaffold db/infra/ with 001_roles.sql and 002_schemas.sql for PostgREST. + * Safe to call multiple times — never overwrites existing files. + * Returns true if any file was created, false if all already existed. + */ +export function scaffoldDbInfra(): boolean { + const infraDir = path.join(projectRoot, "db", "infra"); + fs.mkdirSync(infraDir, {recursive: true}); + + let created = false; + + const rolesFile = path.join(infraDir, "001_roles.sql"); + if (!fs.existsSync(rolesFile)) { + fs.writeFileSync(rolesFile, ROLES_SQL); + created = true; + } + + const schemasFile = path.join(infraDir, "002_schemas.sql"); + if (!fs.existsSync(schemasFile)) { + fs.writeFileSync(schemasFile, SCHEMAS_SQL); + created = true; + } + + return created; +} diff --git a/cli/src/modules/stack/commands/down.ts b/cli/src/modules/stack/commands/down.ts new file mode 100644 index 0000000..2c4e621 --- /dev/null +++ b/cli/src/modules/stack/commands/down.ts @@ -0,0 +1,46 @@ +import fs from "fs"; +import ora from "ora"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getComposeFilePath} from "../utils/stack-config"; +import {composeDown} from "../services/docker-compose"; +import {PostkitError} from "../../../common/errors"; + +export interface DownOptions extends CommandOptions { + volumes?: boolean; +} + +export async function downCommand(options: DownOptions): Promise { + logger.heading("PostKit Stack Down"); + + const composeFile = getComposeFilePath(); + if (!fs.existsSync(composeFile)) { + throw new PostkitError( + "No stack found.", + "Run 'postkit stack up' first to start the stack.", + ); + } + + const spinner = ora("Stopping stack services...").start(); + const result = await composeDown(composeFile, {volumes: options.volumes}); + + if (result.exitCode !== 0) { + spinner.fail("Failed to stop services"); + logger.error(result.stderr); + return; + } + + spinner.succeed(options.volumes + ? "Stack stopped and volumes removed" + : "Stack stopped", + ); + + logger.blank(); + if (options.volumes) { + logger.info("Containers and volumes removed. All data has been deleted."); + logger.info("Next 'postkit stack up' will re-run realm import and key setup."); + } else { + logger.info("Containers removed. Data preserved in Docker volumes."); + logger.info("Use --volumes to remove persistent data as well."); + } +} diff --git a/cli/src/modules/stack/commands/keys.ts b/cli/src/modules/stack/commands/keys.ts new file mode 100644 index 0000000..d8f7d1b --- /dev/null +++ b/cli/src/modules/stack/commands/keys.ts @@ -0,0 +1,72 @@ +import fs from "fs"; +import ora from "ora"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getStackConfig} from "../utils/stack-config"; +import {getComposeFilePath} from "../utils/stack-config"; +import {fetchAndMergeKeys, writeKeysToSecrets} from "../services/keycloak-keys"; +import {composeRestart} from "../services/docker-compose"; +import {waitForAllServices} from "../services/health"; + +export interface KeysOptions extends CommandOptions { + restart?: boolean; + clients?: string; +} + +export async function keysCommand(options: KeysOptions): Promise { + logger.heading("PostKit Stack Keys"); + + // Check stack compose file exists (stack must have been started at least once) + const composePath = getComposeFilePath(); + if (!fs.existsSync(composePath)) { + logger.error("Stack is not running. Run 'postkit stack up' first."); + return; + } + + const config = getStackConfig(); + + // Override clients from CLI flag if provided + if (options.clients) { + config.keycloakClients = options.clients + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + + const spinner = ora("Connecting to Keycloak...").start(); + try { + const result = await fetchAndMergeKeys(config, spinner); + spinner.succeed("Keys fetched from Keycloak"); + + writeKeysToSecrets(result); + logger.success("Secrets updated in postkit.secrets.json"); + + // Summary + logger.blank(); + logger.info(`RSA key fetched: ${result.jwk.kid ?? "unknown"}`); + logger.info(`Total JWKS keys: ${result.jwks.keys.length}`); + if (Object.keys(result.clients).length > 0) { + logger.info("Client credentials:"); + for (const [name] of Object.entries(result.clients)) { + logger.info(` ${name}: secret + token fetched`); + } + } + + if (options.restart) { + const restartSpinner = ora("Restarting PostgREST with updated JWKS...").start(); + // Re-read config with updated jwks and regenerate the compose file + const updatedConfig = getStackConfig(); + const {writeComposeFile, ALL_SERVICES} = await import("../services/compose"); + writeComposeFile(updatedConfig, [...ALL_SERVICES]); + await composeRestart(composePath, ["postgrest"]); + await waitForAllServices(updatedConfig, ["postgrest"], restartSpinner); + restartSpinner.succeed("PostgREST restarted with updated JWKS"); + } else { + logger.blank(); + logger.info("Run 'postkit stack restart postgrest' to apply JWKS to PostgREST."); + } + } catch (error) { + spinner.fail("Failed to fetch keys from Keycloak"); + logger.error(String((error as Error).message)); + } +} diff --git a/cli/src/modules/stack/commands/logs.ts b/cli/src/modules/stack/commands/logs.ts new file mode 100644 index 0000000..24f817a --- /dev/null +++ b/cli/src/modules/stack/commands/logs.ts @@ -0,0 +1,32 @@ +import fs from "fs"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getComposeFilePath} from "../utils/stack-config"; +import {composeLogs} from "../services/docker-compose"; +import {PostkitError} from "../../../common/errors"; + +export interface LogsOptions extends CommandOptions { + follow?: boolean; + tail?: string; +} + +export async function logsCommand( + options: LogsOptions, + service?: string, +): Promise { + const composeFile = getComposeFilePath(); + if (!fs.existsSync(composeFile)) { + throw new PostkitError( + "No stack found.", + "Run 'postkit stack up' first to start the stack.", + ); + } + + const follow = options.follow !== false; + const tail = options.tail ? parseInt(options.tail, 10) : 100; + + logger.info(`Showing logs${service ? ` for ${service}` : ""}...`); + logger.blank(); + + await composeLogs(composeFile, service, {follow, tail}); +} diff --git a/cli/src/modules/stack/commands/realm.ts b/cli/src/modules/stack/commands/realm.ts new file mode 100644 index 0000000..a30859c --- /dev/null +++ b/cli/src/modules/stack/commands/realm.ts @@ -0,0 +1,35 @@ +import fs from "fs"; +import ora from "ora"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getStackConfig, getComposeFilePath} from "../utils/stack-config"; +import {importRealmTemplate} from "../services/realm-init"; + +export async function realmCommand(options: CommandOptions): Promise { + logger.heading("PostKit Stack Realm Init"); + + if (!fs.existsSync(getComposeFilePath())) { + logger.error("Stack is not running. Run 'postkit stack up' first."); + return; + } + + const config = getStackConfig(); + + if (!config.keycloak.realmTemplate) { + logger.error( + "No realm template configured. Add stack.keycloak.realmTemplate to postkit.config.json.", + ); + return; + } + + const spinner = ora("Importing realm template into Keycloak...").start(); + try { + await importRealmTemplate(config, spinner); + spinner.succeed(`Realm "${config.keycloak.realm}" imported successfully`); + logger.blank(); + logger.success("Realm initialised!"); + } catch (error) { + spinner.fail("Realm import failed"); + logger.error(String((error as Error).message)); + } +} diff --git a/cli/src/modules/stack/commands/restart.ts b/cli/src/modules/stack/commands/restart.ts new file mode 100644 index 0000000..d4e0ddd --- /dev/null +++ b/cli/src/modules/stack/commands/restart.ts @@ -0,0 +1,66 @@ +import fs from "fs"; +import ora from "ora"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getComposeFilePath, getStackConfig} from "../utils/stack-config"; +import {composeRestart} from "../services/docker-compose"; +import {waitForAllServices} from "../services/health"; +import {PostkitError} from "../../../common/errors"; +import {ALL_SERVICES} from "../services/compose"; +import type {ServiceName} from "../services/compose"; + +export async function restartCommand( + options: CommandOptions, + services: string[] = [], +): Promise { + const composeFile = getComposeFilePath(); + if (!fs.existsSync(composeFile)) { + throw new PostkitError( + "No stack found.", + "Run 'postkit stack up' first to start the stack.", + ); + } + + // Validate service names + const valid = new Set(ALL_SERVICES); + const unknown = services.filter((s) => !valid.has(s)); + if (unknown.length > 0) { + throw new PostkitError( + `Unknown service(s): ${unknown.join(", ")}`, + `Available services: ${ALL_SERVICES.join(", ")}`, + ); + } + + const targets = services.length > 0 + ? (services as ServiceName[]) + : [...ALL_SERVICES]; + + const label = targets.join(", "); + + if (options.dryRun) { + logger.info(`Dry run: would restart ${label}`); + return; + } + + const spinner = ora(`Restarting: ${label}...`).start(); + + const result = await composeRestart(composeFile, services.length > 0 ? services : undefined); + + if (result.exitCode !== 0) { + spinner.fail(`Failed to restart ${label}`); + logger.error(result.stderr); + return; + } + + spinner.succeed(`Restarted: ${label}`); + + // Health check the restarted services + const config = getStackConfig(); + const healthSpinner = ora("Waiting for services to become healthy...").start(); + try { + await waitForAllServices(config, targets, healthSpinner); + healthSpinner.succeed(`${label} healthy`); + } catch { + healthSpinner.warn(`${label} restarted but may still be starting`); + } +} diff --git a/cli/src/modules/stack/commands/status.ts b/cli/src/modules/stack/commands/status.ts new file mode 100644 index 0000000..7cf3cfb --- /dev/null +++ b/cli/src/modules/stack/commands/status.ts @@ -0,0 +1,34 @@ +import fs from "fs"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getComposeFilePath} from "../utils/stack-config"; +import {composeStatus} from "../services/docker-compose"; +import {PostkitError} from "../../../common/errors"; + +export async function statusCommand(options: CommandOptions): Promise { + const composeFile = getComposeFilePath(); + if (!fs.existsSync(composeFile)) { + throw new PostkitError( + "No stack found.", + "Run 'postkit stack up' first to start the stack.", + ); + } + + const services = await composeStatus(composeFile); + + if (options.json) { + console.log(JSON.stringify(services, null, 2)); + return; + } + + if (services.length === 0) { + logger.warn("No running services found. Run 'postkit stack up' to start the stack."); + return; + } + + logger.heading("PostKit Stack Status"); + logger.table( + ["Service", "Container", "State", "Health", "Ports"], + services.map((s) => [s.service, s.name, s.state, s.health, s.ports]), + ); +} diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts new file mode 100644 index 0000000..7936443 --- /dev/null +++ b/cli/src/modules/stack/commands/up.ts @@ -0,0 +1,173 @@ +import ora from "ora"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getStackConfig, ensureStackSecrets, getComposeFilePath} from "../utils/stack-config"; +import {checkDockerComposeAvailable, composeUp} from "../services/docker-compose"; +import {writeComposeFile, getSelectedServices} from "../services/compose"; +import type {ServiceName} from "../services/compose"; +import {waitForAllServices} from "../services/health"; +import {applyStackDeploy} from "../services/db-init"; +import {readStackIsInitial, setStackInitialized} from "../utils/stack-state"; + +export interface UpOptions extends CommandOptions { + wait?: boolean; + keysRun?: boolean; +} + +export async function upCommand( + options: UpOptions, + services: string[] = [], +): Promise { + logger.heading("PostKit Stack Up"); + + // Step 1: Check Docker + Compose availability + const spinner = ora("Checking Docker...").start(); + await checkDockerComposeAvailable(); + spinner.succeed("Docker and Docker Compose available"); + + // Step 2: Load config and ensure secrets + let config = getStackConfig(); + config = ensureStackSecrets(config); + + // Step 3: Resolve which services to start + const selected = getSelectedServices(config, services); + const serviceList = selected.join(", "); + + // Step 4: Generate compose file + const composeSpinner = ora(`Generating docker-compose.yml for: ${serviceList}`).start(); + const composeFile = writeComposeFile(config, selected); + composeSpinner.succeed(`Compose file written to .postkit/stack/docker-compose.yml`); + + // Step 5: Start infrastructure services first (postgres + traefik) + const infraServices = (["postgres", "traefik"] as ServiceName[]).filter((s) => selected.includes(s)); + const infraList = infraServices.join(", "); + const infraSpinner = ora(`Starting infrastructure: ${infraList}`).start(); + const infraResult = await composeUp(composeFile, infraServices); + + if (infraResult.exitCode !== 0) { + infraSpinner.fail("Failed to start infrastructure services"); + logger.error(infraResult.stderr); + logger.info("Run 'postkit stack logs' for details."); + return; + } + infraSpinner.succeed(`Infrastructure started: ${infraList}`); + + // Step 6: Wait for infrastructure to be healthy + if (options.wait !== false && infraServices.length > 0) { + const healthSpinner = ora("Waiting for infrastructure to become healthy...").start(); + try { + await waitForAllServices(config, infraServices, healthSpinner); + healthSpinner.succeed("Infrastructure healthy"); + } catch (error) { + healthSpinner.warn(String((error as Error).message)); + logger.warn("Infrastructure may still be starting. Attempting DB init anyway..."); + } + } + + // Step 7: Apply DB infra + migrations + seeds — hard failure if this fails + if (infraServices.includes("postgres")) { + const dbDeploySpinner = ora("Deploying DB (infra + migrations + seeds)...").start(); + await applyStackDeploy(config, dbDeploySpinner); + } + + // Step 8: Start keycloak + postgrest — only after DB migrations are applied + const remainingServices = selected.filter((s) => !infraServices.includes(s)); + if (remainingServices.length > 0) { + const remainingList = remainingServices.join(", "); + const upSpinner = ora(`Starting services: ${remainingList}`).start(); + const result = await composeUp(composeFile, remainingServices); + if (result.exitCode !== 0) { + upSpinner.fail(`Failed to start services: ${remainingList}`); + logger.error(result.stderr); + logger.info("Run 'postkit stack logs' for details."); + return; + } + upSpinner.succeed(`Services started: ${remainingList}`); + } + + // Step 9: Health checks for all services + if (options.wait !== false) { + const healthSpinner = ora("Waiting for all services to become healthy...").start(); + try { + await waitForAllServices(config, selected, healthSpinner); + healthSpinner.succeed("All services healthy"); + } catch (error) { + healthSpinner.warn(String((error as Error).message)); + logger.warn("Some services may still be starting. Check with 'postkit stack status'."); + } + } + + // Step 7: Initial setup — realm import + JWKs fetch + // Only runs when is_initial != 'false' in postkit.stack_config. + // Skipped on normal restarts. Resets automatically when DB volumes are wiped. + if (selected.includes("keycloak")) { + const isInitial = await readStackIsInitial(config); + + if (isInitial) { + // Step 7a: Import realm template (must run before JWKs fetch) + if (config.keycloak.realmTemplate) { + const realmSpinner = ora("Importing realm template into Keycloak...").start(); + try { + const {importRealmTemplate} = await import("../services/realm-init"); + await importRealmTemplate(config, realmSpinner); + realmSpinner.succeed(`Realm "${config.keycloak.realm}" imported`); + } catch (error) { + realmSpinner.warn(`Realm import failed: ${(error as Error).message}`); + logger.warn("Run 'postkit stack init' to retry."); + } + } + + // Step 7b: Fetch JWKs and client credentials — realm must exist first + if (options.keysRun !== false) { + const keysSpinner = ora("Fetching JWKs and client credentials from Keycloak...").start(); + try { + const {fetchAndMergeKeys, writeKeysToSecrets} = await import("../services/keycloak-keys"); + const result = await fetchAndMergeKeys(config, keysSpinner); + writeKeysToSecrets(result); + // Regenerate compose with new jwks and recreate postgrest + if (selected.includes("postgrest")) { + const updatedConfig = getStackConfig(); + const newComposeFile = writeComposeFile(updatedConfig, selected); + await composeUp(newComposeFile, ["postgrest"]); + } + keysSpinner.succeed("Keycloak JWKs fetched and PostgREST updated"); + } catch (error) { + keysSpinner.warn(`Could not fetch Keycloak keys: ${(error as Error).message}`); + logger.warn("Run 'postkit stack keys' after Keycloak is configured."); + } + } + + // Mark stack as initialized — subsequent stack up skips realm import + JWKs + await setStackInitialized(config); + } else { + logger.info("Stack already initialized. Run 'postkit stack init' to re-run realm import."); + } + } + + // Step 9: Print summary + logger.blank(); + logger.success("Stack is running!"); + logger.blank(); + logger.table( + ["Service", "URL", "Port"], + selected.map((s) => { + switch (s) { + case "postgres": + return ["PostgreSQL", `postgres://${config.postgres.user}:***@localhost:${config.postgres.port}/${config.postgres.database}`, String(config.postgres.port)]; + case "keycloak": + return ["Keycloak", `http://keycloak.localhost`, `${config.traefik.httpPort} (Traefik)`]; + case "postgrest": + return ["PostgREST", `http://api.localhost`, `${config.traefik.httpPort} (Traefik)`]; + case "traefik": + return ["Traefik", `http://localhost:${config.traefik.dashboardPort}/dashboard/`, String(config.traefik.dashboardPort)]; + default: + return [s, "", ""]; + } + }), + ); + logger.blank(); + logger.info("Useful commands:"); + logger.info(" postkit stack status — Check service health"); + logger.info(" postkit stack logs — Tail service logs"); + logger.info(" postkit stack down — Stop all services"); +} diff --git a/cli/src/modules/stack/index.ts b/cli/src/modules/stack/index.ts new file mode 100644 index 0000000..7a739f6 --- /dev/null +++ b/cli/src/modules/stack/index.ts @@ -0,0 +1,103 @@ +import {Command} from "commander"; +import {withInitCheck} from "../../common/init-check"; +import {upCommand} from "./commands/up"; +import {downCommand} from "./commands/down"; +import {statusCommand} from "./commands/status"; +import {logsCommand} from "./commands/logs"; +import {restartCommand} from "./commands/restart"; +import {keysCommand} from "./commands/keys"; +import {realmCommand} from "./commands/realm"; + +export function registerStackModule(program: Command): void { + const stack = program + .command("stack") + .description("Manage local backend service stack"); + + // Up command + stack + .command("up") + .description("Start all or selected backend services") + .argument("[services...]", "Services to start (postgres, keycloak, postgrest)") + .option("--no-wait", "Skip health check waiting") + .option("--no-keys", "Skip auto-fetching Keycloak JWKs after startup") + .action(async (services: string[], cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await upCommand(options as never, services); + }); + }); + + // Down command + stack + .command("down") + .description("Stop and remove all stack containers") + .option("--volumes", "Remove persistent volumes too") + .action(async (cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await downCommand(options as never); + }); + }); + + // Status command + stack + .command("status") + .description("Show running services, ports, and health") + .action(async (cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await statusCommand(options as never); + }); + }); + + // Logs command + stack + .command("logs") + .description("Tail logs for stack services") + .argument("[service]", "Service name to tail (omit for all)") + .option("-f, --follow", "Follow log output (default: true)") + .option("--no-follow", "Don't follow log output") + .option("-n, --tail ", "Number of lines to show", "100") + .action(async (service: string | undefined, cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await logsCommand(options as never, service); + }); + }); + + // Restart command + stack + .command("restart") + .description("Restart all or selected stack services") + .argument("[services...]", "Services to restart (omit for all): postgres, keycloak, postgrest, traefik") + .action(async (services: string[], cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await restartCommand(options as never, services); + }); + }); + + // Keys command + stack + .command("keys") + .description("Fetch JWKs and client credentials from Keycloak into secrets") + .option("--restart", "Restart PostgREST after updating secrets") + .option("--clients ", "Comma-separated client names to fetch (overrides config)") + .action(async (cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await keysCommand(options as never); + }); + }); + + // Realm command + stack + .command("realm") + .description("Import base realm template into local Keycloak") + .action(async (cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await realmCommand(options as never); + }); + }); +} diff --git a/cli/src/modules/stack/services/compose.ts b/cli/src/modules/stack/services/compose.ts new file mode 100644 index 0000000..6360fe4 --- /dev/null +++ b/cli/src/modules/stack/services/compose.ts @@ -0,0 +1,227 @@ +import fs from "fs"; +import path from "path"; +import type {StackConfig} from "../types/config"; +import {getStackDir} from "../utils/stack-config"; +import {getProvidersDir} from "./sync-providers"; +import {loadPostkitConfig} from "../../../common/config"; + +/** All supported service names. */ +export const ALL_SERVICES = ["postgres", "keycloak", "postgrest", "traefik"] as const; +export type ServiceName = (typeof ALL_SERVICES)[number]; + +/** + * Resolve which services to start based on user selection. + * Always includes postgres if keycloak or postgrest are selected (dependency). + */ +export function getSelectedServices( + config: StackConfig, + requested: string[], +): ServiceName[] { + // Validate requested names + const valid = new Set(ALL_SERVICES); + for (const name of requested) { + if (!valid.has(name)) { + throw new Error( + `Unknown service: "${name}". Available services: ${ALL_SERVICES.join(", ")}`, + ); + } + } + + // If none specified, use all enabled services + const selected = requested.length > 0 + ? requested + : ALL_SERVICES.filter((s) => { + const svc = config[s as keyof StackConfig]; + return typeof svc === "object" && "enabled" in svc ? svc.enabled : true; + }); + + // Always include postgres if keycloak or postgrest are selected + // Always include traefik if keycloak or postgrest are selected + const set = new Set(selected as ServiceName[]); + if (set.has("keycloak") || set.has("postgrest")) { + set.add("postgres"); + set.add("traefik"); + } + + return Array.from(set); +} + +/** + * Generate a docker-compose.yml string from the resolved config. + */ +export function generateComposeFile( + config: StackConfig, + services: ServiceName[], +): string { + const projectName = loadPostkitConfig().name ?? "postkit"; + const sections: string[] = [`name: ${projectName}`, "services:"]; + + if (services.includes("traefik")) { + sections.push(renderTraefik(config)); + } + + if (services.includes("postgres")) { + sections.push(renderPostgres(config)); + } + + if (services.includes("keycloak")) { + sections.push(renderKeycloak(config)); + } + + if (services.includes("postgrest")) { + sections.push(renderPostgrest(config)); + } + + // Network — explicit name prevents docker-compose project prefix, + // so external containers (keycloak-config-cli) can join by this exact name. + sections.push(` +networks: + ${config.network}: + name: ${config.network} + driver: bridge +`); + + // Volumes + const volumes: string[] = []; + if (services.includes("postgres")) { + volumes.push(` ${config.postgres.volume}:`); + } + if (services.includes("keycloak")) { + volumes.push(` ${config.keycloak.volume}:`); + } + if (volumes.length > 0) { + sections.push("volumes:\n" + volumes.join("\n") + "\n"); + } + + return sections.join("\n") + "\n"; +} + +/** + * Write the compose file to .postkit/stack/docker-compose.yml. + * Returns the file path. + */ +export function writeComposeFile( + config: StackConfig, + services: ServiceName[], +): string { + const stackDir = getStackDir(); + fs.mkdirSync(stackDir, {recursive: true}); + + const content = generateComposeFile(config, services); + const filePath = path.join(stackDir, "docker-compose.yml"); + fs.writeFileSync(filePath, content, "utf-8"); + return filePath; +} + +// ============================================ +// Service Renderers +// ============================================ + +function renderPostgres(config: StackConfig): string { + const pg = config.postgres; + const image = pg.image.replace("${pgVersion}", String(pg.pgVersion)); + return ` + postgres: + image: ${image} + container_name: postkit-postgres + ports: + - "${pg.port}:5432" + environment: + POSTGRES_USER: ${pg.user} + POSTGRES_PASSWORD: ${pg.password} + POSTGRES_DB: ${pg.database} + volumes: + - ${pg.volume}:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${pg.user}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - ${config.network} +`; +} + +function renderKeycloak(config: StackConfig): string { + const kc = config.keycloak; + const pg = config.postgres; + const providersDir = getProvidersDir(); + return ` + keycloak: + image: ${kc.image} + container_name: postkit-keycloak + command: start-dev + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/${pg.database} + KC_DB_USERNAME: ${pg.user} + KC_DB_PASSWORD: ${pg.password} + KC_DB_SCHEMA: auth + KC_DB_POOL_INITIAL_SIZE: 1 + KC_DB_POOL_MIN_SIZE: 1 + KC_DB_POOL_MAX_SIZE: 10 + KC_BOOTSTRAP_ADMIN_USERNAME: ${kc.adminUser} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${kc.adminPassword} + KEYCLOAK_ADMIN: ${kc.adminUser} + KEYCLOAK_ADMIN_PASSWORD: ${kc.adminPassword} + volumes: + - ${providersDir}:/opt/keycloak/providers + labels: + - "traefik.enable=true" + - "traefik.http.routers.keycloak.rule=Host(\`keycloak.localhost\`)" + - "traefik.http.routers.keycloak.entrypoints=web" + - "traefik.http.services.keycloak.loadbalancer.server.port=8080" + depends_on: + postgres: + condition: service_healthy + networks: + - ${config.network} +`; +} + +function renderPostgrest(config: StackConfig): string { + const pr = config.postgrest; + const pg = config.postgres; + return ` + postgrest: + image: ${pr.image} + container_name: postkit-postgrest + environment: + PGRST_DB_URI: postgres://${pg.user}:${pg.password}@postgres:5432/${pg.database} + PGRST_DB_SCHEMAS: ${pr.dbSchema} + PGRST_DB_ANON_ROLE: ${pr.dbAnonRole} + PGRST_JWT_JWKS: '${JSON.stringify(config.jwks)}' + labels: + - "traefik.enable=true" + - "traefik.http.routers.postgrest.rule=Host(\`api.localhost\`)" + - "traefik.http.routers.postgrest.entrypoints=web" + - "traefik.http.services.postgrest.loadbalancer.server.port=3000" + depends_on: + postgres: + condition: service_healthy + networks: + - ${config.network} +`; +} + +function renderTraefik(config: StackConfig): string { + const tr = config.traefik; + return ` + traefik: + image: ${tr.image} + container_name: postkit-traefik + command: + - "--api.insecure=true" + - "--api.dashboard=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:${tr.httpPort}" + ports: + - "${tr.httpPort}:${tr.httpPort}" + - "${tr.dashboardPort}:8080" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + networks: + - ${config.network} +`; +} diff --git a/cli/src/modules/stack/services/db-init.ts b/cli/src/modules/stack/services/db-init.ts new file mode 100644 index 0000000..109be45 --- /dev/null +++ b/cli/src/modules/stack/services/db-init.ts @@ -0,0 +1,78 @@ +import ora from "ora"; +import {Client} from "pg"; +import type {StackConfig} from "../types/config"; +import {applyInfraStep} from "../../db/services/infra-generator"; +import {runCommittedMigrate} from "../../db/services/dbmate"; +import {applySeedsStep} from "../../db/services/seed-generator"; + +const POSTKIT_SCHEMA_SQL = ` +CREATE SCHEMA IF NOT EXISTS postkit; +CREATE TABLE IF NOT EXISTS postkit.stack_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() +); +`.trim(); + +export function buildPgUrl(config: StackConfig): string { + return ( + `postgres://${config.postgres.user}:${encodeURIComponent(config.postgres.password)}` + + `@localhost:${config.postgres.port}/${config.postgres.database}` + ); +} + +async function connectWithRetry(pgUrl: string, retries = 10, delayMs = 2000): Promise { + let last: Error | undefined; + for (let i = 0; i < retries; i++) { + const client = new Client({connectionString: pgUrl}); + try { + await client.connect(); + return client; + } catch (err) { + last = err as Error; + await client.end().catch(() => undefined); + await new Promise((r) => setTimeout(r, delayMs)); + } + } + throw last ?? new Error("Could not connect to postgres after retries"); +} + +export async function applyStackDeploy( + config: StackConfig, + spinner: ReturnType, +): Promise { + const pgUrl = buildPgUrl(config); + + // Wait until postgres is truly ready — pg_isready can pass before queries work + spinner.start("Waiting for postgres to accept connections..."); + const client = await connectWithRetry(pgUrl); + try { + await client.query(POSTKIT_SCHEMA_SQL); + spinner.succeed("postkit schema initialised"); + } finally { + await client.end().catch(() => undefined); + } + + // Phase 1: Apply db/infra/*.sql (roles, schemas, extensions) + await applyInfraStep(spinner, pgUrl, "stack"); + + // Phase 2: Apply committed migrations + spinner.start("Running committed migrations..."); + const result = await runCommittedMigrate(pgUrl); + if (!result.success) { + const out = result.output ?? ""; + if ( + out.toLowerCase().includes("no migration files found") || + out.toLowerCase().includes("no migrations") + ) { + spinner.succeed("No committed migrations to apply"); + } else { + throw new Error(`Migration failed: ${out}`); + } + } else { + spinner.succeed("Committed migrations applied"); + } + + // Phase 3: Apply seeds + await applySeedsStep(spinner, pgUrl, "stack"); +} diff --git a/cli/src/modules/stack/services/docker-compose.ts b/cli/src/modules/stack/services/docker-compose.ts new file mode 100644 index 0000000..4208d4b --- /dev/null +++ b/cli/src/modules/stack/services/docker-compose.ts @@ -0,0 +1,210 @@ +import {spawn} from "child_process"; +import {commandExists, runCommand} from "../../../common/shell"; +import type {ShellResult} from "../../../common/types"; +import {PostkitError} from "../../../common/errors"; +import type {ServiceStatus} from "../types/config"; + +/** + * Verify Docker and Docker Compose v2 are available. + */ +export async function checkDockerComposeAvailable(): Promise { + const installed = await commandExists("docker"); + if (!installed) { + throw new PostkitError( + "Docker not found.", + "Install Docker Desktop from https://docker.com to use stack commands.", + ); + } + + const result = await runCommand("docker info --format '{{.}}'", {timeout: 10000}); + if (result.exitCode !== 0) { + throw new PostkitError( + "Docker is not running.", + "Start Docker Desktop and retry.", + ); + } + + const composeResult = await runCommand("docker compose version", {timeout: 10000}); + if (composeResult.exitCode !== 0) { + throw new PostkitError( + "Docker Compose V2 is not available.", + "Update Docker Desktop to get Docker Compose V2 (included by default).", + ); + } +} + +/** + * Run `docker compose up -d` for selected services. + */ +export async function composeUp( + composeFile: string, + services: string[], +): Promise { + const args = ["compose", "-f", composeFile, "up", "-d", ...services]; + return runDockerCompose(args); +} + +/** + * Run `docker compose down` optionally removing volumes. + */ +export async function composeDown( + composeFile: string, + options?: {volumes?: boolean}, +): Promise { + const args = ["compose", "-f", composeFile, "down"]; + if (options?.volumes) { + args.push("--volumes"); + } + return runDockerCompose(args); +} + +/** + * Run `docker compose ps --format json` and parse the result. + */ +export async function composeStatus( + composeFile: string, +): Promise { + const result = await runDockerCompose([ + "compose", "-f", composeFile, "ps", "--format", "json", + ]); + + if (result.exitCode !== 0) { + return []; + } + + return parseComposeStatus(result.stdout); +} + +/** + * Stream logs from docker compose. For follow mode, spawns a child process + * that pipes directly to stdout/stderr (runs until Ctrl+C). + * For non-follow mode, collects and returns. + */ +export async function composeLogs( + composeFile: string, + service?: string, + options?: {follow?: boolean; tail?: number}, +): Promise { + const args = ["compose", "-f", composeFile, "logs"]; + if (options?.tail) { + args.push("--tail", String(options.tail)); + } + if (options?.follow !== false) { + args.push("--follow"); + } + if (service) { + args.push(service); + } + + return new Promise((resolve) => { + const child = spawn("docker", args, { + stdio: ["ignore", "inherit", "inherit"], + }); + + child.on("close", () => resolve()); + child.on("error", () => resolve()); + }); +} + +/** + * Restart specific services or all services. + */ +export async function composeRestart( + composeFile: string, + services?: string[], +): Promise { + const args = ["compose", "-f", composeFile, "restart"]; + if (services && services.length > 0) { + args.push(...services); + } + return runDockerCompose(args); +} + +/** + * Parse `docker compose ps --format json` output into ServiceStatus[]. + * Docker Compose v2 outputs one JSON object per line (NDJSON). + */ +export function parseComposeStatus(output: string): ServiceStatus[] { + const statuses: ServiceStatus[] = []; + + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const obj = JSON.parse(trimmed); + // Docker Compose v2 format + const health = obj.Health ?? obj.HealthStatus ?? ""; + const ports = obj.Publishers ?? obj.Ports ?? []; + const port = Array.isArray(ports) && ports.length > 0 + ? (ports[0] as Record).PublishedPort ?? (ports[0] as Record).PublicPort ?? null + : null; + + statuses.push({ + name: obj.Name ?? obj.Names ?? "", + service: obj.Service ?? obj.Labels?.["com.docker.compose.service"] ?? "", + state: obj.State ?? obj.Status ?? "", + health: typeof health === "string" ? health : "", + ports: formatPorts(ports), + publisherPort: typeof port === "number" ? port : null, + }); + } catch { + // Skip unparseable lines + } + } + + return statuses; +} + +// ============================================ +// Internal Helpers +// ============================================ + +function runDockerCompose(args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn("docker", args, { + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.on("close", (code) => { + resolve({ + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: code ?? 1, + }); + }); + + child.on("error", (error) => { + resolve({ + stdout: "", + stderr: error.message, + exitCode: 1, + }); + }); + }); +} + +function formatPorts(ports: unknown): string { + if (!Array.isArray(ports)) return ""; + return ports + .map((p: Record) => { + const pub = p.PublishedPort ?? p.PublicPort; + const priv = p.TargetPort ?? p.PrivatePort; + if (pub && priv) return `${pub}:${priv}`; + if (priv) return String(priv); + return ""; + }) + .filter(Boolean) + .join(", "); +} diff --git a/cli/src/modules/stack/services/health.ts b/cli/src/modules/stack/services/health.ts new file mode 100644 index 0000000..8bf8d4c --- /dev/null +++ b/cli/src/modules/stack/services/health.ts @@ -0,0 +1,130 @@ +import http from "http"; +import net from "net"; +import type {Ora} from "ora"; +import type {StackConfig} from "../types/config"; + +const DEFAULT_MAX_ATTEMPTS = 60; +const RETRY_DELAY_MS = 2000; + +/** + * Wait for a TCP connection to become available (used for PostgreSQL). + */ +export async function waitForPostgres( + host: string, + port: number, + maxAttempts = DEFAULT_MAX_ATTEMPTS, +): Promise { + for (let i = 0; i < maxAttempts; i++) { + if (await isTcpReachable(host, port)) return; + await sleep(RETRY_DELAY_MS); + } + throw new Error(`PostgreSQL at ${host}:${port} did not become ready within ${maxAttempts * RETRY_DELAY_MS / 1000}s`); +} + +/** + * Wait for an HTTP health endpoint to return 2xx. + */ +export async function waitForHttp( + url: string, + serviceName: string, + maxAttempts = DEFAULT_MAX_ATTEMPTS, +): Promise { + for (let i = 0; i < maxAttempts; i++) { + try { + const ok = await httpGetOk(url); + if (ok) return; + } catch { + // Connection refused / reset — service not up yet + } + await sleep(RETRY_DELAY_MS); + } + throw new Error(`${serviceName} at ${url} did not become ready within ${maxAttempts * RETRY_DELAY_MS / 1000}s`); +} + +/** + * Wait for all started services to become healthy. + * Updates the spinner with progress as services become ready. + */ +export async function waitForAllServices( + config: StackConfig, + services: string[], + spinner: Ora, +): Promise { + const checks: Promise[] = []; + + for (const service of services) { + switch (service) { + case "postgres": { + const check = waitForPostgres("localhost", config.postgres.port) + .then(() => { spinner.text = `${spinner.text} (postgres ready)`; }); + checks.push(check); + break; + } + case "keycloak": { + const url = `http://keycloak.localhost/realms/master`; + const check = waitForHttp(url, "Keycloak") + .then(() => { spinner.text = `${spinner.text} (keycloak ready)`; }); + checks.push(check); + break; + } + case "postgrest": { + const url = `http://api.localhost/`; + const check = waitForHttp(url, "PostgREST") + .then(() => { spinner.text = `${spinner.text} (postgrest ready)`; }); + checks.push(check); + break; + } + case "traefik": { + const url = `http://localhost:${config.traefik.dashboardPort}/dashboard/`; + const check = waitForHttp(url, "Traefik") + .then(() => { spinner.text = `${spinner.text} (traefik ready)`; }); + checks.push(check); + break; + } + } + } + + await Promise.all(checks); +} + +// ============================================ +// Internal Helpers +// ============================================ + +function isTcpReachable(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({host, port}); + socket.setTimeout(2000); + socket.on("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + resolve(false); + }); + socket.on("timeout", () => { + socket.destroy(); + resolve(false); + }); + }); +} + +function httpGetOk(url: string): Promise { + return new Promise((resolve, reject) => { + const req = http.get(url, {timeout: 3000}, (res) => { + res.resume(); // drain the response + // Accept 1xx–4xx; reject 5xx (e.g. Traefik 502 when backend not ready yet) + resolve(res.statusCode !== undefined && res.statusCode > 0 && res.statusCode < 500); + }); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("timeout")); + }); + }); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/cli/src/modules/stack/services/keycloak-keys.ts b/cli/src/modules/stack/services/keycloak-keys.ts new file mode 100644 index 0000000..37c54aa --- /dev/null +++ b/cli/src/modules/stack/services/keycloak-keys.ts @@ -0,0 +1,300 @@ +import http from "http"; +import fs from "fs"; +import type {Ora} from "ora"; +import {getSecretsFilePath} from "../../../common/config"; +import type {StackConfig, StackJwkKey, StackJwksSecrets, StackClientSecrets} from "../types/config"; + +// ============================================ +// Public Result Types +// ============================================ + +export interface KeysResult { + jwks: StackJwksSecrets; + jwk: StackJwkKey; + clients: Record; +} + +// ============================================ +// URL Helpers +// ============================================ + +/** + * Returns the Keycloak URL via Traefik. + * Uses http://keycloak.localhost if httpPort is 80, otherwise includes the port. + */ +export function getKeycloakUrl(config: StackConfig): string { + if (config.traefik.httpPort === 80) { + return "http://keycloak.localhost"; + } + return `http://keycloak.localhost:${config.traefik.httpPort}`; +} + +// ============================================ +// HTTP Helpers (Node built-in only) +// ============================================ + +/** + * Perform an HTTP GET request. Returns the response body string. + * Throws on non-2xx status codes. + */ +function httpGet(url: string, bearerToken?: string): Promise { + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (bearerToken) { + headers["Authorization"] = `Bearer ${bearerToken}`; + } + + const req = http.get(url, {headers, timeout: 15000}, (res) => { + let body = ""; + res.on("data", (chunk: Buffer) => { body += chunk.toString(); }); + res.on("end", () => { + const status = res.statusCode ?? 0; + if (status >= 200 && status < 300) { + resolve(body); + } else { + reject(new Error(`GET ${url} returned ${status}: ${body.slice(0, 200)}`)); + } + }); + }); + + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error(`GET ${url} timed out`)); + }); + }); +} + +/** + * Perform an HTTP POST request. Returns the response body string. + * Throws on non-2xx status codes. + */ +function httpPost( + url: string, + body: string, + contentType = "application/x-www-form-urlencoded", +): Promise { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const options: http.RequestOptions = { + hostname: urlObj.hostname, + port: urlObj.port || 80, + path: urlObj.pathname + urlObj.search, + method: "POST", + headers: { + "Content-Type": contentType, + "Content-Length": Buffer.byteLength(body), + }, + timeout: 15000, + }; + + const req = http.request(options, (res) => { + let responseBody = ""; + res.on("data", (chunk: Buffer) => { responseBody += chunk.toString(); }); + res.on("end", () => { + const status = res.statusCode ?? 0; + if (status >= 200 && status < 300) { + resolve(responseBody); + } else { + reject(new Error(`POST ${url} returned ${status}: ${responseBody.slice(0, 200)}`)); + } + }); + }); + + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error(`POST ${url} timed out`)); + }); + + req.write(body); + req.end(); + }); +} + +// ============================================ +// Keycloak API Calls +// ============================================ + +/** + * Fetch the JWKS from Keycloak's OIDC endpoint for the given realm. + */ +export async function fetchKeycloakJwks( + keycloakUrl: string, + realm: string, +): Promise { + const url = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/certs`; + const body = await httpGet(url); + const parsed = JSON.parse(body) as {keys: StackJwkKey[]}; + return parsed.keys ?? []; +} + +/** + * Extract the primary RSA signing key from a JWKS key list. + * Returns only the fields needed for JWT verification. + */ +export function extractRsaKey(keys: StackJwkKey[]): StackJwkKey | undefined { + const rsaKey = keys.find( + (k) => k.kty === "RSA" && (k.use === "sig" || k.use === undefined), + ); + if (!rsaKey) return undefined; + + return { + kid: rsaKey.kid, + kty: "RSA", + alg: "RS256", + use: "sig", + key_ops: ["verify"], + n: rsaKey.n, + e: rsaKey.e, + }; +} + +/** + * Obtain a Keycloak admin access token from the master realm. + */ +export async function getAdminToken( + keycloakUrl: string, + adminUser: string, + adminPassword: string, +): Promise { + const url = `${keycloakUrl}/realms/master/protocol/openid-connect/token`; + const body = [ + `username=${encodeURIComponent(adminUser)}`, + `password=${encodeURIComponent(adminPassword)}`, + "grant_type=password", + "client_id=admin-cli", + ].join("&"); + + const responseBody = await httpPost(url, body); + const parsed = JSON.parse(responseBody) as {access_token: string}; + return parsed.access_token; +} + +/** + * Fetch credentials for a single Keycloak client. + * Returns the client secret and a client-credentials access token. + */ +export async function fetchClientCredentials( + keycloakUrl: string, + clientRealm: string, + clientName: string, + adminToken: string, +): Promise { + // Step 1: Look up the client's internal UUID + const listUrl = `${keycloakUrl}/admin/realms/${clientRealm}/clients?clientId=${encodeURIComponent(clientName)}`; + const listBody = await httpGet(listUrl, adminToken); + const clients = JSON.parse(listBody) as Array<{id: string}>; + if (!clients || clients.length === 0 || !clients[0]) { + throw new Error(`Keycloak client "${clientName}" not found in realm "${clientRealm}"`); + } + const uuid = clients[0].id; + + // Step 2: Fetch the client secret + const secretUrl = `${keycloakUrl}/admin/realms/${clientRealm}/clients/${uuid}/client-secret`; + const secretBody = await httpGet(secretUrl, adminToken); + const secretParsed = JSON.parse(secretBody) as {value: string}; + const secret = secretParsed.value; + + // Step 3: Exchange client credentials for an access token + const tokenUrl = `${keycloakUrl}/realms/${clientRealm}/protocol/openid-connect/token`; + const tokenBody = [ + `client_id=${encodeURIComponent(clientName)}`, + `client_secret=${encodeURIComponent(secret)}`, + "grant_type=client_credentials", + ].join("&"); + + const tokenResponse = await httpPost(tokenUrl, tokenBody); + const tokenParsed = JSON.parse(tokenResponse) as {access_token: string}; + const token = tokenParsed.access_token; + + return {secret, token}; +} + +// ============================================ +// Main Fetch Orchestration +// ============================================ + +/** + * Fetch JWKs and client credentials from a running Keycloak and merge them + * with the existing oct signing key. + */ +export async function fetchAndMergeKeys( + config: StackConfig, + spinner?: Ora, +): Promise { + const keycloakUrl = getKeycloakUrl(config); + + // Fetch OIDC JWKs + if (spinner) spinner.text = "Fetching JWKs from Keycloak..."; + const oidcKeys = await fetchKeycloakJwks(keycloakUrl, config.keycloak.realm); + + // Extract RSA signing key + const jwk = extractRsaKey(oidcKeys); + if (!jwk) { + throw new Error( + `No RSA signing key found in Keycloak realm "${config.keycloak.realm}". ` + + "Ensure the realm is configured and Keycloak is fully initialised.", + ); + } + + // Preserve the existing oct URL-signing key + const existingOctKey = config.jwks.urlSigningKey; + + // Build merged JWKS: RSA keys from Keycloak + existing oct key + const mergedKeys: StackJwkKey[] = [...oidcKeys]; + if (existingOctKey) { + mergedKeys.push(existingOctKey); + } + + const jwks: StackJwksSecrets = { + keys: mergedKeys, + ...(existingOctKey ? {urlSigningKey: existingOctKey} : {}), + }; + + // Fetch admin token + if (spinner) spinner.text = "Getting admin token..."; + const adminToken = await getAdminToken( + keycloakUrl, + config.keycloak.adminUser, + config.keycloak.adminPassword, + ); + + // Fetch credentials for each configured client + const clients: Record = {}; + for (const clientName of config.keycloakClients) { + if (spinner) spinner.text = `Fetching credentials for client "${clientName}"...`; + clients[clientName] = await fetchClientCredentials( + keycloakUrl, + config.keycloak.clientRealm, + clientName, + adminToken, + ); + } + + return {jwks, jwk, clients}; +} + +// ============================================ +// Secrets Writer +// ============================================ + +/** + * Write the fetched keys/clients back to postkit.secrets.json. + */ +export function writeKeysToSecrets(result: KeysResult): void { + const secretsPath = getSecretsFilePath(); + const secrets: Record = fs.existsSync(secretsPath) + ? (JSON.parse(fs.readFileSync(secretsPath, "utf-8")) as Record) + : {}; + + if (!secrets.stack) { + secrets.stack = {}; + } + const ss = secrets.stack as Record; + ss.jwks = result.jwks; + ss.jwk = result.jwk; + ss.clients = result.clients; + + fs.writeFileSync(secretsPath, JSON.stringify(secrets, null, 2) + "\n", "utf-8"); +} diff --git a/cli/src/modules/stack/services/realm-init.ts b/cli/src/modules/stack/services/realm-init.ts new file mode 100644 index 0000000..1871baf --- /dev/null +++ b/cli/src/modules/stack/services/realm-init.ts @@ -0,0 +1,216 @@ +import fs from "fs"; +import path from "path"; +import {mkdtemp, writeFile, rm} from "fs/promises"; +import {tmpdir} from "os"; +import type {Ora} from "ora"; +import {projectRoot} from "../../../common/config"; +import {runSpawnCommand} from "../../../common/shell"; +import type {StackConfig} from "../types/config"; +const CONFIG_CLI_IMAGE = "adorsys/keycloak-config-cli:latest-26"; + +// ============================================ +// Built-in Keycloak clients — never import these +// ============================================ + +const BUILTIN_CLIENTS = new Set([ + "account", + "account-console", + "admin-cli", + "broker", + "realm-management", + "security-admin-console", +]); + +// ============================================ +// PostKit default protocol mapper — injected into every non-builtin client +// ============================================ + +const JWT_ROLE_MAPPER = { + name: "JWT Role Mapper", + protocol: "openid-connect", + protocolMapper: "script-primary-role.js", + consentRequired: false, + config: {}, +}; + +// ============================================ +// Types +// ============================================ + +interface RealmRole { + id?: string; + name?: string; + description?: string; + composite?: boolean; + clientRole?: boolean; + attributes?: Record; + [key: string]: unknown; +} + +interface RealmClient { + id?: string; + clientId?: string; + secret?: string; + registrationAccessToken?: string; + attributes?: Record; + serviceAccountRealmRoles?: string[]; + [key: string]: unknown; +} + +// ============================================ +// Realm Template Cleaner +// ============================================ + +export function cleanRealmTemplate( + raw: Record, + realmName: string, +): Record { + // Deep clone to avoid mutating the original + const cleaned = JSON.parse(JSON.stringify(raw)) as Record; + + // Set realm name and remove id + cleaned.realm = realmName; + delete cleaned.id; + + // Filter and clean clients + if (Array.isArray(cleaned.clients)) { + const filteredClients = (cleaned.clients as RealmClient[]) + .filter((client) => { + const clientId = client.clientId as string | undefined; + return clientId !== undefined && !BUILTIN_CLIENTS.has(clientId); + }) + .map((client) => { + // Strip sensitive/generated fields + delete client.id; + delete client.secret; + delete client.registrationAccessToken; + if (client.attributes) { + delete client.attributes["client.secret.creation.time"]; + } + + // Set service account realm roles for known clients + const clientId = client.clientId as string | undefined; + if (clientId === "supabase_service") { + client.serviceAccountRealmRoles = ["service_role", "app_user"]; + } else if (clientId === "anon") { + client.serviceAccountRealmRoles = ["anon"]; + } + + // Inject JWT Role Mapper if not already present + const mappers = (client.protocolMappers ?? []) as Array>; + const hasJwtMapper = mappers.some((m) => m.name === JWT_ROLE_MAPPER.name); + if (!hasJwtMapper) { + client.protocolMappers = [...mappers, JWT_ROLE_MAPPER]; + } + + return client; + }); + + cleaned.clients = filteredClients; + } + + // Ensure admin role exists in realm roles and strip ids + const roles = (cleaned.roles ?? {}) as Record; + cleaned.roles = roles; + + if (!Array.isArray(roles.realm)) { + roles.realm = []; + } + + const realmRoles = roles.realm as RealmRole[]; + + const hasAdminRole = realmRoles.some((r) => r.name === "admin"); + if (!hasAdminRole) { + realmRoles.push({ + name: "admin", + description: "Administrator role", + composite: false, + clientRole: false, + attributes: {}, + }); + } + + // Strip id from every realm role + roles.realm = realmRoles.map((role) => { + const cleaned = {...role}; + delete cleaned.id; + return cleaned; + }); + + // Remove built-in client keys from roles.client + if (roles.client && typeof roles.client === "object" && !Array.isArray(roles.client)) { + const clientRoles = roles.client as Record; + for (const builtinKey of BUILTIN_CLIENTS) { + delete clientRoles[builtinKey]; + } + } + + return cleaned; +} + +// ============================================ +// Main Import Function +// ============================================ + +export async function importRealmTemplate( + config: StackConfig, + spinner?: Ora, +): Promise { + if (!config.keycloak.realmTemplate) { + return; + } + + const templatePath = path.resolve(projectRoot, config.keycloak.realmTemplate); + + if (!fs.existsSync(templatePath)) { + throw new Error(`Realm template not found: ${templatePath}`); + } + + const raw = JSON.parse(fs.readFileSync(templatePath, "utf-8")) as Record; + const cleaned = cleanRealmTemplate(raw, config.keycloak.realm); + + const tmpDir = await mkdtemp(path.join(tmpdir(), "postkit-realm-")); + + try { + // Write cleaned realm JSON to temp file + const cleanedRealmFile = path.join(tmpDir, "realm.json"); + await writeFile(cleanedRealmFile, JSON.stringify(cleaned, null, 2), {mode: 0o600}); + + // Use internal Docker DNS name — keycloak-config-cli runs inside Docker, + // so it must reach Keycloak via the container network, not the Traefik hostname. + const internalKeycloakUrl = `http://keycloak:8080`; + + // Write env file + const envFile = path.join(tmpDir, "realm-import.env"); + const envContent = [ + `KEYCLOAK_URL=${internalKeycloakUrl}/`, + `KEYCLOAK_USER=${config.keycloak.adminUser}`, + `KEYCLOAK_PASSWORD=${config.keycloak.adminPassword}`, + "KEYCLOAK_AVAILABILITYCHECK_ENABLED=true", + "KEYCLOAK_AVAILABILITYCHECK_TIMEOUT=120s", + "IMPORT_FILES_LOCATIONS=/config/realm.json", + ].join("\n"); + + await writeFile(envFile, envContent, {mode: 0o600}); + + if (spinner) { + spinner.text = `Importing realm "${config.keycloak.realm}" into Keycloak...`; + } + + const result = await runSpawnCommand([ + "docker", "run", "--rm", + "--network", config.network, + "--env-file", envFile, + "-v", `${cleanedRealmFile}:/config/realm.json`, + CONFIG_CLI_IMAGE, + ]); + + if (result.exitCode !== 0) { + throw new Error( + `keycloak-config-cli import failed:\n${result.stderr || result.stdout}`, + ); + } + } finally { + await rm(tmpDir, {recursive: true, force: true}); + } +} diff --git a/cli/src/modules/stack/services/scaffold.ts b/cli/src/modules/stack/services/scaffold.ts new file mode 100644 index 0000000..e741998 --- /dev/null +++ b/cli/src/modules/stack/services/scaffold.ts @@ -0,0 +1,34 @@ +import fs from "fs"; +import path from "path"; +import {projectRoot} from "../../../common/config"; + +const DEFAULT_REALM_NAME = "postkit"; +const DEFAULT_REALM_TEMPLATE_PATH = ".postkit/auth/realm/postkit.json"; + +const MINIMAL_REALM_TEMPLATE = { + realm: DEFAULT_REALM_NAME, + enabled: true, + clients: [], + roles: { + realm: [], + client: {}, + }, +}; + +/** + * Scaffold the default realm template at .postkit/auth/realm/postkit.json. + * Safe to call multiple times — never overwrites existing files. + * Returns true if created, false if already existed. + */ +export function scaffoldRealmTemplate(): boolean { + const realmDir = path.join(projectRoot, ".postkit", "auth", "realm"); + const realmFile = path.join(realmDir, "postkit.json"); + + fs.mkdirSync(realmDir, {recursive: true}); + + if (fs.existsSync(realmFile)) return false; + fs.writeFileSync(realmFile, JSON.stringify(MINIMAL_REALM_TEMPLATE, null, 2) + "\n"); + return true; +} + +export {DEFAULT_REALM_TEMPLATE_PATH}; diff --git a/cli/src/modules/stack/services/sync-providers.ts b/cli/src/modules/stack/services/sync-providers.ts new file mode 100644 index 0000000..3fe529b --- /dev/null +++ b/cli/src/modules/stack/services/sync-providers.ts @@ -0,0 +1,49 @@ +import fs from "fs"; +import path from "path"; +import type {Ora} from "ora"; +import {cliRoot, projectRoot, getPostkitAuthDir} from "../../../common/config"; + +export function getProvidersDir(): string { + return path.join(getPostkitAuthDir(), "providers"); +} + +/** + * Copy Keycloak provider JARs into .postkit/auth/providers/ from two sources: + * 1. cli/vendor/providers/ — bundled JARs shipped with PostKit + * 2. auth/providers//target/ — project-specific JARs built locally + * The dest dir is mounted into the Keycloak container at /opt/keycloak/providers. + */ +export function syncKeycloakProviders(spinner?: Ora): void { + const destDir = getProvidersDir(); + fs.mkdirSync(destDir, {recursive: true}); + + const copied: string[] = []; + + // Source 1: bundled vendor JARs + const vendorProvidersDir = path.join(cliRoot, "vendor", "providers"); + if (fs.existsSync(vendorProvidersDir)) { + for (const file of fs.readdirSync(vendorProvidersDir)) { + if (!file.endsWith(".jar")) continue; + fs.copyFileSync(path.join(vendorProvidersDir, file), path.join(destDir, file)); + copied.push(file); + } + } + + // Source 2: project-specific JARs from auth/providers//target/ + const projectProvidersDir = path.join(projectRoot, "auth", "providers"); + if (fs.existsSync(projectProvidersDir)) { + for (const providerDir of fs.readdirSync(projectProvidersDir)) { + const targetDir = path.join(projectProvidersDir, providerDir, "target"); + if (!fs.existsSync(targetDir)) continue; + for (const file of fs.readdirSync(targetDir)) { + if (!file.endsWith(".jar")) continue; + fs.copyFileSync(path.join(targetDir, file), path.join(destDir, file)); + copied.push(file); + } + } + } + + if (copied.length > 0 && spinner) { + spinner.succeed(`Keycloak providers synced: ${copied.join(", ")}`); + } +} diff --git a/cli/src/modules/stack/types/config.ts b/cli/src/modules/stack/types/config.ts new file mode 100644 index 0000000..c8f7bf4 --- /dev/null +++ b/cli/src/modules/stack/types/config.ts @@ -0,0 +1,168 @@ +/** + * Stack module types - single source of truth for stack configuration + */ + +// ============================================ +// Per-Service Runtime Config (fully resolved with defaults) +// ============================================ + +export interface StackPostgresConfig { + image: string; + enabled: boolean; + port: number; + user: string; + password: string; + database: string; + pgVersion: number; + volume: string; +} + +export interface StackKeycloakConfig { + image: string; + enabled: boolean; + port: number; + adminUser: string; + adminPassword: string; + realm: string; + clientRealm: string; + volume: string; + realmTemplate: string; +} + +export interface StackPostgrestConfig { + image: string; + enabled: boolean; + port: number; + dbSchema: string; + dbAnonRole: string; +} + +// ============================================ +// JWKS / JWK Types +// ============================================ + +export interface StackJwkKey { + kty: string; + kid?: string; + alg?: string; + use?: string; + n?: string; + e?: string; + k?: string; + key_ops?: string[]; +} + +export interface StackJwksSecrets { + keys: StackJwkKey[]; + urlSigningKey?: StackJwkKey; +} + +export interface StackClientSecrets { + secret?: string; + token?: string; +} + +export interface StackTraefikConfig { + image: string; + enabled: boolean; + httpPort: number; + dashboardPort: number; +} + +// ============================================ +// Fully Resolved Runtime Config +// ============================================ + +export interface StackConfig { + postgres: StackPostgresConfig; + keycloak: StackKeycloakConfig; + postgrest: StackPostgrestConfig; + traefik: StackTraefikConfig; + network: string; + jwks: StackJwksSecrets; + jwk?: StackJwkKey; + clients?: Record; + keycloakClients: string[]; +} + +// ============================================ +// Public Config Shape (postkit.config.json — committed) +// ============================================ + +export interface StackPostgresPublicConfig { + enabled?: boolean; + port?: number; + pgVersion?: number; + image?: string; + database?: string; + volume?: string; +} + +export interface StackKeycloakPublicConfig { + enabled?: boolean; + port?: number; + image?: string; + realm?: string; + volume?: string; + clientRealm?: string; + clients?: string[]; + realmTemplate?: string; +} + +export interface StackPostgrestPublicConfig { + enabled?: boolean; + port?: number; + image?: string; + dbSchema?: string; + dbAnonRole?: string; +} + +export interface StackTraefikPublicConfig { + enabled?: boolean; + httpPort?: number; + dashboardPort?: number; + image?: string; +} + +export interface StackPublicConfig { + postgres?: StackPostgresPublicConfig; + keycloak?: StackKeycloakPublicConfig; + postgrest?: StackPostgrestPublicConfig; + traefik?: StackTraefikPublicConfig; + network?: string; +} + +// ============================================ +// Secrets Config Shape (postkit.secrets.json — gitignored) +// ============================================ + +export interface StackPostgresSecrets { + user?: string; + password?: string; +} + +export interface StackKeycloakSecrets { + adminUser?: string; + adminPassword?: string; +} + +export interface StackSecretsConfig { + postgres?: StackPostgresSecrets; + keycloak?: StackKeycloakSecrets; + jwks?: StackJwksSecrets; + jwk?: StackJwkKey; + clients?: Record; +} + +// ============================================ +// Docker Compose Status Types +// ============================================ + +export interface ServiceStatus { + name: string; + service: string; + state: string; + health: string; + ports: string; + publisherPort: number | null; +} diff --git a/cli/src/modules/stack/types/index.ts b/cli/src/modules/stack/types/index.ts new file mode 100644 index 0000000..0e011a4 --- /dev/null +++ b/cli/src/modules/stack/types/index.ts @@ -0,0 +1,17 @@ +export type { + StackPostgresConfig, + StackKeycloakConfig, + StackPostgrestConfig, + StackConfig, + StackPostgresPublicConfig, + StackKeycloakPublicConfig, + StackPostgrestPublicConfig, + StackPublicConfig, + StackPostgresSecrets, + StackKeycloakSecrets, + StackJwkKey, + StackJwksSecrets, + StackClientSecrets, + StackSecretsConfig, + ServiceStatus, +} from "./config"; diff --git a/cli/src/modules/stack/utils/stack-config.ts b/cli/src/modules/stack/utils/stack-config.ts new file mode 100644 index 0000000..3c26d64 --- /dev/null +++ b/cli/src/modules/stack/utils/stack-config.ts @@ -0,0 +1,306 @@ +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; +import {z} from "zod"; +import {getPostkitDir, loadPostkitConfig, getSecretsFilePath} from "../../../common/config"; +import type { + StackConfig, + StackPostgresConfig, + StackKeycloakConfig, + StackPostgrestConfig, + StackTraefikConfig, + StackSecretsConfig, + StackJwksSecrets, + StackJwkKey, + StackClientSecrets, +} from "../types/config"; + +// Re-export for convenience +export type {StackConfig, StackSecretsConfig} from "../types/config"; + +// ============================================ +// Constants & Defaults +// ============================================ + +const DEFAULT_POSTGRES_IMAGE = "postgres:16-alpine"; +const DEFAULT_KEYCLOAK_IMAGE = "quay.io/keycloak/keycloak:26.6"; +const DEFAULT_POSTGREST_IMAGE = "postgrest/postgrest:latest"; +const DEFAULT_TRAEFIK_IMAGE = "traefik:v3.3"; +const DEFAULT_NETWORK = "postkit-net"; + +const DEFAULT_POSTGRES_PORT = 25432; +const DEFAULT_KEYCLOAK_PORT = 28080; +const DEFAULT_POSTGREST_PORT = 3000; +const DEFAULT_TRAEFIK_HTTP_PORT = 80; +const DEFAULT_TRAEFIK_DASHBOARD_PORT = 8080; + +// ============================================ +// Zod Schemas +// ============================================ + +const PostgresPublicSchema = z.object({ + enabled: z.boolean().optional(), + port: z.number().int().min(1).max(65535).optional(), + pgVersion: z.number().int().min(12).max(18).optional(), + image: z.string().min(1).optional(), + database: z.string().min(1).optional(), + volume: z.string().min(1).optional(), +}); + +const KeycloakPublicSchema = z.object({ + enabled: z.boolean().optional(), + port: z.number().int().min(1).max(65535).optional(), + image: z.string().min(1).optional(), + realm: z.string().min(1).optional(), + volume: z.string().min(1).optional(), + clientRealm: z.string().min(1).optional(), + clients: z.array(z.string()).optional(), + realmTemplate: z.string().optional(), +}); + +const PostgrestPublicSchema = z.object({ + enabled: z.boolean().optional(), + port: z.number().int().min(1).max(65535).optional(), + image: z.string().min(1).optional(), + dbSchema: z.string().min(1).optional(), + dbAnonRole: z.string().min(1).optional(), +}); + +const TraefikPublicSchema = z.object({ + enabled: z.boolean().optional(), + httpPort: z.number().int().min(1).max(65535).optional(), + dashboardPort: z.number().int().min(1).max(65535).optional(), + image: z.string().min(1).optional(), +}); + +const StackPublicSchema = z.object({ + postgres: PostgresPublicSchema.optional(), + keycloak: KeycloakPublicSchema.optional(), + postgrest: PostgrestPublicSchema.optional(), + traefik: TraefikPublicSchema.optional(), + network: z.string().min(1).optional(), +}); + +const PostgresSecretsSchema = z.object({ + user: z.string().min(1).optional(), + password: z.string().min(1).optional(), +}); + +const KeycloakSecretsSchema = z.object({ + adminUser: z.string().min(1).optional(), + adminPassword: z.string().min(1).optional(), +}); + +const StackSecretsSchema = z.object({ + postgres: PostgresSecretsSchema.optional(), + keycloak: KeycloakSecretsSchema.optional(), +}); + +// ============================================ +// Helpers +// ============================================ + +function generateSecret(length = 32): string { + return crypto.randomBytes(length).toString("hex"); +} + +function generateOctJwk(kid = "postkit-signing-key"): StackJwkKey { + const k = crypto.randomBytes(32).toString("base64url"); + return {kty: "oct", kid, alg: "HS256", k}; +} + +function formatZodErrors(error: z.ZodError): string { + const lines = ["Invalid stack configuration:"]; + for (const issue of error.issues) { + const p = issue.path.join("."); + lines.push(` - ${p}: ${issue.message}`); + } + return lines.join("\n"); +} + +// ============================================ +// Config Loader +// ============================================ + +export function getStackConfig(): StackConfig { + const config = loadPostkitConfig(); + const raw = config.stack ?? {}; + + // Validate public config + const pubResult = StackPublicSchema.safeParse(raw); + if (!pubResult.success) { + throw new Error(formatZodErrors(pubResult.error)); + } + const pub = pubResult.data; + + // Validate secrets + const secretsRaw = (raw as Record).postgres || + (raw as Record).keycloak || + (raw as Record).postgrest + ? raw + : {}; + + const secResult = StackSecretsSchema.safeParse(secretsRaw); + if (!secResult.success) { + throw new Error(formatZodErrors(secResult.error)); + } + // Secrets are already merged into config by loadPostkitConfig() + + // Build resolved configs + const pgSec = ((raw as Record).postgres ?? {}) as Record; + const kcSec = ((raw as Record).keycloak ?? {}) as Record; + const pgPub = (pub.postgres ?? {}) as Record; + const kcPub = (pub.keycloak ?? {}) as Record; + const prPub = (pub.postgrest ?? {}) as Record; + const trPub = (pub.traefik ?? {}) as Record; + + const postgres: StackPostgresConfig = { + image: (pgPub.image as string) ?? DEFAULT_POSTGRES_IMAGE, + enabled: (pgPub.enabled as boolean) ?? true, + port: (pgPub.port as number) ?? DEFAULT_POSTGRES_PORT, + user: (pgSec.user as string) ?? "postgres", + password: (pgSec.password as string) ?? "", + database: (pgPub.database as string) ?? "postkit", + pgVersion: (pgPub.pgVersion as number) ?? 16, + volume: (pgPub.volume as string) ?? "postkit-pgdata", + }; + + const kcRealm = (kcPub.realm as string) ?? "postkit"; + const keycloak: StackKeycloakConfig = { + image: (kcPub.image as string) ?? DEFAULT_KEYCLOAK_IMAGE, + enabled: (kcPub.enabled as boolean) ?? true, + port: (kcPub.port as number) ?? DEFAULT_KEYCLOAK_PORT, + adminUser: (kcSec.adminUser as string) ?? "admin", + adminPassword: (kcSec.adminPassword as string) ?? "", + realm: kcRealm, + clientRealm: (kcPub.clientRealm as string) ?? kcRealm, + volume: (kcPub.volume as string) ?? "postkit-keycloak-data", + realmTemplate: (kcPub.realmTemplate as string) ?? "", + }; + + const keycloakClients: string[] = (kcPub.clients as string[]) ?? []; + + const postgrest: StackPostgrestConfig = { + image: (prPub.image as string) ?? DEFAULT_POSTGREST_IMAGE, + enabled: (prPub.enabled as boolean) ?? true, + port: (prPub.port as number) ?? DEFAULT_POSTGREST_PORT, + dbSchema: (prPub.dbSchema as string) ?? "public", + dbAnonRole: (prPub.dbAnonRole as string) ?? "anon", + }; + + const traefik: StackTraefikConfig = { + image: (trPub.image as string) ?? DEFAULT_TRAEFIK_IMAGE, + enabled: (trPub.enabled as boolean) ?? true, + httpPort: (trPub.httpPort as number) ?? DEFAULT_TRAEFIK_HTTP_PORT, + dashboardPort: (trPub.dashboardPort as number) ?? DEFAULT_TRAEFIK_DASHBOARD_PORT, + }; + + // Read jwks / jwk / clients from secrets (populated by ensureStackSecrets / stack keys) + const secretsFile: Record = fs.existsSync(getSecretsFilePath()) + ? JSON.parse(fs.readFileSync(getSecretsFilePath(), "utf-8")) + : {}; + const ss = ((secretsFile.stack ?? {}) as Record); + + const jwks: StackJwksSecrets = (ss.jwks as StackJwksSecrets) ?? {keys: []}; + const jwk: StackJwkKey | undefined = ss.jwk as StackJwkKey | undefined; + const clients: Record | undefined = + ss.clients as Record | undefined; + + return { + postgres, + keycloak, + postgrest, + traefik, + network: pub.network ?? DEFAULT_NETWORK, + jwks, + jwk, + clients, + keycloakClients, + }; +} + +// ============================================ +// Secrets Auto-Generation +// ============================================ + +/** + * Ensure all required secrets have values. Generates random ones for any that + * are empty and writes them back to postkit.secrets.json. + * Returns the updated StackConfig. + */ +export function ensureStackSecrets(config: StackConfig): StackConfig { + let needsWrite = false; + const secretsPath = getSecretsFilePath(); + + // Read current secrets file + const secrets: Record = fs.existsSync(secretsPath) + ? JSON.parse(fs.readFileSync(secretsPath, "utf-8")) + : {}; + + const stackSecrets = ((secrets.stack ?? {}) as Record>); + if (!secrets.stack) { + secrets.stack = {}; + } + const ss = secrets.stack as Record>; + + // Ensure postgres secrets + if (!ss.postgres) ss.postgres = {}; + if (!config.postgres.password) { + if (!ss.postgres.password) { + ss.postgres.password = generateSecret(16); + needsWrite = true; + } + config.postgres.password = ss.postgres.password; + } + if (!ss.postgres.user) { + ss.postgres.user = config.postgres.user; + needsWrite = true; + } else { + config.postgres.user = ss.postgres.user; + } + + // Ensure keycloak secrets + if (!ss.keycloak) ss.keycloak = {}; + if (!config.keycloak.adminPassword) { + if (!ss.keycloak.adminPassword) { + ss.keycloak.adminPassword = generateSecret(16); + needsWrite = true; + } + config.keycloak.adminPassword = ss.keycloak.adminPassword; + } + if (!ss.keycloak.adminUser) { + ss.keycloak.adminUser = config.keycloak.adminUser; + needsWrite = true; + } else { + config.keycloak.adminUser = ss.keycloak.adminUser; + } + + // Ensure jwks — auto-generate initial oct key if absent + const jwksEntry = ss.jwks as {keys?: StackJwkKey[]; urlSigningKey?: StackJwkKey} | undefined; + if (!jwksEntry || !jwksEntry.keys || jwksEntry.keys.length === 0) { + const octKey = generateOctJwk("storage-url-signing-key"); + ss.jwks = {keys: [octKey], urlSigningKey: octKey} as unknown as Record; + config.jwks = {keys: [octKey], urlSigningKey: octKey}; + needsWrite = true; + } else { + config.jwks = jwksEntry as StackJwksSecrets; + } + + if (needsWrite) { + fs.writeFileSync(secretsPath, JSON.stringify(secrets, null, 2) + "\n", "utf-8"); + } + + return config; +} + +// ============================================ +// Path Helpers +// ============================================ + +export function getStackDir(): string { + return path.join(getPostkitDir(), "stack"); +} + +export function getComposeFilePath(): string { + return path.join(getStackDir(), "docker-compose.yml"); +} diff --git a/cli/src/modules/stack/utils/stack-state.ts b/cli/src/modules/stack/utils/stack-state.ts new file mode 100644 index 0000000..e669d61 --- /dev/null +++ b/cli/src/modules/stack/utils/stack-state.ts @@ -0,0 +1,44 @@ +import {Client} from "pg"; +import type {StackConfig} from "../types/config"; +import {buildPgUrl} from "../services/db-init"; + +const KEY = "is_initial"; + +/** + * Read the is_initial flag from postkit.stack_config. + * Returns true (treat as initial) if the table/row doesn't exist or on any error. + */ +export async function readStackIsInitial(config: StackConfig): Promise { + const client = new Client({connectionString: buildPgUrl(config)}); + try { + await client.connect(); + const res = await client.query<{value: string}>( + "SELECT value FROM postkit.stack_config WHERE key = $1", + [KEY], + ); + if (res.rows.length === 0) return true; + return (res.rows[0]?.value ?? "true") !== "false"; + } catch { + return true; + } finally { + await client.end().catch(() => undefined); + } +} + +/** + * Mark the stack as initialized by setting is_initial = 'false' in the DB. + */ +export async function setStackInitialized(config: StackConfig): Promise { + const client = new Client({connectionString: buildPgUrl(config)}); + try { + await client.connect(); + await client.query( + `INSERT INTO postkit.stack_config (key, value, updated_at) + VALUES ($1, 'false', now()) + ON CONFLICT (key) DO UPDATE SET value = 'false', updated_at = now()`, + [KEY], + ); + } finally { + await client.end().catch(() => undefined); + } +} diff --git a/cli/test/e2e/error-handling/stack-config-errors.test.ts b/cli/test/e2e/error-handling/stack-config-errors.test.ts new file mode 100644 index 0000000..0c888f6 --- /dev/null +++ b/cli/test/e2e/error-handling/stack-config-errors.test.ts @@ -0,0 +1,84 @@ +import fs from "fs/promises"; +import path from "path"; +import {describe, it, expect, beforeAll, afterAll} from "vitest"; +import {runCli} from "../helpers/cli-runner"; +import {createTestProject, cleanupTestProject, type TestProject} from "../helpers/test-project"; + +/** + * Write a minimal stub docker-compose.yml into the project's stack directory + * so that commands which check for the compose file's existence can proceed + * past that guard and reach subsequent validation logic (e.g., service name checks). + */ +async function writeStubComposeFile(project: TestProject): Promise { + const stackDir = path.join(project.postkitDir, "stack"); + await fs.mkdir(stackDir, {recursive: true}); + const stub = [ + "name: postkit-test", + "services:", + " postgres:", + " image: postgres:16-alpine", + ].join("\n") + "\n"; + await fs.writeFile(path.join(stackDir, "docker-compose.yml"), stub, "utf-8"); +} + +describe("Error handling — stack commands with initialized project (no Docker)", () => { + let project: TestProject; + + beforeAll(async () => { + // Create a project with config but no active Docker stack + project = await createTestProject({ + localDbUrl: "postgres://localhost:5432/test", + }); + // Place a stub compose file so restart/down/status reach their post-file-check logic + await writeStubComposeFile(project); + }); + + afterAll(async () => { + await cleanupTestProject(project); + }); + + it("stack restart with unknown service name exits non-zero and mentions 'Unknown service'", async () => { + const result = await runCli( + ["stack", "restart", "unknown-service"], + {cwd: project.rootDir}, + ); + expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output).toMatch(/Unknown service/i); + expect(output).toContain("unknown-service"); + }); + + it("stack restart with mixed valid and unknown services reports the unknown service", async () => { + const result = await runCli( + ["stack", "restart", "postgres", "keycloak", "unknown-svc"], + {cwd: project.rootDir}, + ); + expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output).toContain("unknown-svc"); + }); + + it("stack down with no running stack exits gracefully (no crash)", async () => { + const result = await runCli( + ["stack", "down"], + {cwd: project.rootDir}, + ); + // Must not emit a raw JS stack trace regardless of exit code + const output = result.stdout + result.stderr; + expect(output).not.toContain("at Object."); + expect(output).not.toContain("TypeError:"); + expect(output).not.toContain("ReferenceError:"); + }); + + it("stack status with no running stack fails gracefully with a helpful message", async () => { + const result = await runCli( + ["stack", "status"], + {cwd: project.rootDir}, + ); + // status either fails (compose file exists but docker not running) or exits non-zero + // The key assertion: no raw stack trace, message is user-facing + const output = result.stdout + result.stderr; + expect(output).not.toContain("TypeError:"); + expect(output).not.toContain("at Object."); + }); +}); diff --git a/cli/test/e2e/smoke/basic-commands.test.ts b/cli/test/e2e/smoke/basic-commands.test.ts index 2f632dc..17b5ae7 100644 --- a/cli/test/e2e/smoke/basic-commands.test.ts +++ b/cli/test/e2e/smoke/basic-commands.test.ts @@ -195,8 +195,13 @@ describe("init command — detailed tests (no Docker)", () => { path.join(tmpDir, "postkit.config.json"), "utf-8", ); - // Config should be identical after second init - expect(firstConfig).toBe(secondConfig); + // Non-name fields should be identical after second init + // (name includes a random suffix so it changes each run) + const cfg1 = JSON.parse(firstConfig) as Record; + const cfg2 = JSON.parse(secondConfig) as Record; + delete cfg1.name; + delete cfg2.name; + expect(cfg1).toEqual(cfg2); } finally { await cleanupDir(tmpDir); } diff --git a/cli/test/e2e/smoke/stack-commands.test.ts b/cli/test/e2e/smoke/stack-commands.test.ts new file mode 100644 index 0000000..e848003 --- /dev/null +++ b/cli/test/e2e/smoke/stack-commands.test.ts @@ -0,0 +1,82 @@ +import {describe, it, expect} from "vitest"; +import {runCli} from "../helpers/cli-runner"; +import {createEmptyDir, cleanupDir} from "../helpers/test-project"; + +describe("Smoke tests — stack subcommand help (no Docker)", () => { + it("stack --help lists all subcommands", async () => { + const result = await runCli(["stack", "--help"]); + expect(result.exitCode).toBe(0); + const output = result.stdout + result.stderr; + expect(output).toContain("up"); + expect(output).toContain("down"); + expect(output).toContain("restart"); + expect(output).toContain("status"); + expect(output).toContain("logs"); + }); + + it("stack up --help shows --wait and --keys flags", async () => { + const result = await runCli(["stack", "up", "--help"]); + expect(result.exitCode).toBe(0); + const output = result.stdout + result.stderr; + expect(output).toContain("--no-wait"); + expect(output).toContain("--no-keys"); + }); + + it("stack restart --help shows variadic [services...] argument", async () => { + const result = await runCli(["stack", "restart", "--help"]); + expect(result.exitCode).toBe(0); + const output = result.stdout + result.stderr; + // Commander renders variadic arguments as [services...] + expect(output).toContain("services"); + }); +}); + +describe("Smoke tests — stack in uninitialized directory (no Docker)", () => { + it("stack up fails with not-initialized error in empty dir", async () => { + const tmpDir = await createEmptyDir(); + try { + const result = await runCli(["stack", "up"], {cwd: tmpDir}); + expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output).toMatch(/not initialized|Config file not found/i); + } finally { + await cleanupDir(tmpDir); + } + }); + + it("stack status fails with not-initialized error in empty dir", async () => { + const tmpDir = await createEmptyDir(); + try { + const result = await runCli(["stack", "status"], {cwd: tmpDir}); + expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output).toMatch(/not initialized|Config file not found/i); + } finally { + await cleanupDir(tmpDir); + } + }); + + it("stack restart fails with not-initialized error in empty dir", async () => { + const tmpDir = await createEmptyDir(); + try { + const result = await runCli(["stack", "restart"], {cwd: tmpDir}); + expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output).toMatch(/not initialized|Config file not found/i); + } finally { + await cleanupDir(tmpDir); + } + }); + + it("stack down fails with not-initialized error in empty dir", async () => { + const tmpDir = await createEmptyDir(); + try { + const result = await runCli(["stack", "down"], {cwd: tmpDir}); + expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output).toMatch(/not initialized|Config file not found/i); + } finally { + await cleanupDir(tmpDir); + } + }); +}); diff --git a/cli/test/e2e/workflows/infra-grants-seeds.test.ts b/cli/test/e2e/workflows/infra-grants-seeds.test.ts index 351bb48..5c0544a 100644 --- a/cli/test/e2e/workflows/infra-grants-seeds.test.ts +++ b/cli/test/e2e/workflows/infra-grants-seeds.test.ts @@ -45,7 +45,7 @@ describe("Infra and seeds workflow", () => { afterAll(async () => { // Clean up session - await runCli(["db", "abort", "--force"], {cwd: project.rootDir}).catch(() => {}); + if (project) await runCli(["db", "abort", "--force"], {cwd: project.rootDir}).catch(() => {}); if (project) await cleanupTestProject(project); if (db) await stopPostgres(db); }); diff --git a/cli/test/e2e/workflows/stack-init-workflow.test.ts b/cli/test/e2e/workflows/stack-init-workflow.test.ts new file mode 100644 index 0000000..46a1a6f --- /dev/null +++ b/cli/test/e2e/workflows/stack-init-workflow.test.ts @@ -0,0 +1,159 @@ +import {describe, it, expect, beforeAll, afterAll} from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import {runCli} from "../helpers/cli-runner"; + +/** + * Stack Init Workflow + * + * Tests the `postkit init` command's scaffold outputs: + * directory structure, config files, infra SQL, realm template, and gitignore. + * + * No Docker required — all assertions are filesystem-based. + */ +describe("stack init workflow", () => { + let rootDir: string; + + beforeAll(async () => { + rootDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "postkit-e2e-init-"), + ); + + // --force skips all interactive prompts so the test runs non-interactively. + // The project name prompt returns "" with --force, yielding name "_". + const result = await runCli(["init", "--force"], {cwd: rootDir}); + + if (result.exitCode !== 0) { + throw new Error( + `postkit init --force failed (exit ${result.exitCode}):\n${result.stderr || result.stdout}`, + ); + } + }); + + afterAll(async () => { + if (rootDir) { + await fs.promises.rm(rootDir, {recursive: true, force: true}); + } + }); + + // ── Directory structure ─────────────────────────────────────────────── + + it("postkit init creates .postkit/auth/providers/ directory", () => { + const providersDir = path.join(rootDir, ".postkit", "auth", "providers"); + expect(fs.existsSync(providersDir)).toBe(true); + expect(fs.statSync(providersDir).isDirectory()).toBe(true); + }); + + it("postkit init creates .postkit/stack/ directory", () => { + const stackDir = path.join(rootDir, ".postkit", "stack"); + expect(fs.existsSync(stackDir)).toBe(true); + expect(fs.statSync(stackDir).isDirectory()).toBe(true); + }); + + // ── Infra SQL files ─────────────────────────────────────────────────── + + it("postkit init creates db/infra/001_roles.sql with IF NOT EXISTS pattern", () => { + const rolesFile = path.join(rootDir, "db", "infra", "001_roles.sql"); + expect(fs.existsSync(rolesFile)).toBe(true); + const content = fs.readFileSync(rolesFile, "utf-8"); + expect(content).toContain("IF NOT EXISTS"); + }); + + it("postkit init creates db/infra/002_schemas.sql with public/auth/storage schemas", () => { + const schemasFile = path.join(rootDir, "db", "infra", "002_schemas.sql"); + expect(fs.existsSync(schemasFile)).toBe(true); + const content = fs.readFileSync(schemasFile, "utf-8"); + expect(content).toContain("CREATE SCHEMA IF NOT EXISTS auth;"); + expect(content).toContain("CREATE SCHEMA IF NOT EXISTS public;"); + expect(content).toContain("CREATE SCHEMA IF NOT EXISTS storage;"); + }); + + // ── Realm template ──────────────────────────────────────────────────── + + it("postkit init creates realm template at configured path", () => { + const configPath = path.join(rootDir, "postkit.config.json"); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + stack?: {keycloak?: {realmTemplate?: string}}; + }; + + const realmTemplatePath = config?.stack?.keycloak?.realmTemplate; + expect(realmTemplatePath).toBeTruthy(); + + const realmFile = path.join(rootDir, realmTemplatePath as string); + expect(fs.existsSync(realmFile)).toBe(true); + }); + + // ── postkit.config.json ─────────────────────────────────────────────── + + it("generated postkit.config.json has 'name' field matching _ pattern", () => { + const configPath = path.join(rootDir, "postkit.config.json"); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + name?: string; + }; + expect(config.name).toBeDefined(); + // With --force and no default, slug is empty → name is "_<8hex>" + // Pattern allows optional slug prefix: [a-z0-9-]*_[0-9a-f]{8} + expect(config.name).toMatch(/^[a-z0-9-]*_[0-9a-f]{8}$/); + }); + + it("postkit.config.json name matches pattern: lowercase-slug_[0-9a-f]{8}", () => { + const configPath = path.join(rootDir, "postkit.config.json"); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + name?: string; + }; + // The hex suffix is always exactly 8 characters (4 random bytes) + const parts = (config.name as string).split("_"); + const hexSuffix = parts[parts.length - 1]; + expect(hexSuffix).toMatch(/^[0-9a-f]{8}$/); + }); + + // ── Secrets files ───────────────────────────────────────────────────── + + it("postkit init creates postkit.secrets.example.json", () => { + const exampleFile = path.join(rootDir, "postkit.secrets.example.json"); + expect(fs.existsSync(exampleFile)).toBe(true); + // Should be valid JSON with expected top-level keys + const content = JSON.parse(fs.readFileSync(exampleFile, "utf-8")) as Record; + expect(content).toHaveProperty("db"); + expect(content).toHaveProperty("auth"); + }); + + // ── .gitignore ──────────────────────────────────────────────────────── + + it("postkit init adds postkit.secrets.json to .gitignore", () => { + const gitignorePath = path.join(rootDir, ".gitignore"); + expect(fs.existsSync(gitignorePath)).toBe(true); + const content = fs.readFileSync(gitignorePath, "utf-8"); + expect(content).toContain("postkit.secrets.json"); + }); + + // ── Idempotency ─────────────────────────────────────────────────────── + + it("running postkit init a second time with --force overwrites config", async () => { + // Capture the name from the first run + const configPath = path.join(rootDir, "postkit.config.json"); + const firstConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + name?: string; + }; + const firstName = firstConfig.name; + + // Second init with --force should succeed and regenerate the name + const result = await runCli(["init", "--force"], {cwd: rootDir}); + expect(result.exitCode).toBe(0); + + const secondConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + name?: string; + }; + expect(secondConfig.name).toBeDefined(); + expect(secondConfig.name).toMatch(/^[a-z0-9-]*_[0-9a-f]{8}$/); + + // The random ID will almost certainly differ — but regardless the config is valid + // (very low probability both runs produce identical 4-byte random values) + // Just verify the name field exists and is properly shaped + expect(secondConfig.name).not.toBe(undefined); + // Note: firstName !== secondConfig.name in the vast majority of cases + // (1 in 4 billion chance of collision) — no strict inequality assertion here + void firstName; + }); +}); diff --git a/cli/test/modules/stack/commands/restart.test.ts b/cli/test/modules/stack/commands/restart.test.ts new file mode 100644 index 0000000..ca4c1ef --- /dev/null +++ b/cli/test/modules/stack/commands/restart.test.ts @@ -0,0 +1,246 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks — declared BEFORE importing the module under test +// --------------------------------------------------------------------------- + +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + }, +})); + +vi.mock("../../../../src/modules/stack/services/docker-compose", () => ({ + composeRestart: vi.fn(), +})); + +vi.mock("../../../../src/modules/stack/utils/stack-config", () => ({ + getStackConfig: vi.fn(), + getComposeFilePath: vi.fn(() => "/project/.postkit/stack/docker-compose.yml"), +})); + +vi.mock("../../../../src/modules/stack/services/health", () => ({ + waitForAllServices: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../../src/common/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + heading: vi.fn(), + }, +})); + +// ora must return a chainable spinner object +vi.mock("ora", () => ({ + default: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + text: "", + })), +})); + +import fs from "fs"; +import {composeRestart} from "../../../../src/modules/stack/services/docker-compose"; +import {getStackConfig, getComposeFilePath} from "../../../../src/modules/stack/utils/stack-config"; +import {logger} from "../../../../src/common/logger"; +import {restartCommand} from "../../../../src/modules/stack/commands/restart"; +import {PostkitError} from "../../../../src/common/errors"; +import type {CommandOptions} from "../../../../src/common/types"; +import type {StackConfig} from "../../../../src/modules/stack/types/config"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const DEFAULT_OPTIONS: CommandOptions = {verbose: false, dryRun: false, json: false}; + +function makeMockStackConfig(): StackConfig { + return { + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "secret", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "kcpass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + postgrest: { + image: "postgrest/postgrest:latest", + enabled: true, + port: 3000, + dbSchema: "public", + dbAnonRole: "anon", + }, + traefik: { + image: "traefik:v3.3", + enabled: true, + httpPort: 80, + dashboardPort: 8080, + }, + network: "postkit-net", + jwks: {keys: []}, + keycloakClients: [], + }; +} + +const SUCCESS_RESULT = {stdout: "", stderr: "", exitCode: 0}; +const FAILURE_RESULT = {stdout: "", stderr: "restart failed", exitCode: 1}; + +// --------------------------------------------------------------------------- +// restartCommand() +// --------------------------------------------------------------------------- + +describe("restartCommand()", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Compose file exists by default + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(getComposeFilePath).mockReturnValue( + "/project/.postkit/stack/docker-compose.yml", + ); + vi.mocked(getStackConfig).mockReturnValue(makeMockStackConfig()); + vi.mocked(composeRestart).mockResolvedValue(SUCCESS_RESULT); + }); + + describe("when compose file does not exist", () => { + it("throws PostkitError when no stack found", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await expect(restartCommand(DEFAULT_OPTIONS)).rejects.toThrow(PostkitError); + await expect(restartCommand(DEFAULT_OPTIONS)).rejects.toThrow("No stack found"); + }); + }); + + describe("with unknown services", () => { + it("throws PostkitError with clear message for unknown service name", async () => { + await expect( + restartCommand(DEFAULT_OPTIONS, ["unknown-service"]), + ).rejects.toThrow(PostkitError); + await expect( + restartCommand(DEFAULT_OPTIONS, ["unknown-service"]), + ).rejects.toThrow("Unknown service(s): unknown-service"); + }); + + it("includes available services in error hint", async () => { + let thrown: PostkitError | undefined; + try { + await restartCommand(DEFAULT_OPTIONS, ["bad-service"]); + } catch (e) { + thrown = e as PostkitError; + } + expect(thrown).toBeInstanceOf(PostkitError); + expect(thrown!.hint).toContain("Available services"); + }); + }); + + describe("restarting all services", () => { + it("restarts all services when no service args provided", async () => { + await restartCommand(DEFAULT_OPTIONS, []); + + expect(composeRestart).toHaveBeenCalledWith( + "/project/.postkit/stack/docker-compose.yml", + undefined, + ); + }); + + it("calls composeRestart with the compose file path", async () => { + await restartCommand(DEFAULT_OPTIONS); + + expect(composeRestart).toHaveBeenCalledWith( + "/project/.postkit/stack/docker-compose.yml", + undefined, + ); + }); + }); + + describe("restarting specific services", () => { + it("restarts only specified services when args given", async () => { + await restartCommand(DEFAULT_OPTIONS, ["postgres"]); + + expect(composeRestart).toHaveBeenCalledWith( + "/project/.postkit/stack/docker-compose.yml", + ["postgres"], + ); + }); + + it("passes multiple specified services to composeRestart", async () => { + await restartCommand(DEFAULT_OPTIONS, ["postgres", "keycloak"]); + + expect(composeRestart).toHaveBeenCalledWith( + "/project/.postkit/stack/docker-compose.yml", + ["postgres", "keycloak"], + ); + }); + + it("accepts all valid service names", async () => { + const validServices = ["postgres", "keycloak", "postgrest", "traefik"]; + for (const svc of validServices) { + vi.clearAllMocks(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(getStackConfig).mockReturnValue(makeMockStackConfig()); + vi.mocked(composeRestart).mockResolvedValue(SUCCESS_RESULT); + + await expect(restartCommand(DEFAULT_OPTIONS, [svc])).resolves.not.toThrow(); + } + }); + }); + + describe("dry-run mode", () => { + it("logs intent without calling composeRestart", async () => { + await restartCommand({...DEFAULT_OPTIONS, dryRun: true}); + + expect(composeRestart).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Dry run")); + }); + + it("logs the services that would be restarted", async () => { + await restartCommand({...DEFAULT_OPTIONS, dryRun: true}, ["postgres"]); + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("postgres")); + }); + }); + + describe("on non-zero exit from composeRestart", () => { + it("does not throw but reports failure via spinner", async () => { + vi.mocked(composeRestart).mockResolvedValue(FAILURE_RESULT); + + // Should not throw even when exitCode !== 0 + await expect(restartCommand(DEFAULT_OPTIONS)).resolves.not.toThrow(); + }); + + it("logs the stderr output on failure", async () => { + vi.mocked(composeRestart).mockResolvedValue(FAILURE_RESULT); + + await restartCommand(DEFAULT_OPTIONS); + + expect(logger.error).toHaveBeenCalledWith("restart failed"); + }); + }); + + describe("on successful restart", () => { + it("calls getStackConfig for health check after restart", async () => { + await restartCommand(DEFAULT_OPTIONS, ["postgres"]); + + expect(getStackConfig).toHaveBeenCalled(); + }); + }); +}); diff --git a/cli/test/modules/stack/services/compose.test.ts b/cli/test/modules/stack/services/compose.test.ts new file mode 100644 index 0000000..d936f8a --- /dev/null +++ b/cli/test/modules/stack/services/compose.test.ts @@ -0,0 +1,230 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +vi.mock("../../../../src/common/config", () => ({ + loadPostkitConfig: vi.fn(), + projectRoot: "/project", + cliRoot: "/cli", + getPostkitAuthDir: vi.fn(() => "/project/.postkit/auth"), +})); + +vi.mock("../../../../src/modules/stack/utils/stack-config", () => ({ + getStackDir: vi.fn(() => "/project/.postkit/stack"), +})); + +vi.mock("../../../../src/modules/stack/services/sync-providers", () => ({ + getProvidersDir: vi.fn(() => "/project/.postkit/auth/providers"), +})); + +vi.mock("fs", () => ({ + default: { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +import {loadPostkitConfig} from "../../../../src/common/config"; +import {getProvidersDir} from "../../../../src/modules/stack/services/sync-providers"; +import { + getSelectedServices, + generateComposeFile, + ALL_SERVICES, +} from "../../../../src/modules/stack/services/compose"; +import type {StackConfig} from "../../../../src/modules/stack/types/config"; + +function makeConfig(overrides: Partial = {}): StackConfig { + return { + postgres: { + image: "postgres:${pgVersion}-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "secret", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "admin-pass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + postgrest: { + image: "postgrest/postgrest:latest", + enabled: true, + port: 3000, + dbSchema: "public", + dbAnonRole: "anon", + }, + traefik: { + image: "traefik:v3.3", + enabled: true, + httpPort: 80, + dashboardPort: 8080, + }, + network: "postkit-net", + jwks: {keys: []}, + keycloakClients: [], + ...overrides, + }; +} + +describe("compose", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(loadPostkitConfig).mockReturnValue({ + name: "myproject", + db: {} as any, + auth: {} as any, + }); + vi.mocked(getProvidersDir).mockReturnValue("/project/.postkit/auth/providers"); + }); + + describe("getSelectedServices()", () => { + it("returns all enabled services when no requested services provided", () => { + const config = makeConfig(); + const result = getSelectedServices(config, []); + // All 4 services are enabled by default + expect(result).toEqual(expect.arrayContaining(["postgres", "keycloak", "postgrest", "traefik"])); + expect(result).toHaveLength(4); + }); + + it("auto-adds postgres and traefik when keycloak is requested", () => { + const config = makeConfig(); + const result = getSelectedServices(config, ["keycloak"]); + expect(result).toContain("postgres"); + expect(result).toContain("traefik"); + expect(result).toContain("keycloak"); + }); + + it("auto-adds postgres and traefik when postgrest is requested", () => { + const config = makeConfig(); + const result = getSelectedServices(config, ["postgrest"]); + expect(result).toContain("postgres"); + expect(result).toContain("traefik"); + expect(result).toContain("postgrest"); + }); + + it("explicit keycloak results in postgres + traefik included", () => { + const config = makeConfig(); + const result = getSelectedServices(config, ["keycloak"]); + expect(result).toContain("postgres"); + expect(result).toContain("traefik"); + expect(result).toContain("keycloak"); + }); + + it("throws on unknown service name", () => { + const config = makeConfig(); + expect(() => getSelectedServices(config, ["unknown-service"])).toThrow( + /Unknown service.*unknown-service/, + ); + }); + + it("returns only postgres when only postgres requested (no dep services)", () => { + const config = makeConfig(); + const result = getSelectedServices(config, ["postgres"]); + expect(result).toEqual(["postgres"]); + }); + + it("filters out disabled services when no requested services provided", () => { + const config = makeConfig({ + postgrest: { + image: "postgrest/postgrest:latest", + enabled: false, + port: 3000, + dbSchema: "public", + dbAnonRole: "anon", + }, + }); + const result = getSelectedServices(config, []); + // postgrest is disabled, but keycloak is still enabled so traefik/postgres get added + expect(result).not.toContain("postgrest"); + }); + }); + + describe("generateComposeFile()", () => { + it("output includes 'name: ' line", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + name: "myapp", + db: {} as any, + auth: {} as any, + }); + const config = makeConfig(); + const services = ALL_SERVICES.slice() as any; + const output = generateComposeFile(config, services); + expect(output).toMatch(/^name: myapp/m); + }); + + it("uses 'postkit' as project name when config.name is undefined", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + db: {} as any, + auth: {} as any, + }); + const config = makeConfig(); + const output = generateComposeFile(config, ["postgres"] as any); + expect(output).toMatch(/^name: postkit/m); + }); + + it("output includes all 4 service blocks when all services selected", () => { + const config = makeConfig(); + const services = ALL_SERVICES.slice() as any; + const output = generateComposeFile(config, services); + expect(output).toContain(" postgres:"); + expect(output).toContain(" keycloak:"); + expect(output).toContain(" postgrest:"); + expect(output).toContain(" traefik:"); + }); + + it("network block has explicit 'name:' field", () => { + const config = makeConfig(); + const output = generateComposeFile(config, ["postgres"] as any); + expect(output).toContain("name: postkit-net"); + }); + + it("renderKeycloak includes KC_DB_SCHEMA: auth", () => { + const config = makeConfig(); + const output = generateComposeFile(config, ["keycloak", "postgres", "traefik"] as any); + expect(output).toContain("KC_DB_SCHEMA: auth"); + }); + + it("renderKeycloak includes KC_DB_POOL_MIN_SIZE and KC_DB_POOL_MAX_SIZE", () => { + const config = makeConfig(); + const output = generateComposeFile(config, ["keycloak", "postgres", "traefik"] as any); + expect(output).toContain("KC_DB_POOL_MIN_SIZE:"); + expect(output).toContain("KC_DB_POOL_MAX_SIZE:"); + }); + + it("renderKeycloak includes providers volume mount", () => { + vi.mocked(getProvidersDir).mockReturnValue("/project/.postkit/auth/providers"); + const config = makeConfig(); + const output = generateComposeFile(config, ["keycloak", "postgres", "traefik"] as any); + expect(output).toContain("/project/.postkit/auth/providers:/opt/keycloak/providers"); + }); + + it("only includes requested service blocks", () => { + const config = makeConfig(); + const output = generateComposeFile(config, ["postgres"] as any); + expect(output).toContain(" postgres:"); + expect(output).not.toContain(" keycloak:"); + expect(output).not.toContain(" postgrest:"); + expect(output).not.toContain(" traefik:"); + }); + + it("volumes section includes postgres volume when postgres selected", () => { + const config = makeConfig(); + const output = generateComposeFile(config, ["postgres"] as any); + expect(output).toContain("postkit-pgdata:"); + }); + + it("volumes section includes keycloak volume when keycloak selected", () => { + const config = makeConfig(); + const output = generateComposeFile(config, ["keycloak", "postgres", "traefik"] as any); + expect(output).toContain("postkit-keycloak-data:"); + }); + }); +}); diff --git a/cli/test/modules/stack/services/db-init.test.ts b/cli/test/modules/stack/services/db-init.test.ts new file mode 100644 index 0000000..b62e975 --- /dev/null +++ b/cli/test/modules/stack/services/db-init.test.ts @@ -0,0 +1,330 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +// --------------------------------------------------------------------------- +// pg mock — Client must be mockable as a constructor (new Client(...)). +// Vitest requires a real function (not arrow) when called with `new`. +// We use a module-level mock object so its methods can be reset in beforeEach. +// --------------------------------------------------------------------------- +const mockClient = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockResolvedValue({rows: [], rowCount: 0}), + end: vi.fn().mockResolvedValue(undefined), +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +vi.mock("pg", () => ({ + // Use a regular function (not arrow) so `new Client(...)` works. + // The function returns mockClient from the outer scope via closure. + Client: vi.fn(function MockClient() { + return mockClient; + }), +})); + +vi.mock("ora", () => ({ + default: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + text: "", + })), +})); + +vi.mock("../../../../src/modules/db/services/infra-generator", () => ({ + applyInfraStep: vi.fn(), +})); + +vi.mock("../../../../src/modules/db/services/dbmate", () => ({ + runCommittedMigrate: vi.fn(), +})); + +vi.mock("../../../../src/modules/db/services/seed-generator", () => ({ + applySeedsStep: vi.fn(), +})); + +import {Client} from "pg"; +import ora from "ora"; +import {applyInfraStep} from "../../../../src/modules/db/services/infra-generator"; +import {runCommittedMigrate} from "../../../../src/modules/db/services/dbmate"; +import {applySeedsStep} from "../../../../src/modules/db/services/seed-generator"; +import {buildPgUrl, applyStackDeploy} from "../../../../src/modules/stack/services/db-init"; +import type {StackConfig} from "../../../../src/modules/stack/types/config"; + +function makeConfig(overrides: Partial = {}): StackConfig { + return { + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "pguser", + password: "pgpass", + database: "testdb", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "admin-pass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + postgrest: { + image: "postgrest/postgrest:latest", + enabled: true, + port: 3000, + dbSchema: "public", + dbAnonRole: "anon", + }, + traefik: { + image: "traefik:v3.3", + enabled: true, + httpPort: 80, + dashboardPort: 8080, + }, + network: "postkit-net", + jwks: {keys: []}, + keycloakClients: [], + ...overrides, + }; +} + +function makeSpinner() { + return { + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + text: "", + } as unknown as ReturnType; +} + +describe("db-init", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + // Restore default happy-path behaviour after clearAllMocks() wipes implementations. + // Re-apply the constructor factory (must use regular function, not arrow). + vi.mocked(Client).mockImplementation(function MockClient() { + return mockClient as any; + } as any); + mockClient.connect.mockResolvedValue(undefined); + mockClient.query.mockResolvedValue({rows: [], rowCount: 0}); + mockClient.end.mockResolvedValue(undefined); + }); + + describe("buildPgUrl()", () => { + it("builds correct postgres URL from config", () => { + const config = makeConfig(); + const url = buildPgUrl(config); + expect(url).toBe("postgres://pguser:pgpass@localhost:25432/testdb"); + }); + + it("URL-encodes special characters in password", () => { + const config = makeConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "pguser", + password: "p@ss#word!", + database: "testdb", + pgVersion: 16, + volume: "postkit-pgdata", + }, + }); + const url = buildPgUrl(config); + // encodeURIComponent encodes @, #, ! + expect(url).toContain("p%40ss%23word!"); + expect(url).not.toContain("p@ss"); + }); + + it("uses localhost as host", () => { + const config = makeConfig(); + const url = buildPgUrl(config); + expect(url).toContain("@localhost:"); + }); + + it("includes port from config", () => { + const config = makeConfig(); + const url = buildPgUrl(config); + expect(url).toContain(":25432/"); + }); + + it("includes database name from config", () => { + const config = makeConfig(); + const url = buildPgUrl(config); + expect(url).toContain("/testdb"); + }); + }); + + describe("applyStackDeploy()", () => { + it("connects to postgres and creates postkit schema + table", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({success: true, output: ""}); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await applyStackDeploy(config, spinner); + + expect(mockClient.connect).toHaveBeenCalledOnce(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining("CREATE SCHEMA IF NOT EXISTS postkit"), + ); + }); + + it("closes client connection after schema query", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({success: true, output: ""}); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await applyStackDeploy(config, spinner); + + expect(mockClient.end).toHaveBeenCalled(); + }); + + it("calls applyInfraStep for phase 1", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({success: true, output: ""}); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await applyStackDeploy(config, spinner); + + expect(applyInfraStep).toHaveBeenCalledOnce(); + expect(applyInfraStep).toHaveBeenCalledWith( + spinner, + expect.stringContaining("postgres://"), + "stack", + ); + }); + + it("calls runCommittedMigrate for phase 2", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({success: true, output: ""}); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await applyStackDeploy(config, spinner); + + expect(runCommittedMigrate).toHaveBeenCalledOnce(); + expect(runCommittedMigrate).toHaveBeenCalledWith( + expect.stringContaining("postgres://"), + ); + }); + + it("calls applySeedsStep for phase 3", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({success: true, output: ""}); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await applyStackDeploy(config, spinner); + + expect(applySeedsStep).toHaveBeenCalledOnce(); + expect(applySeedsStep).toHaveBeenCalledWith( + spinner, + expect.stringContaining("postgres://"), + "stack", + ); + }); + + it("retries pg connection on failure and succeeds on later attempt", async () => { + // First 2 attempts fail, 3rd succeeds — all using the same shared mockClient object. + // We override connect to fail twice then succeed. + let callCount = 0; + mockClient.connect.mockImplementation(() => { + callCount++; + if (callCount < 3) { + return Promise.reject(new Error("ECONNREFUSED")); + } + return Promise.resolve(); + }); + + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({success: true, output: ""}); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + vi.useFakeTimers(); + const config = makeConfig(); + const spinner = makeSpinner(); + + const deployPromise = applyStackDeploy(config, spinner); + await vi.runAllTimersAsync(); + await deployPromise; + + // connect was called at least 3 times (2 failures + 1 success) + expect(mockClient.connect).toHaveBeenCalledTimes(3); + }); + + it("throws after all retries exhausted", async () => { + mockClient.connect.mockRejectedValue(new Error("ECONNREFUSED")); + + vi.useFakeTimers(); + const config = makeConfig(); + const spinner = makeSpinner(); + + // Capture the error immediately so no unhandled rejection leaks out + let caughtError: unknown; + const deployPromise = applyStackDeploy(config, spinner).catch((err) => { + caughtError = err; + }); + await vi.runAllTimersAsync(); + await deployPromise; + + expect(caughtError).toBeDefined(); + expect((caughtError as Error).message).toBeTruthy(); + }); + + it("does not throw when runCommittedMigrate reports no migration files found", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({ + success: false, + output: "no migration files found", + }); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await expect(applyStackDeploy(config, spinner)).resolves.not.toThrow(); + }); + + it("throws when runCommittedMigrate fails with non-trivial error", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({ + success: false, + output: "syntax error at or near 'CREAT'", + }); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await expect(applyStackDeploy(config, spinner)).rejects.toThrow(/Migration failed/); + }); + + it("closes the pg client even when query throws", async () => { + mockClient.query.mockRejectedValue(new Error("query error")); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await expect(applyStackDeploy(config, spinner)).rejects.toThrow("query error"); + expect(mockClient.end).toHaveBeenCalled(); + }); + }); +}); diff --git a/cli/test/modules/stack/services/realm-init.test.ts b/cli/test/modules/stack/services/realm-init.test.ts new file mode 100644 index 0000000..f130cc0 --- /dev/null +++ b/cli/test/modules/stack/services/realm-init.test.ts @@ -0,0 +1,249 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +vi.mock("../../../../src/common/config", () => ({ + projectRoot: "/project", + cliRoot: "/cli", +})); + +vi.mock("../../../../src/common/shell", () => ({ + runSpawnCommand: vi.fn(), +})); + +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + readFileSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +vi.mock("fs/promises", () => ({ + mkdtemp: vi.fn(), + writeFile: vi.fn(), + rm: vi.fn(), +})); + +import {cleanRealmTemplate} from "../../../../src/modules/stack/services/realm-init"; + +const JWT_ROLE_MAPPER_NAME = "JWT Role Mapper"; +const BUILTIN_CLIENT_IDS = [ + "account", + "account-console", + "admin-cli", + "broker", + "realm-management", + "security-admin-console", +]; + +function makeRawRealm(overrides: Record = {}): Record { + return { + id: "some-uuid-123", + realm: "original-realm", + enabled: true, + clients: [], + roles: {realm: [], client: {}}, + ...overrides, + }; +} + +describe("cleanRealmTemplate()", () => { + it("sets realm to provided name", () => { + const raw = makeRawRealm(); + const result = cleanRealmTemplate(raw, "my-realm"); + expect(result.realm).toBe("my-realm"); + }); + + it("deletes top-level id", () => { + const raw = makeRawRealm({id: "some-uuid"}); + const result = cleanRealmTemplate(raw, "test"); + expect(result.id).toBeUndefined(); + }); + + it("does not mutate the original input", () => { + const raw = makeRawRealm({id: "original-id"}); + cleanRealmTemplate(raw, "test"); + expect(raw.id).toBe("original-id"); + }); + + describe("client filtering", () => { + it("filters out all builtin clients", () => { + const builtinClients = BUILTIN_CLIENT_IDS.map((clientId) => ({clientId, id: "id-" + clientId})); + const userClient = {clientId: "my-app", id: "user-id"}; + const raw = makeRawRealm({clients: [...builtinClients, userClient]}); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array<{clientId: string}>; + const ids = clients.map((c) => c.clientId); + for (const builtin of BUILTIN_CLIENT_IDS) { + expect(ids).not.toContain(builtin); + } + }); + + it("preserves non-builtin clients", () => { + const raw = makeRawRealm({ + clients: [ + {clientId: "my-app", id: "user-id"}, + {clientId: "account", id: "builtin-id"}, + ], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array<{clientId: string}>; + expect(clients.map((c) => c.clientId)).toContain("my-app"); + }); + + it("removes id, secret, and registrationAccessToken from non-builtin clients", () => { + const raw = makeRawRealm({ + clients: [ + { + clientId: "my-app", + id: "some-id", + secret: "super-secret", + registrationAccessToken: "reg-token", + }, + ], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + expect(clients[0]!.id).toBeUndefined(); + expect(clients[0]!.secret).toBeUndefined(); + expect(clients[0]!.registrationAccessToken).toBeUndefined(); + }); + + it("removes client.secret.creation.time from attributes", () => { + const raw = makeRawRealm({ + clients: [ + { + clientId: "my-app", + attributes: { + "client.secret.creation.time": "12345678", + "other-attr": "keep-me", + }, + }, + ], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + const attrs = clients[0]!.attributes as Record; + expect(attrs["client.secret.creation.time"]).toBeUndefined(); + expect(attrs["other-attr"]).toBe("keep-me"); + }); + + it("sets serviceAccountRealmRoles for supabase_service client", () => { + const raw = makeRawRealm({ + clients: [{clientId: "supabase_service"}], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + expect(clients[0]!.serviceAccountRealmRoles).toEqual(["service_role", "app_user"]); + }); + + it("sets serviceAccountRealmRoles for anon client", () => { + const raw = makeRawRealm({ + clients: [{clientId: "anon"}], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + expect(clients[0]!.serviceAccountRealmRoles).toEqual(["anon"]); + }); + + it("injects JWT Role Mapper when absent", () => { + const raw = makeRawRealm({ + clients: [{clientId: "my-app", protocolMappers: []}], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + const mappers = clients[0]!.protocolMappers as Array<{name: string}>; + expect(mappers.some((m) => m.name === JWT_ROLE_MAPPER_NAME)).toBe(true); + }); + + it("does NOT re-inject JWT Role Mapper when already present (idempotent)", () => { + const existingMapper = {name: JWT_ROLE_MAPPER_NAME, protocol: "openid-connect"}; + const raw = makeRawRealm({ + clients: [{clientId: "my-app", protocolMappers: [existingMapper]}], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + const mappers = clients[0]!.protocolMappers as Array<{name: string}>; + const jwtMappers = mappers.filter((m) => m.name === JWT_ROLE_MAPPER_NAME); + expect(jwtMappers).toHaveLength(1); + }); + + it("injects JWT Role Mapper when protocolMappers is absent", () => { + const raw = makeRawRealm({ + clients: [{clientId: "my-app"}], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + const mappers = clients[0]!.protocolMappers as Array<{name: string}>; + expect(mappers.some((m) => m.name === JWT_ROLE_MAPPER_NAME)).toBe(true); + }); + }); + + describe("realm roles", () => { + it("creates admin realm role when roles array is empty", () => { + const raw = makeRawRealm({roles: {realm: [], client: {}}}); + const result = cleanRealmTemplate(raw, "test"); + const roles = (result.roles as {realm: Array<{name: string}>}).realm; + expect(roles.some((r) => r.name === "admin")).toBe(true); + }); + + it("does NOT duplicate admin role when already present", () => { + const raw = makeRawRealm({ + roles: { + realm: [{name: "admin", id: "admin-id", composite: false, clientRole: false}], + client: {}, + }, + }); + const result = cleanRealmTemplate(raw, "test"); + const roles = (result.roles as {realm: Array<{name: string}>}).realm; + const adminRoles = roles.filter((r) => r.name === "admin"); + expect(adminRoles).toHaveLength(1); + }); + + it("strips id from every realm role", () => { + const raw = makeRawRealm({ + roles: { + realm: [ + {name: "admin", id: "admin-id"}, + {name: "user", id: "user-id"}, + ], + client: {}, + }, + }); + const result = cleanRealmTemplate(raw, "test"); + const roles = (result.roles as {realm: Array>}).realm; + for (const role of roles) { + expect(role.id).toBeUndefined(); + } + }); + + it("removes builtin keys from roles.client", () => { + const raw = makeRawRealm({ + roles: { + realm: [], + client: { + account: [{name: "manage-account"}], + "realm-management": [{name: "manage-users"}], + "my-app": [{name: "app-role"}], + }, + }, + }); + const result = cleanRealmTemplate(raw, "test"); + const clientRoles = (result.roles as {client: Record}).client; + expect(clientRoles["account"]).toBeUndefined(); + expect(clientRoles["realm-management"]).toBeUndefined(); + expect(clientRoles["my-app"]).toBeDefined(); + }); + + it("initializes realm roles as array when missing from input", () => { + const raw: Record = { + id: "uuid", + realm: "test", + roles: {}, + }; + const result = cleanRealmTemplate(raw, "test"); + const roles = (result.roles as {realm: Array<{name: string}>}).realm; + expect(Array.isArray(roles)).toBe(true); + }); + }); +}); diff --git a/cli/test/modules/stack/services/scaffold.test.ts b/cli/test/modules/stack/services/scaffold.test.ts new file mode 100644 index 0000000..e0fddc0 --- /dev/null +++ b/cli/test/modules/stack/services/scaffold.test.ts @@ -0,0 +1,87 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +vi.mock("../../../../src/common/config", () => ({ + projectRoot: "/project", + cliRoot: "/cli", +})); + +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +import fs from "fs"; +import {scaffoldRealmTemplate} from "../../../../src/modules/stack/services/scaffold"; + +describe("scaffoldRealmTemplate()", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates the realm file and returns true when file does not exist", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = scaffoldRealmTemplate(); + + expect(result).toBe(true); + expect(fs.writeFileSync).toHaveBeenCalledOnce(); + }); + + it("creates parent directories if missing", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + scaffoldRealmTemplate(); + + expect(fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining(".postkit/auth/realm"), + {recursive: true}, + ); + }); + + it("writes file at path containing DEFAULT_REALM_TEMPLATE_PATH segments", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + scaffoldRealmTemplate(); + + const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]!; + const writtenPath = writeCall[0] as string; + expect(writtenPath).toContain(".postkit"); + expect(writtenPath).toContain("auth"); + expect(writtenPath).toContain("realm"); + expect(writtenPath).toContain("postkit.json"); + }); + + it("writes valid JSON content containing realm template structure", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + scaffoldRealmTemplate(); + + const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]!; + const content = writeCall[1] as string; + const parsed = JSON.parse(content); + expect(parsed).toHaveProperty("realm"); + expect(parsed).toHaveProperty("enabled"); + expect(parsed).toHaveProperty("clients"); + expect(parsed).toHaveProperty("roles"); + }); + + it("skips write and returns false when file already exists", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + const result = scaffoldRealmTemplate(); + + expect(result).toBe(false); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it("always calls mkdirSync even when file exists", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + scaffoldRealmTemplate(); + + expect(fs.mkdirSync).toHaveBeenCalledOnce(); + }); +}); diff --git a/cli/test/modules/stack/services/sync-providers.test.ts b/cli/test/modules/stack/services/sync-providers.test.ts new file mode 100644 index 0000000..c55b59f --- /dev/null +++ b/cli/test/modules/stack/services/sync-providers.test.ts @@ -0,0 +1,159 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +vi.mock("../../../../src/common/config", () => ({ + projectRoot: "/project", + cliRoot: "/cli", + getPostkitAuthDir: vi.fn(() => "/project/.postkit/auth"), +})); + +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), + copyFileSync: vi.fn(), + }, +})); + +import fs from "fs"; +import {getProvidersDir, syncKeycloakProviders} from "../../../../src/modules/stack/services/sync-providers"; +import {getPostkitAuthDir} from "../../../../src/common/config"; + +describe("sync-providers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getProvidersDir()", () => { + it("returns a path ending in .postkit/auth/providers", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + const dir = getProvidersDir(); + expect(dir).toMatch(/\.postkit[\\/]auth[\\/]providers$/); + }); + }); + + describe("syncKeycloakProviders()", () => { + it("creates target providers directory", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + // vendor dir does not exist, project dir does not exist + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.readdirSync).mockReturnValue([]); + + syncKeycloakProviders(); + + expect(fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining("providers"), + {recursive: true}, + ); + }); + + it("copies .jar files from vendor/providers/ to target dir", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = String(p); + // vendor dir exists, project providers dir does not + return pathStr.includes("vendor/providers"); + }); + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.includes("vendor/providers")) { + return ["plugin.jar", "another.jar"] as any; + } + return [] as any; + }); + + syncKeycloakProviders(); + + expect(fs.copyFileSync).toHaveBeenCalledTimes(2); + expect(fs.copyFileSync).toHaveBeenCalledWith( + expect.stringContaining("plugin.jar"), + expect.stringContaining("providers"), + ); + expect(fs.copyFileSync).toHaveBeenCalledWith( + expect.stringContaining("another.jar"), + expect.stringContaining("providers"), + ); + }); + + it("skips non-JAR files in vendor dir", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + vi.mocked(fs.existsSync).mockImplementation((p) => { + return String(p).includes("vendor/providers"); + }); + vi.mocked(fs.readdirSync).mockImplementation((p) => { + if (String(p).includes("vendor/providers")) { + return ["readme.txt", "plugin.jar", "config.xml"] as any; + } + return [] as any; + }); + + syncKeycloakProviders(); + + expect(fs.copyFileSync).toHaveBeenCalledTimes(1); + expect(fs.copyFileSync).toHaveBeenCalledWith( + expect.stringContaining("plugin.jar"), + expect.any(String), + ); + }); + + it("silently returns when vendor/providers/ directory does not exist", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + vi.mocked(fs.existsSync).mockReturnValue(false); + + expect(() => syncKeycloakProviders()).not.toThrow(); + expect(fs.copyFileSync).not.toHaveBeenCalled(); + }); + + it("copies project-specific JARs from auth/providers//target/", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = String(p); + // vendor dir does not exist, but project providers exist + if (pathStr.includes("vendor/providers")) return false; + if (pathStr.endsWith("auth/providers")) return true; + if (pathStr.endsWith("target")) return true; + return false; + }); + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("auth/providers")) { + return ["my-provider"] as any; + } + if (pathStr.endsWith("target")) { + return ["my-provider.jar"] as any; + } + return [] as any; + }); + + syncKeycloakProviders(); + + expect(fs.copyFileSync).toHaveBeenCalledTimes(1); + expect(fs.copyFileSync).toHaveBeenCalledWith( + expect.stringContaining("my-provider.jar"), + expect.stringContaining("providers"), + ); + }); + + it("skips project provider directories that have no target/ folder", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.includes("vendor/providers")) return false; + if (pathStr.endsWith("auth/providers")) return true; + // target does not exist + if (pathStr.endsWith("target")) return false; + return false; + }); + vi.mocked(fs.readdirSync).mockImplementation((p) => { + if (String(p).endsWith("auth/providers")) { + return ["my-provider"] as any; + } + return [] as any; + }); + + syncKeycloakProviders(); + + expect(fs.copyFileSync).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/cli/test/modules/stack/utils/stack-config.test.ts b/cli/test/modules/stack/utils/stack-config.test.ts new file mode 100644 index 0000000..324c00f --- /dev/null +++ b/cli/test/modules/stack/utils/stack-config.test.ts @@ -0,0 +1,454 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +// Mock fs BEFORE any imports that use it +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +vi.mock("../../../../src/common/config", () => ({ + loadPostkitConfig: vi.fn(), + getSecretsFilePath: vi.fn(() => "/project/postkit.secrets.json"), + getPostkitDir: vi.fn(() => "/project/.postkit"), +})); + +import fs from "fs"; +import {loadPostkitConfig, getSecretsFilePath} from "../../../../src/common/config"; +import {getStackConfig, ensureStackSecrets} from "../../../../src/modules/stack/utils/stack-config"; +import type {StackConfig} from "../../../../src/modules/stack/types/config"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeFullStackConfig(overrides: Partial = {}): StackConfig { + return { + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "secret", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "kcsecret", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + postgrest: { + image: "postgrest/postgrest:latest", + enabled: true, + port: 3000, + dbSchema: "public", + dbAnonRole: "anon", + }, + traefik: { + image: "traefik:v3.3", + enabled: true, + httpPort: 80, + dashboardPort: 8080, + }, + network: "postkit-net", + jwks: {keys: []}, + jwk: undefined, + clients: undefined, + keycloakClients: [], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// getStackConfig() +// --------------------------------------------------------------------------- + +describe("getStackConfig()", () => { + beforeEach(() => { + vi.clearAllMocks(); + // No secrets file by default + vi.mocked(fs.existsSync).mockReturnValue(false); + }); + + it("returns config with default port 25432 for postgres when none configured", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({stack: {}} as any); + + const cfg = getStackConfig(); + + expect(cfg.postgres.port).toBe(25432); + }); + + it("returns all service defaults when stack config is empty", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({stack: {}} as any); + + const cfg = getStackConfig(); + + expect(cfg.postgres.image).toBe("postgres:16-alpine"); + expect(cfg.postgres.enabled).toBe(true); + expect(cfg.postgres.database).toBe("postkit"); + expect(cfg.postgres.user).toBe("postgres"); + expect(cfg.postgres.password).toBe(""); + expect(cfg.keycloak.port).toBe(28080); + expect(cfg.keycloak.adminUser).toBe("admin"); + expect(cfg.keycloak.adminPassword).toBe(""); + expect(cfg.postgrest.port).toBe(3000); + expect(cfg.traefik.httpPort).toBe(80); + expect(cfg.traefik.dashboardPort).toBe(8080); + expect(cfg.network).toBe("postkit-net"); + }); + + it("merges user-supplied postgres port over the default", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + stack: {postgres: {port: 54321}}, + } as any); + + const cfg = getStackConfig(); + + expect(cfg.postgres.port).toBe(54321); + }); + + it("reads postgres user and password from merged secrets in config", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + stack: { + postgres: {user: "myuser", password: "mypass"}, + }, + } as any); + + const cfg = getStackConfig(); + + expect(cfg.postgres.user).toBe("myuser"); + expect(cfg.postgres.password).toBe("mypass"); + }); + + it("reads jwks from secrets file when it exists", () => { + const jwks = {keys: [{kty: "oct", kid: "k1", alg: "HS256", k: "abc"}]}; + vi.mocked(loadPostkitConfig).mockReturnValue({stack: {}} as any); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({stack: {jwks}}) as any, + ); + + const cfg = getStackConfig(); + + expect(cfg.jwks.keys).toHaveLength(1); + expect(cfg.jwks.keys[0]!.kid).toBe("k1"); + }); + + it("returns empty jwks when secrets file does not exist", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({stack: {}} as any); + vi.mocked(fs.existsSync).mockReturnValue(false); + + const cfg = getStackConfig(); + + expect(cfg.jwks).toEqual({keys: []}); + }); + + it("throws when postgres port is out of valid range", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + stack: {postgres: {port: 99999}}, + } as any); + + expect(() => getStackConfig()).toThrow(); + }); + + it("throws when postgres pgVersion is below minimum (12)", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + stack: {postgres: {pgVersion: 10}}, + } as any); + + expect(() => getStackConfig()).toThrow(); + }); + + it("returns keycloakClients array from config", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + stack: {keycloak: {clients: ["app", "mobile"]}}, + } as any); + + const cfg = getStackConfig(); + + expect(cfg.keycloakClients).toEqual(["app", "mobile"]); + }); + + it("returns empty keycloakClients when none configured", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({stack: {}} as any); + + const cfg = getStackConfig(); + + expect(cfg.keycloakClients).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// ensureStackSecrets() +// --------------------------------------------------------------------------- + +describe("ensureStackSecrets()", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSecretsFilePath).mockReturnValue("/project/postkit.secrets.json"); + }); + + it("generates random postgres password when password is empty", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const config = makeFullStackConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + }); + + const result = ensureStackSecrets(config); + + expect(result.postgres.password).toBeTruthy(); + expect(result.postgres.password.length).toBeGreaterThan(0); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it("generates random keycloak adminPassword when adminPassword is empty", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const config = makeFullStackConfig({ + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + }); + + const result = ensureStackSecrets(config); + + expect(result.keycloak.adminPassword).toBeTruthy(); + expect(result.keycloak.adminPassword.length).toBeGreaterThan(0); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it("preserves existing postgres password and does not overwrite it", () => { + const existingSecrets = { + stack: { + postgres: {user: "postgres", password: "existing-pg-pass"}, + keycloak: {adminUser: "admin", adminPassword: "existing-kc-pass"}, + jwks: {keys: [{kty: "oct", kid: "k1", alg: "HS256", k: "akey"}], urlSigningKey: {kty: "oct", kid: "k1", alg: "HS256", k: "akey"}}, + }, + }; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(existingSecrets) as any); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const config = makeFullStackConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "existing-pg-pass", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "existing-kc-pass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + }); + + const result = ensureStackSecrets(config); + + // Should NOT change existing passwords + expect(result.postgres.password).toBe("existing-pg-pass"); + expect(result.keycloak.adminPassword).toBe("existing-kc-pass"); + }); + + it("does not write secrets file when all secrets already exist", () => { + const existingSecrets = { + stack: { + postgres: {user: "postgres", password: "existing-pg-pass"}, + keycloak: {adminUser: "admin", adminPassword: "existing-kc-pass"}, + jwks: {keys: [{kty: "oct", kid: "k1", alg: "HS256", k: "akey"}], urlSigningKey: {kty: "oct", kid: "k1", alg: "HS256", k: "akey"}}, + }, + }; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(existingSecrets) as any); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const config = makeFullStackConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "existing-pg-pass", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "existing-kc-pass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + }); + + ensureStackSecrets(config); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it("writes generated secrets to postkit.secrets.json", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + let writtenPath = ""; + let writtenContent = ""; + vi.mocked(fs.writeFileSync).mockImplementation((p, c) => { + writtenPath = p as string; + writtenContent = c as string; + }); + + const config = makeFullStackConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + }); + + ensureStackSecrets(config); + + expect(writtenPath).toBe("/project/postkit.secrets.json"); + const written = JSON.parse(writtenContent); + expect(written.stack.postgres.password).toBeTruthy(); + expect(written.stack.keycloak.adminPassword).toBeTruthy(); + }); + + it("generates jwks when absent from secrets", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + stack: { + postgres: {user: "postgres", password: "pass"}, + keycloak: {adminUser: "admin", adminPassword: "kcpass"}, + // no jwks + }, + }) as any, + ); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const config = makeFullStackConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "pass", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "kcpass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + jwks: {keys: []}, + }); + + const result = ensureStackSecrets(config); + + expect(result.jwks.keys.length).toBeGreaterThan(0); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it("returns the updated StackConfig", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const config = makeFullStackConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + }); + + const result = ensureStackSecrets(config); + + expect(result).toBeDefined(); + expect(result.postgres).toBeDefined(); + expect(result.keycloak).toBeDefined(); + }); +}); diff --git a/cli/test/modules/stack/utils/stack-state.test.ts b/cli/test/modules/stack/utils/stack-state.test.ts new file mode 100644 index 0000000..3c26fa2 --- /dev/null +++ b/cli/test/modules/stack/utils/stack-state.test.ts @@ -0,0 +1,242 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +// --------------------------------------------------------------------------- +// pg mock — Client is vi.fn() with no default implementation. +// Each test calls vi.mocked(Client).mockImplementation(function() {...}) +// NOTE: Must use regular `function` keyword (not arrow) — Vitest requires +// constructable functions when using `new` with mocked classes. +// --------------------------------------------------------------------------- +vi.mock("pg", () => ({ + Client: vi.fn(), +})); + +// Mock buildPgUrl so we don't need a real DB config +vi.mock("../../../../src/modules/stack/services/db-init", () => ({ + buildPgUrl: vi.fn(function() { return "postgres://postgres:secret@localhost:25432/postkit"; }), +})); + +import {Client} from "pg"; +import {readStackIsInitial, setStackInitialized} from "../../../../src/modules/stack/utils/stack-state"; +import type {StackConfig} from "../../../../src/modules/stack/types/config"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMockClient(overrides: { + connectError?: Error; + queryResult?: {rows: {value: string}[]; rowCount: number}; + queryError?: Error; +} = {}) { + return { + connect: overrides.connectError + ? vi.fn().mockRejectedValue(overrides.connectError) + : vi.fn().mockResolvedValue(undefined), + query: overrides.queryError + ? vi.fn().mockRejectedValue(overrides.queryError) + : vi.fn().mockResolvedValue(overrides.queryResult ?? {rows: [], rowCount: 0}), + end: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeMockConfig(): StackConfig { + return { + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "secret", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "kcpass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + postgrest: { + image: "postgrest/postgrest:latest", + enabled: true, + port: 3000, + dbSchema: "public", + dbAnonRole: "anon", + }, + traefik: { + image: "traefik:v3.3", + enabled: true, + httpPort: 80, + dashboardPort: 8080, + }, + network: "postkit-net", + jwks: {keys: []}, + keycloakClients: [], + }; +} + +// Helper to mock Client constructor using a regular function (required by Vitest) +function setupClientMock(mockClient: ReturnType) { + vi.mocked(Client).mockImplementation(function() { + return mockClient as any; + } as any); +} + +// --------------------------------------------------------------------------- +// readStackIsInitial() +// --------------------------------------------------------------------------- + +describe("readStackIsInitial()", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true when no row exists (empty result set)", async () => { + const mockClient = makeMockClient({queryResult: {rows: [], rowCount: 0}}); + setupClientMock(mockClient); + + const result = await readStackIsInitial(makeMockConfig()); + + expect(result).toBe(true); + }); + + it("returns false when row has value = 'false'", async () => { + const mockClient = makeMockClient({ + queryResult: {rows: [{value: "false"}], rowCount: 1}, + }); + setupClientMock(mockClient); + + const result = await readStackIsInitial(makeMockConfig()); + + expect(result).toBe(false); + }); + + it("returns true when row has value = 'true'", async () => { + const mockClient = makeMockClient({ + queryResult: {rows: [{value: "true"}], rowCount: 1}, + }); + setupClientMock(mockClient); + + const result = await readStackIsInitial(makeMockConfig()); + + expect(result).toBe(true); + }); + + it("returns true when DB query throws (table doesn't exist yet)", async () => { + const mockClient = makeMockClient({ + queryError: new Error("relation does not exist"), + }); + setupClientMock(mockClient); + + const result = await readStackIsInitial(makeMockConfig()); + + expect(result).toBe(true); + }); + + it("returns true when connect throws", async () => { + const mockClient = makeMockClient({ + connectError: new Error("connection refused"), + }); + setupClientMock(mockClient); + + const result = await readStackIsInitial(makeMockConfig()); + + expect(result).toBe(true); + }); + + it("closes pg client even on query error", async () => { + const mockClient = makeMockClient({ + queryError: new Error("some query error"), + }); + setupClientMock(mockClient); + + await readStackIsInitial(makeMockConfig()); + + expect(mockClient.end).toHaveBeenCalledTimes(1); + }); + + it("closes pg client on success", async () => { + const mockClient = makeMockClient({queryResult: {rows: [], rowCount: 0}}); + setupClientMock(mockClient); + + await readStackIsInitial(makeMockConfig()); + + expect(mockClient.end).toHaveBeenCalledTimes(1); + }); + + it("queries the correct table and key", async () => { + const mockClient = makeMockClient({queryResult: {rows: [], rowCount: 0}}); + setupClientMock(mockClient); + + await readStackIsInitial(makeMockConfig()); + + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining("postkit.stack_config"), + ["is_initial"], + ); + }); +}); + +// --------------------------------------------------------------------------- +// setStackInitialized() +// --------------------------------------------------------------------------- + +describe("setStackInitialized()", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("executes upsert with value = 'false'", async () => { + const mockClient = makeMockClient(); + setupClientMock(mockClient); + + await setStackInitialized(makeMockConfig()); + + const call = mockClient.query.mock.calls[0]!; + const sql = call[0] as string; + const params = call[1] as string[]; + + expect(sql).toContain("INSERT INTO postkit.stack_config"); + expect(sql).toContain("'false'"); + expect(sql.toLowerCase()).toContain("on conflict"); + expect(params).toContain("is_initial"); + }); + + it("closes pg client even when query throws", async () => { + const mockClient = makeMockClient({ + queryError: new Error("insert failed"), + }); + setupClientMock(mockClient); + + await expect(setStackInitialized(makeMockConfig())).rejects.toThrow("insert failed"); + + expect(mockClient.end).toHaveBeenCalledTimes(1); + }); + + it("closes pg client on success", async () => { + const mockClient = makeMockClient(); + setupClientMock(mockClient); + + await setStackInitialized(makeMockConfig()); + + expect(mockClient.end).toHaveBeenCalledTimes(1); + }); + + it("connects before querying", async () => { + const mockClient = makeMockClient(); + setupClientMock(mockClient); + + await setStackInitialized(makeMockConfig()); + + const connectOrder = mockClient.connect.mock.invocationCallOrder[0]!; + const queryOrder = mockClient.query.mock.invocationCallOrder[0]!; + + expect(connectOrder).toBeLessThan(queryOrder); + }); +}); diff --git a/cli/vendor/providers/primary-role-mapper-1.0.0.jar b/cli/vendor/providers/primary-role-mapper-1.0.0.jar new file mode 100644 index 0000000..9ce1598 Binary files /dev/null and b/cli/vendor/providers/primary-role-mapper-1.0.0.jar differ diff --git a/docs/docs/getting-started/quick-start.md b/docs/docs/getting-started/quick-start.md index 8d4f58d..00be065 100644 --- a/docs/docs/getting-started/quick-start.md +++ b/docs/docs/getting-started/quick-start.md @@ -105,9 +105,13 @@ PostKit performs a dry-run first to verify the migration works, then deploys to | `postkit db deploy` | Deploy to remote database | | `postkit db status` | Show session state | | `postkit db abort` | Cancel session and clean up | +| `postkit stack up` | Start local backend stack (Postgres, Keycloak, PostgREST, Traefik) | +| `postkit stack down` | Stop all stack services | +| `postkit stack status` | Show stack service health | ## Next Steps - [DB Module Overview](/docs/modules/db/overview) - Learn about the full migration workflow - [Auth Module Overview](/docs/modules/auth/overview) - Manage Keycloak configurations +- [Stack Module Overview](/docs/modules/stack/overview) - Manage local backend services - [Global Options](/docs/reference/global-options) - See all available CLI options diff --git a/docs/docs/modules/stack/commands/down.md b/docs/docs/modules/stack/commands/down.md new file mode 100644 index 0000000..0e40abc --- /dev/null +++ b/docs/docs/modules/stack/commands/down.md @@ -0,0 +1,31 @@ +--- +sidebar_position: 2 +--- + +# stack down + +Stop and remove all stack containers. + +## Usage + +```bash +postkit stack down # Stop containers, keep volumes +postkit stack down --volumes # Stop containers AND remove volumes +``` + +## Options + +| Option | Description | +|--------|-------------| +| `--volumes` | Remove persistent volumes (Postgres data, Keycloak data) | + +## What It Does + +1. Reads `.postkit/stack/docker-compose.yml` +2. Runs `docker compose down` (with `--volumes` if flag is set) + +## Data Safety + +Without `--volumes`, PostgreSQL and Keycloak data survive in Docker named volumes. Re-running `stack up` resumes where you left off. + +With `--volumes`, all data is deleted and the `is_initial` flag resets automatically (the `postkit.stack_config` table is in the Postgres volume). The next `stack up` runs full initialization — realm import and JWKs fetch. diff --git a/docs/docs/modules/stack/commands/keys.md b/docs/docs/modules/stack/commands/keys.md new file mode 100644 index 0000000..d892469 --- /dev/null +++ b/docs/docs/modules/stack/commands/keys.md @@ -0,0 +1,31 @@ +--- +sidebar_position: 6 +--- + +# stack keys + +Fetch JWKs and client secrets from Keycloak and write them to `postkit.secrets.json`. + +## Usage + +```bash +postkit stack keys # Fetch and write keys +postkit stack keys --restart # Fetch + restart PostgREST +postkit stack keys --clients "app,admin" # Fetch keys for specific clients only +``` + +## Options + +| Option | Description | +|--------|-------------| +| `--restart` | Restart PostgREST after updating secrets with new JWKs | +| `--clients ` | Comma-separated client names to fetch (overrides `stack.keycloak.clients` in config) | + +## What It Does + +1. Fetches public JWKs from Keycloak's JWKS endpoint +2. Fetches client secrets for configured clients +3. Writes the merged result to `postkit.secrets.json` under `stack.jwks` and `stack.clients` +4. If `--restart`: regenerates the compose file with updated JWT config and restarts PostgREST + +This command is run automatically during `stack up` Phase 4 (first run). Use it manually to refresh keys without restarting the whole stack. diff --git a/docs/docs/modules/stack/commands/logs.md b/docs/docs/modules/stack/commands/logs.md new file mode 100644 index 0000000..f2234d1 --- /dev/null +++ b/docs/docs/modules/stack/commands/logs.md @@ -0,0 +1,32 @@ +--- +sidebar_position: 4 +--- + +# stack logs + +Tail logs for all services or a specific service. + +## Usage + +```bash +postkit stack logs # Follow all services +postkit stack logs keycloak # Keycloak logs only +postkit stack logs postgres --no-follow # Print last 100 lines and exit +postkit stack logs postgrest -n 50 # Last 50 lines, then follow +``` + +## Arguments + +| Argument | Description | +|----------|-------------| +| `[service]` | Service name to tail. Omit for all services. | + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `-f, --follow` | true | Stream logs continuously | +| `--no-follow` | — | Print last N lines and exit | +| `-n, --tail ` | 100 | Number of lines to show | + +Press `Ctrl+C` to stop following. diff --git a/docs/docs/modules/stack/commands/realm.md b/docs/docs/modules/stack/commands/realm.md new file mode 100644 index 0000000..14a526a --- /dev/null +++ b/docs/docs/modules/stack/commands/realm.md @@ -0,0 +1,31 @@ +--- +sidebar_position: 7 +--- + +# stack realm + +Re-import the Keycloak realm template into the running Keycloak instance. + +## Usage + +```bash +postkit stack realm +``` + +## What It Does + +1. Reads the realm template from `stack.keycloak.realmTemplate` (default: `.postkit/auth/realm/postkit.json`) +2. Runs `cleanRealmTemplate()` — strips builtin clients, strips IDs/secrets, injects JWT Role Mapper +3. Imports the cleaned template via `keycloak-config-cli` (`docker run --network postkit-net`) + +Keycloak must be running before this command can succeed. + +## When to Use + +- After editing the realm template manually +- When Keycloak loses its configuration (e.g., after a container restart without a volume) +- To retry a failed Phase 4 initialization without restarting the whole stack + +## JWT Role Mapper + +The import automatically injects `script-primary-role.js` as a protocol mapper into every non-builtin client. This mapper converts Keycloak realm roles into JWT claims compatible with PostgREST role-based access control. diff --git a/docs/docs/modules/stack/commands/restart.md b/docs/docs/modules/stack/commands/restart.md new file mode 100644 index 0000000..ff0b483 --- /dev/null +++ b/docs/docs/modules/stack/commands/restart.md @@ -0,0 +1,23 @@ +--- +sidebar_position: 5 +--- + +# stack restart + +Restart one or more services. + +## Usage + +```bash +postkit stack restart # Restart all services +postkit stack restart keycloak # Restart keycloak only +postkit stack restart keycloak postgrest # Restart multiple services +``` + +## Arguments + +| Argument | Description | +|----------|-------------| +| `[services...]` | Services to restart. Omit for all. Valid: `postgres`, `keycloak`, `postgrest`, `traefik` | + +Service names are validated before restarting. An unknown service name produces an error listing valid options. diff --git a/docs/docs/modules/stack/commands/status.md b/docs/docs/modules/stack/commands/status.md new file mode 100644 index 0000000..9922a76 --- /dev/null +++ b/docs/docs/modules/stack/commands/status.md @@ -0,0 +1,17 @@ +--- +sidebar_position: 3 +--- + +# stack status + +Show running services, ports, and health status. + +## Usage + +```bash +postkit stack status +``` + +## What It Does + +Reads `.postkit/stack/docker-compose.yml` and queries Docker for the current state of each container. Displays a table with service name, container name, state, health, and ports. diff --git a/docs/docs/modules/stack/commands/up.md b/docs/docs/modules/stack/commands/up.md new file mode 100644 index 0000000..52ef2ff --- /dev/null +++ b/docs/docs/modules/stack/commands/up.md @@ -0,0 +1,45 @@ +--- +sidebar_position: 1 +--- + +# stack up + +Start the full stack or selected services. + +## Usage + +```bash +postkit stack up # Start all services +postkit stack up postgres traefik # Start specific services +postkit stack up --no-wait # Skip health check waiting +postkit stack up --no-keys # Skip auto-fetching JWKs on init +``` + +## Arguments + +| Argument | Description | +|----------|-------------| +| `[services...]` | Services to start: `postgres`, `keycloak`, `postgrest`, `traefik`. Omit for all. | + +## Options + +| Option | Description | +|--------|-------------| +| `--no-wait` | Skip waiting for health checks | +| `--no-keys` | Skip auto-fetching Keycloak JWKs during first-run initialization | + +## What It Does + +1. Checks Docker and Docker Compose availability +2. Loads config, auto-generates missing secrets (passwords, JWKs) +3. Generates `.postkit/stack/docker-compose.yml` +4. **Phase 1** — Starts `postgres` + `traefik`, waits for health checks +5. **Phase 2** — Applies `db/infra/` SQL, committed migrations, and seeds +6. **Phase 3** — Starts `keycloak` + `postgrest`, waits for health checks +7. **Phase 4** (first run only) — Imports realm template, fetches JWKs, restarts PostgREST + +Phase 4 is skipped when `is_initial = false` in `postkit.stack_config`. It resets automatically after `stack down --volumes`. + +## Dependency Rule + +Selecting `keycloak` or `postgrest` automatically includes `postgres` and `traefik`. diff --git a/docs/docs/modules/stack/overview.mdx b/docs/docs/modules/stack/overview.mdx new file mode 100644 index 0000000..95cb332 --- /dev/null +++ b/docs/docs/modules/stack/overview.mdx @@ -0,0 +1,131 @@ +--- +sidebar_position: 1 +--- + +# Stack Module + +The `stack` module manages a local backend service stack for development — PostgreSQL, Keycloak, PostgREST, and Traefik — using Docker Compose. It handles DB initialization, Keycloak realm import, and JWK key fetching automatically on the first run. + +## Services + +| Service | Image | URL / Port | Purpose | +|---------|-------|-----------|---------| +| `postgres` | `postgres:16-alpine` | `localhost:25432` | Database | +| `keycloak` | `quay.io/keycloak/keycloak:26.6` | `http://keycloak.localhost` | Auth server | +| `postgrest` | `postgrest/postgrest:latest` | `http://api.localhost` | REST API | +| `traefik` | `traefik:v3.3` | Port 80 / dashboard `localhost:8080` | Reverse proxy | + +## Workflow + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ stack up (two-phase) │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ Phase 1 Start postgres + traefik → wait for health │ +│ │ │ +│ Phase 2 Apply DB: infra SQL + migrations + seeds │ +│ │ │ +│ Phase 3 Start keycloak + postgrest → wait for health │ +│ │ │ +│ Phase 4 First run only: import realm → fetch JWKs │ +│ │ │ +│ Mark stack as initialized (is_initial = false in DB) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +Phase 4 only runs when the database has no `is_initial = false` record in `postkit.stack_config`. It resets automatically when volumes are wiped with `stack down --volumes`. + +## Commands + +| Command | Description | +|---------|-------------| +| [`up`](/docs/modules/stack/commands/up) | Start all or selected services | +| [`down`](/docs/modules/stack/commands/down) | Stop services, optionally remove volumes | +| [`status`](/docs/modules/stack/commands/status) | Show service health | +| [`logs`](/docs/modules/stack/commands/logs) | Tail service logs | +| [`restart`](/docs/modules/stack/commands/restart) | Restart one or more services | +| [`keys`](/docs/modules/stack/commands/keys) | Fetch Keycloak JWKs and client secrets | +| [`realm`](/docs/modules/stack/commands/realm) | Re-import the Keycloak realm template | + +## Prerequisites + +- Docker Desktop installed and **running** +- Docker Compose V2 (included with Docker Desktop 4.x+) +- Project initialized with `postkit init` + +## Quick Start + +```bash +# Initialize (creates infra SQL, realm template, provider JARs) +postkit init + +# Start full stack — first run imports realm and fetches JWKs automatically +postkit stack up + +# Check status +postkit stack status + +# Stop (keeps data volumes) +postkit stack down + +# Full reset — wipes all data, runs initialization again on next up +postkit stack down --volumes +``` + +## Configuration + +Stack configuration is split across two files: + +### `postkit.config.json` (committed) + +```json +{ + "name": "myapp_a3f2b1c0", + "stack": { + "postgres": { "port": 25432, "database": "postkit" }, + "keycloak": { + "realm": "postkit", + "realmTemplate": ".postkit/auth/realm/postkit.json", + "clients": ["app"] + }, + "postgrest": { "dbSchema": "public", "dbAnonRole": "anon" }, + "traefik": { "httpPort": 80, "dashboardPort": 8080 } + } +} +``` + +### `postkit.secrets.json` (gitignored) + +Auto-generated on first `stack up`. Missing passwords and JWKs are generated automatically. + +```json +{ + "stack": { + "postgres": { "user": "postgres", "password": "" }, + "keycloak": { "adminUser": "admin", "adminPassword": "" } + } +} +``` + +## `is_initial` State + +The stack tracks whether first-run initialization has completed in the `postkit.stack_config` database table. This means the state resets automatically when you wipe volumes — no manual cleanup needed. + +| How to reset | When to use | +|-------------|-------------| +| `postkit stack down --volumes` | Full reset — wipes all data and re-initializes on next `stack up` | +| `postkit stack realm` | Re-import realm template only | +| `postkit stack keys` | Re-fetch JWKs and client secrets only | + +## Output Structure + +``` +.postkit/ +├── auth/ +│ ├── realm/ +│ │ └── postkit.json # Realm template (committed) +│ └── providers/ # Keycloak JARs (gitignored, rebuilt by init) +└── stack/ + └── docker-compose.yml # Generated compose file (gitignored) +``` diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 1721b43..98c325c 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -113,6 +113,31 @@ const sidebars: SidebarsConfig = { ], }, + { + type: 'category', + label: 'Stack Module', + collapsible: true, + collapsed: false, + items: [ + 'modules/stack/overview', + { + type: 'category', + label: 'Commands', + collapsible: true, + collapsed: false, + items: [ + 'modules/stack/commands/up', + 'modules/stack/commands/down', + 'modules/stack/commands/status', + 'modules/stack/commands/logs', + 'modules/stack/commands/restart', + 'modules/stack/commands/keys', + 'modules/stack/commands/realm', + ], + }, + ], + }, + { type: 'category', label: 'Reference',