@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.
Files changed (83) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +2 -6
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +3 -1
  14. package/src/control.ts +203 -450
  15. package/src/descriptor.ts +179 -0
  16. package/src/index.ts +23 -127
  17. package/src/installation/admin-options.ts +100 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +127 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +390 -0
  25. package/src/installation/init-options.ts +148 -0
  26. package/src/installation/init.ts +394 -0
  27. package/src/installation/install-collaborators.ts +27 -0
  28. package/src/installation/install-environment.ts +193 -0
  29. package/src/installation/install-options.ts +156 -0
  30. package/src/installation/install-project.ts +201 -0
  31. package/src/installation/install-provisioning.ts +185 -0
  32. package/src/installation/install.ts +262 -0
  33. package/src/installation/installation-source.ts +42 -0
  34. package/src/installation/log.ts +77 -0
  35. package/src/installation/machine-key.ts +65 -0
  36. package/src/installation/manifest.ts +95 -0
  37. package/src/installation/placement-options.ts +33 -0
  38. package/src/installation/platform-source.ts +106 -0
  39. package/src/installation/platform-upload.ts +34 -0
  40. package/src/installation/proxy-manifest.ts +113 -0
  41. package/src/installation/secrets.ts +79 -0
  42. package/src/installation/sidecar.ts +125 -0
  43. package/src/installation/source-options.ts +12 -0
  44. package/src/installation/templates/README.md +116 -0
  45. package/src/installation/templates/fabrika.ref +1 -0
  46. package/src/installation/templates/gitignore +3 -0
  47. package/src/installation/templates/platform.yml +144 -0
  48. package/src/installation/topology/artifacts.ts +141 -0
  49. package/src/installation/topology/index.ts +13 -0
  50. package/src/installation/topology/installation-schemas.ts +225 -0
  51. package/src/installation/topology/invariants.ts +86 -0
  52. package/src/installation/topology/setups.ts +442 -0
  53. package/src/installation/topology/topology.ts +501 -0
  54. package/src/installation/topology/validate.ts +26 -0
  55. package/src/installation/upgrade-options.ts +99 -0
  56. package/src/installation/upgrade-output.ts +31 -0
  57. package/src/installation/upgrade.ts +373 -0
  58. package/src/managed-env.ts +113 -0
  59. package/src/manifest.ts +194 -344
  60. package/src/namespace/claims.ts +77 -0
  61. package/src/namespace/codec.ts +179 -0
  62. package/src/namespace/constants.ts +30 -0
  63. package/src/namespace/errors.ts +60 -0
  64. package/src/namespace/manifest.ts +28 -0
  65. package/src/namespace/operator.ts +151 -0
  66. package/src/namespace/provisioning.ts +482 -0
  67. package/src/namespace/proxy-attempt.ts +220 -0
  68. package/src/namespace/proxy-source.ts +22 -0
  69. package/src/namespace/topology.ts +115 -0
  70. package/src/namespace/types.ts +114 -0
  71. package/src/namespace.ts +34 -1271
  72. package/src/plan.ts +21 -23
  73. package/src/provider.ts +266 -241
  74. package/src/source/constants.ts +11 -0
  75. package/src/source/port.ts +49 -0
  76. package/src/source/types.ts +4 -0
  77. package/src/source/validation.ts +72 -0
  78. package/src/source.ts +2 -1220
  79. package/src/types.ts +8 -7
  80. package/src/version-attempt.ts +18 -0
  81. package/src/codec.ts +0 -108
  82. package/src/namespace-command.ts +0 -209
  83. package/src/source-connection.ts +0 -344
