@fabrika/provider-zerops 0.8.1 → 0.9.0-cloudflare.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 (133) hide show
  1. package/package.json +16 -14
  2. package/src/api/client.ts +18 -0
  3. package/src/api/readers.ts +29 -0
  4. package/src/api/types.ts +64 -0
  5. package/src/api.ts +2 -0
  6. package/src/cli.ts +11 -0
  7. package/src/collaborators.ts +0 -14
  8. package/src/control.ts +35 -57
  9. package/src/execution.ts +113 -102
  10. package/src/index.ts +2 -0
  11. package/src/installation/admin.ts +1 -1
  12. package/src/installation/deploy-options.ts +1 -2
  13. package/src/installation/deploy.ts +225 -80
  14. package/src/installation/generated/platform-console-schema.ts +3 -0
  15. package/src/installation/generated/platform-proxy-manifest.ts +21 -1
  16. package/src/installation/guide.md +48 -18
  17. package/src/installation/hosts.ts +1 -7
  18. package/src/installation/index.ts +13 -16
  19. package/src/installation/init-options.ts +2 -1
  20. package/src/installation/install-environment.ts +47 -108
  21. package/src/installation/install-options.ts +3 -1
  22. package/src/installation/install-secrets.ts +24 -68
  23. package/src/installation/install.ts +9 -4
  24. package/src/installation/sidecar.ts +5 -26
  25. package/src/installation/topology/setups.ts +15 -4
  26. package/src/installation/topology/topology.ts +31 -21
  27. package/src/installation/upgrade-options.ts +2 -2
  28. package/src/managed-env.ts +1 -174
  29. package/src/manifest.ts +11 -14
  30. package/src/namespace/claims.ts +56 -1
  31. package/src/namespace/errors.ts +1 -1
  32. package/src/namespace/manifest.ts +6 -19
  33. package/src/namespace/provisioning.ts +24 -4
  34. package/src/namespace/proxy-attempt.ts +43 -2
  35. package/src/namespace/proxy-reads.ts +12 -22
  36. package/src/namespace/types.ts +2 -0
  37. package/src/namespace-cli.ts +152 -0
  38. package/src/plan.ts +2 -7
  39. package/src/provider.ts +128 -126
  40. package/src/proxy-manifest-carrier.ts +18 -0
  41. package/src/types.ts +0 -2
  42. package/types/src/api/client.d.ts +5 -0
  43. package/types/src/api/errors.d.ts +24 -0
  44. package/types/src/api/polling.d.ts +41 -0
  45. package/types/src/api/readers.d.ts +1115 -0
  46. package/types/src/api/service-env-limit.d.ts +17 -0
  47. package/types/src/api/service-env-response.d.ts +23 -0
  48. package/types/src/api/types.d.ts +687 -0
  49. package/types/src/api.d.ts +6 -0
  50. package/types/src/authoring.d.ts +9 -0
  51. package/types/src/cli-args.d.ts +19 -0
  52. package/types/src/cli.d.ts +13 -0
  53. package/types/src/collaborators.d.ts +16 -0
  54. package/types/src/compile.d.ts +91 -0
  55. package/types/src/control.d.ts +40 -0
  56. package/types/src/database-budget.d.ts +38 -0
  57. package/types/src/descriptor.d.ts +28 -0
  58. package/types/src/execution.d.ts +125 -0
  59. package/types/src/exposure.d.ts +47 -0
  60. package/types/src/index.d.ts +31 -0
  61. package/types/src/installation/admin-options.d.ts +24 -0
  62. package/types/src/installation/admin.d.ts +35 -0
  63. package/types/src/installation/checker-release.d.ts +12 -0
  64. package/types/src/installation/deploy-options.d.ts +37 -0
  65. package/types/src/installation/deploy.d.ts +136 -0
  66. package/types/src/installation/generated/platform-console-schema.d.ts +2 -0
  67. package/types/src/installation/generated/platform-proxy-manifest.d.ts +2 -0
  68. package/types/src/installation/hosts.d.ts +34 -0
  69. package/types/src/installation/index.d.ts +7 -0
  70. package/types/src/installation/init-options.d.ts +38 -0
  71. package/types/src/installation/init.d.ts +60 -0
  72. package/types/src/installation/install-collaborators.d.ts +24 -0
  73. package/types/src/installation/install-environment.d.ts +63 -0
  74. package/types/src/installation/install-options.d.ts +49 -0
  75. package/types/src/installation/install-project.d.ts +13 -0
  76. package/types/src/installation/install-provisioning.d.ts +58 -0
  77. package/types/src/installation/install-secrets.d.ts +17 -0
  78. package/types/src/installation/install-token.d.ts +10 -0
  79. package/types/src/installation/install.d.ts +23 -0
  80. package/types/src/installation/installation-source.d.ts +8 -0
  81. package/types/src/installation/log.d.ts +44 -0
  82. package/types/src/installation/machine-key.d.ts +19 -0
  83. package/types/src/installation/placement-options.d.ts +11 -0
  84. package/types/src/installation/platform-archive.d.ts +7 -0
  85. package/types/src/installation/platform-source.d.ts +8 -0
  86. package/types/src/installation/platform-upload.d.ts +3 -0
  87. package/types/src/installation/sidecar.d.ts +49 -0
  88. package/types/src/installation/source-options.d.ts +1 -0
  89. package/types/src/installation/topology/artifacts.d.ts +8 -0
  90. package/types/src/installation/topology/index.d.ts +5 -0
  91. package/types/src/installation/topology/installation-schemas.d.ts +2242 -0
  92. package/types/src/installation/topology/invariants.d.ts +23 -0
  93. package/types/src/installation/topology/setups.d.ts +59 -0
  94. package/types/src/installation/topology/topology.d.ts +153 -0
  95. package/types/src/installation/topology/validate.d.ts +11 -0
  96. package/types/src/installation/upgrade-options.d.ts +26 -0
  97. package/types/src/installation/upgrade-output.d.ts +6 -0
  98. package/types/src/installation/upgrade.d.ts +88 -0
  99. package/types/src/managed-env.d.ts +24 -0
  100. package/types/src/manifest.d.ts +204 -0
  101. package/types/src/namespace/claims.d.ts +16 -0
  102. package/types/src/namespace/codec.d.ts +334 -0
  103. package/types/src/namespace/constants.d.ts +25 -0
  104. package/types/src/namespace/errors.d.ts +15 -0
  105. package/types/src/namespace/manifest.d.ts +4 -0
  106. package/types/src/namespace/operator.d.ts +12 -0
  107. package/types/src/namespace/provisioning.d.ts +4 -0
  108. package/types/src/namespace/proxy-attempt.d.ts +39 -0
  109. package/types/src/namespace/proxy-reads.d.ts +22 -0
  110. package/types/src/namespace/proxy-secrets.d.ts +8 -0
  111. package/types/src/namespace/proxy-source.d.ts +74 -0
  112. package/types/src/namespace/topology.d.ts +9 -0
  113. package/types/src/namespace/types.d.ts +101 -0
  114. package/types/src/namespace/update.d.ts +6 -0
  115. package/types/src/namespace/validation.d.ts +12 -0
  116. package/types/src/namespace-cli.d.ts +2 -0
  117. package/types/src/namespace.d.ts +9 -0
  118. package/types/src/plan.d.ts +11 -0
  119. package/types/src/provider.d.ts +63 -0
  120. package/types/src/proxy-manifest-carrier.d.ts +24 -0
  121. package/types/src/schema.generated.d.ts +376 -0
  122. package/types/src/service-names.d.ts +4 -0
  123. package/types/src/service-references.d.ts +13 -0
  124. package/types/src/source/constants.d.ts +6 -0
  125. package/types/src/source/port.d.ts +60 -0
  126. package/types/src/source/types.d.ts +4 -0
  127. package/types/src/source/validation.d.ts +19 -0
  128. package/types/src/source.d.ts +2 -0
  129. package/types/src/types.d.ts +119 -0
  130. package/types/src/version-attempt.d.ts +8 -0
  131. package/src/installation/mcp.ts +0 -102
  132. package/src/installation/proxy-manifest.ts +0 -214
  133. package/src/installation/secrets.ts +0 -78
