@fabrika/provider-zerops 0.8.2 → 0.9.0-cloudflare.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 (128) hide show
  1. package/package.json +16 -14
  2. package/src/api/client.ts +6 -0
  3. package/src/api/types.ts +12 -0
  4. package/src/cli.ts +11 -0
  5. package/src/collaborators.ts +0 -14
  6. package/src/control.ts +33 -57
  7. package/src/execution.ts +113 -102
  8. package/src/installation/admin.ts +1 -1
  9. package/src/installation/deploy-options.ts +1 -2
  10. package/src/installation/deploy.ts +41 -75
  11. package/src/installation/generated/platform-console-schema.ts +3 -0
  12. package/src/installation/generated/platform-proxy-manifest.ts +21 -1
  13. package/src/installation/guide.md +9 -6
  14. package/src/installation/hosts.ts +1 -7
  15. package/src/installation/index.ts +0 -13
  16. package/src/installation/init-options.ts +2 -1
  17. package/src/installation/install-environment.ts +42 -106
  18. package/src/installation/install-options.ts +3 -1
  19. package/src/installation/install-secrets.ts +24 -68
  20. package/src/installation/install.ts +8 -4
  21. package/src/installation/sidecar.ts +5 -26
  22. package/src/installation/topology/setups.ts +5 -0
  23. package/src/installation/upgrade-options.ts +2 -2
  24. package/src/managed-env.ts +1 -174
  25. package/src/manifest.ts +11 -14
  26. package/src/namespace/claims.ts +56 -1
  27. package/src/namespace/errors.ts +1 -1
  28. package/src/namespace/manifest.ts +6 -19
  29. package/src/namespace/provisioning.ts +15 -3
  30. package/src/namespace/proxy-attempt.ts +43 -2
  31. package/src/namespace/proxy-reads.ts +12 -22
  32. package/src/namespace-cli.ts +152 -0
  33. package/src/plan.ts +2 -7
  34. package/src/provider.ts +128 -126
  35. package/src/proxy-manifest-carrier.ts +18 -0
  36. package/src/types.ts +0 -2
  37. package/types/src/api/client.d.ts +5 -0
  38. package/types/src/api/errors.d.ts +24 -0
  39. package/types/src/api/polling.d.ts +41 -0
  40. package/types/src/api/readers.d.ts +1115 -0
  41. package/types/src/api/service-env-limit.d.ts +17 -0
  42. package/types/src/api/service-env-response.d.ts +23 -0
  43. package/types/src/api/types.d.ts +687 -0
  44. package/types/src/api.d.ts +6 -0
  45. package/types/src/authoring.d.ts +9 -0
  46. package/types/src/cli-args.d.ts +19 -0
  47. package/types/src/cli.d.ts +13 -0
  48. package/types/src/collaborators.d.ts +16 -0
  49. package/types/src/compile.d.ts +91 -0
  50. package/types/src/control.d.ts +40 -0
  51. package/types/src/database-budget.d.ts +38 -0
  52. package/types/src/descriptor.d.ts +28 -0
  53. package/types/src/execution.d.ts +125 -0
  54. package/types/src/exposure.d.ts +47 -0
  55. package/types/src/index.d.ts +31 -0
  56. package/types/src/installation/admin-options.d.ts +24 -0
  57. package/types/src/installation/admin.d.ts +35 -0
  58. package/types/src/installation/checker-release.d.ts +12 -0
  59. package/types/src/installation/deploy-options.d.ts +37 -0
  60. package/types/src/installation/deploy.d.ts +136 -0
  61. package/types/src/installation/generated/platform-console-schema.d.ts +2 -0
  62. package/types/src/installation/generated/platform-proxy-manifest.d.ts +2 -0
  63. package/types/src/installation/hosts.d.ts +34 -0
  64. package/types/src/installation/index.d.ts +7 -0
  65. package/types/src/installation/init-options.d.ts +38 -0
  66. package/types/src/installation/init.d.ts +60 -0
  67. package/types/src/installation/install-collaborators.d.ts +24 -0
  68. package/types/src/installation/install-environment.d.ts +63 -0
  69. package/types/src/installation/install-options.d.ts +49 -0
  70. package/types/src/installation/install-project.d.ts +13 -0
  71. package/types/src/installation/install-provisioning.d.ts +58 -0
  72. package/types/src/installation/install-secrets.d.ts +17 -0
  73. package/types/src/installation/install-token.d.ts +10 -0
  74. package/types/src/installation/install.d.ts +23 -0
  75. package/types/src/installation/installation-source.d.ts +8 -0
  76. package/types/src/installation/log.d.ts +44 -0
  77. package/types/src/installation/machine-key.d.ts +19 -0
  78. package/types/src/installation/placement-options.d.ts +11 -0
  79. package/types/src/installation/platform-archive.d.ts +7 -0
  80. package/types/src/installation/platform-source.d.ts +8 -0
  81. package/types/src/installation/platform-upload.d.ts +3 -0
  82. package/types/src/installation/sidecar.d.ts +49 -0
  83. package/types/src/installation/source-options.d.ts +1 -0
  84. package/types/src/installation/topology/artifacts.d.ts +8 -0
  85. package/types/src/installation/topology/index.d.ts +5 -0
  86. package/types/src/installation/topology/installation-schemas.d.ts +2242 -0
  87. package/types/src/installation/topology/invariants.d.ts +23 -0
  88. package/types/src/installation/topology/setups.d.ts +59 -0
  89. package/types/src/installation/topology/topology.d.ts +153 -0
  90. package/types/src/installation/topology/validate.d.ts +11 -0
  91. package/types/src/installation/upgrade-options.d.ts +26 -0
  92. package/types/src/installation/upgrade-output.d.ts +6 -0
  93. package/types/src/installation/upgrade.d.ts +88 -0
  94. package/types/src/managed-env.d.ts +24 -0
  95. package/types/src/manifest.d.ts +204 -0
  96. package/types/src/namespace/claims.d.ts +16 -0
  97. package/types/src/namespace/codec.d.ts +334 -0
  98. package/types/src/namespace/constants.d.ts +25 -0
  99. package/types/src/namespace/errors.d.ts +15 -0
  100. package/types/src/namespace/manifest.d.ts +4 -0
  101. package/types/src/namespace/operator.d.ts +12 -0
  102. package/types/src/namespace/provisioning.d.ts +4 -0
  103. package/types/src/namespace/proxy-attempt.d.ts +39 -0
  104. package/types/src/namespace/proxy-reads.d.ts +22 -0
  105. package/types/src/namespace/proxy-secrets.d.ts +8 -0
  106. package/types/src/namespace/proxy-source.d.ts +74 -0
  107. package/types/src/namespace/topology.d.ts +9 -0
  108. package/types/src/namespace/types.d.ts +101 -0
  109. package/types/src/namespace/update.d.ts +6 -0
  110. package/types/src/namespace/validation.d.ts +12 -0
  111. package/types/src/namespace-cli.d.ts +2 -0
  112. package/types/src/namespace.d.ts +9 -0
  113. package/types/src/plan.d.ts +11 -0
  114. package/types/src/provider.d.ts +63 -0
  115. package/types/src/proxy-manifest-carrier.d.ts +24 -0
  116. package/types/src/schema.generated.d.ts +376 -0
  117. package/types/src/service-names.d.ts +4 -0
  118. package/types/src/service-references.d.ts +13 -0
  119. package/types/src/source/constants.d.ts +6 -0
  120. package/types/src/source/port.d.ts +60 -0
  121. package/types/src/source/types.d.ts +4 -0
  122. package/types/src/source/validation.d.ts +19 -0
  123. package/types/src/source.d.ts +2 -0
  124. package/types/src/types.d.ts +119 -0
  125. package/types/src/version-attempt.d.ts +8 -0
  126. package/src/installation/mcp.ts +0 -102
  127. package/src/installation/proxy-manifest.ts +0 -214
  128. package/src/installation/secrets.ts +0 -78