@@ -0,0 +1,501 @@
1
+ // fabrika's OWN Zerops topology: the two projects an installation is made of, declared in TypeScript and
2
+ // compiled to `zerops-import.yaml` by the same compiler the deploy driver uses
3
+ // (`compileImportYaml` / `compileProvisioningYaml` in `src/compile.ts`).
4
+ //
5
+ // ── The two projects, and why two ─────────────────────────────────────────────────────────────────
6
+ //
7
+ // platform corePackage SERIOUS proxy · iam · operations · control · db/storage pairs
8
+ // apps-prod corePackage SERIOUS proxy — the apps themselves arrive as their own imports
9
+ //
10
+ // A Zerops project is the unit of isolation: one VXLAN private network, its own balancers, DNS and
11
+ // firewall. Inside a project every service reaches every other by hostname; ACROSS projects there is no
12
+ // private network at all. ADR-0006 therefore puts the control plane in its own project — "a client who
13
+ // breaks the apps project cannot take down the thing that repairs it" — and that decision is the only
14
+ // reason this file emits two documents instead of one.
15
+ //
16
+ // It also means the apps-project proxy reaches the IAM service over a PUBLIC endpoint. That is fine and
17
+ // was priced in: the warm path verifies tokens locally against a cached JWKS, so the cross-project hop
18
+ // is cold-path only (ADR-0022).
19
+ //
20
+ // ── What these documents are, and what they are not ───────────────────────────────────────────────
21
+ //
22
+ // They are IMPORT documents: `POST /client/{id}/project/import` creates the project and its services.
23
+ // They are NOT deploy targets — no `deployService` is set, because no single service "carries the code"
24
+ // for a whole project. Each platform service is deployed on its own, by its own pipeline, selecting its
25
+ // own named setup from the repository-root `zerops.yaml` (see `./setups.ts`).
26
+ //
27
+ // The light tier ALSO emits the same services with no `project:` block, for a project the operator
28
+ // created by hand — that is `POST /project/{id}/service-stack/import`, and a `project:` block is invalid
29
+ // there. See `ProjectTopology.servicesTarget`.
30
+ //
31
+ // ── Two things that cannot be fixed afterwards ────────────────────────────────────────────────────
32
+ //
33
+ // 1. `envIsolation` is settable at project CREATION only — `RequestPutProject` has no such field. A
34
+ // project created without it can never be corrected, so the compiler writes it unconditionally at
35
+ // both levels of a document that creates a project, and on every service of one that does not;
36
+ // `assertZeropsInvariants` refuses to serialize a document missing it.
37
+ // 2. `corePackage` is upgrade-only and its upgrade is partially destructive. `assertCorePackageIsExplicit`
38
+ // makes leaving it to the LIGHT default an error rather than a silent choice.
39
+ //
40
+ // ── Where the secrets are ─────────────────────────────────────────────────────────────────────────
41
+ //
42
+ // Nowhere in here, and that is structural rather than careful: `ZeropsProjectSpec` has no `envVariables`
43
+ // and `ZeropsServiceSpec` has no `envSecrets`/`dotEnvSecrets`, so a secret in this file would not
44
+ // compile. Every value below is a hostname, a size or a boolean. The service-level secrets each service
45
+ // needs are listed in `./setups.ts` next to the setup that consumes them; they are written through the
46
+ // env API, addressed by service, after the provisioning import creates it (ADR-0004).
47
+
48
+ import {
49
+ compileImportYaml,
50
+ compileProvisioningYaml,
51
+ type ZeropsImportDocument,
52
+ type ZeropsNamespacePostgres,
53
+ type ZeropsServiceSpec,
54
+ type ZeropsSourceTarget,
55
+ } from '../../index'
56
+ import { compileZeropsNamespaceTopology, zeropsNamespacePolicy } from '../../namespace/topology'
57
+ import { assertTopologyInvariants } from './invariants'
58
+
59
+ /**
60
+ * How a project's single public entry point is published.
61
+ *
62
+ * `enableSubdomainAccess` is documented as "not suitable for production", so production takes
63
+ * `custom-domain`: nothing in the import is publicly routed, and the domain is bound to the project's L7
64
+ * balancer out of band — the import format has NO field for a custom domain, so that step is manual by
65
+ * construction and not an oversight here. `zerops-subdomain` is the ONLY thing that ever writes
66
+ * `enableSubdomainAccess: true` — on the proxy, and on nothing else.
67
+ *
68
+ * **NEITHER VALUE IS DELIVERED BY THE IMPORT.** Live-verified: the platform accepts
69
+ * `enableSubdomainAccess` on a service the document CREATES and then silently drops it, so the service
70
+ * reads back `subdomainAccess: false` and stays there. The subdomain is established afterwards, by
71
+ * `PUT /service-stack/{id}/enable-subdomain-access` on a service that already publishes an HTTP port —
72
+ * which the namespace lifecycle does for itself (`ensureSubdomainAccess` in `src/namespace/provisioning.ts`)
73
+ * and which an operator applying the generated platform artifacts must do by hand; the artifact's header
74
+ * names the call. So `true` here records the INTENT and feeds ADR-0022's `assertOnlyPublicService`; it
75
+ * does not turn anything on.
76
+ */
77
+ export type PublicAccess = 'custom-domain' | 'zerops-subdomain'
78
+
79
+ /**
80
+ * How much platform an installation pays for.
81
+ *
82
+ * `standard` is the production shape: HA Postgres, a separate database and bucket for Operations, and
83
+ * enough containers that no single one is a point of failure.
84
+ *
85
+ * `light` is one project, one `postgresql:single@18` and one bucket shared by IAM, Operations, control
86
+ * AND the apps deployed alongside them, at one container per service. This tier exists because the standard
87
+ * shape costs ~13 containers before a single application is deployed, which is more platform than a
88
+ * small fleet — or an evaluation — can justify.
89
+ *
90
+ * Sharing is safe rather than merely cheap, and that is a property of the code, not of this comment:
91
+ *
92
+ * • The three services' table names do not intersect (IAM 10, Operations 24, control 16), and
93
+ * ADR-0017 already gives each its own migration ledger and advisory lock, so one database holds
94
+ * all three without coordination.
95
+ * • Their blob keys are prefix-disjoint — control writes `runs/`, Operations writes `events/`,
96
+ * `dead/` and `source-maps/` — so one bucket cannot collide.
97
+ *
98
+ * What `light` genuinely gives up is FAILURE-DOMAIN isolation: Operations' high-volume error history
99
+ * now shares capacity with the identity path, and losing the one database loses all three services.
100
+ * That is the trade, and it is why `standard` remains the default rather than the special case.
101
+ */
102
+ export type PlatformTier = 'standard' | 'light'
103
+
104
+ export interface TopologyOptions {
105
+ /** Environment label handed to `services(ctx)`. Part of the project name, so it lands in the GUI too. */
106
+ env: string
107
+ /** How the proxy is published. Production is `custom-domain`. */
108
+ publicAccess?: PublicAccess
109
+ /** How much platform to provision. Defaults to `standard`. */
110
+ tier?: PlatformTier
111
+ }
112
+
113
+ /** One project's topology plus the facts the invariant checks need. */
114
+ export interface ProjectTopology {
115
+ /** Stem of the generated filenames, and how this topology is referred to in prose. */
116
+ id: string
117
+ /** The source declaration compiled into the generated topology artifacts. */
118
+ target: ZeropsSourceTarget
119
+ /**
120
+ * The SAME services with no `project` block, for a project the operator created by hand.
121
+ *
122
+ * A document carrying `project:` is only valid at `POST /client/{clientId}/project/import`, which
123
+ * CREATES a project — and Zerops permits duplicate project names, so applying one at an existing
124
+ * installation risks a second project rather than failing. Present only on the tiers a bootstrap
125
+ * imports into an existing project.
126
+ */
127
+ servicesTarget?: ZeropsSourceTarget
128
+ /** The one service allowed to be publicly routed (ADR-0022). Always the proxy. */
129
+ publicService: string
130
+ /** Present on app namespaces so fixtures can prove the selected isolation tier. */
131
+ namespacePreset?: 'cheap' | 'mid' | 'full'
132
+ /** Present only when this namespace is reserved for one app. */
133
+ exclusiveAppId?: string
134
+ }
135
+
136
+ /** The proxy's hostname, in every project. Also the `zerops.yaml` setup name it builds from. */
137
+ export const PROXY_HOSTNAME = 'proxy'
138
+
139
+ /**
140
+ * A runtime service, with the field that must never be left to a default written explicitly.
141
+ *
142
+ * `enableSubdomainAccess` is written even when it is `false` — which is also Zerops' default — on
143
+ * purpose. The default protects a service nobody thought about; an explicit `false` makes turning it on
144
+ * a visible one-line diff in review. What it does NOT do is turn anything on or off: the platform drops
145
+ * the field on create and `override: true` leaves an existing service untouched, so this document can
146
+ * neither establish a subdomain nor correct one someone enabled in the GUI. Both directions are separate
147
+ * calls — `EnableSubdomainAccess` and `DisableSubdomainAccess`. See `PublicAccess` above.
148
+ *
149
+ * `verticalAutoscaling` is deliberately NOT written, and that is a decision rather than an omission.
150
+ * A live Bun runtime reads back a floor of 1 shared core / 0.125 GB and a ceiling of 8 cores / 48 GB /
151
+ * 250 GB. The floor is already the minimum the platform offers, so there is nothing to save by
152
+ * restating it; and capping the ceiling would convert a traffic spike into an outage on services whose
153
+ * only other lever is `maxContainers`, which IS written on every one of them. Where a floor genuinely
154
+ * costs money — the managed databases — it is chosen explicitly through `profile` instead.
155
+ *
156
+ * `zeropsSetup` is deliberately NOT written. It used to be, as a redundant restatement of Zerops' own
157
+ * "setup name defaults to the hostname" rule that would keep working the day a service was renamed. On a
158
+ * real account it does not keep working at all: the platform treats it as pipeline configuration and
159
+ * rejects the entire import with `{"iam.buildFromGit": ["parameter is required for use of
160
+ * pipelineConfig"]}` unless the service also names a repository to build from — which none of these do
161
+ * (ADR-0003: fabrika triggers Zerops' pipeline, it does not configure a git integration). The setup name
162
+ * travels at TRIGGER time instead, where the driver already passes it and no repository is required.
163
+ * `assertZeropsInvariants` now refuses the combination so this fails at generation rather than at import.
164
+ */
165
+ const runtime = (
166
+ spec: Omit<ZeropsServiceSpec, 'enableSubdomainAccess' | 'zeropsSetup'> & { public?: boolean },
167
+ ): ZeropsServiceSpec => {
168
+ const { public: isPublic, ...rest } = spec
169
+ return { ...rest, enableSubdomainAccess: isPublic === true }
170
+ }
171
+
172
+ /**
173
+ * The **platform** project: fabrika itself.
174
+ *
175
+ * `SERIOUS` is not a comfort choice. This project holds the thing that repairs the other one, so its
176
+ * availability tier is the recovery story; and because `corePackage` cannot be downgraded, starting
177
+ * LIGHT and "upgrading when it matters" costs a partially destructive migration at the worst moment.
178
+ */
179
+ export const platformTopology = (options: TopologyOptions): ProjectTopology => {
180
+ const publicAccess = options.publicAccess ?? 'custom-domain'
181
+ const tier = options.tier ?? 'standard'
182
+ if (tier === 'light') {
183
+ return lightPlatformTopology(options, publicAccess)
184
+ }
185
+ return {
186
+ id: 'platform',
187
+ publicService: PROXY_HOSTNAME,
188
+ target: {
189
+ platform: 'zerops',
190
+ project: {
191
+ name: 'platform',
192
+ description: 'fabrika control plane, Operations, IAM and the auth proxy. Separate from the apps project by ADR-0006.',
193
+ corePackage: 'SERIOUS',
194
+ tags: ['fabrika', 'platform', options.env],
195
+ },
196
+ services: () => [
197
+ // ── data, created first (priority is descending) ──────────────────────────────────
198
+ {
199
+ hostname: 'db',
200
+ // HA is encoded in the TYPE. The deprecated `mode` field is not merely avoided here — it
201
+ // is absent from `ZeropsServiceSpec`, so it cannot be written. One database serves both
202
+ // IAM and the control plane; they are separate schemas in it, not separate services,
203
+ // because a second HA Postgres triples the cost of the isolation the private network
204
+ // already gives us.
205
+ type: 'postgresql:ha@18',
206
+ // The identity and control-plane database: every login, every token mint, every run's
207
+ // state. `oltp-production` is also what an HA service gets by DEFAULT — stated here so
208
+ // the bill is a decision rather than a discovery, and because this is the one place the
209
+ // standard tier should pay for it: dedicated CPU is what keeps auth latency off the
210
+ // noisy-neighbour curve.
211
+ profile: 'oltp-production',
212
+ priority: 100,
213
+ },
214
+ {
215
+ hostname: 'storage',
216
+ // S3-compatible object storage: run logs (`FABRIKA_CONTROL_RUN_LOGS_*`) and deploy artifacts.
217
+ // `@fabrika/platform-node`'s `S3BlobStore` speaks to this unchanged — it is the same
218
+ // implementation that speaks to R2.
219
+ type: 'object-storage',
220
+ objectStorageSize: 25,
221
+ // Never `public-read`: a run log can quote a build's environment.
222
+ objectStoragePolicy: 'private',
223
+ // A CDN in front of private run logs would be a cache in front of a thing nobody reads twice.
224
+ enableCdn: false,
225
+ priority: 100,
226
+ },
227
+ {
228
+ hostname: 'operationsdb',
229
+ // Operations has an independent database service. This prevents its high-volume error
230
+ // history and migrations from sharing IAM/control's failure and capacity domain.
231
+ type: 'postgresql:ha@18',
232
+ // NOT `db`'s profile, and that is the point of stating both. `oltp-staging` keeps the
233
+ // same three-container redundancy and the same 8-core/48 GB ceiling, but starts at one
234
+ // SHARED core and 1 GB instead of two DEDICATED cores and 4 GB — measured off a live
235
+ // HA service, see `docs/reference/zerops-platform.md`. Error history is bursty and
236
+ // tolerant of jitter: slow ingest is a queue, not an outage. Holding two dedicated
237
+ // cores idle for it is the half of the default nobody chose.
238
+ profile: 'oltp-staging',
239
+ priority: 100,
240
+ },
241
+ {
242
+ hostname: 'operationsstorage',
243
+ type: 'object-storage',
244
+ objectStorageSize: 25,
245
+ objectStoragePolicy: 'private',
246
+ enableCdn: false,
247
+ priority: 100,
248
+ },
249
+ // ── services ──────────────────────────────────────────────────────────────────────
250
+ runtime({
251
+ hostname: 'iam',
252
+ type: 'alpine/bun@1.3',
253
+ priority: 50,
254
+ // Identity is on the critical path of every login and every cold-path token mint, and the
255
+ // service is stateless (its state is in `db`), so two containers cost little and remove a
256
+ // single point of failure the product cannot afford.
257
+ minContainers: 2,
258
+ maxContainers: 4,
259
+ }),
260
+ runtime({
261
+ hostname: 'operations',
262
+ type: 'alpine/bun@1.3',
263
+ priority: 40,
264
+ minContainers: 1,
265
+ maxContainers: 3,
266
+ }),
267
+ runtime({
268
+ hostname: 'control',
269
+ type: 'alpine/bun@1.3',
270
+ // IAM → Operations → proxy → control is the code deployment order too:
271
+ // control must never start against a missing private dependency.
272
+ priority: 20,
273
+ // Deliberately allowed to be a single container. The control plane is CRASH-SAFE across a
274
+ // deploy by design — it may trigger its own redeploy and die, then reconcile in-flight runs
275
+ // by polling `/app-version` on restart (ADR-0003) — so an outage window here is a delay,
276
+ // not a lost run. Scaling it out would also multiply the queue consumers for no gain.
277
+ minContainers: 1,
278
+ maxContainers: 2,
279
+ }),
280
+ runtime({
281
+ hostname: PROXY_HOSTNAME,
282
+ // Alpine custom runtime: a Caddy binary plus a compiled Bun auth service, no interpreter
283
+ // (ADR-0022). `run.os` is deprecated — the OS is the service type.
284
+ type: 'alpine@3.21',
285
+ priority: 30,
286
+ // THE ONLY PUBLICLY ROUTED SERVICE. Everything above is reachable only over the project's
287
+ // private network; the proxy is what the internet talks to, including for IAM's own OIDC
288
+ // login and the control-plane dashboard.
289
+ public: publicAccess === 'zerops-subdomain',
290
+ // It is on every request to every service in this project, and it is stateless by
291
+ // construction (ADR-0022 insists on that precisely so this line is allowed to exist).
292
+ minContainers: 2,
293
+ maxContainers: 6,
294
+ }),
295
+ ],
296
+ },
297
+ }
298
+ }
299
+
300
+ /**
301
+ * The **light** platform: the same six roles in one project, on shared data services.
302
+ *
303
+ * Deliberately NOT a parameterized variation of the standard declaration above. The two differ in
304
+ * which services exist, not merely in their sizes, and a single declaration threaded with conditionals
305
+ * would make both harder to read than either is alone — the standard topology's per-field commentary is
306
+ * the reason that file is worth reading at all.
307
+ *
308
+ * `corePackage: LIGHT` is stated rather than defaulted for exactly the reason `assertCorePackageIsExplicit`
309
+ * exists: it is upgrade-only, and an installation that starts here should have chosen to.
310
+ */
311
+ const lightPlatformTopology = (options: TopologyOptions, publicAccess: PublicAccess): ProjectTopology => {
312
+ // ONE services function behind both targets, so the services-only form cannot drift from the one
313
+ // that creates the project. Same shape as `compileZeropsNamespaceTopology` in `src/namespace/topology.ts`.
314
+ const services = (): ZeropsServiceSpec[] => [
315
+ {
316
+ // ONE database for IAM, control, Operations and every app in this project. Which schema each
317
+ // one gets is not expressed here — it is `FABRIKA_*_DATABASE_URL`, written per installation
318
+ // through the env API, because this same repository-root `zerops.yaml` serves both tiers and
319
+ // a `${operationsdb_connectionString}` baked into it would name a service `light` does not have.
320
+ hostname: 'db',
321
+ type: 'postgresql:single@18',
322
+ // `oltp-hobby`, written out because a default is not a choice — this type's default is
323
+ // `oltp-staging`, whose only difference here is a 1 GB memory FLOOR.
324
+ //
325
+ // A floor is not headroom. Every PostgreSQL profile shares the same 8-core / 48 GB / 250 GB
326
+ // ceiling and the same vertical autoscaler, so `oltp-staging` does not let this service grow
327
+ // any further than `oltp-hobby` does — it only pre-pays for memory the tier is idle on. That
328
+ // this single service holds IAM, control, Operations and the apps beside them is an argument
329
+ // about the ceiling, which is unchanged.
330
+ //
331
+ // An installation that wants a specific floor sets one on the service — `minRam` accepts a
332
+ // float — which is where a per-installation value belongs (ADR-0004), not in this document.
333
+ profile: 'oltp-hobby',
334
+ priority: 100,
335
+ },
336
+ {
337
+ // ONE bucket. Prefix-disjoint by construction: `runs/` (control) vs `events/`, `dead/` and
338
+ // `source-maps/` (Operations).
339
+ hostname: 'storage',
340
+ type: 'object-storage',
341
+ objectStorageSize: 25,
342
+ objectStoragePolicy: 'private',
343
+ enableCdn: false,
344
+ priority: 100,
345
+ },
346
+ runtime({ hostname: 'iam', type: 'alpine/bun@1.3', priority: 50, minContainers: 1, maxContainers: 2 }),
347
+ runtime({ hostname: 'operations', type: 'alpine/bun@1.3', priority: 40, minContainers: 1, maxContainers: 2 }),
348
+ runtime({ hostname: 'control', type: 'alpine/bun@1.3', priority: 20, minContainers: 1, maxContainers: 2 }),
349
+ runtime({
350
+ hostname: PROXY_HOSTNAME,
351
+ type: 'alpine@3.21',
352
+ priority: 30,
353
+ public: publicAccess === 'zerops-subdomain',
354
+ // Still the only publicly routed service (ADR-0022). One container is a real availability
355
+ // trade and the reason this tier is not for production; it is not a relaxation of the rule
356
+ // about WHICH service faces the internet.
357
+ minContainers: 1,
358
+ maxContainers: 2,
359
+ }),
360
+ ]
361
+ return {
362
+ id: 'platform-light',
363
+ publicService: PROXY_HOSTNAME,
364
+ target: {
365
+ platform: 'zerops',
366
+ project: {
367
+ name: 'platform',
368
+ description: 'fabrika light tier: control plane, Operations, IAM, the auth proxy and the apps they serve, on shared data services.',
369
+ corePackage: 'LIGHT',
370
+ tags: ['fabrika', 'platform', 'light', options.env],
371
+ },
372
+ services,
373
+ },
374
+ // The light tier is the one an operator installs into a project they created themselves.
375
+ servicesTarget: { platform: 'zerops', services },
376
+ }
377
+ }
378
+
379
+ /**
380
+ * An application namespace: one proxy plus the namespace-owned resources selected by its preset.
381
+ *
382
+ * This is only an adapter over the provider-owned namespace compiler. The control plane uses that same
383
+ * compiler for live create/adopt/reconcile operations, so generated bring-up artifacts cannot drift
384
+ * into a second definition of what an application namespace contains.
385
+ *
386
+ * `mid` stays the default for the committed apps-prod artifact: each app imports its own prefixed
387
+ * database. `cheap` adds namespace-owned Postgres. `full` reserves the project for one app, while the
388
+ * app's own import still owns its runtime services and database.
389
+ */
390
+ export interface AppsTopologyOptions extends TopologyOptions {
391
+ readonly corePackage: 'LIGHT' | 'SERIOUS'
392
+ readonly preset?: 'cheap' | 'mid' | 'full'
393
+ readonly projectName?: string
394
+ readonly exclusiveAppId?: string
395
+ readonly postgres?: ZeropsNamespacePostgres
396
+ }
397
+
398
+ export const appsTopology = (options: AppsTopologyOptions): ProjectTopology => {
399
+ const preset = options.preset ?? 'mid'
400
+ const id = options.projectName ?? `apps-${options.env}`
401
+ if (preset === 'full' && options.exclusiveAppId === undefined) {
402
+ throw new Error('A full Zerops namespace requires exclusiveAppId')
403
+ }
404
+ if (preset !== 'full' && options.exclusiveAppId !== undefined) {
405
+ throw new Error(`A ${preset} Zerops namespace cannot reserve an exclusive app`)
406
+ }
407
+ const namespacePolicy = zeropsNamespacePolicy({
408
+ preset,
409
+ env: options.env,
410
+ projectName: id,
411
+ corePackage: options.corePackage,
412
+ publicAccess: options.publicAccess ?? 'custom-domain',
413
+ ...(options.postgres === undefined ? {} : { postgres: options.postgres }),
414
+ })
415
+ const namespace = compileZeropsNamespaceTopology({
416
+ id,
417
+ env: options.env,
418
+ ...(options.exclusiveAppId === undefined ? {} : { exclusiveAppId: options.exclusiveAppId }),
419
+ target: { provider: 'zerops', version: 1, payload: {} },
420
+ }, namespacePolicy)
421
+ return {
422
+ id,
423
+ publicService: PROXY_HOSTNAME,
424
+ target: namespace.source,
425
+ namespacePreset: preset,
426
+ ...(options.exclusiveAppId === undefined ? {} : { exclusiveAppId: options.exclusiveAppId }),
427
+ }
428
+ }
429
+
430
+ /** The topologies this repo commits generated documents for. */
431
+ export const fabrikaTopologies = (): ProjectTopology[] => [
432
+ platformTopology({ env: 'prod' }),
433
+ // The light tier is committed for the same reason the other two are: an operator applies the file, so
434
+ // it should be reviewable in a diff. `zerops-subdomain` because a single-project installation has no
435
+ // second project to bind a custom domain in front of, and this tier is where a throwaway starts.
436
+ platformTopology({ env: 'prod', tier: 'light', publicAccess: 'zerops-subdomain' }),
437
+ appsTopology({ env: 'prod', corePackage: 'SERIOUS' }),
438
+ ]
439
+
440
+ /** One compiled form: the document, and the YAML text an operator applies. */
441
+ export interface CompiledDocument {
442
+ document: ZeropsImportDocument
443
+ yaml: string
444
+ }
445
+
446
+ /** A compiled topology in every form an operator needs. */
447
+ export interface CompiledTopology {
448
+ topology: ProjectTopology
449
+ /**
450
+ * First bring-up ONLY: every service `startWithoutCode: true`, so secrets can be written before any
451
+ * deploy. Applying this a second time at a project whose services already carry code activates a new
452
+ * EMPTY app version on each of them — verified live. It is not a reconcile document.
453
+ */
454
+ provision: CompiledDocument
455
+ /**
456
+ * Steady state: the same document, re-appliable because every service carries `override: true`.
457
+ *
458
+ * "Re-appliable" is the whole claim, and deliberately not "reconciling". Live-verified: applying
459
+ * this at an existing project creates whatever is missing and leaves everything that exists exactly
460
+ * as it is — a changed `profile`, `minContainers` or `objectStorageSize` in here is accepted by the
461
+ * API and silently ignored. Changing a live service is a separate, field-specific API call.
462
+ */
463
+ steady: CompiledDocument
464
+ /**
465
+ * The two forms above with no `project:` block, present only when the topology declares a
466
+ * `servicesTarget`. These are what `POST /project/{projectId}/service-stack/import` takes, so they
467
+ * are the ones a bootstrap applies to a project the operator created.
468
+ *
469
+ * Nothing is lost by dropping the block: `envIsolation` is settable at project CREATION only, so a
470
+ * `project:` here could not correct an existing project even if the endpoint tolerated it, and the
471
+ * per-service `envIsolation: service` the compiler writes overrides the project's value anyway
472
+ * (ADR-0004).
473
+ */
474
+ servicesProvision?: CompiledDocument
475
+ servicesSteady?: CompiledDocument
476
+ }
477
+
478
+ /**
479
+ * Compile one topology to both documents, asserting every invariant on each.
480
+ *
481
+ * The provisioning form exists because of the cost ADR-0004 accepted out loud: on Zerops no secret can
482
+ * be set before the service exists, since the env API is addressed BY SERVICE. So bring-up is
483
+ * import-without-code → write secrets → deploy, and `compileProvisioningYaml` is the first of those three.
484
+ */
485
+ export const compileTopology = (topology: ProjectTopology, env: string): CompiledTopology => {
486
+ const input = { target: topology.target, ctx: { env } }
487
+ const provision = compileProvisioningYaml(input)
488
+ const steady = compileImportYaml(input)
489
+ assertTopologyInvariants(provision.document, topology.publicService)
490
+ assertTopologyInvariants(steady.document, topology.publicService)
491
+ const servicesTarget = topology.servicesTarget
492
+ if (servicesTarget === undefined) {
493
+ return { topology, provision, steady }
494
+ }
495
+ const servicesInput = { target: servicesTarget, ctx: { env } }
496
+ const servicesProvision = compileProvisioningYaml(servicesInput)
497
+ const servicesSteady = compileImportYaml(servicesInput)
498
+ assertTopologyInvariants(servicesProvision.document, topology.publicService)
499
+ assertTopologyInvariants(servicesSteady.document, topology.publicService)
500
+ return { topology, provision, steady, servicesProvision, servicesSteady }
501
+ }
@@ -0,0 +1,26 @@
1
+ import type { Artifact } from './artifacts'
2
+ import { importSchema, zeropsYamlSchema } from './installation-schemas'
3
+
4
+ export type SchemaKind = 'import' | 'zerops-yaml'
5
+ export interface SchemaViolation {
6
+ path: string
7
+ message: string
8
+ }
9
+ export const parseYaml = (text: string): unknown => Bun.YAML.parse(text)
10
+ export const validateDocument = (kind: SchemaKind, document: unknown): SchemaViolation[] => {
11
+ const result = (kind === 'import' ? importSchema : zeropsYamlSchema).safeParse(document)
12
+ return result.success ? [] : result.error.issues.map((issue) => ({
13
+ path: issue.path.map((part, index) => typeof part === 'number' ? `[${part}]` : `${index === 0 ? '' : '.'}${part}`).join(''),
14
+ message: issue.message,
15
+ }))
16
+ }
17
+ export const validateYaml = (kind: SchemaKind, text: string): SchemaViolation[] => validateDocument(kind, parseYaml(text))
18
+ export const schemaKindFor = (path: string): SchemaKind => path.endsWith('.zerops-import.yaml') ? 'import' : 'zerops-yaml'
19
+ export const assertArtifactMatchesSchema = (artifact: Artifact): void => {
20
+ const kind = schemaKindFor(artifact.path)
21
+ const violations = validateYaml(kind, artifact.content)
22
+ if (violations.length > 0) {
23
+ const detail = violations.map((violation) => ` ${violation.path === '' ? '(root)' : violation.path}: ${violation.message}`).join('\n')
24
+ throw new Error(`${artifact.path} does not match the supported installation ${kind} schema:\n${detail}`)
25
+ }
26
+ }
@@ -0,0 +1,99 @@
1
+ // The CLI surface of `fabrika platform upgrade --provider=zerops`, parsed once.
2
+ //
3
+ // The same shape the other verbs use: an unknown flag is an error rather than a value silently
4
+ // ignored, and every message names what would have supplied the missing input. This command holds no
5
+ // credential of its own — it drives `git` and `gh`, which carry the operator's own login — so unlike
6
+ // `deploy` there is nothing here that must be kept off the command line.
7
+ //
8
+ // The pin is checked BEFORE anything leaves this machine: `assertPinnedTag` refuses a branch or a SHA
9
+ // (ADR-0025) without a network call, so `--to=main` fails on the spot rather than after a clone.
10
+
11
+ import { describeArgument, readFlag } from '@fabrika/installation-init'
12
+ import { assertPinnedTag, defaultCheckoutDir } from './sidecar'
13
+
14
+ /** Where the sidecar repository this command edits is found. */
15
+ export type UpgradeSidecar =
16
+ /** An existing local checkout — the one `platform init` left behind, or any other clone. */
17
+ | { readonly kind: 'checkout'; readonly dir: string }
18
+ /** `<owner>/<name>`, cloned into a temporary directory for the length of the run. */
19
+ | { readonly kind: 'repo'; readonly repo: string }
20
+
21
+ /** Every input `runUpgrade` takes from the operator. */
22
+ export interface PlatformUpgradeInput {
23
+ /** The published fabrika tag to roll onto, already checked to be one. */
24
+ readonly to: string
25
+ readonly sidecar: UpgradeSidecar
26
+ /** The operator's name for this installation, when they gave one. Only ever used in the commit subject. */
27
+ readonly installation?: string
28
+ /** Read everything, write nothing, push nothing. */
29
+ readonly dryRun: boolean
30
+ }
31
+
32
+ const FLAGS = ['--to', '--sidecar'] as const
33
+
34
+ /** The one value-less flag. */
35
+ export const UPGRADE_DRY_RUN_FLAG = '--dry-run'
36
+
37
+ /**
38
+ * `<owner>/<name>`, and nothing that could also be a directory.
39
+ *
40
+ * `--sidecar` takes either form, so the two need a rule an operator can predict rather than a
41
+ * filesystem probe whose answer depends on what happens to exist. A value with exactly one slash and
42
+ * no leading `.`, `~` or `/` is a REPOSITORY; anything else is a path, which makes `./owner/name` the
43
+ * way to name a local directory shaped like a repository.
44
+ */
45
+ const REPO_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/
46
+
47
+ const assertKnownArguments = (argv: readonly string[]): void => {
48
+ for (const arg of argv) {
49
+ if (arg === UPGRADE_DRY_RUN_FLAG || !arg.startsWith('-')) {
50
+ continue
51
+ }
52
+ const name = arg.split('=')[0] ?? arg
53
+ if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
54
+ throw new Error(
55
+ `unexpected argument ${describeArgument(arg)}. Run \`fabrika platform upgrade --provider=zerops --help\` for the surface`,
56
+ )
57
+ }
58
+ }
59
+ }
60
+
61
+ /** The single positional: the installation name, exactly as `platform init` takes it. */
62
+ const readInstallation = (argv: readonly string[]): string | undefined => {
63
+ const positional = argv.filter((arg) => !arg.startsWith('-'))
64
+ if (positional.length > 1) {
65
+ throw new Error('unexpected second positional argument: name ONE installation')
66
+ }
67
+ const installation = positional[0]?.trim()
68
+ return installation === undefined || installation === '' ? undefined : installation
69
+ }
70
+
71
+ const readSidecar = (argv: readonly string[], installation: string | undefined): UpgradeSidecar => {
72
+ const named = readFlag(argv, '--sidecar')
73
+ if (named !== undefined) {
74
+ return REPO_PATTERN.test(named) ? { kind: 'repo', repo: named } : { kind: 'checkout', dir: named }
75
+ }
76
+ if (installation === undefined) {
77
+ throw new Error(
78
+ 'name the installation (`fabrika platform upgrade --provider=zerops --to=<tag> <installation>`) or its sidecar '
79
+ + 'with --sidecar=<path>|<owner>/<name>',
80
+ )
81
+ }
82
+ return { kind: 'checkout', dir: defaultCheckoutDir(installation) }
83
+ }
84
+
85
+ /** Parse the command's arguments into the one input `runUpgrade` takes. */
86
+ export const parsePlatformUpgradeArgs = (argv: readonly string[]): PlatformUpgradeInput => {
87
+ assertKnownArguments(argv)
88
+ const to = readFlag(argv, '--to')
89
+ if (to === undefined) {
90
+ throw new Error('--to=<tag> is required — the published fabrika release to roll onto, for example --to=v0.1.0')
91
+ }
92
+ const installation = readInstallation(argv)
93
+ return {
94
+ to: assertPinnedTag(to),
95
+ sidecar: readSidecar(argv, installation),
96
+ dryRun: argv.includes(UPGRADE_DRY_RUN_FLAG),
97
+ ...(installation === undefined ? {} : { installation }),
98
+ }
99
+ }
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod'
2
+
3
+ const refs = z.array(z.unknown())
4
+ const tagRef = z.object({ ref: z.string() })
5
+ const runs = z.array(z.unknown())
6
+ const run = z.object({
7
+ databaseId: z.union([z.number(), z.string()]).transform(String),
8
+ url: z.string().min(1),
9
+ status: z.string().min(1).catch('unknown'),
10
+ })
11
+ const readJson = (raw: string): unknown => {
12
+ try {
13
+ return JSON.parse(raw)
14
+ } catch {
15
+ return undefined
16
+ }
17
+ }
18
+ export const carriesTag = (raw: string, tag: string): boolean => {
19
+ const parsed = refs.safeParse(readJson(raw))
20
+ return parsed.success && parsed.data.some((entry) => {
21
+ const parsedRef = tagRef.safeParse(entry)
22
+ return parsedRef.success && parsedRef.data.ref === `refs/tags/${tag}`
23
+ })
24
+ }
25
+ export const firstRun = (raw: string): { id: string; url: string; status: string } | undefined => {
26
+ const parsed = runs.safeParse(readJson(raw))
27
+ if (!parsed.success) return undefined
28
+ const first = run.safeParse(parsed.data[0])
29
+ if (!first.success) return undefined
30
+ return { id: first.data.databaseId, url: first.data.url, status: first.data.status }
31
+ }