@fabrika/provider-zerops 0.2.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -5
- package/src/api/client.ts +522 -0
- package/src/api/errors.ts +50 -0
- package/src/api/polling.ts +59 -0
- package/src/api/readers.ts +185 -0
- package/src/api/service-env-response.ts +34 -0
- package/src/api/types.ts +602 -0
- package/src/api.ts +29 -1378
- package/src/authoring.ts +7 -10
- package/src/cli-args.ts +2 -6
- package/src/cli.ts +3 -28
- package/src/collaborators.ts +2 -4
- package/src/compile.ts +3 -1
- package/src/control.ts +203 -450
- package/src/descriptor.ts +179 -0
- package/src/index.ts +23 -127
- package/src/installation/admin-options.ts +100 -0
- package/src/installation/admin.ts +123 -0
- package/src/installation/deploy-options.ts +127 -0
- package/src/installation/deploy.ts +668 -0
- package/src/installation/generated/platform-console-schema.ts +41 -0
- package/src/installation/generated/platform-proxy-manifest.ts +72 -0
- package/src/installation/hosts.ts +117 -0
- package/src/installation/index.ts +390 -0
- package/src/installation/init-options.ts +148 -0
- package/src/installation/init.ts +394 -0
- package/src/installation/install-collaborators.ts +27 -0
- package/src/installation/install-environment.ts +193 -0
- package/src/installation/install-options.ts +156 -0
- package/src/installation/install-project.ts +201 -0
- package/src/installation/install-provisioning.ts +185 -0
- package/src/installation/install.ts +262 -0
- package/src/installation/installation-source.ts +42 -0
- package/src/installation/log.ts +77 -0
- package/src/installation/machine-key.ts +65 -0
- package/src/installation/manifest.ts +95 -0
- package/src/installation/placement-options.ts +33 -0
- package/src/installation/platform-source.ts +106 -0
- package/src/installation/platform-upload.ts +34 -0
- package/src/installation/proxy-manifest.ts +113 -0
- package/src/installation/secrets.ts +79 -0
- package/src/installation/sidecar.ts +125 -0
- package/src/installation/source-options.ts +12 -0
- package/src/installation/templates/README.md +116 -0
- package/src/installation/templates/fabrika.ref +1 -0
- package/src/installation/templates/gitignore +3 -0
- package/src/installation/templates/platform.yml +144 -0
- package/src/installation/topology/artifacts.ts +141 -0
- package/src/installation/topology/index.ts +13 -0
- package/src/installation/topology/installation-schemas.ts +225 -0
- package/src/installation/topology/invariants.ts +86 -0
- package/src/installation/topology/setups.ts +442 -0
- package/src/installation/topology/topology.ts +501 -0
- package/src/installation/topology/validate.ts +26 -0
- package/src/installation/upgrade-options.ts +99 -0
- package/src/installation/upgrade-output.ts +31 -0
- package/src/installation/upgrade.ts +373 -0
- package/src/managed-env.ts +113 -0
- package/src/manifest.ts +194 -344
- package/src/namespace/claims.ts +77 -0
- package/src/namespace/codec.ts +179 -0
- package/src/namespace/constants.ts +30 -0
- package/src/namespace/errors.ts +60 -0
- package/src/namespace/manifest.ts +28 -0
- package/src/namespace/operator.ts +151 -0
- package/src/namespace/provisioning.ts +482 -0
- package/src/namespace/proxy-attempt.ts +220 -0
- package/src/namespace/proxy-source.ts +22 -0
- package/src/namespace/topology.ts +115 -0
- package/src/namespace/types.ts +114 -0
- package/src/namespace.ts +34 -1271
- package/src/plan.ts +21 -23
- package/src/provider.ts +266 -241
- package/src/source/constants.ts +11 -0
- package/src/source/port.ts +49 -0
- package/src/source/types.ts +4 -0
- package/src/source/validation.ts +72 -0
- package/src/source.ts +2 -1220
- package/src/types.ts +8 -7
- package/src/version-attempt.ts +18 -0
- package/src/codec.ts +0 -108
- package/src/namespace-command.ts +0 -209
- package/src/source-connection.ts +0 -344
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Invariants asserted on a COMPILED Zerops import document, on top of the provider compiler
|
|
2
|
+
// already enforces (`assertZeropsInvariants` — no project-level env, `envIsolation: service`, no secret
|
|
3
|
+
// values, no duplicate hostnames; ADR-0004).
|
|
4
|
+
//
|
|
5
|
+
// These three are here rather than in the compiler (`src/compile.ts`) because each needs a fact the compiler does not
|
|
6
|
+
// have:
|
|
7
|
+
//
|
|
8
|
+
// • WHICH service is allowed to be public is a property of the topology (ADR-0022 says "the proxy"),
|
|
9
|
+
// not of any single document. The compiler cannot know the proxy's hostname.
|
|
10
|
+
// • The hostname RULE is in the schema's prose (`description`), not in its `pattern` — there is no
|
|
11
|
+
// `pattern` keyword anywhere in the published document, so schema validation cannot catch it.
|
|
12
|
+
// • `corePackage` cannot be DOWNGRADED once a project exists, so "did you mean to say SERIOUS?" is a
|
|
13
|
+
// question worth failing on rather than a comment (ADR-0006).
|
|
14
|
+
//
|
|
15
|
+
// Everything here throws rather than returning a report: these are all cases where emitting the
|
|
16
|
+
// document would be worse than emitting nothing.
|
|
17
|
+
|
|
18
|
+
import type { ZeropsImportDocument } from '../../index'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The hostname rule, quoted from the published schema's own description: "duplicates in the same
|
|
22
|
+
* project forbidden; maximum 25 characters, lowercase ASCII letters (a-z) or numbers (0-9) only".
|
|
23
|
+
*
|
|
24
|
+
* NOTE THE ABSENT HYPHEN. It is the one that bites — `run-logs` and `apps-prod` are natural names and
|
|
25
|
+
* both are illegal. The schema does not encode this as a `pattern`, so nothing but this check catches it.
|
|
26
|
+
*/
|
|
27
|
+
const HOSTNAME = /^[a-z0-9]{1,25}$/
|
|
28
|
+
|
|
29
|
+
export const assertZeropsHostnames = (document: ZeropsImportDocument): void => {
|
|
30
|
+
for (const service of document.services) {
|
|
31
|
+
if (!HOSTNAME.test(service.hostname)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`zerops: hostname \`${service.hostname}\` is illegal — max 25 chars, lowercase a-z and 0-9 only (no hyphens, no underscores, no dots)`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ADR-0022: **only the proxy is publicly routed.** App services, the IAM service, the control plane and
|
|
41
|
+
* the databases stay internal and are reached over the project's private network.
|
|
42
|
+
*
|
|
43
|
+
* Zerops makes this the default — a service is not publicly accessible until you opt in — so the failure
|
|
44
|
+
* this guards against is not "we forgot to lock something down" but "someone added
|
|
45
|
+
* `enableSubdomainAccess: true` to debug a service and it shipped". That is a one-word diff and it is
|
|
46
|
+
* exactly the kind of thing review misses.
|
|
47
|
+
*
|
|
48
|
+
* `publicService` may be absent, which asserts that NOTHING in the document is publicly routed — the
|
|
49
|
+
* right assertion for a document whose public entry point lives in another import.
|
|
50
|
+
*/
|
|
51
|
+
export const assertOnlyPublicService = (document: ZeropsImportDocument, publicService?: string): void => {
|
|
52
|
+
for (const service of document.services) {
|
|
53
|
+
if (service.enableSubdomainAccess === true && service.hostname !== publicService) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`zerops: service \`${service.hostname}\` enables public subdomain access; only \`${
|
|
56
|
+
publicService ?? '(none)'
|
|
57
|
+
}\` may be publicly routed (ADR-0022)`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (publicService !== undefined && !document.services.some((service) => service.hostname === publicService)) {
|
|
62
|
+
throw new Error(`zerops: \`${publicService}\` is named as the public service but the document declares no such service`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `corePackage` is per-project, upgrade-only, and its upgrade is partially destructive (logs and
|
|
68
|
+
* statistics are lost) — so it is the one field in this document that a later `PUT` cannot fix
|
|
69
|
+
* (ADR-0006). Require it to be stated rather than inherited from the platform default (`LIGHT`).
|
|
70
|
+
*/
|
|
71
|
+
export const assertCorePackageIsExplicit = (document: ZeropsImportDocument): void => {
|
|
72
|
+
const project = document.project
|
|
73
|
+
if (project === undefined) {
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
if (project.corePackage === undefined) {
|
|
77
|
+
throw new Error(`zerops: project \`${project.name}\` must state its corePackage — it defaults to LIGHT and cannot be downgraded later (ADR-0006)`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** All three, for a document that creates its own project. */
|
|
82
|
+
export const assertTopologyInvariants = (document: ZeropsImportDocument, publicService?: string): void => {
|
|
83
|
+
assertZeropsHostnames(document)
|
|
84
|
+
assertOnlyPublicService(document, publicService)
|
|
85
|
+
assertCorePackageIsExplicit(document)
|
|
86
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// The repository-root `zerops.yaml`, as typed values.
|
|
2
|
+
//
|
|
3
|
+
// ── The decision this file IS ──────────────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Zerops reads `zerops.yaml` from the REPOSITORY ROOT, and fabrika is a monorepo with three deployable
|
|
6
|
+
// services in it. There were two ways to make that coherent:
|
|
7
|
+
//
|
|
8
|
+
// A. ONE root `zerops.yaml` with several named setups; each service's build selects its own by name
|
|
9
|
+
// (`zeropsSetup`, which defaults to the service hostname).
|
|
10
|
+
// B. Per-service `zeropsYaml` inlined into the import document fabrika generates.
|
|
11
|
+
//
|
|
12
|
+
// **A, and the per-package `packages/*/zerops.yaml` files are superseded by this one.** Four reasons,
|
|
13
|
+
// in the order they matter:
|
|
14
|
+
//
|
|
15
|
+
// 1. **The build spec must be the same however the build was triggered.** An inline `zeropsYaml` is
|
|
16
|
+
// present only on builds fabrika itself triggers. A `zcli service push`, a git-push trigger, a tag,
|
|
17
|
+
// or the GUI's "rebuild" button would all build with whatever the repo says instead — i.e. with
|
|
18
|
+
// something else. That divergence surfaces exactly when someone is debugging a failed deploy, which
|
|
19
|
+
// is the worst possible moment to discover the build they are looking at is not the build that ran.
|
|
20
|
+
// 2. **An inline `zeropsYaml` would be frozen at the service's creation.** Verified live: re-applying
|
|
21
|
+
// an import at a service that already exists changes NOTHING — `override: true` only stops the
|
|
22
|
+
// hostname collision from failing the import, it does not update any field. So under option B a
|
|
23
|
+
// service's build specification would be whatever the FIRST import wrote, and every later edit to
|
|
24
|
+
// it would be accepted by the API and silently ignored. A build spec that cannot be changed after
|
|
25
|
+
// day one is worse than one that lives in the repository.
|
|
26
|
+
// 3. Setup name == service hostname is the platform's own default matching rule, so option A needs no
|
|
27
|
+
// extra wiring for the common case, and `zeropsSetup` stays available for the uncommon one.
|
|
28
|
+
// 4. It keeps `zeropsYaml` free for what it is genuinely good at: a targeted per-deploy OVERRIDE. The
|
|
29
|
+
// provider compiler still supports it (`ZeropsServiceSpec.zeropsYaml`); it is simply not how a
|
|
30
|
+
// service's normal build is described.
|
|
31
|
+
//
|
|
32
|
+
// ── The rule for `envVariables` in here ────────────────────────────────────────────────────────────
|
|
33
|
+
//
|
|
34
|
+
// This file is COMMITTED and PLAINTEXT, and it is the same file for every installation. So it carries
|
|
35
|
+
// only values that are true for EVERY installation: ports, asset directories, and intra-project
|
|
36
|
+
// hostnames. Everything else is a service-level variable written through the env API (ADR-0004).
|
|
37
|
+
//
|
|
38
|
+
// Three categories are therefore deliberately ABSENT, and each setup lists its own below:
|
|
39
|
+
//
|
|
40
|
+
// • per-installation configuration (public origins, cookie domains, authentication switches,
|
|
41
|
+
// OIDC/email coordinates, admission lists, `ENVIRONMENT`) — set as service-level variables through the env API;
|
|
42
|
+
// • every secret — set as service-level `envSecrets`, addressed by service, never through a document;
|
|
43
|
+
// • **every `${service_variable}` reference to a DATA service** — `${db_connectionString}`,
|
|
44
|
+
// `${storage_*}` and friends.
|
|
45
|
+
//
|
|
46
|
+
// That third category used to live here, on the reasoning that a reference is not a value: it points
|
|
47
|
+
// into the platform's variable store rather than carrying a credential. That reasoning is still sound
|
|
48
|
+
// and it is no longer sufficient, because a reference names a SERVICE — and which data services exist
|
|
49
|
+
// is exactly what differs between the `standard` and `light` tiers (`./topology.ts`). One committed
|
|
50
|
+
// file cannot say `${operationsdb_connectionString}` on behalf of a tier that has no `operationsdb`.
|
|
51
|
+
// So the file's own rule decides it: a data-service reference is not true for every installation, and
|
|
52
|
+
// it moves to the env API alongside the origins and the secrets.
|
|
53
|
+
//
|
|
54
|
+
// Verified live: a variable written through the env API resolves `${x_y}` at container start exactly as
|
|
55
|
+
// one written here does, so nothing is lost in the move.
|
|
56
|
+
//
|
|
57
|
+
// (The superseded `packages/iam/zerops.yaml` baked `ISSUER: https://iam.example.com`. That is
|
|
58
|
+
// per-installation, and a placeholder domain in a committed file is a value that boots wrong rather
|
|
59
|
+
// than not booting.)
|
|
60
|
+
//
|
|
61
|
+
// ── The canonical PostgreSQL DSN, wherever those variables are written ─────────────────────────────
|
|
62
|
+
//
|
|
63
|
+
// The URLs are not in this file, but their FORM is fabrika's to decide, so it is stated once here.
|
|
64
|
+
// Whichever service it names, a fabrika PostgreSQL URL on Zerops is written as:
|
|
65
|
+
//
|
|
66
|
+
// ${<host>_connectionTlsString}/${<host>_dbName}?sslmode=require
|
|
67
|
+
//
|
|
68
|
+
// Every part is load-bearing, and all of it was settled on a live account rather than inferred:
|
|
69
|
+
//
|
|
70
|
+
// • `connectionTlsString` is `postgresql://${user}:${password}@${hostname}:${portTls}` — it carries
|
|
71
|
+
// no database path, so without `/${…_dbName}` the driver falls back to the USER name. Every Zerops
|
|
72
|
+
// PostgreSQL service names its database AND its user `db`, whatever the service hostname is
|
|
73
|
+
// (checked on a service called `wu2db`), so the fallback is right — by an undocumented platform
|
|
74
|
+
// convention rather than by anything fabrika states. Naming it removes the dependency.
|
|
75
|
+
// • THE TLS PORT, NOT THE DIRECT ONE. `postgresql:single@18` speaks TLS on both, but
|
|
76
|
+
// `postgresql:ha@18` — what the `standard` tier runs — answers `sslmode=require` on the direct
|
|
77
|
+
// port with "Server does not support SSL". One form has to serve both tiers, and only this one does.
|
|
78
|
+
// • The TLS port preserves SESSION state on both types: same backend pid across statements, `SET
|
|
79
|
+
// SESSION` kept, advisory lock taken and visible. So the migration runner's session-level lock is
|
|
80
|
+
// safe there — it is a TLS endpoint, not the transaction pool the port number suggests.
|
|
81
|
+
// • `verify-full` fails on the self-signed certificate, so `require` is the strongest available mode,
|
|
82
|
+
// and pinning it beats inheriting whatever the client defaults to.
|
|
83
|
+
//
|
|
84
|
+
// ── What a build container can and cannot see ──────────────────────────────────────────────────────
|
|
85
|
+
//
|
|
86
|
+
// Verified live, and it is not what the platform documentation implies: a build container sees NONE of
|
|
87
|
+
// its own service's env-API variables. Reading `${FOO}` in a `buildCommand` yields an empty string, with
|
|
88
|
+
// no error. The bridge is `build.envVariables: { FOO: '${RUNTIME_FOO}' }`, which does work, and which
|
|
89
|
+
// resolves nested references (a `RUNTIME_` lift of a variable that is itself `${db_connectionString}`
|
|
90
|
+
// arrives fully resolved). Only the proxy needs this, and it declares it explicitly below.
|
|
91
|
+
|
|
92
|
+
import { FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
|
|
93
|
+
import type { ZeropsHealthCheckSpec, ZeropsReadinessCheckSpec, ZeropsYamlSetupSpec, ZeropsYamlSpec } from '../../index'
|
|
94
|
+
|
|
95
|
+
// ── Readiness and liveness: two mechanisms, two questions ──────────────────────────────────────────
|
|
96
|
+
//
|
|
97
|
+
// `run.healthCheck` is LIVENESS. It runs for the life of the container and answers "is this process
|
|
98
|
+
// still serving?"; a failure pulls the container out of the balancer and eventually restarts it.
|
|
99
|
+
//
|
|
100
|
+
// `deploy.readinessCheck` is a DEPLOY GATE. It answers "may this new version take traffic at all?"; a
|
|
101
|
+
// version that never passes it fails the deploy and the previous version keeps serving. Verified on a
|
|
102
|
+
// live account: a build whose readiness path answered 503 ended `DEPLOY_FAILED` and never activated,
|
|
103
|
+
// while the previously active version stayed `ACTIVE` and serving.
|
|
104
|
+
//
|
|
105
|
+
// Both point at `/healthz`, and that is a decision rather than an economy. The question a deploy gate
|
|
106
|
+
// has to answer here is exactly "did this build come up and start answering HTTP" — the DATABASE
|
|
107
|
+
// dependency is already gated one step earlier, because `run.initCommands` runs the migrations and a
|
|
108
|
+
// non-zero exit there aborts the deploy before any check runs. A readiness path that queried Postgres
|
|
109
|
+
// would therefore add no gate and would fail deploys during an unrelated database blip.
|
|
110
|
+
//
|
|
111
|
+
// ── Why every duration is a quoted string ──────────────────────────────────────────────────────────
|
|
112
|
+
//
|
|
113
|
+
// The published `zerops.yaml` JSON schema types all six of these as `integer`. The platform refuses an
|
|
114
|
+
// integer — `cannot unmarshal !!int into time.Duration` — and bounds every one of them to [10s, 1h].
|
|
115
|
+
// Both verified live; see `docs/reference/zerops-platform.md`. `ZeropsDuration` encodes the first fact
|
|
116
|
+
// in the type system; the second is why nothing below is faster than 10s.
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Liveness timings, stated rather than defaulted.
|
|
120
|
+
*
|
|
121
|
+
* Probe at the platform's floor so a wedged container is noticed quickly; take it out of the balancer
|
|
122
|
+
* after three consecutive misses; put it back after one success; and only RESTART after two minutes of
|
|
123
|
+
* continuous failure. The asymmetry is the point — disconnecting is cheap and reversible, restarting is
|
|
124
|
+
* neither, and a short `failureTimeout` turns one slow dependency into a rolling restart of every
|
|
125
|
+
* container at once.
|
|
126
|
+
*/
|
|
127
|
+
const LIVENESS: Omit<ZeropsHealthCheckSpec, 'httpGet' | 'exec'> = {
|
|
128
|
+
execPeriod: '10s',
|
|
129
|
+
disconnectTimeout: '30s',
|
|
130
|
+
recoveryTimeout: '10s',
|
|
131
|
+
failureTimeout: '120s',
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Readiness timings for a service that migrates at container start.
|
|
136
|
+
*
|
|
137
|
+
* `failureTimeout` has to cover a cold container plus whatever `run.initCommands` does, which for these
|
|
138
|
+
* three is a schema migration; three minutes is generous on purpose, because the cost of it being too
|
|
139
|
+
* short is a failed deploy of a build that was fine.
|
|
140
|
+
*/
|
|
141
|
+
const READINESS_WITH_MIGRATIONS: Omit<ZeropsReadinessCheckSpec, 'httpGet' | 'exec'> = {
|
|
142
|
+
retryPeriod: '10s',
|
|
143
|
+
failureTimeout: '180s',
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Readiness for a service with no init step: a static binary that either answers quickly or never will. */
|
|
147
|
+
const READINESS_IMMEDIATE: Omit<ZeropsReadinessCheckSpec, 'httpGet' | 'exec'> = {
|
|
148
|
+
retryPeriod: '10s',
|
|
149
|
+
failureTimeout: '60s',
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Deploy the whole workspace as complete top-level trees, not the individual packages.
|
|
154
|
+
*
|
|
155
|
+
* The workspace links `node_modules/@fabrika/*` at `packages/*`, so copying `node_modules` and
|
|
156
|
+
* `packages` whole keeps those links resolvable whether the platform preserves symlinks or dereferences
|
|
157
|
+
* them. Listing leaf packages individually breaks on the dereferencing case.
|
|
158
|
+
*/
|
|
159
|
+
const WORKSPACE_DEPLOY_FILES = ['package.json', 'bun.lock', 'node_modules', 'packages']
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The IAM service — identity, tokens, audit, and the private admin API.
|
|
163
|
+
*
|
|
164
|
+
* Per-installation variables (env API): `FABRIKA_IAM_DATABASE_URL`
|
|
165
|
+
* (`${db_connectionTlsString}/${db_dbName}?sslmode=require` — the canonical form above, on the TLS
|
|
166
|
+
* port, which is the only one an HA PostgreSQL service offers TLS on and which preserves the session
|
|
167
|
+
* state the migration runner's advisory lock needs),
|
|
168
|
+
* `ENVIRONMENT`, `ISSUER` (this service's public origin; it is the `iss` of every minted token AND the
|
|
169
|
+
* OIDC redirect base, so it must match the domain routed to the proxy in front of it),
|
|
170
|
+
* `HUMAN_EMAIL_DOMAINS`, `HUMAN_EMAILS`, `IAM_BOOTSTRAP_ADMINS`,
|
|
171
|
+
* `FABRIKA_IAM_OIDC_ENABLED`, `FABRIKA_IAM_PASSWORD_ENABLED`, `FABRIKA_EMAIL_PROVIDER`, and
|
|
172
|
+
* `FABRIKA_EMAIL_FROM` when email delivery is enabled. OIDC-enabled installations also set
|
|
173
|
+
* `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_SCOPES`, `OIDC_REQUIRE_VERIFIED_EMAIL`.
|
|
174
|
+
*
|
|
175
|
+
* Secrets (`envSecrets`): `FABRIKA_IAM_SIGNING_KEYS` (ES256 private JWKs — an empty value is refused at
|
|
176
|
+
* boot rather than silently issuing ephemeral tokens), `OIDC_CLIENT_SECRET` when OIDC is enabled,
|
|
177
|
+
* `FABRIKA_EMAIL_RESEND_API_KEY` when Resend is enabled, `FABRIKA_IAM_RPC_KEY` (gates
|
|
178
|
+
* the management `/rpc/*` surface; unset 404s it, which is the fail-closed default), `FABRIKA_IAM_PROXY_KEY`
|
|
179
|
+
* (gates `/auth/mint/*`, which the proxy calls on the cold path — a SEPARATE secret by least privilege,
|
|
180
|
+
* since the proxy is the only publicly routed component and must not also be able to write audit rows),
|
|
181
|
+
* `FABRIKA_IAM_PROVISIONING_KEY`. Both shared secrets must be at least 32 characters; a shorter one fails
|
|
182
|
+
* the boot rather than standing in front of an internet-reachable surface while looking configured.
|
|
183
|
+
*/
|
|
184
|
+
const iam: ZeropsYamlSetupSpec = {
|
|
185
|
+
setup: 'iam',
|
|
186
|
+
build: {
|
|
187
|
+
// Bun on Alpine. `build.os` is deprecated in the published schema — the OS is part of the base id.
|
|
188
|
+
base: ['alpine/bun@1.3'],
|
|
189
|
+
buildCommands: ['bun install --frozen-lockfile'],
|
|
190
|
+
deployFiles: WORKSPACE_DEPLOY_FILES,
|
|
191
|
+
cache: ['node_modules'],
|
|
192
|
+
},
|
|
193
|
+
deploy: { readinessCheck: { httpGet: { port: 3000, path: '/healthz' }, ...READINESS_WITH_MIGRATIONS } },
|
|
194
|
+
run: {
|
|
195
|
+
base: 'alpine/bun@1.3',
|
|
196
|
+
// Migrations at container start — the Zerops analogue of a discrete migrate step in a Cloudflare
|
|
197
|
+
// plan, and the reason the Zerops deploy plan has no `migrate`. Safe to repeat: the runner records
|
|
198
|
+
// what it applied and serializes concurrent containers behind a session-level advisory lock.
|
|
199
|
+
// Runs in /var/www.
|
|
200
|
+
initCommands: ['bun packages/iam/src/node/migrate.ts'],
|
|
201
|
+
start: 'bun packages/iam/src/node/server.ts',
|
|
202
|
+
// `protocol` is omitted deliberately. TCP is the default at both ends, and the two published
|
|
203
|
+
// sources disagree about its spelling: the JSON schema's enum is lowercase (`tcp`/`udp`) while its
|
|
204
|
+
// own description — and every doc example — says `TCP`. Not writing it sidesteps a contradiction we
|
|
205
|
+
// cannot resolve without an account.
|
|
206
|
+
ports: [{
|
|
207
|
+
port: 3000,
|
|
208
|
+
// The project's L7 balancer terminates TLS and forwards plain HTTP over the private network, so
|
|
209
|
+
// this process holds no certificates.
|
|
210
|
+
httpSupport: true,
|
|
211
|
+
}],
|
|
212
|
+
// Liveness only — it never touches Postgres. A health check that queried the database would let
|
|
213
|
+
// one slow query restart every container at once. The deploy gate is `deploy.readinessCheck`.
|
|
214
|
+
healthCheck: { httpGet: { port: 3000, path: '/healthz' }, ...LIVENESS },
|
|
215
|
+
// The Cloudflare `scheduled` handler's replacement, running the same code path. `allContainers:
|
|
216
|
+
// false` so a horizontally scaled service still prunes once.
|
|
217
|
+
crontab: [{ timing: '0 3 * * *', command: 'bun packages/iam/src/node/prune.ts', allContainers: false }],
|
|
218
|
+
envVariables: { PORT: '3000' },
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The control plane — registry, run lifecycle, vault, webhook, and the dashboard SPA.
|
|
224
|
+
*
|
|
225
|
+
* Per-installation variables (env API): `FABRIKA_CONTROL_DATABASE_URL`
|
|
226
|
+
* (`${db_connectionTlsString}/${db_dbName}?sslmode=require`), the
|
|
227
|
+
* four `FABRIKA_CONTROL_RUN_LOGS_*` coordinates of its object storage (`${storage_bucketName}`,
|
|
228
|
+
* `${storage_apiUrl}`, `${storage_accessKeyId}`, `${storage_secretAccessKey}` — `S3BlobStore` uses
|
|
229
|
+
* path-style addressing, which MinIO wants and R2/AWS accept, so one implementation serves both
|
|
230
|
+
* platforms), `FABRIKA_CONTROL_RUN_LOGS_REGION`, `ENVIRONMENT`, `FABRIKA_CONTROL_DOMAIN`,
|
|
231
|
+
* `FABRIKA_IAM_ISSUER` (the public IAM issuer),
|
|
232
|
+
* `OPERATIONS_ARTIFACT_ORIGIN` (the public proxy origin for source-map upload), `CLOUDFLARE_ACCOUNT_ID`,
|
|
233
|
+
* `FABRIKA_CONTROL_BOOTSTRAP_ADMINS`, `FABRIKA_ZEROPS_CLIENT_ID`, `FABRIKA_ZEROPS_PROXY_SOURCE_JSON`, and
|
|
234
|
+
* `FABRIKA_ZEROPS_PROXY_IAM_URL` (the public IAM origin reachable from application projects).
|
|
235
|
+
*
|
|
236
|
+
* The `FABRIKA_` prefix on that last group is not cosmetic: Zerops RESERVES the bare `ZEROPS_` prefix
|
|
237
|
+
* and rejects any custom variable using it (`userDataZeropsPrefixForbidden`), so the earlier names could
|
|
238
|
+
* not be written through the env API at all — on the one platform they were named for.
|
|
239
|
+
*
|
|
240
|
+
* Secrets (`envSecrets`): `FABRIKA_IAM_RPC_KEY` (authenticates this process to IAM's RPC surface — the
|
|
241
|
+
* same value IAM holds), `FABRIKA_CONTROL_VAULT_KEY` (the vault KEK; its loss is unrecoverable by design),
|
|
242
|
+
* `CLOUDFLARE_API_TOKEN`,
|
|
243
|
+
* `FABRIKA_IAM_PROVISIONING_KEY`, `FABRIKA_ZEROPS_PROXY_IAM_KEY` (the same credential IAM receives as
|
|
244
|
+
* `FABRIKA_IAM_PROXY_KEY`), `OPERATIONS_SYNC_KEY` (the same catalog credential Operations receives),
|
|
245
|
+
* and — when this control plane deploys to Zerops — `FABRIKA_ZEROPS_ACCESS_TOKEN`. Give it a Zerops
|
|
246
|
+
* INTEGRATION token scoped to the projects this installation manages (`POST
|
|
247
|
+
* /client/{id}/integration-token`, client role `NO_ACCESS`, per-project role `ADMIN`), never a personal
|
|
248
|
+
* access token: a personal token carries account-wide admin rights and is the single most dangerous
|
|
249
|
+
* thing an installation could hold.
|
|
250
|
+
*
|
|
251
|
+
* There is NO runner service anywhere in this topology, and that is ADR-0003, not an omission: Zerops
|
|
252
|
+
* has its own CI, so a deploy here is five HTTP calls made by this process.
|
|
253
|
+
*/
|
|
254
|
+
const control: ZeropsYamlSetupSpec = {
|
|
255
|
+
setup: 'control',
|
|
256
|
+
build: {
|
|
257
|
+
base: ['alpine/bun@1.3'],
|
|
258
|
+
buildCommands: ['bun install --frozen-lockfile', 'bun run --filter @fabrika/dashboard build'],
|
|
259
|
+
deployFiles: WORKSPACE_DEPLOY_FILES,
|
|
260
|
+
cache: ['node_modules'],
|
|
261
|
+
},
|
|
262
|
+
deploy: { readinessCheck: { httpGet: { port: 3000, path: '/healthz' }, ...READINESS_WITH_MIGRATIONS } },
|
|
263
|
+
run: {
|
|
264
|
+
base: 'alpine/bun@1.3',
|
|
265
|
+
initCommands: ['bun packages/control/src/zerops/migrate.ts'],
|
|
266
|
+
start: 'bun packages/control/src/zerops/server.ts',
|
|
267
|
+
ports: [{ port: 3000, httpSupport: true }],
|
|
268
|
+
healthCheck: { httpGet: { port: 3000, path: '/healthz' }, ...LIVENESS },
|
|
269
|
+
crontab: [{ timing: '*/5 * * * *', command: 'bun packages/control/src/zerops/cron.ts', allContainers: false }],
|
|
270
|
+
envVariables: {
|
|
271
|
+
PORT: '3000',
|
|
272
|
+
FABRIKA_CONTROL_ASSETS_DIR: 'packages/dashboard/dist',
|
|
273
|
+
// Intra-project transport for management RPC. `FABRIKA_IAM_ISSUER` is deliberately absent here:
|
|
274
|
+
// it is the public token issuer and is supplied per installation through the env API.
|
|
275
|
+
FABRIKA_IAM_RPC_URL: 'http://iam:3000',
|
|
276
|
+
FABRIKA_OPERATIONS_URL: 'http://operations:3000',
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Operations is private inside the platform project. The proxy is its only public ingress and its
|
|
283
|
+
* manifest must route only `/api/{projectId}/envelope/` for the configured public hostname.
|
|
284
|
+
*
|
|
285
|
+
* Per-installation variables (env API): `FABRIKA_OPERATIONS_DATABASE_URL`, the five
|
|
286
|
+
* `FABRIKA_OPERATIONS_BLOB_*` coordinates, `ENVIRONMENT`, `FABRIKA_OPERATIONS_PUBLIC_HOST` and
|
|
287
|
+
* `FABRIKA_IAM_ISSUER` (the public IAM issuer). `FABRIKA_OPERATIONS_DATABASE_URL` takes the canonical
|
|
288
|
+
* form above. On the `standard` tier the data references are `${operationsdb_*}` and
|
|
289
|
+
* `${operationsstorage_*}`; on `light` they are `${db_*}` and `${storage_*}`, which is precisely why
|
|
290
|
+
* they cannot be written here.
|
|
291
|
+
*
|
|
292
|
+
* Secrets (`envSecrets`): `OPERATIONS_SYNC_KEY`, shared only with control for catalog projection, and
|
|
293
|
+
* `FABRIKA_IAM_RPC_KEY`, used only for Operations → IAM management RPC.
|
|
294
|
+
*/
|
|
295
|
+
const operations: ZeropsYamlSetupSpec = {
|
|
296
|
+
setup: 'operations',
|
|
297
|
+
build: {
|
|
298
|
+
base: ['alpine/bun@1.3'],
|
|
299
|
+
buildCommands: ['bun install --frozen-lockfile'],
|
|
300
|
+
deployFiles: WORKSPACE_DEPLOY_FILES,
|
|
301
|
+
cache: ['node_modules'],
|
|
302
|
+
},
|
|
303
|
+
deploy: { readinessCheck: { httpGet: { port: 3000, path: '/healthz' }, ...READINESS_WITH_MIGRATIONS } },
|
|
304
|
+
run: {
|
|
305
|
+
base: 'alpine/bun@1.3',
|
|
306
|
+
initCommands: ['bun packages/operations/src/node/migrate.ts'],
|
|
307
|
+
start: 'bun packages/operations/src/node/server.ts',
|
|
308
|
+
ports: [{ port: 3000, httpSupport: true }],
|
|
309
|
+
healthCheck: { httpGet: { port: 3000, path: '/healthz' }, ...LIVENESS },
|
|
310
|
+
crontab: [{ timing: '* * * * *', command: 'bun packages/operations/src/node/cron.ts', allContainers: false }],
|
|
311
|
+
envVariables: {
|
|
312
|
+
PORT: '3000',
|
|
313
|
+
FABRIKA_IAM_RPC_URL: 'http://iam:3000',
|
|
314
|
+
FABRIKA_CONTROL_URL: 'http://control:3000',
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The auth enforcement proxy — Caddy for HTTP correctness, a compiled Bun auth service for every
|
|
321
|
+
* authorization decision (ADR-0022, as amended by ADR-0022: gates are NEVER compiled into Caddy routes).
|
|
322
|
+
*
|
|
323
|
+
* This is the only setup deployed into BOTH projects, and the only publicly routed service in either.
|
|
324
|
+
*
|
|
325
|
+
* Per-service variables (env API): `FABRIKA_IAM_ISSUER` — `http://iam:3000` in the platform project, the
|
|
326
|
+
* IAM service's PUBLIC origin in an apps project, because there is no private network between projects
|
|
327
|
+
* (ADR-0006). And `FABRIKA_PROXY_MANIFEST_JSON`; see below.
|
|
328
|
+
*
|
|
329
|
+
* Secret (`envSecrets`): `FABRIKA_IAM_KEY` — the value IAM knows as `FABRIKA_IAM_PROXY_KEY`.
|
|
330
|
+
*
|
|
331
|
+
* ── How the manifest reaches the build, and what is unverified about it ───────────────────────────
|
|
332
|
+
*
|
|
333
|
+
* `proxy.manifest.json` tells the proxy which apps it fronts, at which upstream, under which gates. It
|
|
334
|
+
* is baked into the artifact at BUILD time, which is what `docs/backlog/08` chose ("redeploy") over
|
|
335
|
+
* pushing to Caddy's admin API. But on Zerops fabrika has no filesystem in the deploy path — a Zerops
|
|
336
|
+
* deploy is five HTTP calls (ADR-0003) — so the driver cannot write a file into the build the way the
|
|
337
|
+
* Cloudflare path can. The only write channel it has is the service-level env API, the same one it uses
|
|
338
|
+
* for secrets, so the manifest travels as a service variable and the build materializes it to a file.
|
|
339
|
+
*
|
|
340
|
+
* The control plane writes `FABRIKA_PROXY_MANIFEST_JSON` through the service-level env API and rolls
|
|
341
|
+
* this service before each app deploy (`republishProxy`, `packages/provider-zerops/src/namespace/proxy-attempt.ts`).
|
|
342
|
+
*
|
|
343
|
+
* That live-account fact is now settled, and the answer was the bad one: **a build container sees none
|
|
344
|
+
* of its service's env-API variables.** Read directly, `${FABRIKA_PROXY_MANIFEST_JSON}` in a build
|
|
345
|
+
* command is the empty string — no error, no warning. Left uncorrected, every proxy build would have
|
|
346
|
+
* written an empty manifest, failed `generate-config.ts`, and made this service undeployable on Zerops.
|
|
347
|
+
*
|
|
348
|
+
* The fix is the `build.envVariables` declaration below rather than the move into `run.initCommands`
|
|
349
|
+
* this header used to anticipate: `${RUNTIME_x}` lifts a service's own runtime variable into its build
|
|
350
|
+
* context, and it resolves nested references too. The delivery channel and the fail-closed parser are
|
|
351
|
+
* unchanged, which is what that contingency promised.
|
|
352
|
+
*
|
|
353
|
+
* What IS structural: missing or malformed JSON makes `generate-config.ts` exit non-zero, so the
|
|
354
|
+
* pipeline fails and the previous version keeps serving. A valid empty app list generates only the
|
|
355
|
+
* terminal 404 route, and the auth service denies every forwarded request.
|
|
356
|
+
*/
|
|
357
|
+
const proxyManifestReference = `\${${FABRIKA_PROXY_MANIFEST_JSON}}`
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The proxy's public listeners.
|
|
361
|
+
*
|
|
362
|
+
* Routing is by HOST — one app owns one hostname, and `assertUniqueHosts` refuses to let two apps share
|
|
363
|
+
* one so that Caddy's route order can never decide which app answers. A production installation gets
|
|
364
|
+
* those hostnames from custom domains bound to the project's balancer, and needs exactly ONE listener:
|
|
365
|
+
* every domain arrives on the same port and the Host header decides.
|
|
366
|
+
*
|
|
367
|
+
* A `zerops-subdomain` installation has no such source of names. Zerops mints one generated hostname per
|
|
368
|
+
* HTTP PORT, so on that path a port IS a hostname — and the number of listeners is the number of
|
|
369
|
+
* distinct public hosts the installation can ever serve. Three are the platform's own (IAM's JWKS and
|
|
370
|
+
* OIDC redirect, the control dashboard, Operations ingest); the rest are a fixed pool of APPLICATION
|
|
371
|
+
* slots, and running out of them is what caps a light-tier installation rather than any resource limit.
|
|
372
|
+
*
|
|
373
|
+
* That ceiling is the honest cost of the subdomain path and the reason it is not the production one.
|
|
374
|
+
* A custom-domain installation ignores all of this: with `enableSubdomainAccess: false` nothing here is
|
|
375
|
+
* published, the extra listeners answer the same host-matched routes, and an unknown Host still falls
|
|
376
|
+
* through to the terminal 404. One setup serves both tiers rather than a second that must be kept in
|
|
377
|
+
* step with this one.
|
|
378
|
+
*
|
|
379
|
+
* 8081 is deliberately absent: it is the health listener, which is never published.
|
|
380
|
+
*/
|
|
381
|
+
export const PROXY_PUBLIC_PORTS = [8080, 8082, 8083, 8084, 8085, 8086]
|
|
382
|
+
|
|
383
|
+
/** Liveness only, on its own unpublished listener. */
|
|
384
|
+
export const PROXY_HEALTH_PORT = 8081
|
|
385
|
+
|
|
386
|
+
const proxy: ZeropsYamlSetupSpec = {
|
|
387
|
+
setup: 'proxy',
|
|
388
|
+
build: {
|
|
389
|
+
// Two toolchains: Go builds Caddy, Bun compiles the auth service to a single musl binary so the
|
|
390
|
+
// runtime container needs no interpreter at all.
|
|
391
|
+
//
|
|
392
|
+
// `alpine/go@latest` rather than a pin, and that is a real trade stated out loud: Caddy v2.10.2
|
|
393
|
+
// requires a Go toolchain newer than the newest PINNED Go in the published schema's build enum
|
|
394
|
+
// (`alpine/go@1.22`). So the Caddy version is pinned by module — verified against the Go checksum
|
|
395
|
+
// database, a stronger supply-chain guarantee than a release tarball with a hand-copied hash — and
|
|
396
|
+
// the toolchain floats. If a future Go release breaks the build, this is the line that moved.
|
|
397
|
+
base: ['alpine/go@latest', 'alpine/bun@1.3'],
|
|
398
|
+
// Runtime values reach the build only through the explicit RUNTIME_ bridge.
|
|
399
|
+
envVariables: {
|
|
400
|
+
[FABRIKA_PROXY_MANIFEST_JSON]: `\${RUNTIME_${FABRIKA_PROXY_MANIFEST_JSON}}`,
|
|
401
|
+
FABRIKA_PROXY_SESSION_OWNER_JSON: '${RUNTIME_FABRIKA_PROXY_SESSION_OWNER_JSON}',
|
|
402
|
+
},
|
|
403
|
+
// CGO off makes the binary static, which is what lets it run on Alpine/musl.
|
|
404
|
+
prepareCommands: ['CGO_ENABLED=0 GOBIN=/tmp/gobin go install github.com/caddyserver/caddy/v2/cmd/caddy@v2.10.2'],
|
|
405
|
+
buildCommands: [
|
|
406
|
+
'bun install --frozen-lockfile',
|
|
407
|
+
// See the header: the manifest arrives as a service variable because the driver has no
|
|
408
|
+
// filesystem. `printf` rather than `echo` so a leading `-` or a backslash survives intact.
|
|
409
|
+
`printf %s "${proxyManifestReference}" > ./proxy.manifest.json`,
|
|
410
|
+
// Missing/malformed JSON fails the build; an empty app list emits only deny/404 routes.
|
|
411
|
+
`bun run packages/proxy/src/generate-config.ts ./proxy.manifest.json ./caddy.json --auth-upstream 127.0.0.1:9000 --listen ${
|
|
412
|
+
PROXY_PUBLIC_PORTS.map((port) => `:${port}`).join(',')
|
|
413
|
+
}`,
|
|
414
|
+
'bun build --compile --target=bun-linux-x64-musl ./packages/proxy/src/main.ts --outfile ./fabrika-proxy',
|
|
415
|
+
'cp /tmp/gobin/caddy ./caddy',
|
|
416
|
+
],
|
|
417
|
+
deployFiles: ['caddy', 'fabrika-proxy', 'caddy.json', 'proxy.manifest.json', 'packages/proxy/start.sh'],
|
|
418
|
+
cache: ['node_modules', '/root/go/pkg/mod'],
|
|
419
|
+
},
|
|
420
|
+
// The service this gate matters most for: it is the only publicly routed one in either project, so a
|
|
421
|
+
// version that builds but cannot serve would take the whole project's public surface with it.
|
|
422
|
+
deploy: { readinessCheck: { httpGet: { port: PROXY_HEALTH_PORT, path: '/healthz' }, ...READINESS_IMMEDIATE } },
|
|
423
|
+
run: {
|
|
424
|
+
// Alpine custom runtime: an arbitrary static binary with an arbitrary start command. No TLS config
|
|
425
|
+
// anywhere on purpose — the project's L7 balancer terminates TLS, so Caddy serves plain HTTP behind
|
|
426
|
+
// it and needs no ACME, no certificate storage and therefore no disk.
|
|
427
|
+
base: 'alpine@3.21',
|
|
428
|
+
ports: PROXY_PUBLIC_PORTS.map((port) => ({ port, httpSupport: true })),
|
|
429
|
+
// Deliberately NOT one of the public ports: a health path on a public listener would be one path per
|
|
430
|
+
// proxy that answers 200 without consulting the gates, and would shadow an app path of the same name.
|
|
431
|
+
healthCheck: { httpGet: { port: PROXY_HEALTH_PORT, path: '/healthz' }, ...LIVENESS },
|
|
432
|
+
envVariables: {
|
|
433
|
+
FABRIKA_PROXY_MANIFEST: '/var/www/proxy.manifest.json',
|
|
434
|
+
// Loopback only; Caddy dials it. It must never be routable.
|
|
435
|
+
FABRIKA_PROXY_PORT: '9000',
|
|
436
|
+
},
|
|
437
|
+
start: 'sh /var/www/packages/proxy/start.sh',
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The repository-root `zerops.yaml`, in the order the setups appear in it. */
|
|
442
|
+
export const fabrikaZeropsYaml: ZeropsYamlSpec = { zerops: [iam, operations, proxy, control] }
|