package/src/execution.ts CHANGED
@@ -2,15 +2,20 @@
2
2
  // runs. Of the authored setup only `build.base`, `build.os` and the SPA's `build.buildCommands` survive; the rest of
3
3
  // the build, the deploy gate and the whole `run` section are generated here. PURE — no network, no filesystem.
4
4
 
5
+ import { type ContractRecipe, contractRecipe } from '@fabrika/app/build'
5
6
  import {
6
7
  type ContractArtifact,
8
+ DEV_LAUNCHER_BOOTED_FILE,
9
+ DEV_LAUNCHER_CONFIG_FILE,
10
+ DEV_LAUNCHER_MODULE,
11
+ type DevLauncherConfig,
7
12
  FABRIKA_CONTRACT_DIGEST,
8
13
  FABRIKA_SOURCE_MAP_UPLOAD,
9
14
  FABRIKA_SOURCE_MAP_UPLOAD_KEY,
10
15
  type FrameworkVariableName,
11
16
  } from '@fabrika/auth-core'
12
17
  import type { OperationsAssetRoot, OperationsSourceMapBuildUpload } from '@fabrika/operations-contract/releases'
13
- import { FRAMEWORK_RELEASE_PATTERN, type JsonValue } from '@fabrika/provider-contract'
18
+ import type { JsonValue } from '@fabrika/provider-contract'
14
19
  import packageManifest from '../package.json'
15
20
  import { renderYaml } from './compile'