@@ -21,21 +21,35 @@
21
21
  //
22
22
  // It composes the proxy manifest and ships it in the proxy's build archive. It writes the exact namespace
23
23
  // planning source default and the per-installation configuration derived from the resolved public hosts
24
- // (each service's own origin, 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.
24
+ // (each service's own origin, the origins it must know about, and the installation's environment name),
25
+ // and the platform database's backup schedule. **It writes one credential, and only a derived one**: the
26
+ // secret the proxy proves its requests to IAM with, HMAC-SHA256 of a fixed label keyed by the IAM admin key,
27
+ // written sensitive to `proxy` and `iam` and never read back. Every other secret an installation holds is
28
+ // `platform install`'s to place. The two credentials this command HOLDS are the Zerops access token and the
29
+ // IAM admin key, both from the environment, neither ever logged.
28
30
  //
29
31
  // ── Idempotence ───────────────────────────────────────────────────────────────────────────────────
30
32
  //
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
33
+ // Every readable variable is READ before it is written and skipped when it already matches, so a second
34
+ // run writes only the derived secret, which it cannot read back; the subdomain is decided by reading
35
+ // `subdomainAccess` back, so a second run enables nothing; the database's backup period is read and set
36
+ // only when it differs; and the schema reconcile is an upsert. What a re-run does do is redeploy — that is
37
+ // what "deploy" means, and the platform keeps the previous version serving whenever the new one fails its
35
38
  // readiness gate.
36
39
 
37
- import { FABRIKA_SERVICE_TABLE_DIGEST, readEnvironmentName } from '@fabrika/auth-core'
40
+ import { IAM_PROXY_SECRET_VARIABLE, readEnvironmentName } from '@fabrika/auth-core'
38
41
  import { IAM_APP_ID, IAM_APP_SCHEMA } from '@fabrika/iam-contract'
42
+ import {
43
+ deployedServiceEnvironment,
44
+ IAM_OAUTH_CONFIG_VARIABLE,
45
+ type IamOAuthConfig,
46
+ platformIamOAuthConfig,
47
+ platformOrigin,
48
+ platformProxyAppFor,
49
+ type PlatformProxyComposition,
50
+ platformServiceTableDigest,
51
+ resolvePlatformProxyManifest,
52
+ } from '@fabrika/installation-init'
39
53
  import type { SchemaReconciler } from '@fabrika/provider-contract'
40
54
  import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
41
55
  import { assertServiceEnvValueFits } from '../api'
@@ -44,12 +58,14 @@ import {
44
58
  prepareZeropsDescriptor,
45
59
  readableContent,
46
60
  type Sleeper,
61
+ waitForProcess,
47
62
  ZEROPS_ACTIVE,
48
63
  ZEROPS_SERVICE_NOT_HTTP,
49
64
  ZEROPS_TERMINAL,
50
65
  type ZeropsApi,
51
66
  ZeropsApiError,
52
67
  type ZeropsNamespaceProxySource,
68
+ type ZeropsProcess,
53
69
  type ZeropsService,
54
70
  type ZeropsServiceEnv,
55
71
  } from '../index'
@@ -58,7 +74,7 @@ import { assertCheckerPublished, type CheckerPublication } from './checker-relea
58
74
  import type { PlatformDeployInput } from './deploy-options'
59
75
  import { PLATFORM_CONSOLE_APP_SCHEMA } from './generated/platform-console-schema'
60
76
  import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
