refactor(iac): cluster split — 1 cluster per plane, breakpilot-* naming (#6)
ci / shared (push) Successful in 9s
ci / validate (push) Successful in 4s

3 Orca clusters (breakpilot-edge / breakpilot-control / breakpilot-app), 1 plane each. Single-VM core with Keycloak co-tenant on vm-edge. App cluster gets prod + stage VMs. Manifests reorganized into clusters/<name>/services/; validator now enforces per-cluster node whitelist. Multi-VM rollout gated on legal entity.
This commit was merged in pull request #6.
This commit is contained in:
2026-06-30 20:30:14 +00:00
parent f1c3fd14b9
commit 6be727d404
44 changed files with 428 additions and 125 deletions
+85
View File
@@ -0,0 +1,85 @@
# Clusters
Each subdirectory under `clusters/` is the manifest set for one Orca cluster.
At migration time each will become its own Gitea repository under
`platform/` so the cluster boundary doubles as a repository boundary.
| Subdir | Future repo | VMs | Plane |
|---|---|---|---|
| [`breakpilot-edge/`](./breakpilot-edge/) | `platform/breakpilot-edge` | `vm-edge` (1) | Identity + Infra |
| [`breakpilot-control/`](./breakpilot-control/) | `platform/breakpilot-control` | `vm-control` (1) | Control |
| [`breakpilot-app/`](./breakpilot-app/) | `platform/breakpilot-app` | `vm-app-prod` + `vm-app-stage` (2) | App (was "Data") |
## Why one cluster per plane
- **Independent failure domain.** A runaway reconcile loop in `breakpilot-app`
cannot restart Keycloak. An ERPNext migration on `breakpilot-control` does
not stall portal logins.
- **Independent backup policy.** Each cluster has its own `[backup]` block
and its own S3 bucket so RPO targets in `INFRASTRUCTURE.md §6` can be set
per plane without cross-talk.
- **Independent IaC.** Each future repo has its own CI, CODEOWNERS, and
reconcile cadence. Touching identity does not gate a portal hotfix.
- **Same Orca everywhere.** Per-node ingress (every Orca node binds 80/443
with its own ACME) means each cluster's services get A records pointing
at their own VM. No central proxy SPOF.
## Why this is still a single repo today
We hold the three-cluster design in this one staging repo until the legal
entity is established and we can sign SysEleven / Hetzner business
contracts to provision actual VMs. Until then, everything runs on the
existing single VM under `orca-infra` and these manifests are
design-only — `make validate` keeps them honest, `make apply` is a no-op
for now.
When the entity lands the migration is mechanical: split each subdir into
its own Gitea repo, point its `cluster.toml` at a real VM, run
`orca deploy`.
## VM topology after migration
```
breakpilot-edge cluster → vm-edge (Keycloak + Gitea + Infisical + PowerDNS + Orca-Proxy)
breakpilot-control cluster → vm-control (Portal + Tenant-Registry + ERPNext + MariaDB + Stalwart)
breakpilot-app cluster → vm-app-prod (CERTifAI + compliance-* + Mongo + MinIO + Qdrant + LiteLLM)
\→ vm-app-stage (slim parallel set for staging)
```
`vm-app-stage` lives in the same Orca cluster as `vm-app-prod` and points
at PROD `breakpilot-edge` Keycloak + PROD `breakpilot-control`
tenant-registry, isolated by `tenant.kind = "stage"` per
`INFRASTRUCTURE.md §5`. No duplicated identity, no duplicated control plane.
## Identity is part of the core (single VM)
The May 18 `INFRASTRUCTURE.md` had Identity (Keycloak) co-tenant with Infra
on `vm-edge`, with JVM heap pinned at 1.5 GB so it cannot starve PowerDNS
/ Infisical. That decision stands — Identity lives in `breakpilot-edge` on
the same VM as the rest of infra. We will revisit splitting Keycloak onto
its own VM only if the JVM vs. infra fight actually shows up in alerts.
## Repurposing the existing Keycloak
`benjamin_boenisch/certifai` already runs a Keycloak instance. Plan is to
**repurpose that instance into `breakpilot-edge/services/keycloak.toml`**
rather than stand up a fresh one — same realm exports, same client
configurations, same user database. Migration is a hostname swap and a
realm export/import, not a re-bootstrap.
## Adding a new service
Pick the plane, then drop the manifest into the right `services/` dir:
```bash
clusters/breakpilot-<plane>/services/<service>.toml
# breakpilot-app extras:
clusters/breakpilot-app/services/{prod,stage}/<service>.toml
```
`scripts/validate.sh` enforces:
- `placement.node` must match one of the cluster's declared VMs
(`vm-edge` / `vm-control` / `vm-app-prod` / `vm-app-stage`)
- `resources.memory` is mandatory (§8 rule 5)
- no nested `depends_on` / `extra_ports` / `cmd` / `mounts` under
`[service.placement|resources|env|volume]`
+83
View File
@@ -0,0 +1,83 @@
# breakpilot-app
App plane (formerly "Data plane" in the May 18 doc). Two VMs in one cluster:
- **`vm-app-prod`** — production workloads, customer data, real keys
- **`vm-app-stage`** — staging workloads, demo data, sandbox tenant
Becomes its own Gitea repo `platform/breakpilot-app` at migration time.
## Services
### Prod ([`services/prod/`](./services/prod/), 9 services)
| Service | Purpose |
|---|---|
| `admin-compliance.toml` | Compliance back-office admin |
| `ai-compliance-sdk.toml` | LLM-mediated compliance SDK |
| `backend-compliance.toml` | Compliance scanner backend |
| `certifai-dashboard.toml` | CERTifAI dashboard (first product) |
| `litellm.toml` | LLM gateway |
| `minio.toml` | S3-compatible object store |
| `mongodb.toml` | Compliance + CERTifAI data |
| `pg-app.toml` | Per-tenant application Postgres (SPOF — RISK-1 in `§7`) |
| `qdrant.toml` | Vector store (rebuildable) |
### Stage ([`services/stage/`](./services/stage/), 11 services)
| Service | Purpose |
|---|---|
| `admin-compliance.toml` | Stage admin |
| `ai-compliance-sdk.toml` | Stage SDK |
| `backend-compliance.toml` | Stage backend |
| `certifai-dashboard.toml` | Stage CERTifAI |
| `customer-portal.toml` | Stage portal (calls prod KC + prod tenant-registry with `tenant.kind = "stage"`) |
| `litellm.toml` | Stage LLM gateway (may reuse prod) |
| `mongodb-stage.toml` | Stage Mongo |
| `orca-proxy.toml` | Per-node ingress on `vm-app-stage` |
| `pg-app-stage.toml` | Stage Postgres |
| `qdrant-stage.toml` | Stage vector store |
| `tenant-registry.toml` | (placeholder; stage calls prod registry — manifest exists for parity) |
## SLA targets (per `INFRASTRUCTURE.md §6`)
```
Owns: DATA_RESIDENCY — all customer data (MongoDB, pg-app, MinIO) must stay EU
RPO (product) — compliance records ≤ 6h; chat history ≤ 24h
DATA_ISOLATION — every query scoped by org_id / tenant_id
AUDIT_TRAIL — product-level actions
AVAILABILITY — CERTifAI ≥ 99.5%; compliance ≥ 99.5%
```
## Why one cluster, two VMs
- **Same orca-platform config, different physical workloads.** Stage and
prod don't drift on infra config because they're in the same cluster.
- **No "oops touched prod" accidents.** Per-service `placement.node`
pins which VM each container lands on; prod manifests live in `prod/`,
stage in `stage/`.
- **Blast radius is physical.** A stage misconfiguration cannot exhaust
`vm-app-prod`'s memory because they share nothing but the Orca control
socket.
## Stage isolation contract
Stage services never:
- email real customers (Stalwart accept-rule on `breakpilot-control` drops
recipients not matching `*+stage@*`)
- trigger real Polar charges (`POLAR_API_URL` points at sandbox)
- carry real customer data (sandbox tenant resets nightly)
Stage services always:
- authenticate via prod Keycloak with `tenant.kind = "stage"`
- read tenant config from prod `tenant-registry` (read-only for stage tenants)
- expose `*.stage.breakpilot.com` (or whatever the staging subdomain is)
See `INFRASTRUCTURE.md §5` for the full stage-prod sharing contract.
## Sandbox tenant
The `sandbox` tenant in `stage/` is the only place that accepts writes
from public demo visitors. A nightly cron (lands as `sandbox-reset.toml`
when seed-data fixtures are wired in M13.1) resets its Mongo collections
and Keycloak user attributes from a versioned seed.
@@ -0,0 +1,15 @@
# admin-compliance stub — full config lands in M7.x.
# Host: vm-app-prod. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "admin-compliance"
image = "registry.breakpilot.com/admin-compliance:placeholder"
port = 3002
depends_on = ["backend-compliance", "ai-compliance-sdk"]
[service.placement]
node = "vm-app-prod"
[service.resources]
memory = "512Mi"
cpu = 0.25
@@ -0,0 +1,15 @@
# ai-compliance-sdk stub — full config lands in M7.x.
# Host: vm-app-prod. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "ai-compliance-sdk"
image = "registry.breakpilot.com/ai-compliance-sdk:placeholder"
port = 3001
depends_on = ["pg-app", "qdrant", "litellm"]
[service.placement]
node = "vm-app-prod"
[service.resources]
memory = "1Gi"
cpu = 0.5
@@ -0,0 +1,15 @@
# backend-compliance stub — full config lands in M7.x.
# Host: vm-app-prod. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "backend-compliance"
image = "registry.breakpilot.com/backend-compliance:placeholder"
port = 3000
depends_on = ["pg-app", "minio"]
[service.placement]
node = "vm-app-prod"
[service.resources]
memory = "1Gi"
cpu = 0.5
@@ -0,0 +1,15 @@
# certifai-dashboard stub — full config lands in M6.x.
# Host: vm-app-prod. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "certifai-dashboard"
image = "registry.breakpilot.com/certifai:placeholder"
port = 3000
depends_on = ["mongodb", "litellm"]
[service.placement]
node = "vm-app-prod"
[service.resources]
memory = "1Gi"
cpu = 0.5
@@ -0,0 +1,18 @@
# litellm stub — full config lands in M6.x.
# Host: vm-app-prod. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "litellm"
image = "ghcr.io/berriai/litellm:main-stable"
port = 4000
[service.placement]
node = "vm-app-prod"
[service.resources]
memory = "1Gi"
cpu = 0.5
[service.env]
LITELLM_MASTER_KEY = "${secrets.LITELLM_MASTER_KEY}"
LITELLM_SALT_KEY = "${secrets.LITELLM_SALT_KEY}"
@@ -0,0 +1,23 @@
# minio stub — full config lands in M7.x.
# Host: vm-app-prod. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "minio"
image = "minio/minio:latest"
port = 9000
extra_ports = ["9001:9001"]
cmd = ["server", "/data", "--console-address", ":9001"]
[service.placement]
node = "vm-app-prod"
[service.resources]
memory = "1Gi"
cpu = 0.5
[service.volume]
path = "/data"
[service.env]
MINIO_ROOT_USER = "${secrets.MINIO_ROOT_USER}"
MINIO_ROOT_PASSWORD = "${secrets.MINIO_ROOT_PASSWORD}"
@@ -0,0 +1,21 @@
# mongodb stub — full config lands in M6.x.
# Host: vm-app-prod. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "mongodb"
image = "mongo:7"
port = 27017
[service.placement]
node = "vm-app-prod"
[service.resources]
memory = "2Gi"
cpu = 1.0
[service.volume]
path = "/data/db"
[service.env]
MONGO_INITDB_ROOT_USERNAME = "${secrets.MONGO_ADMIN_USER}"
MONGO_INITDB_ROOT_PASSWORD = "${secrets.MONGO_ADMIN_PASSWORD}"
@@ -0,0 +1,23 @@
# pg-app stub — full config lands in M4.1.
# Host: vm-app-prod. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
# RISK-1 (§12): single instance owns tenant_registry + compliance schemas. Split into pg-registry + pg-compliance at Tier B.
[[service]]
name = "pg-app"
image = "postgres:16-alpine"
port = 5432
[service.placement]
node = "vm-app-prod"
[service.resources]
memory = "3Gi"
cpu = 1.0
[service.volume]
path = "/var/lib/postgresql/data"
[service.env]
POSTGRES_DB = "platform"
POSTGRES_USER = "platform"
POSTGRES_PASSWORD = "${secrets.PG_APP_PASSWORD}"
@@ -0,0 +1,17 @@
# qdrant stub — full config lands in M7.x.
# Host: vm-app-prod. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "qdrant"
image = "qdrant/qdrant:v1.10.0"
port = 6333
[service.placement]
node = "vm-app-prod"
[service.resources]
memory = "1Gi"
cpu = 0.5
[service.volume]
path = "/qdrant/storage"
@@ -0,0 +1,14 @@
# admin-compliance stub — full config lands in M7.x.
# Host: vm-app-stage. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "admin-compliance"
image = "registry.breakpilot.com/admin-compliance:env-stage"
port = 3002
[service.placement]
node = "vm-app-stage"
[service.resources]
memory = "256Mi"
cpu = 0.25
@@ -0,0 +1,14 @@
# ai-compliance-sdk stub — full config lands in M7.x.
# Host: vm-app-stage. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "ai-compliance-sdk"
image = "registry.breakpilot.com/ai-compliance-sdk:env-stage"
port = 3001
[service.placement]
node = "vm-app-stage"
[service.resources]
memory = "512Mi"
cpu = 0.25
@@ -0,0 +1,14 @@
# backend-compliance stub — full config lands in M7.x.
# Host: vm-app-stage. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "backend-compliance"
image = "registry.breakpilot.com/backend-compliance:env-stage"
port = 3000
[service.placement]
node = "vm-app-stage"
[service.resources]
memory = "512Mi"
cpu = 0.25
@@ -0,0 +1,14 @@
# certifai-dashboard stub — full config lands in M6.x.
# Host: vm-app-stage. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "certifai-dashboard"
image = "registry.breakpilot.com/certifai:env-stage"
port = 3000
[service.placement]
node = "vm-app-stage"
[service.resources]
memory = "1Gi"
cpu = 0.5
@@ -0,0 +1,15 @@
# customer-portal stub — full config lands in M5.1.
# Host: vm-app-stage. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "customer-portal"
image = "registry.breakpilot.com/portal:env-stage"
port = 3000
domain = "*.stage.breakpilot.com"
[service.placement]
node = "vm-app-stage"
[service.resources]
memory = "1Gi"
cpu = 0.5
@@ -0,0 +1,14 @@
# litellm stub — full config lands in M6.x.
# Host: vm-app-stage. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "litellm"
image = "ghcr.io/berriai/litellm:main-stable"
port = 4000
[service.placement]
node = "vm-app-stage"
[service.resources]
memory = "512Mi"
cpu = 0.25
@@ -0,0 +1,15 @@
# mongodb-stage stub — full config lands in M6.x.
# Host: vm-app-stage. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
# Ephemeral.
[[service]]
name = "mongodb-stage"
image = "mongo:7"
port = 27017
[service.placement]
node = "vm-app-stage"
[service.resources]
memory = "512Mi"
cpu = 0.25
@@ -0,0 +1,15 @@
# orca-proxy stub — full config lands in M1.2.
# Host: vm-app-stage. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
# Stage proxy only routes to stage app containers.
[[service]]
name = "orca-proxy"
image = "orca-managed/orca-proxy:placeholder"
port = 443
[service.placement]
node = "vm-app-stage"
[service.resources]
memory = "256Mi"
cpu = 0.5
@@ -0,0 +1,15 @@
# pg-app-stage stub — full config lands in M4.1.
# Host: vm-app-stage. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
# Ephemeral; no backup, no volume; reset on each release.
[[service]]
name = "pg-app-stage"
image = "postgres:16-alpine"
port = 5432
[service.placement]
node = "vm-app-stage"
[service.resources]
memory = "1Gi"
cpu = 0.5
@@ -0,0 +1,15 @@
# qdrant-stage stub — full config lands in M7.x.
# Host: vm-app-stage. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
# Ephemeral, tiny corpus.
[[service]]
name = "qdrant-stage"
image = "qdrant/qdrant:v1.10.0"
port = 6333
[service.placement]
node = "vm-app-stage"
[service.resources]
memory = "512Mi"
cpu = 0.25
@@ -0,0 +1,19 @@
# tenant-registry stub — full config lands in M4.1.
# Host: vm-app-stage. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
# Calls PROD Keycloak per §2 "Calls OUT to prod"; audience is stage_client_id.
[[service]]
name = "tenant-registry"
image = "registry.breakpilot.com/tenant-registry:env-stage"
port = 8090
depends_on = ["pg-app-stage"]
[service.placement]
node = "vm-app-stage"
[service.resources]
memory = "512Mi"
cpu = 0.25
[service.env]
KEYCLOAK_ISSUER = "https://auth.breakpilot.com/realms/breakpilot-prod"
+47
View File
@@ -0,0 +1,47 @@
# breakpilot-control
Control plane. Single VM (`vm-control`).
Becomes its own Gitea repo `platform/breakpilot-control` at migration time.
## Services (7)
| Service | Purpose |
|---|---|
| [`customer-portal.toml`](./services/customer-portal.toml) | Next.js 15 customer-facing portal |
| [`erpnext.toml`](./services/erpnext.toml) | Internal-only ERP (sales, invoices, fulfillment) |
| [`frappe-hd.toml`](./services/frappe-hd.toml) | Helpdesk on the Frappe stack |
| [`mariadb.toml`](./services/mariadb.toml) | DB for ERPNext + Frappe HD |
| [`redis-erpnext.toml`](./services/redis-erpnext.toml) | ERPNext cache + queue Redis |
| [`stalwart.toml`](./services/stalwart.toml) | Outbound mail relay; per-stage accept-rule blocks real customers from receiving stage mail |
| [`tenant-registry.toml`](./services/tenant-registry.toml) | Tenant glue: orgs, entitlements, API keys, audit. M7.3's `TENANT_REGISTRY_URL` resolves here. |
## SLA targets (per `INFRASTRUCTURE.md §6`)
```
Owns: RPO (tenant) — tenant registry & compliance schemas RPO ≤ 6h
RPO (ERPNext) — sales orders, invoices RPO ≤ 6h
AUDIT_TRAIL — all portal actions (invites, IdP changes, impersonations)
AVAILABILITY — portal ≥ 99.5%; ERPNext ≥ 99% (internal)
RTO (portal) — ≤ 10 min
RTO (ERPNext) — ≤ 60 min
```
## Co-tenant note
ERPNext + Portal + Stalwart on one VM. Orca resource limits per `§6`:
| Service | Memory cap |
|---|---|
| customer-portal | 1 GB |
| erpnext | 6 GB |
| mariadb | 3 GB |
| stalwart | 1 GB |
| tenant-registry | 500 MB |
## Stage callers
Stage workloads in `breakpilot-app/services/stage/` call the **prod**
tenant-registry here under `tenant.kind = "stage"` rather than running a
duplicate. Isolation is by tenant kind, not by replicating the control
plane.
@@ -0,0 +1,20 @@
# customer-portal stub — full config lands in M5.1.
# Host: vm-control. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "customer-portal"
image = "registry.breakpilot.com/portal:placeholder"
port = 3000
domain = "*.breakpilot.com"
depends_on = ["tenant-registry"]
[service.placement]
node = "vm-control"
[service.resources]
memory = "1Gi"
cpu = 1.0
[service.env]
KEYCLOAK_ISSUER = "https://auth.breakpilot.com/realms/breakpilot-prod"
TENANT_REGISTRY_URL = "http://tenant-registry:8090"
@@ -0,0 +1,25 @@
# erpnext stub — full config lands in M8.1.
# Host: vm-control. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "erpnext"
image = "frappe/erpnext:v15"
port = 8000
domain = "erp.breakpilot.com"
depends_on = ["mariadb", "redis-erpnext"]
[service.placement]
node = "vm-control"
[service.resources]
memory = "6Gi"
cpu = 2.0
[service.volume]
path = "/home/frappe/frappe-bench/sites"
[service.env]
DB_HOST = "mariadb"
REDIS_QUEUE = "redis://redis-erpnext:6379/0"
REDIS_CACHE = "redis://redis-erpnext:6379/1"
ADMIN_PASSWORD = "${secrets.ERPNEXT_ADMIN_PASSWORD}"
@@ -0,0 +1,15 @@
# frappe-hd stub — full config lands in M9.1.
# Host: vm-control. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "frappe-hd"
image = "frappe/helpdesk:v1"
port = 8001
depends_on = ["mariadb", "redis-erpnext"]
[service.placement]
node = "vm-control"
[service.resources]
memory = "1Gi"
cpu = 0.5
@@ -0,0 +1,20 @@
# mariadb stub — full config lands in M8.1.
# Host: vm-control. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "mariadb"
image = "mariadb:11"
port = 3306
[service.placement]
node = "vm-control"
[service.resources]
memory = "3Gi"
cpu = 1.0
[service.volume]
path = "/var/lib/mysql"
[service.env]
MARIADB_ROOT_PASSWORD = "${secrets.MARIADB_ROOT_PASSWORD}"
@@ -0,0 +1,14 @@
# redis-erpnext stub — full config lands in M8.1.
# Host: vm-control. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "redis-erpnext"
image = "redis:7-alpine"
port = 6379
[service.placement]
node = "vm-control"
[service.resources]
memory = "256Mi"
cpu = 0.25
@@ -0,0 +1,22 @@
# stalwart stub — full config lands in M3.2.
# Host: vm-control. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "stalwart"
image = "stalwartlabs/mail-server:latest"
port = 587
domain = "mail.breakpilot.com"
extra_ports = ["25:25", "465:465", "587:587", "993:993"]
[service.placement]
node = "vm-control"
[service.resources]
memory = "1Gi"
cpu = 0.5
[service.volume]
path = "/opt/stalwart-mail"
[service.env]
STALWART__SERVER__HOSTNAME = "mail.breakpilot.com"
@@ -0,0 +1,20 @@
# tenant-registry stub — full config lands in M4.1.
# Host: vm-control. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "tenant-registry"
image = "registry.breakpilot.com/tenant-registry:placeholder"
port = 8090
[service.placement]
node = "vm-control"
[service.resources]
memory = "512Mi"
cpu = 0.5
[service.env]
DATABASE_URL = "${secrets.TENANT_REGISTRY_DB_URL}"
KEYCLOAK_ISSUER = "https://auth.breakpilot.com/realms/breakpilot-prod"
KEYCLOAK_ADMIN_USER = "${secrets.KEYCLOAK_ADMIN_USER}"
KEYCLOAK_ADMIN_PASS = "${secrets.KEYCLOAK_ADMIN_PASS}"
+42
View File
@@ -0,0 +1,42 @@
# breakpilot-edge
Identity + Infra plane. Single VM (`vm-edge`).
Becomes its own Gitea repo `platform/breakpilot-edge` at migration time.
## Services (8)
| Service | Purpose |
|---|---|
| [`gitea.toml`](./services/gitea.toml) | Source forge — every config change to every cluster lands here |
| [`infisical.toml`](./services/infisical.toml) | Secret store. Every other plane's `${secrets.*}` resolves here |
| [`keycloak.toml`](./services/keycloak.toml) | Single tenant of truth for identity. **Repurposed from `certifai` Keycloak — same realm, same users**. |
| [`orca-proxy.toml`](./services/orca-proxy.toml) | Ingress for vm-edge: TLS termination + Let's Encrypt ACME for `*.breakpilot.com` |
| [`pg-infisical.toml`](./services/pg-infisical.toml) | Infisical's Postgres |
| [`pg-keycloak.toml`](./services/pg-keycloak.toml) | Keycloak's Postgres |
| [`powerdns-auth.toml`](./services/powerdns-auth.toml) | Authoritative DNS for `breakpilot.com`; bound to port 53 on vm-edge |
| [`redis-infisical.toml`](./services/redis-infisical.toml) | Cache + session store for Infisical |
## SLA targets (per `INFRASTRUCTURE.md §6`)
```
Owns: AVAILABILITY ≥ 99.5% (root dep for every other plane)
RTO ≤ 15 min (Keycloak)
AUDIT_TRAIL — realm-level audit (logins, token issuance, IdP events)
DATA_RESIDENCY — Keycloak realm data must stay EU
STAGE_ISOLATION — rate-limits stage_client_id; rejects stage JWTs in prod audiences
```
## Co-tenant note
Keycloak's JVM heap is pinned to 1.5 GB in `keycloak.toml`
(`JAVA_OPTS_APPEND = "-Xms1g -Xmx1500m"`) so it cannot starve PowerDNS or
Infisical. If the heap fight ever shows up in alerts, the escape hatch is
to peel Keycloak off onto its own VM inside the same cluster — no schema
migration, just a `placement.node` flip.
## Bootstrap exception
Per `INFRASTRUCTURE.md §8 rule 3`, `KC_DB_URL` lives in Orca env (not in
Infisical), because Infisical runs on the same VM and we'd otherwise have
a circular bootstrap dependency.
@@ -0,0 +1,25 @@
# gitea stub — full config lands in M3.x.
# Host: vm-edge. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "gitea"
image = "gitea/gitea:1.22"
port = 3000
domain = "git.breakpilot.com"
[service.placement]
node = "vm-edge"
[service.resources]
memory = "512Mi"
cpu = 0.5
[service.volume]
path = "/data"
[service.env]
USER_UID = "1000"
USER_GID = "1000"
GITEA__database__DB_TYPE = "sqlite3"
GITEA__database__PATH = "/data/gitea/gitea.db"
GITEA__server__ROOT_URL = "https://git.breakpilot.com"
@@ -0,0 +1,22 @@
# infisical stub — full config lands in M3.1.
# Host: vm-edge. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "infisical"
image = "infisical/infisical:latest"
port = 8080
depends_on = ["pg-infisical", "redis-infisical"]
[service.placement]
node = "vm-edge"
[service.resources]
memory = "512Mi"
cpu = 0.5
[service.env]
DB_CONNECTION_URI = "${secrets.INFISICAL_DB_URI}"
REDIS_URL = "redis://redis-infisical:6379"
ENCRYPTION_KEY = "${secrets.INFISICAL_ENCRYPTION_KEY}"
AUTH_SECRET = "${secrets.INFISICAL_AUTH_SECRET}"
SITE_URL = "https://infisical.breakpilot.com"
@@ -0,0 +1,25 @@
# keycloak stub — full config lands in M2.1.
# Host: vm-edge. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
# Bootstrap exception per §8 rule 3: KC_DB_URL lives in Orca env, not Infisical (Infisical runs on same VM).
[[service]]
name = "keycloak"
image = "quay.io/keycloak/keycloak:26.0"
port = 8443
domain = "auth.breakpilot.com"
depends_on = ["pg-keycloak"]
[service.placement]
node = "vm-edge"
[service.resources]
memory = "2Gi"
cpu = 1.0
[service.env]
KC_DB = "postgres"
KC_DB_URL = "${secrets.KC_DB_URL}"
KC_HOSTNAME = "auth.breakpilot.com"
KC_PROXY_HEADERS = "xforwarded"
KC_HEALTH_ENABLED = "true"
JAVA_OPTS_APPEND = "-Xms1g -Xmx1500m"
@@ -0,0 +1,15 @@
# orca-proxy stub — full config lands in M1.2/M0.3.
# Host: vm-edge. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
# Wildcard TLS terminator; routing rules land with M0.3.
[[service]]
name = "orca-proxy"
image = "orca-managed/orca-proxy:placeholder"
port = 443
[service.placement]
node = "vm-edge"
[service.resources]
memory = "256Mi"
cpu = 0.5
@@ -0,0 +1,22 @@
# pg-infisical stub — full config lands in M3.1.
# Host: vm-edge. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "pg-infisical"
image = "postgres:16-alpine"
port = 5432
[service.placement]
node = "vm-edge"
[service.resources]
memory = "256Mi"
cpu = 0.25
[service.volume]
path = "/var/lib/postgresql/data"
[service.env]
POSTGRES_DB = "infisical"
POSTGRES_USER = "infisical"
POSTGRES_PASSWORD = "${secrets.PG_INFISICAL_PASSWORD}"
@@ -0,0 +1,22 @@
# pg-keycloak stub — full config lands in M2.1.
# Host: vm-edge. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "pg-keycloak"
image = "postgres:16-alpine"
port = 5432
[service.placement]
node = "vm-edge"
[service.resources]
memory = "512Mi"
cpu = 0.5
[service.volume]
path = "/var/lib/postgresql/data"
[service.env]
POSTGRES_DB = "keycloak"
POSTGRES_USER = "keycloak"
POSTGRES_PASSWORD = "${secrets.PG_KEYCLOAK_PASSWORD}"
@@ -0,0 +1,15 @@
# powerdns-auth stub — full config lands in M0.3.
# Host: vm-edge. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
[[service]]
name = "powerdns-auth"
image = "powerdns/pdns-auth:4.9"
port = 53
extra_ports = ["53:53/udp", "53:53/tcp"]
[service.placement]
node = "vm-edge"
[service.resources]
memory = "256Mi"
cpu = 0.25
@@ -0,0 +1,15 @@
# redis-infisical stub — full config lands in M3.1.
# Host: vm-edge. Resource budget per INFRASTRUCTURE.md §6 co-tenant notes.
# Ephemeral cache; no volume.
[[service]]
name = "redis-infisical"
image = "redis:7-alpine"
port = 6379
[service.placement]
node = "vm-edge"
[service.resources]
memory = "128Mi"
cpu = 0.1