16
21
  import { parseZeropsDescriptor, type PreparedZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
@@ -29,8 +34,7 @@ export const ZEROPS_APPLICATION_RUNTIME = 'alpine/bun@1.3'
29
34
  */
30
35
  export const FABRIKA_CHECK_VERSION: string = packageManifest.version
31
36
 
32
- /** The compiler the platform type-checks with. It is fetched by version, never taken from the application's lockfile. */
33
- export const PLATFORM_TYPESCRIPT_VERSION = '5.9.3'
37
+ export { PLATFORM_TYPESCRIPT_VERSION } from '@fabrika/app/build'
34
38
 
35
39
  /** System packages an application may add to its runtime image, installed by the generated `run.prepareCommands`. */
36
40
  export const ZEROPS_RUNTIME_PACKAGES = ['ffmpeg', 'ghostscript', 'imagemagick', 'poppler-utils', 'vips'] as const
@@ -42,12 +46,6 @@ export interface ZeropsContractJob {
42
46
  readonly schedule: string
43
47
  }
44
48
 
45
- /** The framework's file scan job (`@fabrika/app`'s `FILE_SCAN_JOB`), run every minute when a collection scans. */
46
- export const ZEROPS_FILE_SCAN_JOB = 'fabrika-file-scan'
47
- /** The framework's file sweep job (`@fabrika/app`'s `FILE_SWEEP_JOB` and its schedule), run when the contract has files. */
48
- export const ZEROPS_FILE_SWEEP_JOB = 'fabrika-file-sweep'
49
- const FILE_SWEEP_SCHEDULE = '41 * * * *'
50
-
51
49
  /**
52
50
  * The RAM in GB a container of a scanning application starts with: one `clamscan` over a batch peaks at about 1 GB
53
51
  * resident while it loads its signatures (measured with ClamAV 1.4), beside the application's own process.
@@ -107,13 +105,26 @@ export interface ZeropsLockedExecution {
107
105
  readonly runtimePackages: readonly ZeropsRuntimePackage[]
108
106
  /** Where the build uploads its source maps, without the bearer; null when Control minted no upload for the run. */
109
107
  readonly sourceMaps: OperationsSourceMapBuildUpload | null
108
+ /**
109
+ * Present for an environment of kind `dev` (ADR-0049): `run.start` runs the dev launcher instead of the serving
110
+ * entry. The environment's kind selects it; an application never authors it.
111
+ */
112
+ readonly dev?: ZeropsDevExecution
113
+ }
114
+
115
+ export interface ZeropsDevExecution {
116
+ /** The commit the bootstrap run builds: what a new container holds before the launcher's first sync. */
117
+ readonly head: string
110
118
  }
111
119
 
112
120
  /** The application ids the generated scripts may name: the contract's own id rule, so a script needs no quoting. */
113
- export const LOCKED_APP_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/
121
+ export { CONTRACT_APP_ID_PATTERN as LOCKED_APP_ID_PATTERN } from '@fabrika/app/build'
114
122
 
115
123
  export const CONTRACT_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/
116
124
 
125
+ /** A literal path of unreserved characters: it is written into the generated probes unquoted by any shell. */
126
+ export const READINESS_PATH_PATTERN = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/
127
+
117
128
  const byName = <T extends { readonly name: string }>(left: T, right: T): number => left.name < right.name ? -1 : left.name > right.name ? 1 : 0
118
129
 
119
130
  /**
@@ -241,31 +252,6 @@ export function lockedAuthoredBuild(entry: { readonly [key: string]: JsonValue }
241
252
 
242
253
  const SOURCE_ROOT = '/build/source'
243
254
  const RUNTIME_ROOT = '/var/www'
244
- const FABRIKA_BIN = './node_modules/.bin/fabrika'
245
- const MIGRATE_SCRIPT = 'fabrika/migrate.generated.sh'
246
- const VERIFY_SCRIPT = 'fabrika/verify.generated.sh'
247
- const START_SCRIPT = 'fabrika/start.generated.sh'
248
- const PLATFORM_TSCONFIG = 'fabrika/tsconfig.platform.json'
249
-
250
- /** The compiler options of the platform's type check; the application's own `tsconfig.json` and its `paths` are not read. */
251
- const PLATFORM_TSCONFIG_CONTENTS = JSON.stringify({
252
- compilerOptions: {
253
- target: 'ES2022',
254
- module: 'ESNext',
255
- moduleResolution: 'bundler',
256
- lib: ['ES2022', 'DOM', 'DOM.Iterable'],
257
- types: ['bun'],
258
- strict: true,
259
- noUncheckedIndexedAccess: true,
260
- noImplicitOverride: true,
261
- forceConsistentCasingInFileNames: true,
262
- skipLibCheck: true,
263
- resolveJsonModule: true,
264
- isolatedModules: true,
265
- noEmit: true,
266
- },
267
- include: ['../fabrika.contract.ts', '../src', 'entry.generated.ts'],
268
- })
269
255
 
270
256
  /** One `printf` writing `lines` to `path`. Each line is single-quoted, so it may hold neither `'` nor an expansion. */
