@fabrika/provider-zerops 0.2.0 → 0.3.1

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 (82) 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 +0 -5
  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 +135 -0
  16. package/src/index.ts +23 -127
  17. package/src/installation/admin-options.ts +128 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +155 -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 +366 -0
  25. package/src/installation/init.ts +521 -0
  26. package/src/installation/install-collaborators.ts +28 -0
  27. package/src/installation/install-environment.ts +193 -0
  28. package/src/installation/install-options.ts +193 -0
  29. package/src/installation/install-project.ts +201 -0
  30. package/src/installation/install-provisioning.ts +185 -0
  31. package/src/installation/install.ts +286 -0
  32. package/src/installation/installation-source.ts +42 -0
  33. package/src/installation/log.ts +77 -0
  34. package/src/installation/machine-key.ts +65 -0
  35. package/src/installation/manifest.ts +95 -0
  36. package/src/installation/placement-options.ts +33 -0
  37. package/src/installation/platform-source.ts +106 -0
  38. package/src/installation/platform-upload.ts +34 -0
  39. package/src/installation/proxy-manifest.ts +113 -0
  40. package/src/installation/secrets.ts +79 -0
  41. package/src/installation/sidecar.ts +122 -0
  42. package/src/installation/source-options.ts +12 -0
  43. package/src/installation/templates/README.md +116 -0
  44. package/src/installation/templates/fabrika.ref +1 -0
  45. package/src/installation/templates/gitignore +3 -0
  46. package/src/installation/templates/platform.yml +144 -0
  47. package/src/installation/topology/artifacts.ts +141 -0
  48. package/src/installation/topology/index.ts +13 -0
  49. package/src/installation/topology/installation-schemas.ts +225 -0
  50. package/src/installation/topology/invariants.ts +86 -0
  51. package/src/installation/topology/setups.ts +442 -0
  52. package/src/installation/topology/topology.ts +501 -0
  53. package/src/installation/topology/validate.ts +26 -0
  54. package/src/installation/upgrade-options.ts +108 -0
  55. package/src/installation/upgrade-output.ts +31 -0
  56. package/src/installation/upgrade.ts +373 -0
  57. package/src/managed-env.ts +105 -0
  58. package/src/manifest.ts +191 -344
  59. package/src/namespace/claims.ts +77 -0
  60. package/src/namespace/codec.ts +179 -0
  61. package/src/namespace/constants.ts +30 -0
  62. package/src/namespace/errors.ts +60 -0
  63. package/src/namespace/manifest.ts +28 -0
  64. package/src/namespace/operator.ts +151 -0
  65. package/src/namespace/provisioning.ts +482 -0
  66. package/src/namespace/proxy-attempt.ts +220 -0
  67. package/src/namespace/proxy-source.ts +22 -0
  68. package/src/namespace/topology.ts +115 -0
  69. package/src/namespace/types.ts +114 -0
  70. package/src/namespace.ts +34 -1271
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +266 -241
  73. package/src/source/constants.ts +11 -0
  74. package/src/source/port.ts +49 -0
  75. package/src/source/types.ts +4 -0
  76. package/src/source/validation.ts +72 -0
  77. package/src/source.ts +2 -1220
  78. package/src/types.ts +8 -7
  79. package/src/version-attempt.ts +18 -0
  80. package/src/codec.ts +0 -108
  81. package/src/namespace-command.ts +0 -209
  82. package/src/source-connection.ts +0 -344