61
- import { derivePlatformHosts, platformOrigin, type PlatformPlacement, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
77
+ import { derivePlatformHosts, type PlatformPlacement, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
62
78
  import { PRIVATE_IAM_UPSTREAM } from './install-environment'
63
79
  import {
64
80
  FABRIKA_ZEROPS_PROXY_SOURCE_JSON,
@@ -68,11 +84,9 @@ import {
68
84
  validateInstallationSetups,
69
85
  } from './installation-source'
70
86
  import type { DeployLog } from './log'
71
- import { type IamOAuthConfig, platformIamOAuthConfig } from './mcp'
72
87
  import { archiveWithDataFile } from './platform-archive'
73
88
  import type { PreparedPlatformSource } from './platform-source'
74
89
  import { type PlatformUploadFetch, uploadPlatformArchive } from './platform-upload'
75
- import { platformProxyAppFor, type PlatformProxyComposition, platformServiceTableDigest, resolvePlatformProxyManifest } from './proxy-manifest'
76
90
 
77
91
  /**
78
92
  * The four services this command touches, in the order it RESOLVES and WRITES them.
@@ -112,6 +126,18 @@ export const PLATFORM_SEQUENTIAL_DEPLOY = ['proxy', 'control'] as const
112
126
  /** The proxy's hostname. The only publicly routed service, and the one that carries the manifest. */
113
127
  export const PLATFORM_PROXY_SERVICE = 'proxy'
114
128
 
129
+ /** IAM's and control's database, on both tiers. This command deploys nothing to it; it sets its backup schedule. */
130
+ export const PLATFORM_DATABASE_SERVICE = 'db'
131
+
132
+ /** Operations' own database. A light installation created before it existed keeps Operations in `db`. */
133
+ export const OPERATIONS_DATABASE_SERVICE = 'operationsdb'
134
+
135
+ /**
136
+ * Hourly. Zerops has no point-in-time recovery: a single-node database loses everything since its last `pg_dump`, which
137
+ * by default is daily. UNVERIFIED: that `backupPeriod` takes this 5-field cron, as the GUI documentation describes.
138
+ */
139
+ export const PLATFORM_DATABASE_BACKUP_PERIOD = '0 * * * *'
140
+
115
141
  /** How often a triggered pipeline is polled. */
116
142
  const POLL_INTERVAL_MS = 5_000
117
143
  /** Zerops caps a build pipeline at one hour; poll past that so the timeout is ours, not a coincidence. */
@@ -208,43 +234,58 @@ const resolveProject = async (
208
234
  /** The private source service of an earlier layout; Control now streams sources itself (ADR-0029). */
209
235
  const LEFTOVER_SOURCE_SERVICE = 'source'
210
236
 
237
+ /** The services this command touches: the four it deploys, and the database whose backups it schedules. */
238
+ interface ResolvedInstallation {
239
+ readonly services: ReadonlyMap<PlatformDeployService, ResolvedService>
240
+ readonly databaseId: string
241
+ readonly hasOperationsDatabase: boolean
242
+ }
243
+
211
244
  /**
212
245
  * Find every platform service by hostname in one listing.
213
246
  *
214
- * One call rather than four `findService` lookups, so a missing service is reported as the whole set
247
+ * One call rather than five `findService` lookups, so a missing service is reported as the whole set
215
248
  * that is missing — an installation that was never imported fails once, naming all of it, rather than
216
- * four runs later. Build runtimes (`build<hostname>v<n>`) share the project and are ignored by the
249
+ * five runs later. Build runtimes (`build<hostname>v<n>`) share the project and are ignored by the
217
250
  * exact-name match.
218
251
  */
219
252
  const resolveServices = async (
220
253
  projectId: string,
221
254
  { api, log, signal }: PlatformDeployCollaborators,
222
- ): Promise<Map<PlatformDeployService, ResolvedService>> => {
255
+ ): Promise<ResolvedInstallation> => {
223
256
  const services = await api.listProjectServices({ projectId, signal })
224
257
  if (services.some((service: ZeropsService) => service.name === LEFTOVER_SOURCE_SERVICE)) {
225
258
  log.warn('a `source` service from an earlier layout is present and unused; it may still hold GitHub App keys; delete it or reinstall')
226
259
  }
227
- const resolved = new Map<PlatformDeployService, ResolvedService>()
228
- const missing: string[] = []
229
- for (const hostname of PLATFORM_DEPLOY_ORDER) {
260
+ const idOf = (hostname: string): string | undefined => {
230
261
  const matches = services.filter((service: ZeropsService) => service.name === hostname)
231
262
  if (matches.length > 1) {
232
263
  throw new Error(`Zerops project ${projectId} has ${matches.length} services named \`${hostname}\``)
233
264
  }
234
265
  const match = matches[0]
235
- if (match === undefined || match.id === '') {
266
+ return match === undefined || match.id === '' ? undefined : match.id
267
+ }
268
+ const resolved = new Map<PlatformDeployService, ResolvedService>()
269
+ const missing: string[] = []
270
+ for (const hostname of PLATFORM_DEPLOY_ORDER) {
271
+ const id = idOf(hostname)
272
+ if (id === undefined) {
236
273
  missing.push(hostname)
237
274
  continue
238
275
  }
239
- resolved.set(hostname, { hostname, id: match.id })
276
+ resolved.set(hostname, { hostname, id })
240
277
  }
241
- if (missing.length > 0) {
278
+ const databaseId = idOf(PLATFORM_DATABASE_SERVICE)
279
+ if (databaseId === undefined) {
280
+ missing.push(PLATFORM_DATABASE_SERVICE)
281
+ }
282
+ if (missing.length > 0 || databaseId === undefined) {
242
283
  throw new Error(
243
284
  `Zerops project ${projectId} has no ${missing.join(', ')} service — \`platform deploy\` updates an installation, `
244
285
  + 'it does not create one',
245
286
  )
246
287
  }
247
- return resolved
288
+ return { services: resolved, databaseId, hasOperationsDatabase: idOf(OPERATIONS_DATABASE_SERVICE) !== undefined }
248
289
  }
249
290
 
250
291
  export const serviceOf = (services: ReadonlyMap<PlatformDeployService, ResolvedService>, hostname: PlatformDeployService): ResolvedService => {
@@ -290,78 +331,56 @@ const resolvePlacement = (input: PlatformDeployInput, proxyEnv: LiveServiceEnv):
290
331
  return { hosts: derivePlatformHosts(subdomains, listeners), scheme: input.scheme, zeropsSubdomain: true }
291
332
  }
292
333
 
293
- const IAM_OAUTH_CONFIG = 'FABRIKA_IAM_OAUTH_CONFIG'
334
+ /** The credentials this command writes. Sensitive, so they cannot be compared and are rewritten on every deploy. */
335
+ const SENSITIVE_DEPLOY_KEYS: ReadonlySet<string> = new Set([IAM_PROXY_SECRET_VARIABLE])
294
336
 
295
337
  /**
296
- * The per-installation configuration each service needs, derived from the resolved hosts.
338
+ * The per-installation configuration each service needs, derived from the resolved hosts: fabrika's
339
+ * (`deployedServiceEnvironment`), and control's Zerops variables after it.
297
340
  *
298
341
  * The namespace source default comes from the verified snapshot. Values are compared but never logged.
299
- * IAM's OAuth configuration is written only when MCP authoring is enabled; without it the variable is
300
- * left as it is, and the manifest — whose `/mcp` row then admits no OAuth token — is what turns MCP off.
301
342
  */
302
343
  const desiredServiceEnv = (
303
344
  environment: string,
345
+ projectId: string,
304
346
  placement: PlatformPlacement,
305
347
  proxySource: ZeropsNamespaceProxySource,
306
348
  oauthConfig: IamOAuthConfig | undefined,
307
349
  controlTableDigest: string,
350
+ iamAdminKey: string,
308
351
  ): ReadonlyMap<PlatformDeployService, ReadonlyMap<string, string>> => {
309
- const iamOrigin = platformOrigin(placement.scheme, placement.hosts.iam)
310
- const operationsOrigin = platformOrigin(placement.scheme, placement.hosts.operations)
311
- const iam = new Map([
312
- ['ENVIRONMENT', environment],
313
- // The `iss` of every minted token AND the OIDC redirect base, so it must be the address
314
- // routed to the proxy in front of this service.
315
- ['ISSUER', iamOrigin],
352
+ const fabrika = deployedServiceEnvironment({
353
+ environment,
354
+ scheme: placement.scheme,
355
+ hosts: placement.hosts,
356
+ iamUpstream: PRIVATE_IAM_UPSTREAM,
357
+ provisioningKey: iamAdminKey,
358
+ controlTableDigest,
359
+ ...(oauthConfig === undefined ? {} : { oauthConfig }),
360
+ })
361
+ const control = new Map([
362
+ ...fabrika.control,
363
+ [FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource(proxySource)],
364
+ // The public IAM origin an APPLICATION project's proxy reaches — there is no private
365
+ // network between projects (ADR-0006).
366
+ ['FABRIKA_ZEROPS_PROXY_IAM_URL', platformOrigin(placement.scheme, placement.hosts.iam)],
367
+ // The installation's own project, which no deployment namespace may adopt: its proxy is the
368
+ // platform's enforcement point, and a namespace reconcile would rebuild it with only app members.
369
+ ['FABRIKA_ZEROPS_PLATFORM_PROJECT_ID', projectId],
316
370
  ])
317
- if (oauthConfig !== undefined) {
318
- iam.set(IAM_OAUTH_CONFIG, JSON.stringify(oauthConfig))
319
- }
320
371
  return new Map<PlatformDeployService, ReadonlyMap<string, string>>([
321
- ['iam', iam],
322
- [
323
- 'operations',
324
- new Map([
325
- ['ENVIRONMENT', environment],
326
- ['FABRIKA_IAM_ISSUER', iamOrigin],
327
- ['FABRIKA_OPERATIONS_PUBLIC_HOST', placement.hosts.operations],
328
- ]),
329
- ],
330
- [
331
- 'proxy',
332
- new Map([
333
- ['ENVIRONMENT', environment],
334
- // Compared byte-for-byte against a token's `iss`, so this is the PUBLIC issuer and not the
335
- // private `iam:3000` the manifest dials — `readProxyEnv` canonicalizes it to an origin.
336
- ['FABRIKA_IAM_ISSUER', iamOrigin],
337
- ['FABRIKA_PROXY_SESSION_OWNER_JSON', JSON.stringify({ issuer: iamOrigin, upstream: PRIVATE_IAM_UPSTREAM })],
338
- ]),
339
- ],
340
- [
341
- 'control',
342
- new Map([
343
- [FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource(proxySource)],
344
- ['ENVIRONMENT', environment],
345
- ['FABRIKA_IAM_ISSUER', iamOrigin],
346
- // A bare host: `controlPublicOrigin` accepts either spelling.
347
- ['FABRIKA_CONTROL_DOMAIN', placement.hosts.control],
348
- // The digest of the console table the proxy above publishes: Control picks its own table, MCP or not,
349
- // by it, and refuses to start under another, so the two cannot disagree at runtime.
350
- [FABRIKA_SERVICE_TABLE_DIGEST, controlTableDigest],
351
- ['OPERATIONS_ARTIFACT_ORIGIN', operationsOrigin],
352
- // The public IAM origin an APPLICATION project's proxy reaches — there is no private
353
- // network between projects (ADR-0006).
354
- ['FABRIKA_ZEROPS_PROXY_IAM_URL', iamOrigin],
355
- ]),
356
- ],
372
+ ['iam', fabrika.iam],
373
+ ['operations', fabrika.operations],
374
+ ['proxy', fabrika.proxy],
375
+ ['control', control],
357
376
  ])
358
377
  }
359
378
 
360
379
  /**
361
380
  * Write only what differs. Returns the KEYS written — never a value, not even in a debug line.
362
381
  *
363
- * Nothing here is a credential, so everything is written readable. A record an older release wrote
364
- * sensitive reads as different and is rewritten readable, which is what lets the next deploy compare it.
382
+ * Everything but `SENSITIVE_DEPLOY_KEYS` is written readable. A record an older release wrote sensitive
383
+ * reads as different and is rewritten readable, which is what lets the next deploy compare it.
365
384
  */
366
385
  const applyServiceEnv = async (
367
386
  service: ResolvedService,
@@ -372,7 +391,8 @@ const applyServiceEnv = async (
372
391
  ): Promise<string[]> => {
373
392
  const written: string[] = []
374
393
  for (const [key, value] of desired) {
375
- if (readableContent(live.get(key)) === value) {
394
+ const sensitive = SENSITIVE_DEPLOY_KEYS.has(key)
395
+ if (!sensitive && readableContent(live.get(key)) === value) {
376
396
  continue
377
397
  }
378
398
  written.push(key)
@@ -382,7 +402,7 @@ const applyServiceEnv = async (
382
402
  continue
383
403
  }
384
404
  assertRunning(collaborators.signal)
385
- await collaborators.api.putServiceEnv({ serviceId: service.id, key, value, sensitive: false, signal: collaborators.signal })
405
+ await collaborators.api.putServiceEnv({ serviceId: service.id, key, value, sensitive, signal: collaborators.signal })
386
406
  }
387
407
  return written
388
408
  }
@@ -591,6 +611,111 @@ const ensureSubdomainAccess = async (
591
611
  }
592
612
  }
593
613
 
614
+ /** What `ensureBackupPeriod` found and did. */
615
+ export type BackupPeriodOutcome =
616
+ | { readonly kind: 'unchanged' }
617
+ | { readonly kind: 'kept'; readonly current: string }
618
+ | { readonly kind: 'would-change'; readonly from: string }
619
+ | { readonly kind: 'changed'; readonly from: string }
620
+ | { readonly kind: 'unconfirmed'; readonly readBack: string }
621
+
622
+ /**
623
+ * Whether a period is one the operator did not choose: empty, or once a day (`<minute> <hour> * * *`), which is the
624
+ * shape of the platform's daily default. Anything else is kept. UNVERIFIED: how the platform spells its default.
625
+ */
626
+ const isDailyBackupPeriod = (period: string): boolean => period.trim() === '' || /^\d+ \d+ \* \* \*$/.test(period.trim())
627
+
628
+ /**
629
+ * Set a managed service's backup period, read-compare-write, replacing only a daily one.
630
+ *
631
+ * A different period is sent with the retention the service reads — its own, else the default — WHOLE, because a
632
+ * PUT on this API resets what it leaves out. The decision is the read-back, never the PUT returning.
633
+ */
634
+ export const ensureBackupPeriod = async (input: {
635
+ readonly api: Pick<ZeropsApi, 'getBackupConfig' | 'configureBackup' | 'getProcess'>
636
+ readonly sleep: Sleeper
637
+ readonly signal: AbortSignal
638
+ readonly serviceId: string
639
+ readonly hostname: string
640
+ readonly period: string
641
+ readonly dryRun: boolean
642
+ }): Promise<BackupPeriodOutcome> => {
643
+ const { api, sleep, signal, serviceId, hostname, period } = input
644
+ const current = await api.getBackupConfig({ serviceId, signal })
645
+ if (current.backupPeriod === period) {
646
+ return { kind: 'unchanged' }
647
+ }
648
+ if (!isDailyBackupPeriod(current.backupPeriod)) {
649
+ return { kind: 'kept', current: current.backupPeriod }
650
+ }
651
+ if (input.dryRun) {
652
+ return { kind: 'would-change', from: current.backupPeriod }
653
+ }
654
+ assertRunning(signal)
655
+ let process: ZeropsProcess | null
656
+ try {
657
+ process = await api.configureBackup({
658
+ serviceId,
659
+ backupPeriod: period,
660
+ retentionPolicy: current.retentionPolicy ?? current.defaultRetentionPolicy,
661
+ signal,
662
+ })
663
+ } catch (error) {
664
+ if (error instanceof ZeropsApiError && error.status >= 400 && error.status < 500) {
665
+ throw new Error(`zerops: setting the backup schedule of ${hostname} was refused (${error.code === '' ? error.status : error.code})`)
666
+ }
667
+ throw error
668
+ }
669
+ if (process !== null) {
670
+ await waitForProcess({ api, processId: process.id, sleep, signal, label: `the backup schedule change of ${hostname}` })
671
+ }
672
+ const after = await api.getBackupConfig({ serviceId, signal })
673
+ if (after.backupPeriod !== period) {
674
+ return { kind: 'unconfirmed', readBack: after.backupPeriod }
675
+ }
676
+ return { kind: 'changed', from: current.backupPeriod }
677
+ }
678
+
679
+ /** `ensureBackupPeriod` on the platform database, reported as one log line; nothing it meets fails the deploy. */
680
+ const scheduleDatabaseBackups = async (databaseId: string, dryRun: boolean, collaborators: PlatformDeployCollaborators): Promise<void> => {
681
+ const { log, signal } = collaborators
682
+ const db = PLATFORM_DATABASE_SERVICE
683
+ const schedule = `\`${PLATFORM_DATABASE_BACKUP_PERIOD}\``
684
+ let backup: BackupPeriodOutcome
685
+ try {
686
+ backup = await ensureBackupPeriod({
687
+ api: collaborators.api,
688
+ sleep: collaborators.sleep,
689
+ signal,
690
+ serviceId: databaseId,
691
+ hostname: db,
692
+ period: PLATFORM_DATABASE_BACKUP_PERIOD,
693
+ dryRun,
694
+ })
695
+ } catch (error) {
696
+ if (signal.aborted) throw error
697
+ log.warn(`${db}: the backup schedule was not set (${error instanceof Error ? error.message : 'unknown error'}); set it to ${schedule} by hand`)
698
+ return
699
+ }
700
+ switch (backup.kind) {
701
+ case 'unchanged':
702
+ log.info(`${db}: backups already run on ${schedule}`)
703
+ return
704
+ case 'kept':
705
+ log.info(`${db}: backups keep the schedule \`${backup.current}\` someone chose instead of the daily default`)
706
+ return
707
+ case 'would-change':
708
+ log.info(`${db}: backups would run on ${schedule}, were \`${backup.from}\``)
709
+ return
710
+ case 'changed':
711
+ log.info(`${db}: backups now run on ${schedule}, were \`${backup.from}\``)
712
+ return
713
+ case 'unconfirmed':
714
+ log.warn(`${db}: backups were set to ${schedule} but read back \`${backup.readBack}\`; check the schedule by hand`)
715
+ return
716
+ }
717
+ }
718
+
594
719
  /**
595
720
  * The installation's environment name, refused when a public origin contradicts it.
596
721
  *
@@ -637,7 +762,7 @@ export const deployPreparedPlatform = async (
637
762
 
638
763
  log.step('Resolve the installation')
639
764
  const project = await resolveProject(input, collaborators)
640
- const services = await resolveServices(project.id, collaborators)
765
+ const { services, databaseId, hasOperationsDatabase } = await resolveServices(project.id, collaborators)
641
766
  log.info(`project ${project.name} (${project.id}) — ${PLATFORM_DEPLOY_ORDER.join(', ')}`)
642
767
 
643
768
  const proxy = serviceOf(services, PLATFORM_PROXY_SERVICE)
@@ -670,15 +795,23 @@ export const deployPreparedPlatform = async (
670
795
 
671
796
  log.step('Write the service environment')
672
797
  const controlTableDigest = platformServiceTableDigest(platformManifest, platformProxyAppFor(PLATFORM_PROXY_MANIFEST_TEMPLATE, 'control').id)
673
- const desired = desiredServiceEnv(environment, placement, proxySource, oauthConfig, controlTableDigest)
798
+ const desired = desiredServiceEnv(
799
+ environment,
800
+ project.id,
801
+ placement,
802
+ proxySource,
803
+ oauthConfig,
804
+ controlTableDigest,
805
+ input.iamAdminKey,
806
+ )
674
807
  for (const hostname of PLATFORM_DEPLOY_ORDER) {
675
808
  const service = serviceOf(services, hostname)
676
809
  const live = hostname === PLATFORM_PROXY_SERVICE ? proxyEnv : await readServiceEnv(service.id, collaborators)
677
810
  const keys = desired.get(hostname) ?? new Map<string, string>()
678
- const liveOAuthConfig = live.get(IAM_OAUTH_CONFIG)
811
+ const liveOAuthConfig = live.get(IAM_OAUTH_CONFIG_VARIABLE)
679
812
  const oauthStillSet = liveOAuthConfig !== undefined && (liveOAuthConfig.sensitive || liveOAuthConfig.content.trim() !== '')
680
813
  if (hostname === 'iam' && oauthConfig === undefined && oauthStillSet) {
681
- log.warn(`iam: MCP authoring is off but ${IAM_OAUTH_CONFIG} is still set; IAM keeps issuing MCP tokens until it is removed`)
814
+ log.warn(`iam: MCP authoring is off but ${IAM_OAUTH_CONFIG_VARIABLE} is still set; IAM keeps issuing MCP tokens until it is removed`)
682
815
  }
683
816
  const written = await applyServiceEnv(service, keys, live, input, collaborators)
684
817
  log.info(
@@ -746,5 +879,17 @@ export const deployPreparedPlatform = async (
746
879
  log.info(`\`${IAM_APP_ID}\` reconciled`)
747
880
  }
748
881
 
882
+ // Last, and a warning rather than a failure: the backup API's behaviour is not yet measured on a live account
883
+ // (docs/reference/zerops-platform.md), and every service is already deployed, published and registered.
884
+ log.step('Schedule the platform database backups')
885
+ if (hasOperationsDatabase) {
886
+ await scheduleDatabaseBackups(databaseId, input.dryRun, collaborators)
887
+ } else {
888
+ log.warn(
889
+ `${PLATFORM_DATABASE_SERVICE}: backups keep their schedule — Operations shares this database on an installation `
890
+ + `created before \`${OPERATIONS_DATABASE_SERVICE}\`, and hourly dumps would copy its whole error history`,
891
+ )
892
+ }
893
+
749
894
  log.step(input.dryRun ? 'Dry run complete — nothing was written' : 'Installation deployed')
750
895
  }
@@ -30,6 +30,9 @@ export const PLATFORM_CONSOLE_APP_SCHEMA: AppSchema = {
30
30
  { action: 'mail.read', description: 'List relayed mail and read its metadata' },
31
31
  { action: 'mail.content.read', description: 'Read the recipient, subject and bodies of relayed mail' },
32
32
  { action: 'mail.manage', description: 'Release or drop held mail and manage the mail rules' },
33
+ { action: 'data.read', description: 'Read the sizes, counts, state and backup list of environment data' },
34
+ { action: 'data.content.read', description: 'Read the row contents, exports and backups of every tenant (global grant only)' },
35
+ { action: 'data.manage', description: 'Run maintenance, backups and restores, and recover an interrupted operation' },
33
36
  { action: 'operations.read', description: 'Read Operations errors and activity' },
34
37
  { action: 'operations.triage', description: 'Triage Operations errors' },
35
38
  { action: 'operations.manage', description: 'Manage Operations sources, alerts, and retention' },
@@ -16,7 +16,7 @@
16
16
  // `table` and registers the resource only on an installation that enables MCP authoring, and its
17
17
  // `tableWithoutOAuth` otherwise.
18
18
 
19
- import type { PlatformProxyManifestTemplate } from '../proxy-manifest'
19
+ import type { PlatformProxyManifestTemplate } from '@fabrika/installation-init'
20
20
 
21
21
  export const PLATFORM_PROXY_MANIFEST_TEMPLATE: PlatformProxyManifestTemplate = {
22
22
  apps: [
@@ -258,6 +258,16 @@ export const PLATFORM_PROXY_MANIFEST_TEMPLATE: PlatformProxyManifestTemplate = {
258
258
  admission: null,
259
259
  oauth: null,
260
260
  },
261
+ {
262
+ id: 'dev-source',
263
+ kind: 'protocol',
264
+ method: 'POST',
265
+ path: '/platform/source/*',
266
+ callers: [],
267
+ requirement: null,
268
+ admission: null,
269
+ oauth: null,
270
+ },
261
271
  {
262
272
  id: 'webhooks-root',
263
273
  kind: 'service',
@@ -453,6 +463,16 @@ export const PLATFORM_PROXY_MANIFEST_TEMPLATE: PlatformProxyManifestTemplate = {
453
463
  admission: null,
454
464
  oauth: null,
455
465
  },
466
+ {
467
+ id: 'dev-source',
468
+ kind: 'protocol',
469
+ method: 'POST',
470
+ path: '/platform/source/*',
471
+ callers: [],
472
+ requirement: null,
473
+ admission: null,
474
+ oauth: null,
475
+ },
456
476
  {
457
477
  id: 'webhooks-root',
458
478
  kind: 'service',
@@ -44,16 +44,41 @@ cores and 48 GB per container, and costs more while it does.
44
44
  **1. Platform tier** (`platform install --tier`, step 1). Cannot be changed later: a project's core package
45
45
  is upgrade-only, and the data services differ.
46
46
 
47
- | Tier | What runs | Idle floor | Fails when |
48
- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------- |
49
- | `light` (default) | Lightweight core. One single-node PostgreSQL (`oltp-hobby`) and one bucket shared by IAM, control and Operations; one container each. | about $6 | any one container or the database is down: sign-in stops |
50
- | `standard` | Serious core. HA PostgreSQL (3 nodes, `oltp-production`, dedicated CPU) for IAM and control; a second HA PostgreSQL and bucket for Operations; two containers of IAM and the proxy. | about $100 | survives the loss of one node of each |
47
+ | Tier | What runs | Idle floor | Fails when |
48
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------ |
49
+ | `light` (default) | Lightweight core. Two single-node PostgreSQL (`oltp-hobby`): `db` for IAM and control, `operationsdb` for Operations; one shared bucket; one container each. | about $7.50 | any one container or `db` is down: sign-in stops |
50
+ | `standard` | Serious core. HA PostgreSQL (3 nodes, `oltp-production`, dedicated CPU) for IAM and control; a second HA PostgreSQL and bucket for Operations; two containers of IAM and the proxy. | about $100 | survives the loss of one node of each |
51
51
 
52
52
  Most of `standard`'s price is the identity database's dedicated floor (about $72). `light` is right for an
53
53
  evaluation and a small fleet that tolerates an outage; `standard` for sign-in that must stay up.
54
54
 
55
+ **What a lost node costs.** Zerops has no point-in-time recovery. Its backups are logical dumps of a whole
56
+ database service, daily between 00:00 and 01:00 UTC unless the service sets another schedule, and a restore is
57
+ manual (download, then `pg_restore`). A single-node PostgreSQL whose node fails loses everything written since
58
+ its last backup and stays down until someone restores it. An HA PostgreSQL (three nodes) keeps both its data and
59
+ its service. Zerops documents that a deploy starts the new container before it stops the old one, so a deploy
60
+ should not take a one-container runtime down; neither that nor how fast Zerops replaces a container lost with its
61
+ node is measured here. `platform install` and every `platform deploy` set the platform database `db` (users,
62
+ grants, deploy records) to back up hourly, unless someone chose another schedule; a light installation created
63
+ before `operationsdb` existed keeps its schedule. Hourly backups may shorten how far back the restore points
64
+ reach: if Zerops tags each one `daily`, the retention's daily count counts hours. Check `db`'s retention in the GUI. Operations' own `operationsdb`
65
+ keeps the daily default: its error history does not need that schedule.
66
+
67
+ **Presets.** The choices below combine into three shapes. Prices are the idle floor for three applications in
68
+ one production namespace; an application with a database runs at a 0.5 GB floor, about $2.10 per container.
69
+
70
+ | Preset | Platform tier | Production namespace | Idle floor | A lost node costs |
71
+ | ---------- | ------------- | --------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------- |
72
+ | evaluation | `light` | `cheap`, single-node PostgreSQL, one container each | about $16 | the data written since that database's last backup, and an outage |
73
+ | data | `light` | `cheap` with `--postgres-type=postgresql:ha@18 --postgres-profile=oltp-staging`, one container each | about $26 | applications' data survives; platform data since its last backup |
74
+ | stable | `standard` | `cheap` HA as above, `--core-package=SERIOUS`, two containers of each application | $130+ | a short failover; the namespace proxy has one container |
75
+
76
+ A staging namespace (`cheap`, single-node) adds about $9 to any of them.
77
+
55
78
  **2. Namespace preset** (`control namespaces create --preset`, step 5). A namespace is the Zerops project
56
- applications run in. Each one adds its own proxy, about $1.08.
79
+ applications run in. Each one adds its own proxy, about $1.08. A `cheap` namespace's shared PostgreSQL is
80
+ single-node unless `--postgres-type=postgresql:ha@18` asks for HA (about $11 at `--postgres-profile=oltp-staging`; the two flags go together);
81
+ the type is fixed once the namespace exists, the profile is not.
57
82
 
58
83
  | Preset | Where an application's database lives | Use with |
59
84
  | --------------- | ----------------------------------------------------------------------- | ------------------------------------ |
@@ -62,8 +87,8 @@ applications run in. Each one adds its own proxy, about $1.08.
62
87
  | `full` | The namespace belongs to ONE application (`--exclusive-app=<id>`) | an application that must be isolated |
63
88
 
64
89
  **3. The application's database** (the application's `fabrika.config.ts`, before step 5). The example
65
- declares `postgresql:single@18` at `oltp-hobby`, about $1.45, plus its runtime from about $1.08 per
66
- container. `postgresql:ha@18` survives a node failure: about $11 at `oltp-staging` (shared CPU), about $72 at
90
+ declares `postgresql:single@18` at `oltp-hobby`, about $1.45, plus its runtime at about $2.10 per
91
+ container (a deploy raises the floor of an application with a database to 0.5 GB). `postgresql:ha@18` survives a node failure: about $11 at `oltp-staging` (shared CPU), about $72 at
67
92
  `oltp-production` (dedicated). Single to HA later means a new service and a data migration.
68
93
 
69
94
  **4. Namespace core package** (`--core-package=LIGHT|SERIOUS`, step 5). `LIGHT` is free and the default on
@@ -127,10 +152,9 @@ fabrika platform install --provider=zerops --create-project --client-id=<clientI
127
152
  over the same project would destroy the vault key. Start over in a new empty project.
128
153
  - The account must be in the default region, or set `FABRIKA_ZEROPS_API_URL` for every command.
129
154
  - Mail is optional. Without `--email-provider` the installation sends no mail. With one, add
130
- `--email-provider=<resend|smartemailing> --email-from=<address>`, optionally `--email-from-name=<name>`, and
131
- export the provider's variables (`FABRIKA_EMAIL_RESEND_API_KEY`, or `FABRIKA_EMAIL_SMARTEMAILING_USERNAME`,
132
- `FABRIKA_EMAIL_SMARTEMAILING_API_KEY` and optionally `FABRIKA_EMAIL_SMARTEMAILING_TAG`) from their own `0600`
133
- files. They have no flag. The install writes the set on `iam` and `control`, the credentials sensitive. They
155
+ `--email-provider=<provider> --email-from=<address>`, optionally `--email-from-name=<name>`, and export the
156
+ provider's variables from their own `0600` files. `fabrika platform install --provider=zerops --help` lists the
157
+ providers and their variables, each named after its provider (for Resend `FABRIKA_EMAIL_RESEND_API_KEY`). They have no flag. The install writes the set on `iam` and `control`, the credentials sensitive. They
134
158
  are the human's own, so they are not copied into the secrets file.
135
159
 
136
160
  **Check.** The log ends with the `Hand off` step: where the credentials are, `The console answers at
@@ -352,7 +376,10 @@ fabrika control namespaces update --namespace=<id> --proxy-source=current
352
376
  ```
353
377
 
354
378
  It re-pins the namespace and queues the reconcile that rebuilds its proxy; poll `namespaces get` until it is
355
- `ready`. A namespace pinned to a release before the proxy manifest moved into the build archive carries it in a
379
+ `ready`. Re-pin every namespace after each upgrade. Until then, Control refuses a deploy into it that uses a form
380
+ newer than 0.6 (a public procedure, a file output, a capabilities requirement, a forwarded webhook `Authorization`),
381
+ also when its proxy reads the form, and a proxy older than the replacement of the caller's `Authorization` keeps
382
+ forwarding a caller's `px_` key to the application (`docs/reference/deployment-namespaces.md`). A namespace pinned to a release before the proxy manifest moved into the build archive carries it in a
356
383
  service variable, which Zerops caps at about 32 KB: when a deploy fails with `serviceEnvValueTooLarge`, this is
357
384
  the fix, and it also accepts a namespace in `failed`.
358
385
 
@@ -371,12 +398,15 @@ none, and `install` cannot be re-run.
371
398
  2. Restart `iam` and `control`. Each reads the transport at boot. IAM refuses to start on an invalid one.
372
399
  Control starts, logs a line that names the invalid variables, and refuses every deploy that needs the transport
373
400
  until you fix them and restart it.
374
- 3. Redeploy every environment whose contract uses `installation` mail. An application gets the new transport
375
- only at its next deploy. Until then it sends with the old one, and a revoked key fails its mail with
376
- `provider_unauthorized`.
377
-
378
- Without a transport, a contract whose job declares `mailer: 'installation'` still deploys. Its jobs see that mail is
379
- not available and leave their messages queued. After step 3 the next run sends them.
401
+ 3. Redeploy every environment whose contract uses `installation` mail and whose application pins a framework release
402
+ older than 0.7. Such an application holds its own copy of the transport and gets the new one only at its next
403
+ deploy. Until then it sends with the old one, and a revoked key fails its mail with `provider_unauthorized`. An
404
+ application on 0.7 or newer sends through Control's relay, which uses the new transport once step 2 restarted
405
+ Control: it needs no redeploy.
406
+
407
+ Without a transport, a contract whose job declares `mailer: 'installation'` still deploys. On a release older than 0.7
408
+ its jobs see that mail is not available and leave their messages queued, and after step 3 the next run sends them. On
409
+ 0.7 or newer the relay accepts and holds the messages, and Control sends them once it has a transport.
380
410
 
381
411
  ## When something fails
382
412
 
@@ -24,10 +24,7 @@
24
24
  // never been deployed publishes none, so the variable names nothing and this refuses rather than
25
25
  // guessing at a hostname.
26
26
 
27
- import type { PlatformProxyService } from './proxy-manifest'
28
-
29
- /** The public hostname of each platform service, without a scheme and without a port. */
30
- export type PlatformHosts = Readonly<Record<PlatformProxyService, string>>
27
+ import type { PlatformHosts, PlatformProxyService } from '@fabrika/installation-init'
31
28
 
32
29
  /** Everything the deploy needs to know about where this installation answers. */
33
30
  export interface PlatformPlacement {
@@ -112,6 +109,3 @@ export const derivePlatformHosts = (subdomains: string, listeners: readonly Plat
112
109
  }
113
110
  return { iam, control, operations }
114
111
  }
115
-
116
- /** `https://<host>` — the origin a browser, a token's `iss` and a return-origin registry all use. */
117
- export const platformOrigin = (scheme: 'http' | 'https', host: string): string => `${scheme}://${host}`