271
257
  const writeFileCommand = (path: string, lines: readonly string[]): string => {
@@ -275,39 +261,6 @@ const writeFileCommand = (path: string, lines: readonly string[]): string => {
275
261
  return `printf '%s\\n' ${lines.map((line) => `'${line}'`).join(' ')} > ${path}`
276
262
  }
277
263
 
278
- const migrateScript = (appId: string): readonly string[] => [
279
- '#!/bin/sh',
280
- 'set -eu',
281
- `cd ${RUNTIME_ROOT}`,
282
- `FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db migrate`,
283
- ]
284
-
285
- // The committed schema types must match the migrated schema and every generated isolation test must pass on a throwaway
286
- // schema. The build has no database, so they run at container start, after the migrations, as init commands: a failed
287
- // init command stops the activation, and the readiness window opens only with `run.start`, after the init commands (both
288
- // verified live), so a run as long as the CRM's ~3 minutes of generated cases does not race it.
289
- const verifyScript = (appId: string): readonly string[] => [
290
- '#!/bin/sh',
291
- 'set -eu',
292
- `cd ${RUNTIME_ROOT}`,
293
- `FABRIKA_APP_ID=${appId} ${FABRIKA_BIN} db codegen --verify`,
294
- `FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db test-isolation`,
295
- ]
296
-
297
- const startScript: readonly string[] = ['#!/bin/sh', 'set -eu', `cd ${RUNTIME_ROOT}`, 'exec bun fabrika/entry.generated.ts']
298
-
299
- /** The scripts the build writes and every container's init commands run, in this order. */
300
- const initScripts = (execution: ZeropsLockedExecution): readonly { readonly path: string; readonly lines: readonly string[] }[] =>
301
- execution.databaseUrl === null
302
- ? []
303
- : [{ path: MIGRATE_SCRIPT, lines: migrateScript(execution.appId) }, { path: VERIFY_SCRIPT, lines: verifyScript(execution.appId) }]
304
-
305
- const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
306
-
307
- // After every check: a map is uploaded only for a build that can deploy. A failed upload never fails the build; the
308
- // provider reads the state from the report line.
309
- const sourceMapUpload = (release: string): string => `cd /tmp && bun x --bun -p @fabrika/check@${release} fabrika-source-maps ${SOURCE_ROOT} || true`
310
-
311
264
  /**
312
265
  * The upload's build variables: the destination and asset roots as a literal, and the bearer lifted from the service
313
266
  * variable the deploy wrote sensitive, so the descriptor never holds it.
@@ -319,26 +272,81 @@ const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record
319
272
  return { [FABRIKA_SOURCE_MAP_UPLOAD]: value, [FABRIKA_SOURCE_MAP_UPLOAD_KEY]: `\${RUNTIME_${FABRIKA_SOURCE_MAP_UPLOAD_KEY}}` }
320
273
  }
321
274
 
322
- /**
323
- * The generated build commands, in order; the authored SPA commands run before all of them. The application's tests
324
- * run first: they are authored code, so everything after them is reinstalled, regenerated or checked again. They run
325
- * `--isolate`, as the application skill runs them: a test file's DOM globals otherwise leak into the next file.
326
- */
327
- export function generatedBuildCommands(execution: ZeropsLockedExecution): readonly string[] {
328
- if (!LOCKED_APP_ID_PATTERN.test(execution.appId)) throw new Error('zerops: a contract application id must match [a-z0-9][a-z0-9-]*')
329
- if (!FRAMEWORK_RELEASE_PATTERN.test(execution.checkRelease)) throw new Error('zerops: the checker release must be a release version')
330
- return [
331
- INSTALL,
332
- 'bun test --isolate --pass-with-no-tests',
333
- `rm -rf node_modules && ${INSTALL}`,
334
- `bun -e "await import('@fabrika/app/build').then((build) => build.writeGeneratedEntry('.', 'bun'))"`,
335
- `cd /tmp && bun x --bun -p @fabrika/check@${execution.checkRelease} fabrika-check ${SOURCE_ROOT}`,
336
- writeFileCommand(PLATFORM_TSCONFIG, [PLATFORM_TSCONFIG_CONTENTS]),
337
- `cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${SOURCE_ROOT}/${PLATFORM_TSCONFIG}`,
338
- ...initScripts(execution).map((script) => writeFileCommand(script.path, script.lines)),
339
- writeFileCommand(START_SCRIPT, startScript),
340
- ...(execution.sourceMaps === null ? [] : [sourceMapUpload(execution.checkRelease)]),
341
- ]
275
+ // The readiness check is retried no sooner than every 10 s (the platform's floor), and a server is rarely listening at
276
+ // the first attempt, one second after `run.start`. So one attempt waits for the health endpoint itself, for less than
277
+ // the retry period; it passes only on a 2xx, like the `httpGet` it replaces. A dev container also passes once its launcher
278
+ // reported the boot, served or failed: the launcher keeps running and reports a failed migration with its remedy.
279
+ const readinessCommand = (path: string, dev: boolean): string => {
280
+ if (!READINESS_PATH_PATTERN.test(path)) throw new Error('zerops: a readiness path must be a literal path of unreserved characters')
281
+ const url = `http://127.0.0.1:${ZEROPS_APPLICATION_PORT}${path}`
282
+ const booted = dev ? `if (await Bun.file('${RUNTIME_ROOT}/${DEV_LAUNCHER_BOOTED_FILE}').exists()) process.exit(0); ` : ''
283
+ return `bun -e "const end = Date.now() + 9000; while (Date.now() < end) { ${booted}try { if ((await fetch('${url}', { signal: AbortSignal.timeout(2000) })).ok) process.exit(0) } catch {} await Bun.sleep(200) } process.exit(1)"`
284
+ }
285
+
286
+ /** `@fabrika/app/build`'s recipe at this installation's paths; the dev launcher checks in the runtime root. */
287
+ const recipeOf = (execution: ZeropsLockedExecution, sourceRoot: string = SOURCE_ROOT): ContractRecipe =>
288
+ contractRecipe({
289
+ appId: execution.appId,
290
+ checkRelease: execution.checkRelease,
291
+ sourceRoot,
292
+ runtimeRoot: RUNTIME_ROOT,
293
+ // The build has no database; a container's init commands run the migrations and schema checks, and the readiness
294
+ // window opens only with `run.start`, after them (both verified live), so the CRM's ~3 minutes does not race it.
295
+ // A dev container has none: its init would run before Control says whether a reset is pending, over the schema the
296
+ // previous container left, so its launcher migrates and verifies after that answer.
297
+ database: execution.databaseUrl !== null && execution.dev === undefined,
298
+ sourceMaps: execution.sourceMaps !== null,
299
+ jobs: execution.contract.jobs,
300
+ files: execution.contract.files === true,
301
+ scan: execution.contract.scan === true,
302
+ })
303
+
304
+ // A dev environment starts the launcher of the application's own framework release, which runs the serving entry as its
305
+ // child. Double quotes inside: a generated file line may hold no single quote.
306
+ const devStartScript: readonly string[] = [
307
+ '#!/bin/sh',
308
+ 'set -eu',
309
+ `cd ${RUNTIME_ROOT}`,
310
+ `exec bun -e "await import(\\"${DEV_LAUNCHER_MODULE}\\").then((dev) => dev.runDevLauncher(process.env))"`,
311
+ ]
312
+
313
+ /** One command writing `content` to `path` through base64: the content may hold quotes and expansions, the command none. */
314
+ const writeBase64Command = (path: string, content: string): string => {
315
+ const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(content)))
316
+ return `printf '%s' '${encoded}' | base64 -d > ${path}`
317
+ }
318
+
319
+ /** What the dev launcher reads (`DEV_LAUNCHER_CONFIG_FILE`): the platform's, written after the checks. */
320
+ export function devLauncherConfig(execution: ZeropsLockedExecution, authoredSpa: readonly string[]): DevLauncherConfig {
321
+ const dev = execution.dev
322
+ if (dev === undefined) throw new Error('zerops: a dev launcher configuration needs a dev environment')
323
+ if (!/^[0-9a-f]{40}$/.test(dev.head)) throw new Error('zerops: a dev environment builds an exact commit')
324
+ const recipe = recipeOf(execution)
325
+ return {
326
+ format: 1,
327
+ appId: execution.appId,
328
+ head: dev.head,
329
+ port: ZEROPS_APPLICATION_PORT,
330
+ healthPath: execution.contract.readinessPath,
331
+ serve: recipe.serve,
332
+ migrate: recipe.migrate,
333
+ spaBuild: authoredSpa,
334
+ // The build's check chain, in its order, on what the container holds.
335
+ checks: recipeOf(execution, RUNTIME_ROOT).checks,
336
+ }
337
+ }
338
+
339
+ /** The generated build commands, in order; the authored SPA commands run before all of them. */
340
+ export function generatedBuildCommands(execution: ZeropsLockedExecution, authoredSpa: readonly string[] = []): readonly string[] {
341
+ const recipe = recipeOf(execution)
342
+ return recipe.build.flatMap((step) => {
343
+ if ('run' in step) return [step.run]
344
+ if (execution.dev === undefined || step.write !== recipe.start) return [writeFileCommand(step.write, step.lines)]
345
+ return [
346
+ writeFileCommand(recipe.start, devStartScript),
347
+ writeBase64Command(DEV_LAUNCHER_CONFIG_FILE, JSON.stringify(devLauncherConfig(execution, authoredSpa))),
348
+ ]
349
+ })
342
350
  }