@@ -0,0 +1,668 @@
1
+ // `fabrika platform deploy --provider=zerops` — the WHOLE ordered sequence, in code.
2
+ //
3
+ // [ADR-0027](../../../docs/decisions/0027-platform-deploy-is-as-wide-as-the-provider-needs.md) decided
4
+ // that on Zerops this command owns the order rather than the operator's pipeline, and gave two reasons
5
+ // that both live here:
6
+ //
7
+ // • The proxy manifest is CROSS-SERVICE state that must move with the code it describes. No single
8
+ // pipeline step owns a document spanning every service the pipeline deploys.
9
+ // • **The order carries a security property.** Since ADR-0022 the application enforces nothing, so
10
+ // deploying control at a new version while the previous, more permissive manifest is still in
11
+ // front of it leaves `/api/*` open for the length of the deploy. The sequence is therefore
12
+ // (IAM + Operations) → **proxy** → control: the two that order nothing against each
13
+ // other built at once, with the enforcement point ahead of the service whose gates just widened.
14
+ // `__tests__/deploy.test.ts` pins it.
15
+ //
16
+ // Bringing `fabrika-test` to HEAD on 2026-08-05 took `zops push` from a laptop, once per service, in a
17
+ // hand-chosen order, and everything that made that run correct stayed in a run log. This file is that
18
+ // run log, executable.
19
+ //
20
+ // ── What this command writes, and what it deliberately does not ───────────────────────────────────
21
+ //
22
+ // It writes the composed proxy manifest, the exact namespace planning source default, and the
23
+ // per-installation configuration derived from the resolved public hosts (each service's own origin,
24
+ // the origins it must know about, and the installation's environment name). **It writes no credential at all** — every
25
+ // secret an installation holds is `platform init`'s to place, and a deploy that cannot write one cannot
26
+ // leak one. The two credentials this command HOLDS are the Zerops access token and the IAM admin key,
27
+ // both from the environment, neither ever logged.
28
+ //
29
+ // ── Idempotence ───────────────────────────────────────────────────────────────────────────────────
30
+ //
31
+ // Every variable is READ before it is written and skipped when it already matches, so a second run
32
+ // writes nothing; the subdomain is decided by reading `subdomainAccess` back, so a second run enables
33
+ // nothing; and the schema reconcile is an upsert. What a re-run does do is redeploy — that is what
34
+ // "deploy" means, and the platform keeps the previous version serving whenever the new one fails its
35
+ // readiness gate.
36
+
37
+ import { readEnvironmentName } from '@fabrika/auth-core'
38
+ import type { SchemaReconciler } from '@fabrika/provider-contract'
39
+ import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
40
+ import {
41
+ prepareZeropsDescriptor,
42
+ type Sleeper,
43
+ ZEROPS_ACTIVE,
44
+ ZEROPS_SERVICE_NOT_HTTP,
45
+ ZEROPS_TERMINAL,
46
+ type ZeropsApi,
47
+ ZeropsApiError,
48
+ type ZeropsNamespaceProxySource,
49
+ type ZeropsService,
50
+ } from '../index'
51
+ import type { PlatformDeployInput } from './deploy-options'
52
+ import { PLATFORM_CONSOLE_APP_SCHEMA } from './generated/platform-console-schema'
53
+ import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
54
+ import { derivePlatformHosts, platformOrigin, type PlatformPlacement, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
55
+ import { PRIVATE_IAM_UPSTREAM } from './install-environment'
56
+ import {
57
+ FABRIKA_ZEROPS_PROXY_SOURCE_JSON,
58
+ type PlatformSourcePreparer,
59
+ prepareNamespaceProxySource,
60
+ serializeNamespaceProxySource,
61
+ validateInstallationSetups,
62
+ } from './installation-source'
63
+ import type { DeployLog } from './log'
64
+ import { mergePlatformProxyManifest, readLiveProxyManifest } from './manifest'
65
+ import type { PreparedPlatformSource } from './platform-source'
66
+ import { type PlatformUploadFetch, uploadPlatformArchive } from './platform-upload'
67
+ import { platformProxyAppFor, resolvePlatformProxyManifest } from './proxy-manifest'
68
+
69
+ /**
70
+ * The four services this command touches, in the order it RESOLVES and WRITES them.
71
+ *
72
+ * It is no longer the order they are deployed in — that is `PLATFORM_CONCURRENT_DEPLOY` followed by
73
+ * `PLATFORM_SEQUENTIAL_DEPLOY`, and the two stages concatenate back to this list (pinned by
74
+ * `deploy-order.test.ts` in `@fabrika/platform-artifacts`). What this list still decides is which services must exist
75
+ * and which environment is written, both of which happen for ALL FOUR before any of them is deployed.
76
+ *
77
+ * Each name is a service hostname in the topology AND the `zerops.yaml` setup name the pipeline
78
+ * selects — the platform's own "setup name defaults to the hostname" rule.
79
+ * `deploy-order.test.ts` in `@fabrika/platform-artifacts` pins both against the declarations that own them.
80
+ */
81
+ export const PLATFORM_DEPLOY_ORDER = ['iam', 'operations', 'proxy', 'control'] as const
82
+
83
+ export type PlatformDeployService = (typeof PLATFORM_DEPLOY_ORDER)[number]
84
+
85
+ /**
86
+ * The two services built AT ONCE, because nothing orders them against each other.
87
+ *
88
+ * Neither calls a sibling at boot: IAM holds no sibling origin at all, and Operations only
89
+ * CONSTRUCTS its IAM client (the first request is the first call). Each readiness gate hits the
90
+ * service's own `/healthz`, and no deploy-written variable of one is read by another's BUILD — the
91
+ * only build-time read on the whole path is the proxy reading its own manifest. So the order among them was arbitrary, and paying for it sequentially cost about 3 min 45 s
92
+ * of the ~9 min 35 s a warm five-service roll took (two measured runs, 2026-08-21).
93
+ *
94
+ * What is NOT arbitrary stays: every environment write happens before any of these starts, the proxy
95
+ * is deployed after both, and control after the proxy (ADR-0022 — the enforcement point ahead of
96
+ * the service whose gates just widened). `deploy-order.test.ts` in `@fabrika/platform-artifacts` and
97
+ * `__tests__/deploy.test.ts` fail if that sequence changes.
98
+ */
99
+ export const PLATFORM_CONCURRENT_DEPLOY = ['iam', 'operations'] as const
100
+
101
+ /** What is deployed after the concurrent stage, one at a time and in this order. */
102
+ export const PLATFORM_SEQUENTIAL_DEPLOY = ['proxy', 'control'] as const
103
+
104
+ /** The proxy's hostname. The only publicly routed service, and the one that carries the manifest. */
105
+ export const PLATFORM_PROXY_SERVICE = 'proxy'
106
+
107
+ /** How often a triggered pipeline is polled. */
108
+ const POLL_INTERVAL_MS = 5_000
109
+ /** Zerops caps a build pipeline at one hour; poll past that so the timeout is ours, not a coincidence. */
110
+ const POLL_ATTEMPTS = (70 * 60_000) / POLL_INTERVAL_MS
111
+ /** One live run read `subdomainAccess: false` right after a successful enable and `true` 3 s later. */
112
+ const SUBDOMAIN_READBACK_ATTEMPTS = 10
113
+
114
+ /**
115
+ * The platform's code for "a userData synchronisation is already running for this service".
116
+ *
117
+ * Observed live (`docs/reference/zerops-platform.md`): a deploy racing an environment write dies on it
118
+ * and leaves the app version stuck `UPLOADING`, which nothing recovers from. This command writes
119
+ * variables and then deploys, so it is exactly the caller that race was recorded against — retrying
120
+ * the TRIGGER is the whole mitigation, and it is bounded so a genuine misconfiguration still fails.
121
+ */
122
+ const USER_DATA_SYNC_RUNNING = 'userDataSyncRunning'
123
+ const TRIGGER_RETRY_ATTEMPTS = 6
124
+
125
+ /**
126
+ * What deploying ONE service needs. Narrower than the whole command's collaborators so the bring-up
127
+ * (`install.ts`), which deploys the proxy alone before any manifest exists, can reuse `deployPlatformService`
128
+ * without inventing an IAM reconciler it has no use for.
129
+ */
130
+ export interface PlatformServiceDeployCollaborators {
131
+ readonly uploadFetch: PlatformUploadFetch
132
+ readonly api: ZeropsApi
133
+ readonly sleep: Sleeper
134
+ readonly log: DeployLog
135
+ readonly signal: AbortSignal
136
+ }
137
+
138
+ /** Everything `deployPlatform` reaches the outside world through. */
139
+ export interface PlatformDeployCollaborators extends PlatformServiceDeployCollaborators {
140
+ readonly prepareSource: PlatformSourcePreparer
141
+ readonly reconcileSchema: SchemaReconciler
142
+ }
143
+
144
+ /** The prepared snapshot shared by every build in an installation run. */
145
+ export interface PlatformServiceDeployInput {
146
+ readonly source: PreparedPlatformSource
147
+ readonly dryRun: boolean
148
+ }
149
+
150
+ /** One resolved platform service. */
151
+ export interface ResolvedService {
152
+ readonly hostname: PlatformDeployService
153
+ readonly id: string
154
+ }
155
+
156
+ const assertRunning = (signal: AbortSignal): void => {
157
+ if (signal.aborted) {
158
+ throw new Error('the Zerops platform deploy was cancelled')
159
+ }
160
+ }
161
+
162
+ const resolveProject = async (
163
+ input: PlatformDeployInput,
164
+ { api, signal }: PlatformDeployCollaborators,
165
+ ): Promise<{ id: string; name: string }> => {
166
+ if (input.project.kind === 'id') {
167
+ const project = await api.getProject({ projectId: input.project.projectId, signal })
168
+ if (project.id === '') {
169
+ throw new Error(`Zerops project ${input.project.projectId} could not be read`)
170
+ }
171
+ return { id: project.id, name: project.name }
172
+ }
173
+ const matches = await api.findProjects({ clientId: input.project.clientId, name: input.project.name, signal })
174
+ if (matches.length === 0) {
175
+ throw new Error(`no Zerops project named \`${input.project.name}\` under this client`)
176
+ }
177
+ if (matches.length > 1) {
178
+ // Zerops allows duplicate project names, so picking one would make the deploy's target depend on
179
+ // the API's list order. Name the id instead.
180
+ throw new Error(`${matches.length} Zerops projects are named \`${input.project.name}\` — select one with --project-id`)
181
+ }
182
+ const project = matches[0]
183
+ if (project === undefined || project.id === '') {
184
+ throw new Error(`the Zerops project named \`${input.project.name}\` has no id`)
185
+ }
186
+ return { id: project.id, name: project.name }
187
+ }
188
+
189
+ /** The private source service of an earlier layout; Control now streams sources itself (ADR-0029). */
190
+ const LEFTOVER_SOURCE_SERVICE = 'source'
191
+
192
+ /**
193
+ * Find every platform service by hostname in one listing.
194
+ *
195
+ * One call rather than four `findService` lookups, so a missing service is reported as the whole set
196
+ * that is missing — an installation that was never imported fails once, naming all of it, rather than
197
+ * four runs later. Build runtimes (`build<hostname>v<n>`) share the project and are ignored by the
198
+ * exact-name match.
199
+ */
200
+ const resolveServices = async (
201
+ projectId: string,
202
+ { api, log, signal }: PlatformDeployCollaborators,
203
+ ): Promise<Map<PlatformDeployService, ResolvedService>> => {
204
+ const services = await api.listProjectServices({ projectId, signal })
205
+ if (services.some((service: ZeropsService) => service.name === LEFTOVER_SOURCE_SERVICE)) {
206
+ log.warn('a `source` service from an earlier layout is present and unused; it may still hold GitHub App keys; delete it or reinstall')
207
+ }
208
+ const resolved = new Map<PlatformDeployService, ResolvedService>()
209
+ const missing: string[] = []
210
+ for (const hostname of PLATFORM_DEPLOY_ORDER) {
211
+ const matches = services.filter((service: ZeropsService) => service.name === hostname)
212
+ if (matches.length > 1) {
213
+ throw new Error(`Zerops project ${projectId} has ${matches.length} services named \`${hostname}\``)
214
+ }
215
+ const match = matches[0]
216
+ if (match === undefined || match.id === '') {
217
+ missing.push(hostname)
218
+ continue
219
+ }
220
+ resolved.set(hostname, { hostname, id: match.id })
221
+ }
222
+ if (missing.length > 0) {
223
+ throw new Error(
224
+ `Zerops project ${projectId} has no ${missing.join(', ')} service — \`platform deploy\` updates an installation, `
225
+ + 'it does not create one',
226
+ )
227
+ }
228
+ return resolved
229
+ }
230
+
231
+ export const serviceOf = (services: ReadonlyMap<PlatformDeployService, ResolvedService>, hostname: PlatformDeployService): ResolvedService => {
232
+ const service = services.get(hostname)
233
+ if (service === undefined) {
234
+ throw new Error(`the \`${hostname}\` service was not resolved`)
235
+ }
236
+ return service
237
+ }
238
+
239
+ /** Read one service's environment as a key → value map. Values are never logged. */
240
+ export const readServiceEnv = async (
241
+ serviceId: string,
242
+ { api, signal }: Pick<PlatformServiceDeployCollaborators, 'api' | 'signal'>,
243
+ ): Promise<Map<string, string>> => {
244
+ const variables = await api.listServiceEnv({ serviceId, signal })
245
+ return new Map(variables.map((variable) => [variable.key, variable.content]))
246
+ }
247
+
248
+ /**
249
+ * Decide where this installation answers.
250
+ *
251
+ * Naming no host derives them from the proxy's Zerops subdomains AND makes this a `zerops-subdomain`
252
+ * installation, whose public entry point the deploy ensures. Naming all three makes it a
253
+ * `custom-domain` installation, where the domains are bound to the project balancer out of band and
254
+ * publishing a subdomain would add a public address nobody asked for.
255
+ */
256
+ const resolvePlacement = (input: PlatformDeployInput, proxyEnv: ReadonlyMap<string, string>): PlatformPlacement => {
257
+ if (input.hosts !== undefined) {
258
+ return { hosts: input.hosts, scheme: input.scheme, zeropsSubdomain: false }
259
+ }
260
+ const subdomains = proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE)
261
+ if (subdomains === undefined || subdomains.trim() === '') {
262
+ throw new Error(
263
+ 'the proxy service publishes no Zerops subdomain — deploy it once so it has HTTP ports, or name every host '
264
+ + 'explicitly with --iam-host / --console-host / --operations-host',
265
+ )
266
+ }
267
+ const listeners = PLATFORM_PROXY_MANIFEST_TEMPLATE.apps.map((app) => ({ service: app.service, port: app.port }))
268
+ return { hosts: derivePlatformHosts(subdomains, listeners), scheme: input.scheme, zeropsSubdomain: true }
269
+ }
270
+
271
+ /**
272
+ * The per-installation configuration each service needs, derived from the resolved hosts.
273
+ *
274
+ * The namespace source default comes from the verified snapshot. Values are compared but never logged.
275
+ */
276
+ const desiredServiceEnv = (
277
+ environment: string,
278
+ placement: PlatformPlacement,
279
+ manifestJson: string,
280
+ proxySource: ZeropsNamespaceProxySource,
281
+ ): ReadonlyMap<PlatformDeployService, ReadonlyMap<string, string>> => {
282
+ const iamOrigin = platformOrigin(placement.scheme, placement.hosts.iam)
283
+ const operationsOrigin = platformOrigin(placement.scheme, placement.hosts.operations)
284
+ return new Map<PlatformDeployService, ReadonlyMap<string, string>>([
285
+ [
286
+ 'iam',
287
+ new Map([
288
+ ['ENVIRONMENT', environment],
289
+ // The `iss` of every minted token AND the OIDC redirect base, so it must be the address
290
+ // routed to the proxy in front of this service.
291
+ ['ISSUER', iamOrigin],
292
+ ]),
293
+ ],
294
+ [
295
+ 'operations',
296
+ new Map([
297
+ ['ENVIRONMENT', environment],
298
+ ['FABRIKA_IAM_ISSUER', iamOrigin],
299
+ ['FABRIKA_OPERATIONS_PUBLIC_HOST', placement.hosts.operations],
300
+ ]),
301
+ ],
302
+ [
303
+ 'proxy',
304
+ new Map([
305
+ ['ENVIRONMENT', environment],
306
+ // Compared byte-for-byte against a token's `iss`, so this is the PUBLIC issuer and not the
307
+ // private `iam:3000` the manifest dials — `readProxyEnv` canonicalizes it to an origin.
308
+ ['FABRIKA_IAM_ISSUER', iamOrigin],
309
+ [FABRIKA_PROXY_MANIFEST_JSON, manifestJson],
310
+ ['FABRIKA_PROXY_SESSION_OWNER_JSON', JSON.stringify({ issuer: iamOrigin, upstream: PRIVATE_IAM_UPSTREAM })],
311
+ ]),
312
+ ],
313
+ [
314
+ 'control',
315
+ new Map([
316
+ [FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource(proxySource)],
317
+ ['ENVIRONMENT', environment],
318
+ ['FABRIKA_IAM_ISSUER', iamOrigin],
319
+ // A bare host: `controlPublicOrigin` accepts either spelling, and this is also what the
320
+ // same-origin check on both first-party gateways is decided against.
321
+ ['FABRIKA_CONTROL_DOMAIN', placement.hosts.control],
322
+ ['OPERATIONS_ARTIFACT_ORIGIN', operationsOrigin],
323
+ // The public IAM origin an APPLICATION project's proxy reaches — there is no private
324
+ // network between projects (ADR-0006).
325
+ ['FABRIKA_ZEROPS_PROXY_IAM_URL', iamOrigin],
326
+ ]),
327
+ ],
328
+ ])
329
+ }
330
+
331
+ /** Write only what differs. Returns the KEYS written — never a value, not even in a debug line. */
332
+ const applyServiceEnv = async (
333
+ service: ResolvedService,
334
+ desired: ReadonlyMap<string, string>,
335
+ live: ReadonlyMap<string, string>,
336
+ input: PlatformDeployInput,
337
+ collaborators: PlatformDeployCollaborators,
338
+ ): Promise<string[]> => {
339
+ const written: string[] = []
340
+ for (const [key, value] of desired) {
341
+ if (live.get(key) === value) {
342
+ continue
343
+ }
344
+ written.push(key)
345
+ if (input.dryRun) {
346
+ continue
347
+ }
348
+ assertRunning(collaborators.signal)
349
+ await collaborators.api.putServiceEnv({ serviceId: service.id, key, value, signal: collaborators.signal })
350
+ }
351
+ return written
352
+ }
353
+
354
+ /** Only a known rejection permits another request against the same reservation. */
355
+ const triggerPipeline = async (
356
+ service: ResolvedService,
357
+ appVersionId: string,
358
+ input: PlatformServiceDeployInput,
359
+ { api, sleep, log, signal }: PlatformServiceDeployCollaborators,
360
+ ): Promise<void> => {
361
+ const descriptor = prepareZeropsDescriptor(input.source.descriptor.contents, service.hostname)
362
+ let mayHaveStarted = false
363
+ try {
364
+ for (let attempt = 0;; attempt += 1) {
365
+ assertRunning(signal)
366
+ try {
367
+ await api.buildAndDeployAppVersion({
368
+ appVersionId,
369
+ zeropsYaml: descriptor.contents,
370
+ zeropsYamlSetup: descriptor.setup,
371
+ signal,
372
+ })
373
+ mayHaveStarted = true
374
+ return
375
+ } catch (error) {
376
+ if (!(error instanceof ZeropsApiError) || error.code !== USER_DATA_SYNC_RUNNING) {
377
+ mayHaveStarted = true
378
+ log.warn(`${service.hostname}: build response unresolved; observing app-version ${appVersionId}`)
379
+ return
380
+ }
381
+ if (attempt >= TRIGGER_RETRY_ATTEMPTS) {
382
+ throw new Error(`${service.hostname}: variable synchronisation still running; app-version ${appVersionId} was not triggered`)
383
+ }
384
+ log.info(`${service.hostname}: the platform is still synchronising this service's variables — retrying the trigger`)
385
+ await sleep(POLL_INTERVAL_MS, signal)
386
+ }
387
+ }
388
+ } finally {
389
+ if (!mayHaveStarted) await cleanupReservation(service, appVersionId, { api, log })
390
+ }
391
+ }
392
+
393
+ const cleanupReservation = async (
394
+ service: ResolvedService,
395
+ appVersionId: string,
396
+ { api, log }: Pick<PlatformServiceDeployCollaborators, 'api' | 'log'>,
397
+ ): Promise<void> => {
398
+ try {
399
+ await api.deleteAppVersion({ appVersionId, signal: AbortSignal.timeout(5_000) })
400
+ } catch {
401
+ log.warn(`${service.hostname}: cleanup unresolved for app-version ${appVersionId}`)
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Deploy ONE service and wait for it to become the active version.
407
+ *
408
+ * A failed deploy throws, which stops the sequence: the platform keeps the previous version serving
409
+ * (`deploy.readinessCheck` is a real gate, live-verified), so a failure here leaves the installation
410
+ * consistent — old code behind the manifest that was written for it.
411
+ *
412
+ * Exported for the BRING-UP, which builds the proxy on its own before any manifest exists so that the
413
+ * service publishes the HTTP ports its public hostnames are named after (`install.ts`, pass 1). That is
414
+ * one service, not an order — the order below stays this file's.
415
+ */
416
+ export const deployPlatformService = async (
417
+ service: ResolvedService,
418
+ input: PlatformServiceDeployInput,
419
+ collaborators: PlatformServiceDeployCollaborators,
420
+ ): Promise<void> => {
421
+ const { api, sleep, log, signal } = collaborators
422
+ if (input.dryRun) {
423
+ log.info(`${service.hostname}: would trigger its pipeline and wait for the new version to become ACTIVE`)
424
+ return
425
+ }
426
+ prepareZeropsDescriptor(input.source.descriptor.contents, service.hostname)
427
+ const reserved = await api.createAppVersion({ serviceId: service.id, name: input.source.commitSha, signal })
428
+ const appVersionId = reserved.id
429
+ log.info(`${service.hostname}: app-version ${appVersionId}, commit ${input.source.commitSha}, setup ${service.hostname}`)
430
+ try {
431
+ await uploadPlatformArchive(reserved.uploadUrl, input.source.archivePath, signal, collaborators.uploadFetch)
432
+ assertRunning(signal)
433
+ } catch {
434
+ await cleanupReservation(service, appVersionId, collaborators)
435
+ throw new Error(`${service.hostname}: archive upload failed before build for app-version ${appVersionId}`)
436
+ }
437
+ await triggerPipeline(service, appVersionId, input, collaborators)
438
+ for (let attempt = 0;; attempt += 1) {
439
+ assertRunning(signal)
440
+ const version = await api.getAppVersion({ appVersionId, signal })
441
+ // An unknown status means this build does not recognise what Zerops sent — keep waiting, never
442
+ // read it as success or failure.
443
+ if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
444
+ if (version.status !== ZEROPS_ACTIVE) {
445
+ throw new Error(`zerops: the \`${service.hostname}\` deploy ${appVersionId} finished as ${version.status}`)
446
+ }
447
+ log.info(`${service.hostname}: ACTIVE`)
448
+ return
449
+ }
450
+ if (attempt >= POLL_ATTEMPTS) {
451
+ throw new Error(`zerops: the \`${service.hostname}\` deploy ${appVersionId} is unresolved after timeout; no retry or deletion was attempted`)
452
+ }
453
+ await sleep(POLL_INTERVAL_MS, signal)
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Build `PLATFORM_CONCURRENT_DEPLOY` at once, and let every one of them finish before failing.
459
+ *
460
+ * `allSettled` rather than `all`: a rejection from `all` would leave the other build running with
461
+ * nobody polling it, so the command would exit while the platform was still writing app versions —
462
+ * and the second rejection would be unhandled. Here every build is followed to a terminal status.
463
+ *
464
+ * EVERY failure is logged and the first is thrown, where "first" is this list's order and not the order
465
+ * they failed in — builds that started together have no meaningful chronology, and a command that
466
+ * reported only one of two broken services would send the operator back for a second run to find the
467
+ * other. `deployPlatformService` names the service in each message.
468
+ */
469
+ const deployConcurrently = async (
470
+ services: ReadonlyMap<PlatformDeployService, ResolvedService>,
471
+ input: PlatformServiceDeployInput,
472
+ collaborators: PlatformDeployCollaborators,
473
+ ): Promise<void> => {
474
+ const outcomes = await Promise.allSettled(
475
+ PLATFORM_CONCURRENT_DEPLOY.map((hostname) => deployPlatformService(serviceOf(services, hostname), input, collaborators)),
476
+ )
477
+ const failures = outcomes.flatMap((outcome) =>
478
+ outcome.status === 'rejected' ? [outcome.reason instanceof Error ? outcome.reason : new Error(String(outcome.reason))] : []
479
+ )
480
+ for (const failure of failures) {
481
+ collaborators.log.warn(failure.message)
482
+ }
483
+ const first = failures[0]
484
+ if (first !== undefined) {
485
+ throw first
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Publish the proxy on its `*.zerops.app` subdomain — the one thing an import document cannot do.
491
+ *
492
+ * The decision is the READ-BACK and never the call returning: on an already-published service the
493
+ * platform answers 2xx and then fails the process it hands back, and a freshly-enabled one can still
494
+ * read `false` on the next read. Both live-verified. Runs on every deploy, so a subdomain someone
495
+ * turned off in the GUI comes back, and throws rather than leaving the installation unreachable.
496
+ */
497
+ const ensureSubdomainAccess = async (
498
+ proxy: ResolvedService,
499
+ input: PlatformDeployInput,
500
+ { api, sleep, log, signal }: PlatformDeployCollaborators,
501
+ ): Promise<void> => {
502
+ const published = async (): Promise<boolean> => (await api.getService({ serviceId: proxy.id, signal })).subdomainAccess === true
503
+ if (await published()) {
504
+ log.info('the proxy is already published on its Zerops subdomain')
505
+ return
506
+ }
507
+ if (input.dryRun) {
508
+ log.info('the proxy is NOT published on its Zerops subdomain; would enable it')
509
+ return
510
+ }
511
+ try {
512
+ await api.enableSubdomainAccess({ serviceId: proxy.id, signal })
513
+ } catch (error) {
514
+ if (error instanceof ZeropsApiError && error.code === ZEROPS_SERVICE_NOT_HTTP) {
515
+ throw new Error(
516
+ `the proxy cannot be published on a zerops.app subdomain: it exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP})`,
517
+ )
518
+ }
519
+ throw error
520
+ }
521
+ for (let attempt = 0;; attempt += 1) {
522
+ assertRunning(signal)
523
+ if (await published()) {
524
+ log.info('the proxy is published on its Zerops subdomain')
525
+ return
526
+ }
527
+ if (attempt >= SUBDOMAIN_READBACK_ATTEMPTS) {
528
+ throw new Error(
529
+ 'the proxy accepted enable-subdomain-access but still reads subdomainAccess: false — the installation has no public entry point',
530
+ )
531
+ }
532
+ await sleep(POLL_INTERVAL_MS, signal)
533
+ }
534
+ }
535
+
536
+ /**
537
+ * The installation's environment name, refused when a public origin contradicts it.
538
+ *
539
+ * The same rule the services enforce at boot (`readEnvironmentName`, `@fabrika/auth-core`), applied
540
+ * HERE so a deploy that would make IAM or control refuse to start fails before it writes anything.
541
+ * Both origins are checked because both services state one: IAM's `ISSUER` and control's
542
+ * `FABRIKA_CONTROL_DOMAIN` are what each of them enforces against.
543
+ */
544
+ const checkedEnvironmentName = (environment: string, placement: PlatformPlacement): string => {
545
+ readEnvironmentName(environment, platformOrigin(placement.scheme, placement.hosts.iam))
546
+ return readEnvironmentName(environment, platformOrigin(placement.scheme, placement.hosts.control))
547
+ }
548
+
549
+ /**
550
+ * Bring one Zerops platform installation to this checkout, unattended.
551
+ *
552
+ * Every step is idempotent and the whole sequence fails closed: nothing is deployed until the manifest
553
+ * and the environment name have been applied, and nothing after a failed step runs.
554
+ */
555
+ export const deployPlatform = async (
556
+ input: PlatformDeployInput,
557
+ collaborators: PlatformDeployCollaborators,
558
+ ): Promise<void> => {
559
+ const source = await collaborators.prepareSource(input.sourceDir, collaborators.signal)
560
+ try {
561
+ validateInstallationSetups(source, PLATFORM_DEPLOY_ORDER)
562
+ await deployPreparedPlatform(input, collaborators, source)
563
+ } finally {
564
+ await source.dispose()
565
+ }
566
+ }
567
+
568
+ export const deployPreparedPlatform = async (
569
+ input: PlatformDeployInput,
570
+ collaborators: PlatformDeployCollaborators,
571
+ source: PreparedPlatformSource,
572
+ ): Promise<void> => {
573
+ const { log, signal } = collaborators
574
+ assertRunning(signal)
575
+ validateInstallationSetups(source, PLATFORM_DEPLOY_ORDER)
576
+ const proxySource = await prepareNamespaceProxySource(source)
577
+ log.info(`source commit ${source.commitSha}`)
578
+
579
+ log.step('Resolve the installation')
580
+ const project = await resolveProject(input, collaborators)
581
+ const services = await resolveServices(project.id, collaborators)
582
+ log.info(`project ${project.name} (${project.id}) — ${PLATFORM_DEPLOY_ORDER.join(', ')}`)
583
+
584
+ const proxy = serviceOf(services, PLATFORM_PROXY_SERVICE)
585
+ const proxyEnv = await readServiceEnv(proxy.id, collaborators)
586
+ const placement = resolvePlacement(input, proxyEnv)
587
+ const environment = checkedEnvironmentName(input.environment, placement)
588
+ log.info(`environment \`${environment}\`, ${placement.scheme}://${placement.hosts.control} (console)`)
589
+ log.info(`iam ${placement.hosts.iam} · operations ${placement.hosts.operations}`)
590
+
591
+ log.step('Compose the proxy manifest')
592
+ const platformManifest = resolvePlatformProxyManifest(PLATFORM_PROXY_MANIFEST_TEMPLATE, {
593
+ scheme: placement.scheme,
594
+ placement: {
595
+ iam: { hosts: [placement.hosts.iam] },
596
+ control: { hosts: [placement.hosts.control] },
597
+ operations: { hosts: [placement.hosts.operations] },
598
+ },
599
+ })
600
+ const merged = mergePlatformProxyManifest(platformManifest, readLiveProxyManifest(proxyEnv.get(FABRIKA_PROXY_MANIFEST_JSON)))
601
+ log.info(`${merged.manifest.apps.length} app(s): ${merged.manifest.apps.map((app) => app.id).join(', ')}`)
602
+ if (merged.carried.length > 0) {
603
+ log.info(`carried through from the live manifest: ${merged.carried.join(', ')}`)
604
+ }
605
+ for (const displaced of merged.superseded) {
606
+ log.warn(
607
+ `the live manifest entry \`${displaced.id}\` stood on the platform host ${displaced.host} and was replaced — `
608
+ + 'that host now answers for the platform app that owns it',
609
+ )
610
+ }
611
+
612
+ log.step('Write the service environment')
613
+ const desired = desiredServiceEnv(environment, placement, encodeProxyManifestJson(merged.manifest), proxySource)
614
+ for (const hostname of PLATFORM_DEPLOY_ORDER) {
615
+ const service = serviceOf(services, hostname)
616
+ const live = hostname === PLATFORM_PROXY_SERVICE ? proxyEnv : await readServiceEnv(service.id, collaborators)
617
+ const keys = desired.get(hostname) ?? new Map<string, string>()
618
+ const written = await applyServiceEnv(service, keys, live, input, collaborators)
619
+ log.info(
620
+ written.length === 0
621
+ ? `${hostname}: ${keys.size} variable(s) already correct`
622
+ : `${hostname}: ${input.dryRun ? 'would write' : 'wrote'} ${written.join(', ')}`,
623
+ )
624
+ }
625
+
626
+ // EVERY deploy is after EVERY write. That is what makes the sequence fail closed: a manifest this
627
+ // command could not apply leaves the previous manifest in front of the previous code, never in front
628
+ // of new code — and `ENVIRONMENT` is in place before any service that refuses `local` restarts.
629
+ log.step(`Deploy ${PLATFORM_CONCURRENT_DEPLOY.join(' + ')} together, then ${PLATFORM_SEQUENTIAL_DEPLOY.join(' → ')}`)
630
+ const buildInput = { source, dryRun: input.dryRun }
631
+ await deployConcurrently(services, buildInput, collaborators)
632
+ for (const hostname of PLATFORM_SEQUENTIAL_DEPLOY) {
633
+ await deployPlatformService(serviceOf(services, hostname), buildInput, collaborators)
634
+ }
635
+
636
+ // BEFORE the console registration, not after: that step talks to IAM over its PUBLIC host, and on a
637
+ // first bring-up the proxy has no public address until this runs — every call 502s. On an already
638
+ // published installation this is a no-op, which is why the old order survived until `platform install`
639
+ // ran it on an empty project. Publishing first is not a widening: the proxy enforces the manifest it
640
+ // was just built with (ADR-0022), and registration only teaches IAM where a session may return to.
641
+ log.step('Ensure the public entry point')
642
+ if (placement.zeropsSubdomain) {
643
+ await ensureSubdomainAccess(proxy, input, collaborators)
644
+ } else {
645
+ log.info('custom-domain installation: the operator binds each domain to the project balancer; no subdomain is published')
646
+ }
647
+
648
+ log.step('Register the console with IAM')
649
+ const consoleApp = platformProxyAppFor(PLATFORM_PROXY_MANIFEST_TEMPLATE, 'control')
650
+ const consoleOrigin = platformOrigin(placement.scheme, placement.hosts.control)
651
+ if (input.dryRun) {
652
+ log.info(`would reconcile \`${consoleApp.id}\` and register its return origin ${consoleOrigin}`)
653
+ } else {
654
+ // The schema PUT is what REGISTERS the app, and `apps.setReturnOrigins` 404s for an app IAM has
655
+ // never heard of — so both travel in this one call, in that order (ADR-0022/ADR-0023).
656
+ await collaborators.reconcileSchema({
657
+ url: platformOrigin(placement.scheme, placement.hosts.iam),
658
+ app: consoleApp.id,
659
+ schema: PLATFORM_CONSOLE_APP_SCHEMA,
660
+ returnOrigins: [consoleOrigin],
661
+ adminKey: input.iamAdminKey,
662
+ signal,
663
+ })
664
+ log.info(`\`${consoleApp.id}\` reconciled; return origin ${consoleOrigin}`)
665
+ }
666
+
667
+ log.step(input.dryRun ? 'Dry run complete — nothing was written' : 'Installation deployed')
668
+ }