343
351
 
344
352
  const sortedUnique = (values: readonly string[]): string[] => [...new Set(values)].sort()
@@ -354,16 +362,11 @@ const storageVariables = (hostname: string): Record<StorageVariable, string> =>
354
362
  })
355
363
 
356
364
  /** The crontab lines that run a job of the contract or of the framework, each on one container. */
357
- const jobCrontab = (contract: ZeropsContractRun): { readonly timing: string; readonly command: string; readonly allContainers: boolean }[] => [
358
- ...contract.jobs.map((job) => ({ timing: job.schedule, command: `${FABRIKA_BIN} job run ${job.name}`, allContainers: false })),
359
- ...(contract.files === true
360
- ? [{ timing: FILE_SWEEP_SCHEDULE, command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SWEEP_JOB}`, allContainers: false }]
361
- : []),
362
- ...(contract.scan === true ? [{ timing: '* * * * *', command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SCAN_JOB}`, allContainers: false }] : []),
363
- ]
365
+ const jobCrontab = (execution: ZeropsLockedExecution): { readonly timing: string; readonly command: string; readonly allContainers: boolean }[] =>
366
+ recipeOf(execution).jobs.map((job) => ({ timing: job.schedule, command: job.command, allContainers: false }))
364
367
 
365
368
  /** How many job processes the crontab may run at once, each opening the application's database. */
366
- export const zeropsJobProcesses = (contract: ZeropsContractRun): number => jobCrontab(contract).length
369
+ export const zeropsJobProcesses = (execution: ZeropsLockedExecution): number => jobCrontab(execution).length
367
370
 
368
371
  /** The commands every container runs before `run.start`, in order; any failure stops the activation. */
369
372
  export function generatedInitCommands(execution: ZeropsLockedExecution): string[] {
@@ -371,7 +374,7 @@ export function generatedInitCommands(execution: ZeropsLockedExecution): string[
371
374
  // A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
372
375
  // them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
373
376
  ...(execution.contract.scan === true ? [`${FRESHCLAM} || echo 'fabrika: freshclam did not refresh the malware signatures'`] : []),
374
- ...initScripts(execution).map((script) => `sh ${RUNTIME_ROOT}/${script.path}`),
377
+ ...recipeOf(execution).init.map((path) => `sh ${RUNTIME_ROOT}/${path}`),
375
378
  ]
376
379
  }
377
380
 
@@ -396,7 +399,7 @@ export function renderLockedSetup(
396
399
  const scan = contract.scan === true
397
400
  const packages = sortedUnique([...execution.runtimePackages, ...(scan ? [SCANNER_PACKAGE] : [])])
398
401
  // Every container keeps its own signatures; one scan pass at a time holds the job's lease.
399
- const crontab = [...jobCrontab(contract), ...(scan ? [{ timing: FRESHCLAM_SCHEDULE, command: FRESHCLAM, allContainers: true }] : [])]
402
+ const crontab = [...jobCrontab(execution), ...(scan ? [{ timing: FRESHCLAM_SCHEDULE, command: FRESHCLAM, allContainers: true }] : [])]
400
403
  const initCommands = generatedInitCommands(execution)
401
404
  const prepareCommands = packages.length === 0 ? [] : [`sudo apk add --no-cache ${packages.join(' ')}`, ...(scan ? [FRESHCLAM] : [])]
402
405
  return {
@@ -404,21 +407,29 @@ export function renderLockedSetup(
404
407
  build: {
405
408
  base: authored.base ?? [ZEROPS_APPLICATION_RUNTIME],
406
409
  ...(authored.os === null ? {} : { os: authored.os }),
407
- buildCommands: [...authored.commands, ...generatedBuildCommands(execution)],
410
+ buildCommands: [...authored.commands, ...generatedBuildCommands(execution, authored.commands)],
408
411
  ...(execution.sourceMaps === null ? {} : { envVariables: sourceMapBuildVariables(execution.sourceMaps) }),
409
412
  deployFiles: ['./'],
410
413
  cache: ['node_modules'],
411
414
  },
412
415
  deploy: {
413
- readinessCheck: { httpGet: probe, retryPeriod: '10s', failureTimeout: '180s' },
416
+ readinessCheck: {
417
+ exec: { command: readinessCommand(contract.readinessPath, execution.dev !== undefined) },
418
+ retryPeriod: '10s',
419
+ failureTimeout: '180s',
420
+ },
414
421
  },
415
422
  run: {
416
423
  base: ZEROPS_APPLICATION_RUNTIME,
417
424
  ...(prepareCommands.length === 0 ? {} : { prepareCommands }),
418
425
  ...(initCommands.length === 0 ? {} : { initCommands }),
419
- start: `sh ${RUNTIME_ROOT}/${START_SCRIPT}`,
426
+ start: `sh ${RUNTIME_ROOT}/${recipeOf(execution).start}`,
420
427
  ports: [{ port: ZEROPS_APPLICATION_PORT, httpSupport: true }],
421
- healthCheck: { httpGet: probe, execPeriod: '10s', disconnectTimeout: '30s', recoveryTimeout: '10s', failureTimeout: '120s' },
428
+ // A dev container keeps its launcher running while the application it respawns fails: Zerops restarting it
429
+ // would only boot the same failing head again.
430
+ ...(execution.dev === undefined
431
+ ? { healthCheck: { httpGet: probe, execPeriod: '10s', disconnectTimeout: '30s', recoveryTimeout: '10s', failureTimeout: '120s' } }
432
+ : {}),
422
433
  ...(crontab.length === 0 ? {} : { crontab }),
423
434
  envVariables,
424
435
  },
@@ -15,10 +15,10 @@ import {
15
15
  ensureFirstAdministrator,
16
16
  type FirstAdministratorApi,
17
17
  type FirstAdministratorResult,
18
+ platformOrigin,
18
19
  } from '@fabrika/installation-init'
19
20
  import type { PlatformAdminInput } from './admin-options'
20
21
  import { REISSUE_FLAG } from './admin-options'
21
- import { platformOrigin } from './hosts'
22
22
  import { MACHINE_KEY_NEXT_STEP_TITLE, machineKeyNextStepLines } from './machine-key'
23
23
 
24
24
  /**
@@ -12,8 +12,7 @@
12
12
  // The command is unattended by construction: nothing here prompts, and a missing required value is an
13
13
  // error naming both the flag and the variable that would supply it.
14
14
 
15
- import { describeArgument, readEnv, requiredSetting, setting } from '@fabrika/installation-init'
16
- import { type McpClient, parseMcpClientsJson } from './mcp'
15
+ import { describeArgument, type McpClient, parseMcpClientsJson, readEnv, requiredSetting, setting } from '@fabrika/installation-init'
17
16
  import { parsePlatformHosts, parsePlatformScheme } from './placement-options'
18
17
  import { readInstallationSourceDir } from './source-options'
19
18
 
@@ -37,11 +37,21 @@
37
37
  // what "deploy" means, and the platform keeps the previous version serving whenever the new one fails its
38
38
  // readiness gate.
39
39
 
40
- import { FABRIKA_SERVICE_TABLE_DIGEST, IAM_PROXY_SECRET_VARIABLE, readEnvironmentName } from '@fabrika/auth-core'
40
+ import { IAM_PROXY_SECRET_VARIABLE, readEnvironmentName } from '@fabrika/auth-core'
41
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'
42
53
  import type { SchemaReconciler } from '@fabrika/provider-contract'
43
54
  import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
44
- import { createHmac } from 'node:crypto'
45
55
  import { assertServiceEnvValueFits } from '../api'
46
56
  import {
47
57
  enableSubdomainAccessWhenHttp,
@@ -64,7 +74,7 @@ import { assertCheckerPublished, type CheckerPublication } from './checker-relea
64
74
  import type { PlatformDeployInput } from './deploy-options'
65
75
  import { PLATFORM_CONSOLE_APP_SCHEMA } from './generated/platform-console-schema'
66
76
  import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
67
- import { derivePlatformHosts, platformOrigin, type PlatformPlacement, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
77
+ import { derivePlatformHosts, type PlatformPlacement, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
68
78
  import { PRIVATE_IAM_UPSTREAM } from './install-environment'
69
79
  import {
70
80
  FABRIKA_ZEROPS_PROXY_SOURCE_JSON,
@@ -74,11 +84,9 @@ import {
74
84
  validateInstallationSetups,
75
85
  } from './installation-source'
76
86
  import type { DeployLog } from './log'
77
- import { type IamOAuthConfig, platformIamOAuthConfig } from './mcp'
78
87
  import { archiveWithDataFile } from './platform-archive'
79
88
  import type { PreparedPlatformSource } from './platform-source'
80
89
  import { type PlatformUploadFetch, uploadPlatformArchive } from './platform-upload'
81
- import { platformProxyAppFor, type PlatformProxyComposition, platformServiceTableDigest, resolvePlatformProxyManifest } from './proxy-manifest'
82
90
 
83
91
  /**
84
92
  * The four services this command touches, in the order it RESOLVES and WRITES them.
@@ -323,25 +331,14 @@ const resolvePlacement = (input: PlatformDeployInput, proxyEnv: LiveServiceEnv):
323
331
  return { hosts: derivePlatformHosts(subdomains, listeners), scheme: input.scheme, zeropsSubdomain: true }
324
332
  }
325
333
 
326
- const IAM_OAUTH_CONFIG = 'FABRIKA_IAM_OAUTH_CONFIG'
327
-
328
- /**
329
- * The secret the platform proxy sends IAM's route, so IAM honours the client address and request id only from the proxy.
330
- * Derived from the IAM admin key, which every deploy holds, so each deploy writes the same value and nothing stores or
331
- * reads one back. Never from the proxy key: namespace proxies hold that one.
332
- */
333
- const deriveIamProxySecret = (iamAdminKey: string): string =>
334
- createHmac('sha256', iamAdminKey).update('fabrika-installation-proxy-secret:iam').digest('hex')
335
-
336
334
  /** The credentials this command writes. Sensitive, so they cannot be compared and are rewritten on every deploy. */
337
335
  const SENSITIVE_DEPLOY_KEYS: ReadonlySet<string> = new Set([IAM_PROXY_SECRET_VARIABLE])
338
336
 
339
337
  /**
340
- * 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.
341
340
  *
342
341
  * The namespace source default comes from the verified snapshot. Values are compared but never logged.
343
- * IAM's OAuth configuration is written only when MCP authoring is enabled; without it the variable is
344
- * left as it is, and the manifest — whose `/mcp` row then admits no OAuth token — is what turns MCP off.
345
342
  */
346
343
  const desiredServiceEnv = (
347
344
  environment: string,
@@ -350,63 +347,32 @@ const desiredServiceEnv = (
350
347
  proxySource: ZeropsNamespaceProxySource,
351
348
  oauthConfig: IamOAuthConfig | undefined,
352
349
  controlTableDigest: string,
353
- iamProxySecret: string,
350
+ iamAdminKey: string,
354
351
  ): ReadonlyMap<PlatformDeployService, ReadonlyMap<string, string>> => {
355
- const iamOrigin = platformOrigin(placement.scheme, placement.hosts.iam)
356
- const operationsOrigin = platformOrigin(placement.scheme, placement.hosts.operations)
357
- const iam = new Map([
358
- ['ENVIRONMENT', environment],
359
- // The `iss` of every minted token AND the OIDC redirect base, so it must be the address
360
- // routed to the proxy in front of this service.
361
- ['ISSUER', iamOrigin],
362
- [IAM_PROXY_SECRET_VARIABLE, iamProxySecret],
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],
363
370
  ])
364
- if (oauthConfig !== undefined) {
365
- iam.set(IAM_OAUTH_CONFIG, JSON.stringify(oauthConfig))
366
- }
367
371
  return new Map<PlatformDeployService, ReadonlyMap<string, string>>([
368
- ['iam', iam],
369
- [
370
- 'operations',
371
- new Map([
372
- ['ENVIRONMENT', environment],
373
- ['FABRIKA_IAM_ISSUER', iamOrigin],
374
- ['FABRIKA_OPERATIONS_PUBLIC_HOST', placement.hosts.operations],
375
- ]),
376
- ],
377
- [
378
- 'proxy',
379
- new Map([
380
- ['ENVIRONMENT', environment],
381
- // Compared byte-for-byte against a token's `iss`, so this is the PUBLIC issuer and not the
382
- // private `iam:3000` the manifest dials — `readProxyEnv` canonicalizes it to an origin.
383
- ['FABRIKA_IAM_ISSUER', iamOrigin],
384
- ['FABRIKA_PROXY_SESSION_OWNER_JSON', JSON.stringify({ issuer: iamOrigin, upstream: PRIVATE_IAM_UPSTREAM })],
385
- // Caddy sends it on the session owner's route. Until the proxy deploys, IAM keys every request on one shared
386
- // bucket, which is what the balancer's address already does on Zerops.
387
- [IAM_PROXY_SECRET_VARIABLE, iamProxySecret],
388
- ]),
389
- ],
390
- [
391
- 'control',
392
- new Map([
393
- [FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource(proxySource)],
394
- ['ENVIRONMENT', environment],
395
- ['FABRIKA_IAM_ISSUER', iamOrigin],
396
- // A bare host: `controlPublicOrigin` accepts either spelling.
397
- ['FABRIKA_CONTROL_DOMAIN', placement.hosts.control],
398
- // The digest of the console table the proxy above publishes: Control picks its own table, MCP or not,
399
- // by it, and refuses to start under another, so the two cannot disagree at runtime.
400
- [FABRIKA_SERVICE_TABLE_DIGEST, controlTableDigest],
401
- ['OPERATIONS_ARTIFACT_ORIGIN', operationsOrigin],
402
- // The public IAM origin an APPLICATION project's proxy reaches — there is no private
403
- // network between projects (ADR-0006).
404
- ['FABRIKA_ZEROPS_PROXY_IAM_URL', iamOrigin],
405
- // The installation's own project, which no deployment namespace may adopt: its proxy is the
406
- // platform's enforcement point, and a namespace reconcile would rebuild it with only app members.
407
- ['FABRIKA_ZEROPS_PLATFORM_PROJECT_ID', projectId],
408
- ]),
409
- ],
372
+ ['iam', fabrika.iam],
373
+ ['operations', fabrika.operations],
374
+ ['proxy', fabrika.proxy],
375
+ ['control', control],
410
376
  ])
411
377
  }
412
378
 
@@ -836,16 +802,16 @@ export const deployPreparedPlatform = async (
836
802
  proxySource,
837
803
  oauthConfig,
838
804
  controlTableDigest,
839
- deriveIamProxySecret(input.iamAdminKey),
805
+ input.iamAdminKey,
840
806
  )
841
807
  for (const hostname of PLATFORM_DEPLOY_ORDER) {
842
808
  const service = serviceOf(services, hostname)
843
809
  const live = hostname === PLATFORM_PROXY_SERVICE ? proxyEnv : await readServiceEnv(service.id, collaborators)
844
810
  const keys = desired.get(hostname) ?? new Map<string, string>()
845
- const liveOAuthConfig = live.get(IAM_OAUTH_CONFIG)
811
+ const liveOAuthConfig = live.get(IAM_OAUTH_CONFIG_VARIABLE)
846
812
  const oauthStillSet = liveOAuthConfig !== undefined && (liveOAuthConfig.sensitive || liveOAuthConfig.content.trim() !== '')
847
813
  if (hostname === 'iam' && oauthConfig === undefined && oauthStillSet) {
848
- 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`)
849
815
  }
850
816
  const written = await applyServiceEnv(service, keys, live, input, collaborators)
851
817
  log.info(
@@ -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',
@@ -398,12 +398,15 @@ none, and `install` cannot be re-run.
398
398
  2. Restart `iam` and `control`. Each reads the transport at boot. IAM refuses to start on an invalid one.
399
399
  Control starts, logs a line that names the invalid variables, and refuses every deploy that needs the transport
400
400
  until you fix them and restart it.
401
- 3. Redeploy every environment whose contract uses `installation` mail. An application gets the new transport
402
- only at its next deploy. Until then it sends with the old one, and a revoked key fails its mail with
403
- `provider_unauthorized`.
404
-
405
- Without a transport, a contract whose job declares `mailer: 'installation'` still deploys. Its jobs see that mail is
406
- 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.
407
410
 
408
411
  ## When something fails
409
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}`
@@ -31,19 +31,6 @@ import { parsePlatformUpgradeArgs, UPGRADE_DRY_RUN_FLAG } from './upgrade-option
31
31
  export { PLATFORM_CONCURRENT_DEPLOY, PLATFORM_DEPLOY_ORDER, PLATFORM_PROXY_SERVICE, PLATFORM_SEQUENTIAL_DEPLOY } from './deploy'
32
32
  export { PLATFORM_CONSOLE_APP_SCHEMA } from './generated/platform-console-schema'
33
33
  export { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
34
- export { type IamOAuthConfig, type McpClient, parseMcpClientsJson, platformIamOAuthConfig } from './mcp'
35
- export {
36
- type PlatformOAuthResourceTemplate,
37
- type PlatformProxyApp,
38
- platformProxyAppFor,
39
- type PlatformProxyComposition,
40
- type PlatformProxyManifest,
41
- type PlatformProxyManifestTemplate,
42
- type PlatformProxyPlacement,
43
- type PlatformProxyService,
44
- platformServiceTableDigest,
45
- resolvePlatformProxyManifest,
46
- } from './proxy-manifest'
47
34
 
48
35
  const EMAIL_PROVIDERS = Object.keys(builtinEmailProviders).join(', ')
49
36
  /** One line per provider: its variables, each marked secret or optional where it is. */