@fabrika/provider-zerops 0.8.1 → 0.9.0-dev.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 (102) hide show
  1. package/package.json +15 -14
  2. package/src/api/client.ts +6 -0
  3. package/src/api/types.ts +12 -0
  4. package/src/control.ts +26 -0
  5. package/src/execution.ts +147 -14
  6. package/src/installation/generated/platform-proxy-manifest.ts +20 -0
  7. package/src/manifest.ts +9 -5
  8. package/src/namespace/provisioning.ts +4 -2
  9. package/src/namespace/proxy-attempt.ts +29 -0
  10. package/src/plan.ts +1 -1
  11. package/src/provider.ts +40 -0
  12. package/types/src/api/client.d.ts +5 -0
  13. package/types/src/api/errors.d.ts +24 -0
  14. package/types/src/api/polling.d.ts +41 -0
  15. package/types/src/api/readers.d.ts +977 -0
  16. package/types/src/api/service-env-limit.d.ts +17 -0
  17. package/types/src/api/service-env-response.d.ts +23 -0
  18. package/types/src/api/types.d.ts +638 -0
  19. package/types/src/api.d.ts +6 -0
  20. package/types/src/authoring.d.ts +9 -0
  21. package/types/src/cli-args.d.ts +19 -0
  22. package/types/src/cli.d.ts +10 -0
  23. package/types/src/collaborators.d.ts +18 -0
  24. package/types/src/compile.d.ts +91 -0
  25. package/types/src/control.d.ts +41 -0
  26. package/types/src/database-budget.d.ts +38 -0
  27. package/types/src/descriptor.d.ts +28 -0
  28. package/types/src/execution.d.ts +137 -0
  29. package/types/src/exposure.d.ts +47 -0
  30. package/types/src/index.d.ts +31 -0
  31. package/types/src/installation/admin-options.d.ts +24 -0
  32. package/types/src/installation/admin.d.ts +35 -0
  33. package/types/src/installation/checker-release.d.ts +12 -0
  34. package/types/src/installation/deploy-options.d.ts +37 -0
  35. package/types/src/installation/deploy.d.ts +96 -0
  36. package/types/src/installation/generated/platform-console-schema.d.ts +2 -0
  37. package/types/src/installation/generated/platform-proxy-manifest.d.ts +2 -0
  38. package/types/src/installation/hosts.d.ts +38 -0
  39. package/types/src/installation/index.d.ts +9 -0
  40. package/types/src/installation/init-options.d.ts +38 -0
  41. package/types/src/installation/init.d.ts +60 -0
  42. package/types/src/installation/install-collaborators.d.ts +24 -0
  43. package/types/src/installation/install-environment.d.ts +70 -0
  44. package/types/src/installation/install-options.d.ts +49 -0
  45. package/types/src/installation/install-project.d.ts +13 -0
  46. package/types/src/installation/install-provisioning.d.ts +58 -0
  47. package/types/src/installation/install-secrets.d.ts +30 -0
  48. package/types/src/installation/install-token.d.ts +10 -0
  49. package/types/src/installation/install.d.ts +23 -0
  50. package/types/src/installation/installation-source.d.ts +8 -0
  51. package/types/src/installation/log.d.ts +44 -0
  52. package/types/src/installation/machine-key.d.ts +19 -0
  53. package/types/src/installation/mcp.d.ts +18 -0
  54. package/types/src/installation/placement-options.d.ts +11 -0
  55. package/types/src/installation/platform-archive.d.ts +7 -0
  56. package/types/src/installation/platform-source.d.ts +8 -0
  57. package/types/src/installation/platform-upload.d.ts +3 -0
  58. package/types/src/installation/proxy-manifest.d.ts +105 -0
  59. package/types/src/installation/secrets.d.ts +17 -0
  60. package/types/src/installation/sidecar.d.ts +51 -0
  61. package/types/src/installation/source-options.d.ts +1 -0
  62. package/types/src/installation/topology/artifacts.d.ts +8 -0
  63. package/types/src/installation/topology/index.d.ts +5 -0
  64. package/types/src/installation/topology/installation-schemas.d.ts +2242 -0
  65. package/types/src/installation/topology/invariants.d.ts +23 -0
  66. package/types/src/installation/topology/setups.d.ts +58 -0
  67. package/types/src/installation/topology/topology.d.ts +148 -0
  68. package/types/src/installation/topology/validate.d.ts +11 -0
  69. package/types/src/installation/upgrade-options.d.ts +26 -0
  70. package/types/src/installation/upgrade-output.d.ts +6 -0
  71. package/types/src/installation/upgrade.d.ts +88 -0
  72. package/types/src/managed-env.d.ts +57 -0
  73. package/types/src/manifest.d.ts +206 -0
  74. package/types/src/namespace/claims.d.ts +3 -0
  75. package/types/src/namespace/codec.d.ts +334 -0
  76. package/types/src/namespace/constants.d.ts +25 -0
  77. package/types/src/namespace/errors.d.ts +15 -0
  78. package/types/src/namespace/manifest.d.ts +4 -0
  79. package/types/src/namespace/operator.d.ts +12 -0
  80. package/types/src/namespace/provisioning.d.ts +4 -0
  81. package/types/src/namespace/proxy-attempt.d.ts +36 -0
  82. package/types/src/namespace/proxy-reads.d.ts +33 -0
  83. package/types/src/namespace/proxy-secrets.d.ts +8 -0
  84. package/types/src/namespace/proxy-source.d.ts +74 -0
  85. package/types/src/namespace/topology.d.ts +9 -0
  86. package/types/src/namespace/types.d.ts +99 -0
  87. package/types/src/namespace/update.d.ts +6 -0
  88. package/types/src/namespace/validation.d.ts +12 -0
  89. package/types/src/namespace.d.ts +9 -0
  90. package/types/src/plan.d.ts +12 -0
  91. package/types/src/provider.d.ts +65 -0
  92. package/types/src/proxy-manifest-carrier.d.ts +16 -0
  93. package/types/src/schema.generated.d.ts +376 -0
  94. package/types/src/service-names.d.ts +4 -0
  95. package/types/src/service-references.d.ts +13 -0
  96. package/types/src/source/constants.d.ts +6 -0
  97. package/types/src/source/port.d.ts +60 -0
  98. package/types/src/source/types.d.ts +4 -0
  99. package/types/src/source/validation.d.ts +19 -0
  100. package/types/src/source.d.ts +2 -0
  101. package/types/src/types.d.ts +121 -0
  102. package/types/src/version-attempt.d.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.8.1",
3
+ "version": "0.9.0-dev.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,27 +12,28 @@
12
12
  },
13
13
  "files": [
14
14
  "src",
15
- "!src/**/__tests__/**"
15
+ "!src/**/__tests__/**",
16
+ "types"
16
17
  ],
17
18
  "exports": {
18
19
  ".": {
19
20
  "bun": "./src/index.ts",
20
- "types": "./src/index.ts",
21
+ "types": "./types/src/index.d.ts",
21
22
  "default": "./src/index.ts"
22
23
  },
23
24
  "./cli": {
24
25
  "bun": "./src/cli.ts",
25
- "types": "./src/cli.ts",
26
+ "types": "./types/src/cli.d.ts",
26
27
  "default": "./src/cli.ts"
27
28
  },
28
29
  "./installation": {
29
30
  "bun": "./src/installation/index.ts",
30
- "types": "./src/installation/index.ts",
31
+ "types": "./types/src/installation/index.d.ts",
31
32
  "default": "./src/installation/index.ts"
32
33
  },
33
34
  "./installation/topology": {
34
35
  "bun": "./src/installation/topology/index.ts",
35
- "types": "./src/installation/topology/index.ts",
36
+ "types": "./types/src/installation/topology/index.d.ts",
36
37
  "default": "./src/installation/topology/index.ts"
37
38
  }
38
39
  },
@@ -43,14 +44,14 @@
43
44
  },
44
45
  "dependencies": {
45
46
  "zod": "^3.25.76",
46
- "@fabrika/auth": "0.8.1",
47
- "@fabrika/auth-core": "0.8.1",
48
- "@fabrika/email": "0.8.1",
49
- "@fabrika/iam-contract": "0.8.1",
50
- "@fabrika/installation-init": "0.8.1",
51
- "@fabrika/operations-contract": "0.8.1",
52
- "@fabrika/provider-contract": "0.8.1",
53
- "@fabrika/proxy-core": "0.8.1",
47
+ "@fabrika/auth": "0.9.0-dev.2",
48
+ "@fabrika/auth-core": "0.9.0-dev.2",
49
+ "@fabrika/email": "0.9.0-dev.2",
50
+ "@fabrika/iam-contract": "0.9.0-dev.2",
51
+ "@fabrika/installation-init": "0.9.0-dev.2",
52
+ "@fabrika/operations-contract": "0.9.0-dev.2",
53
+ "@fabrika/provider-contract": "0.9.0-dev.2",
54
+ "@fabrika/proxy-core": "0.9.0-dev.2",
54
55
  "yaml": "^2.9.1"
55
56
  }
56
57
  }
package/src/api/client.ts CHANGED
@@ -346,6 +346,12 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
346
346
  await request('enable subdomain access', 'PUT', `/service-stack/${serviceId}/enable-subdomain-access`, { signal })
347
347
  },
348
348
 
349
+ startService: async ({ serviceId, signal }) =>
350
+ readResponse('start service', processSchema, await request('start service', 'PUT', `/service-stack/${serviceId}/start`, { signal })),
351
+
352
+ stopService: async ({ serviceId, signal }) =>
353
+ readResponse('stop service', processSchema, await request('stop service', 'PUT', `/service-stack/${serviceId}/stop`, { signal })),
354
+
349
355
  configureAutoscaling: async ({ serviceId, autoscalingProfileId, signal }) => {
350
356
  const payload = await request('configure autoscaling', 'PUT', `/service-stack/${serviceId}/autoscaling`, {
351
357
  body: { autoscalingProfileId },
package/src/api/types.ts CHANGED
@@ -472,6 +472,18 @@ export interface ZeropsApi {
472
472
  */
473
473
  enableSubdomainAccess(input: { serviceId: string; signal: AbortSignal }): Promise<void>
474
474
 
475
+ // ── service-stack: running state ──────────────────────────────────────────────
476
+
477
+ /**
478
+ * Start a stopped service. VERIFIED AS A SHAPE: `PUT /service-stack/{id}/start` (`StartServiceStack`), no body,
479
+ * `ResponseProcess`. Measured 2026-10-21: a stopped Bun service served again about 9 s after the call. Files written
480
+ * at runtime survive a stop and a start; a deploy replaces the container and loses them.
481
+ */
482
+ startService(input: { serviceId: string; signal: AbortSignal }): Promise<ZeropsProcess>
483
+
484
+ /** Stop a service. VERIFIED AS A SHAPE: `PUT /service-stack/{id}/stop` (`StopServiceStack`), no body, `ResponseProcess`. */
485
+ stopService(input: { serviceId: string; signal: AbortSignal }): Promise<ZeropsProcess>
486
+
475
487
  // ── service-stack: autoscaling ────────────────────────────────────────────────
476
488
 
477
489
  /**
package/src/control.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  ProviderDeployInput,
9
9
  ProviderDeploymentNamespace,
10
10
  ProviderDeployOutcome,
11
+ ProviderDevEnvironmentInput,
11
12
  ProviderEnvelope,
12
13
  ProviderEnvironment,
13
14
  ProviderReconcileOutcome,
@@ -322,6 +323,26 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
322
323
  return { commitSha: result.commitSha }
323
324
  }
324
325
 
326
+ /** Stop or start the service of a `dev` environment outside a run, and wait for the platform's process. */
327
+ const runningState = async (input: ProviderDevEnvironmentInput, action: 'start' | 'stop'): Promise<void> => {
328
+ if (input.environment.kind !== 'dev') throw new Error('zerops: only a dev environment is stopped and started outside a run')
329
+ const placement = resolvedEnvironment(input.environment, { requireReady: false })
330
+ const serviceId = placement.target.serviceId
331
+ // Idempotent: Control's record of a stopped service can lag behind a start from elsewhere.
332
+ const status = (await api.getService({ serviceId, signal: input.signal })).status
333
+ if (status === (action === 'stop' ? 'STOPPED' : 'ACTIVE')) return
334
+ const process = action === 'stop'
335
+ ? await api.stopService({ serviceId, signal: input.signal })
336
+ : await api.startService({ serviceId, signal: input.signal })
337
+ await waitForProcess({
338
+ api,
339
+ processId: process.id,
340
+ sleep: options.sleep ?? defaultSleep,
341
+ signal: input.signal,
342
+ label: `the ${action} of ${input.environment.appId}/${input.environment.env}`,
343
+ })
344
+ }
345
+
325
346
  const controlProvider: ControlProvider = {
326
347
  id: 'zerops',
327
348
  normalizeRegistration,
@@ -380,6 +401,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
380
401
  const result = await deployRun({
381
402
  appId: input.app.id,
382
403
  env: input.environment.env,
404
+ ...(input.environment.kind === 'dev' && source !== undefined ? { dev: { head: source.commitSha } } : {}),
383
405
  ...(input.returnOrigins === undefined ? {} : { returnOrigins: input.returnOrigins }),
384
406
  secrets: input.secrets,
385
407
  vars: input.vars,
@@ -395,6 +417,10 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
395
417
  input.signal.throwIfAborted()
396
418
  return { state: result.status, ...(result.artifactState === undefined ? {} : { artifactState: result.artifactState }) }
397
419
  },
420
+ devEnvironments: {
421
+ stop: (input) => runningState(input, 'stop'),
422
+ start: (input) => runningState(input, 'start'),
423
+ },
398
424
  cancel: async (input): Promise<ProviderCancelOutcome> => {
399
425
  const signal = input.signal
400
426
  signal.throwIfAborted()
package/src/execution.ts CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  import {
6
6
  type ContractArtifact,
7
+ DEV_LAUNCHER_CONFIG_FILE,
8
+ DEV_LAUNCHER_MODULE,
9
+ DEV_LAUNCHER_STATE_FILE,
10
+ type DevLauncherConfig,
7
11
  FABRIKA_CONTRACT_DIGEST,
8
12
  FABRIKA_SOURCE_MAP_UPLOAD,
9
13
  FABRIKA_SOURCE_MAP_UPLOAD_KEY,
@@ -107,6 +111,16 @@ export interface ZeropsLockedExecution {
107
111
  readonly runtimePackages: readonly ZeropsRuntimePackage[]
108
112
  /** Where the build uploads its source maps, without the bearer; null when Control minted no upload for the run. */
109
113
  readonly sourceMaps: OperationsSourceMapBuildUpload | null
114
+ /**
115
+ * Present for an environment of kind `dev` (ADR-0049): `run.start` runs the dev launcher instead of the serving
116
+ * entry. The environment's kind selects it; an application never authors it.
117
+ */
118
+ readonly dev?: ZeropsDevExecution
119
+ }
120
+
121
+ export interface ZeropsDevExecution {
122
+ /** The commit the bootstrap run builds: what a new container holds before the launcher's first sync. */
123
+ readonly head: string
110
124
  }
111
125
 
112
126
  /** The application ids the generated scripts may name: the contract's own id rule, so a script needs no quoting. */
@@ -114,6 +128,9 @@ export const LOCKED_APP_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/
114
128
 
115
129
  export const CONTRACT_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/
116
130
 
131
+ /** A literal path of unreserved characters: it is written into the generated probes unquoted by any shell. */
132
+ export const READINESS_PATH_PATTERN = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/
133
+
117
134
  const byName = <T extends { readonly name: string }>(left: T, right: T): number => left.name < right.name ? -1 : left.name > right.name ? 1 : 0
118
135
 
119
136
  /**
@@ -245,15 +262,20 @@ const FABRIKA_BIN = './node_modules/.bin/fabrika'
245
262
  const MIGRATE_SCRIPT = 'fabrika/migrate.generated.sh'
246
263
  const VERIFY_SCRIPT = 'fabrika/verify.generated.sh'
247
264
  const START_SCRIPT = 'fabrika/start.generated.sh'
265
+ const DEV_INIT_SCRIPT = 'fabrika/dev-init.generated.sh'
248
266
  const PLATFORM_TSCONFIG = 'fabrika/tsconfig.platform.json'
249
267
 
250
- /** The compiler options of the platform's type check; the application's own `tsconfig.json` and its `paths` are not read. */
268
+ /**
269
+ * The compiler options of the platform's type check; the application's own `tsconfig.json` and its `paths` are not read.
270
+ * It covers the SPA under `web/` too: Bun bundles the SPA without checking its types.
271
+ */
251
272
  const PLATFORM_TSCONFIG_CONTENTS = JSON.stringify({
252
273
  compilerOptions: {
253
274
  target: 'ES2022',
254
275
  module: 'ESNext',
255
276
  moduleResolution: 'bundler',
256
277
  lib: ['ES2022', 'DOM', 'DOM.Iterable'],
278
+ jsx: 'react-jsx',
257
279
  types: ['bun'],
258
280
  strict: true,
259
281
  noUncheckedIndexedAccess: true,
@@ -264,7 +286,7 @@ const PLATFORM_TSCONFIG_CONTENTS = JSON.stringify({
264
286
  isolatedModules: true,
265
287
  noEmit: true,
266
288
  },
267
- include: ['../fabrika.contract.ts', '../src', 'entry.generated.ts'],
289
+ include: ['../fabrika.contract.ts', '../src', '../web', 'entry.generated.ts'],
268
290
  })
269
291
 
270
292
  /** One `printf` writing `lines` to `path`. Each line is single-quoted, so it may hold neither `'` nor an expansion. */
@@ -294,16 +316,119 @@ const verifyScript = (appId: string): readonly string[] => [
294
316
  `FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db test-isolation`,
295
317
  ]
296
318
 
297
- const startScript: readonly string[] = ['#!/bin/sh', 'set -eu', `cd ${RUNTIME_ROOT}`, 'exec bun fabrika/entry.generated.ts']
319
+ // The readiness check is retried no sooner than every 10 s (the platform's floor), and a server is rarely listening at
320
+ // the first attempt, one second after `run.start`. So one attempt waits for the health endpoint itself, for less than
321
+ // the retry period; it passes only on a 2xx, like the `httpGet` it replaces.
322
+ const readinessCommand = (path: string): string => {
323
+ if (!READINESS_PATH_PATTERN.test(path)) throw new Error('zerops: a readiness path must be a literal path of unreserved characters')
324
+ const url = `http://127.0.0.1:${ZEROPS_APPLICATION_PORT}${path}`
325
+ return `bun -e "const end = Date.now() + 9000; while (Date.now() < end) { try { if ((await fetch('${url}', { signal: AbortSignal.timeout(2000) })).ok) process.exit(0) } catch {} await Bun.sleep(200) } process.exit(1)"`
326
+ }
327
+
328
+ const SERVE_ENTRY = 'fabrika/entry.generated.ts'
329
+
330
+ const startScript: readonly string[] = ['#!/bin/sh', 'set -eu', `cd ${RUNTIME_ROOT}`, `exec bun ${SERVE_ENTRY}`]
331
+
332
+ // A dev environment starts the launcher of the application's own framework release, which runs the serving entry as its
333
+ // child. Double quotes inside: a generated file line may hold no single quote.
334
+ const devStartScript: readonly string[] = [
335
+ '#!/bin/sh',
336
+ 'set -eu',
337
+ `cd ${RUNTIME_ROOT}`,
338
+ `exec bun -e "await import(\\"${DEV_LAUNCHER_MODULE}\\").then((dev) => dev.runDevLauncher(process.env))"`,
339
+ ]
340
+
341
+ // The init commands of a dev container migrate and verify only what the bootstrap run built. A container that restarts
342
+ // after a stop keeps the files the launcher synced since, and its state file: the launcher reconciles that one itself.
343
+ const devInitScript: readonly string[] = [
344
+ '#!/bin/sh',
345
+ 'set -eu',
346
+ `cd ${RUNTIME_ROOT}`,
347
+ `if [ -f ${DEV_LAUNCHER_STATE_FILE} ]; then exit 0; fi`,
348
+ `sh ${RUNTIME_ROOT}/${MIGRATE_SCRIPT}`,
349
+ `sh ${RUNTIME_ROOT}/${VERIFY_SCRIPT}`,
350
+ ]
298
351
 
299
- /** The scripts the build writes and every container's init commands run, in this order. */
352
+ /** One command writing `content` to `path` through base64: the content may hold quotes and expansions, the command none. */
353
+ const writeBase64Command = (path: string, content: string): string => {
354
+ const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(content)))
355
+ return `printf '%s' '${encoded}' | base64 -d > ${path}`
356
+ }
357
+
358
+ /** The build's check chain, in its order, as the dev launcher runs it in the application root on what the container holds. */
359
+ const devChecks = (release: string): DevLauncherConfig['checks'] => [
360
+ { name: 'tests', command: TESTS_COMMAND },
361
+ { name: 'tsc', command: `cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${RUNTIME_ROOT}/${PLATFORM_TSCONFIG}` },
362
+ { name: 'fabrika-check', command: `cd /tmp && bun x --bun -p @fabrika/check@${release} fabrika-check ${RUNTIME_ROOT}` },
363
+ ]
364
+
365
+ /** What the dev launcher reads (`DEV_LAUNCHER_CONFIG_FILE`): the platform's, written after the checks. */
366
+ export function devLauncherConfig(execution: ZeropsLockedExecution, authoredSpa: readonly string[]): DevLauncherConfig {
367
+ const dev = execution.dev
368
+ if (dev === undefined) throw new Error('zerops: a dev launcher configuration needs a dev environment')
369
+ if (!/^[0-9a-f]{40}$/.test(dev.head)) throw new Error('zerops: a dev environment builds an exact commit')
370
+ return {
371
+ format: 1,
372
+ appId: execution.appId,
373
+ head: dev.head,
374
+ port: ZEROPS_APPLICATION_PORT,
375
+ healthPath: execution.contract.readinessPath,
376
+ serve: ['bun', SERVE_ENTRY],
377
+ migrate: [FABRIKA_BIN, 'db', 'migrate'],
378
+ spaBuild: authoredSpa,
379
+ checks: devChecks(execution.checkRelease),
380
+ }
381
+ }
382
+
383
+ const devLauncherConfigJson = (execution: ZeropsLockedExecution, authoredSpa: readonly string[]): string =>
384
+ JSON.stringify(devLauncherConfig(execution, authoredSpa))
385
+
386
+ /** The scripts the build writes, and the ones every container's init commands run, in this order. */
300
387
  const initScripts = (execution: ZeropsLockedExecution): readonly { readonly path: string; readonly lines: readonly string[] }[] =>
301
388
  execution.databaseUrl === null
302
389
  ? []
303
- : [{ path: MIGRATE_SCRIPT, lines: migrateScript(execution.appId) }, { path: VERIFY_SCRIPT, lines: verifyScript(execution.appId) }]
390
+ : [
391
+ { path: MIGRATE_SCRIPT, lines: migrateScript(execution.appId) },
392
+ { path: VERIFY_SCRIPT, lines: verifyScript(execution.appId) },
393
+ ...(execution.dev === undefined ? [] : [{ path: DEV_INIT_SCRIPT, lines: devInitScript }]),
394
+ ]
395
+
396
+ const initCommandScripts = (execution: ZeropsLockedExecution): readonly string[] =>
397
+ execution.databaseUrl === null ? [] : execution.dev === undefined ? [MIGRATE_SCRIPT, VERIFY_SCRIPT] : [DEV_INIT_SCRIPT]
304
398
 
305
399
  const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
306
400
 
401
+ /**
402
+ * What `@fabrika/db/testing` prints after the label of a test file it skips without `FABRIKA_TEST_POSTGRES_URL`. Every
403
+ * framework release prints it, so the platform counts it for any application's release.
404
+ */
405
+ const DATABASE_TEST_SKIP_MARKER = ' skipped: set FABRIKA_TEST_POSTGRES_URL to run the Postgres-backed tests'
406
+
407
+ /** The prefix of the line the tests command prints after the test run. */
408
+ const TESTS_REPORT_PREFIX = 'fabrika-tests:'
409
+
410
+ /** One `bun -e "…"` running `script`, which may hold none of the characters a double-quoted shell word expands. */
411
+ const bunEvalCommand = (script: readonly string[]): string => {
412
+ const source = script.join(' ')
413
+ if (/["$`\\!]/.test(source)) throw new Error('zerops: a generated bun -e script must not contain a quote, an expansion or a backslash')
414
+ return `bun -e "${source}"`
415
+ }
416
+
417
+ // The build has no database, so every Postgres-backed test file skips and the run still passes. This command runs the
418
+ // tests as before and then says how many files did not run, so a green test step does not read as a tested database.
419
+ const TESTS_COMMAND = bunEvalCommand([
420
+ "const child = Bun.spawn([process.execPath, 'test', '--isolate', '--pass-with-no-tests'], { stdout: 'inherit', stderr: 'pipe' });",
421
+ 'const decoder = new TextDecoder();',
422
+ "let output = '';",
423
+ 'for await (const chunk of child.stderr) { process.stderr.write(chunk); output += decoder.decode(chunk, { stream: true }) }',
424
+ 'const code = await child.exited;',
425
+ `const marker = '${DATABASE_TEST_SKIP_MARKER}';`,
426
+ 'const skipped = output.split(String.fromCharCode(10)).filter((line) => line.includes(marker)).map((line) => line.slice(0, line.indexOf(marker)).trim());',
427
+ `const prefix = '${TESTS_REPORT_PREFIX}';`,
428
+ "console.warn(skipped.length === 0 ? prefix + ' no Postgres-backed test file skipped' : prefix + ' ' + skipped.length + ' Postgres-backed test files did not run, because the build has no database: ' + skipped.join(', '));",
429
+ 'process.exit(code);',
430
+ ])
431
+
307
432
  // After every check: a map is uploaded only for a build that can deploy. A failed upload never fails the build; the
308
433
  // provider reads the state from the report line.
309
434
  const sourceMapUpload = (release: string): string => `cd /tmp && bun x --bun -p @fabrika/check@${release} fabrika-source-maps ${SOURCE_ROOT} || true`
@@ -322,21 +447,25 @@ const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record
322
447
  /**
323
448
  * The generated build commands, in order; the authored SPA commands run before all of them. The application's tests
324
449
  * 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.
450
+ * `--isolate`, as the application skill runs them: a test file's DOM globals otherwise leak into the next file. `tsc`
451
+ * runs before the checker, as in the skill's loop: the usual quick fix of a type error is a cast or an `any`, which only
452
+ * the checker refuses, so the checker is the last word on the code.
326
453
  */
327
- export function generatedBuildCommands(execution: ZeropsLockedExecution): readonly string[] {
454
+ export function generatedBuildCommands(execution: ZeropsLockedExecution, authoredSpa: readonly string[] = []): readonly string[] {
328
455
  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
456
  if (!FRAMEWORK_RELEASE_PATTERN.test(execution.checkRelease)) throw new Error('zerops: the checker release must be a release version')
330
457
  return [
331
458
  INSTALL,
332
- 'bun test --isolate --pass-with-no-tests',
459
+ TESTS_COMMAND,
333
460
  `rm -rf node_modules && ${INSTALL}`,
334
461
  `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
462
  writeFileCommand(PLATFORM_TSCONFIG, [PLATFORM_TSCONFIG_CONTENTS]),
337
463
  `cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${SOURCE_ROOT}/${PLATFORM_TSCONFIG}`,
464
+ `cd /tmp && bun x --bun -p @fabrika/check@${execution.checkRelease} fabrika-check ${SOURCE_ROOT}`,
338
465
  ...initScripts(execution).map((script) => writeFileCommand(script.path, script.lines)),
339
- writeFileCommand(START_SCRIPT, startScript),
466
+ ...(execution.dev === undefined
467
+ ? [writeFileCommand(START_SCRIPT, startScript)]
468
+ : [writeFileCommand(START_SCRIPT, devStartScript), writeBase64Command(DEV_LAUNCHER_CONFIG_FILE, devLauncherConfigJson(execution, authoredSpa))]),
340
469
  ...(execution.sourceMaps === null ? [] : [sourceMapUpload(execution.checkRelease)]),
341
470
  ]
342
471
  }
@@ -371,7 +500,7 @@ export function generatedInitCommands(execution: ZeropsLockedExecution): string[
371
500
  // A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
372
501
  // them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
373
502
  ...(execution.contract.scan === true ? [`${FRESHCLAM} || echo 'fabrika: freshclam did not refresh the malware signatures'`] : []),
374
- ...initScripts(execution).map((script) => `sh ${RUNTIME_ROOT}/${script.path}`),
503
+ ...initCommandScripts(execution).map((script) => `sh ${RUNTIME_ROOT}/${script}`),
375
504
  ]
376
505
  }
377
506
 
@@ -404,13 +533,13 @@ export function renderLockedSetup(
404
533
  build: {
405
534
  base: authored.base ?? [ZEROPS_APPLICATION_RUNTIME],
406
535
  ...(authored.os === null ? {} : { os: authored.os }),
407
- buildCommands: [...authored.commands, ...generatedBuildCommands(execution)],
536
+ buildCommands: [...authored.commands, ...generatedBuildCommands(execution, authored.commands)],
408
537
  ...(execution.sourceMaps === null ? {} : { envVariables: sourceMapBuildVariables(execution.sourceMaps) }),
409
538
  deployFiles: ['./'],
410
539
  cache: ['node_modules'],
411
540
  },
412
541
  deploy: {
413
- readinessCheck: { httpGet: probe, retryPeriod: '10s', failureTimeout: '180s' },
542
+ readinessCheck: { exec: { command: readinessCommand(contract.readinessPath) }, retryPeriod: '10s', failureTimeout: '180s' },
414
543
  },
415
544
  run: {
416
545
  base: ZEROPS_APPLICATION_RUNTIME,
@@ -418,7 +547,11 @@ export function renderLockedSetup(
418
547
  ...(initCommands.length === 0 ? {} : { initCommands }),
419
548
  start: `sh ${RUNTIME_ROOT}/${START_SCRIPT}`,
420
549
  ports: [{ port: ZEROPS_APPLICATION_PORT, httpSupport: true }],
421
- healthCheck: { httpGet: probe, execPeriod: '10s', disconnectTimeout: '30s', recoveryTimeout: '10s', failureTimeout: '120s' },
550
+ // A dev container keeps its launcher running while the application it respawns fails: Zerops restarting it
551
+ // would only boot the same failing head again.
552
+ ...(execution.dev === undefined
553
+ ? { healthCheck: { httpGet: probe, execPeriod: '10s', disconnectTimeout: '30s', recoveryTimeout: '10s', failureTimeout: '120s' } }
554
+ : {}),
422
555
  ...(crontab.length === 0 ? {} : { crontab }),
423
556
  envVariables,
424
557
  },
@@ -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',
package/src/manifest.ts CHANGED
@@ -12,10 +12,12 @@ import {
12
12
  FABRIKA_CHECK_VERSION,
13
13
  LOCKED_APP_ID_PATTERN,
14
14
  lockedAuthoredBuild,
15
+ READINESS_PATH_PATTERN,
15
16
  ZEROPS_RUNTIME_PACKAGES,
16
17
  zeropsAssetRoots,
17
18
  type ZeropsContractRun,
18
19
  zeropsContractRun,
20
+ type ZeropsDevExecution,
19
21
  type ZeropsLockedExecution,
20
22
  type ZeropsRuntimePackage,
21
23
  } from './execution'
@@ -310,14 +312,12 @@ const importDocumentSchema = z.object({ project: importProjectSchema.optional(),
310
312
  /** The import document a manifest carries: every service already holds the ADR-0004 invariants. */
311
313
  export type FabrikaManifestImportDocument = z.infer<typeof importDocumentSchema>
312
314
 
313
- /** A literal path of unreserved characters: it is written into the generated probes unquoted by any shell. */
314
- const READINESS_PATH = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/
315
315
  const JOB_NAME = /^[A-Za-z][A-Za-z0-9_]*$/
316
316
  const CRON_SCHEDULE = /^[0-9*/,-]+(?: [0-9*/,-]+){4}$/
317
317
 
318
318
  const contractRunSchema = z.object({
319
319
  digest: z.string().regex(CONTRACT_DIGEST_PATTERN),
320
- readinessPath: z.string().regex(READINESS_PATH),
320
+ readinessPath: z.string().regex(READINESS_PATH_PATTERN),
321
321
  jobs: z.array(z.object({ name: z.string().regex(JOB_NAME), schedule: z.string().regex(CRON_SCHEDULE) }).strict()),
322
322
  files: z.literal(true).optional(),
323
323
  scan: z.literal(true).optional(),
@@ -476,8 +476,11 @@ export const lockedExecutionOf = (manifest: FabrikaManifest): ZeropsLockedExecut
476
476
  }
477
477
  }
478
478
 
479
- /** What Control lets a provider publish for one environment (`ProviderEnvironment.access`). */
480
- export type ZeropsAccess = Pick<ProviderEnvironment, 'access'>
479
+ /**
480
+ * What Control lets a provider publish for one environment (`ProviderEnvironment.access`), and, for an environment of
481
+ * kind `dev`, the commit its bootstrap builds.
482
+ */
483
+ export type ZeropsAccess = Pick<ProviderEnvironment, 'access'> & { readonly dev?: ZeropsDevExecution }
481
484
 
482
485
  /**
483
486
  * The execution descriptor's lock-down at deploy (ADR-0050): Control's approved contract decides it, not the artifact,
@@ -506,6 +509,7 @@ export function lockedExecutionFor(
506
509
  storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
507
510
  runtimePackages: manifest.target.runtimePackages ?? [],
508
511
  sourceMaps: artifactUpload === undefined ? null : sourceMapUpload(artifactUpload, access.contract),
512
+ ...(granted.dev === undefined ? {} : { dev: granted.dev }),
509
513
  }
510
514
  }
511
515
 
@@ -222,6 +222,7 @@ const deployProxy = async (
222
222
  target: NormalizedZeropsNamespaceTarget,
223
223
  options: ZeropsNamespaceOptions,
224
224
  wait: ProviderNamespaceRepublishInput['wait'],
225
+ reuseActive: boolean,
225
226
  ): Promise<{ namespace: ProviderDeploymentNamespace; target: NormalizedZeropsNamespaceTarget }> => {
226
227
  let currentNamespace = namespace
227
228
  const manifest = compileNamespaceProxyManifest(namespace.id, input.members)
@@ -236,6 +237,7 @@ const deployProxy = async (
236
237
  iamKey: options.iamKey,
237
238
  signal: input.signal,
238
239
  wait,
240
+ reuseActive,
239
241
  sleep: options.sleep ?? defaultSleep,
240
242
  boundary: () => input.events.boundary(),
241
243
  checkpoint: async next => {
@@ -255,7 +257,7 @@ const republishNamespace = async (
255
257
  if (target.proxyServiceId === undefined || (target.proxyBuild === undefined && !target.ready)) {
256
258
  throw new Error(`Namespace ${namespace.id} is not ready`)
257
259
  }
258
- const deployed = await deployProxy(input, namespace, target, options, input.wait)
260
+ const deployed = await deployProxy(input, namespace, target, options, input.wait, true)
259
261
  return { namespace: deployed.namespace, settled: deployed.target.proxyBuild === undefined && deployed.target.ready }
260
262
  }
261
263
 
@@ -433,7 +435,7 @@ const runNamespaceMutation = async (
433
435
  }
434
436
  namespace = await checkpoint(input, namespace, target)
435
437
 
436
- const deployed = await deployProxy(input, namespace, target, options, 'active')
438
+ const deployed = await deployProxy(input, namespace, target, options, 'active', false)
437
439
  namespace = deployed.namespace
438
440
  target = { ...deployed.target, proxyConfigured: true }
439
441
  // After the deploy, because the platform refuses a service with no HTTP port — and never before `ready`
@@ -44,6 +44,11 @@ export interface ZeropsProxyRepublishInput {
44
44
  /** Aborts with `lease_lost` when the namespace lease goes, and with `cancel_requested` when a deploy is cancelled. */
45
45
  readonly signal: AbortSignal
46
46
  readonly wait: 'started' | 'active'
47
+ /**
48
+ * Reuse the proxy's ACTIVE version when it was built from this pin with this manifest identity. A deploy and a
49
+ * registry change pass true; provisioning and reconcile rebuild, so an operator's repair always reaches the platform.
50
+ */
51
+ readonly reuseActive: boolean
47
52
  /** Persist the target; the caller fences the write on the namespace lease. */
48
53
  readonly checkpoint: (target: ZeropsNamespaceTarget) => Promise<void>
49
54
  readonly boundary: () => Promise<void>
@@ -88,6 +93,28 @@ const writeProxyIam = async (input: ZeropsProxyRepublishInput, serviceId: string
88
93
  const proxySecretsDigest = async (secrets: ReadonlyMap<string, string>): Promise<string> =>
89
94
  digest([...secrets].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([key, value]) => `${key}=${value}`).join('\n'))
90
95
 
96
+ /**
97
+ * Whether the proxy already runs `manifestSha256` on this pin: the recorded deployment names both, and its version is
98
+ * still the service's ACTIVE one. Any doubt, a failed read included, answers false, and the proxy is rebuilt.
99
+ */
100
+ const servesManifest = async (input: ZeropsProxyRepublishInput, target: ZeropsNamespaceTarget, manifestSha256: string): Promise<boolean> => {
101
+ const deployed = target.proxyDeployment
102
+ const pin = target.proxySource
103
+ if (
104
+ target.ready !== true || deployed === undefined || deployed.manifestSha256 !== manifestSha256
105
+ || deployed.commitSha !== pin.commitSha || deployed.descriptorSha256 !== pin.descriptor.sha256
106
+ || deployed.repository.owner !== pin.repository.owner || deployed.repository.name !== pin.repository.name
107
+ ) return false
108
+ await input.boundary()
109
+ try {
110
+ const version = await input.api.getAppVersion({ appVersionId: deployed.appVersionId, signal: input.signal })
111
+ return version.id === deployed.appVersionId && version.status === ZEROPS_ACTIVE && version.serviceStackId === target.proxyServiceId
112
+ } catch {
113
+ input.signal.throwIfAborted()
114
+ return false
115
+ }
116
+ }
117
+
91
118
  /**
92
119
  * Rebuild the namespace proxy with `manifest` (ADR-0044). The attempt is recorded as `proxyBuild` and never
93
120
  * resumed: an inherited attempt not yet `triggered` is deleted, a triggered one is polled to a terminal state.
@@ -173,6 +200,8 @@ export async function republishProxy(input: ZeropsProxyRepublishInput): Promise<
173
200
  if (inherited.manifestSha256 === manifestSha256) return target
174
201
  }
175
202
 
203
+ if (input.reuseActive && target.proxyBuild === undefined && await servesManifest(input, target, manifestSha256)) return target
204
+
176
205
  if (input.source === undefined) throw new Error('Zerops namespace source client is required')
177
206
  const runId = crypto.randomUUID()
178
207
  const resolved = await input.source.resolve({
package/src/plan.ts CHANGED
@@ -48,7 +48,7 @@ export const buildZeropsPlan = (manifest: FabrikaManifest, target: ZeropsRuntime
48
48
  steps.push({
49
49
  id: 'raise-start-memory',
50
50
  kind: 'raise-start-memory',
51
- description: "Raise the deploy service's minimum RAM to what its init commands need",
51
+ description: "Raise the deploy service's minimum RAM to what its init commands need, and pin a dev environment to one container",
52
52
  })
53
53
  steps.push({
54
54
  id: 'size-database',
package/src/provider.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  import {
51
51
  PLATFORM_TYPESCRIPT_VERSION,
52
52
  renderLockedExecutionDescriptor,
53
+ type ZeropsDevExecution,
53
54
  zeropsJobProcesses,
54
55
  type ZeropsLockedExecution,
55
56
  zeropsStartMinRam,
@@ -108,6 +109,8 @@ export interface ZeropsDeployRun {
108
109
  readonly access?: ProviderApprovedAccess
109
110
  /** The run-scoped source-map destination Control minted; the build uploads to it. */
110
111
  readonly artifactUpload?: ProviderDeployInput['artifactUpload']
112
+ /** Present for an environment of kind `dev`: the launcher starts instead of the serving entry, on one container. */
113
+ readonly dev?: ZeropsDevExecution
111
114
  readonly dryRun: boolean
112
115
  readonly signal: AbortSignal
113
116
  readonly events: ProviderRunEvents
@@ -690,6 +693,7 @@ const withMinRam = (custom: ZeropsServiceAutoscaling, minRam: number): ZeropsSer
690
693
  */
691
694
  const raiseStartMemory = async (env: StepEnv): Promise<void> => {
692
695
  const { run, zerops, log, signal, dryRun } = env
696
+ if (run.dev !== undefined) await pinOneContainer(env)
693
697
  const locked = lockedExecutionFor(run.artifact, run, dryRun)
694
698
  const minRam = locked === null ? null : zeropsStartMinRam(locked)
695
699
  const hostname = run.artifact.target.deployService
@@ -712,6 +716,42 @@ const raiseStartMemory = async (env: StepEnv): Promise<void> => {
712
716
  log(` minimum RAM of ${hostname}: ${before ?? 'the platform default'} → ${after} GB`)
713
717
  }
714
718
 
719
+ /**
720
+ * A dev environment runs on exactly one container: the launcher writes the files it syncs into that container, and a
721
+ * second one would serve the bootstrap head. Sends the custom autoscaling whole with only the container counts changed,
722
+ * and decides by reading it back.
723
+ */
724
+ const pinOneContainer = async (env: StepEnv): Promise<void> => {
725
+ const { run, zerops, log, signal, dryRun } = env
726
+ const hostname = run.artifact.target.deployService
727
+ if (dryRun) {
728
+ log(` [dry-run] would pin ${hostname} to one container`)
729
+ return
730
+ }
731
+ assertRunning(signal)
732
+ const service = await zerops.api.getService({ serviceId: run.target.serviceId, signal })
733
+ const custom = service.customAutoscaling
734
+ const horizontal = service.currentAutoscaling?.horizontal
735
+ if (horizontal?.minContainerCount === 1 && horizontal.maxContainerCount === 1) {
736
+ log(` ${hostname} runs on one container, unchanged`)
737
+ return
738
+ }
739
+ if (custom === undefined) throw new Error(`zerops: ${hostname} did not report its autoscaling whole, so it cannot be pinned to one container`)
740
+ const process = await zerops.api.configureCustomAutoscaling({
741
+ serviceId: service.id,
742
+ autoscaling: { ...custom, horizontal: { minContainerCount: 1, maxContainerCount: 1 } },
743
+ signal,
744
+ })
745
+ if (process !== null) {
746
+ await waitForProcess({ api: zerops.api, processId: process.id, sleep: zerops.sleep, signal, label: `the autoscaling change of ${hostname}` })
747
+ }
748
+ const after = (await zerops.api.getService({ serviceId: service.id, signal })).currentAutoscaling?.horizontal
749
+ if (after?.minContainerCount !== 1 || after.maxContainerCount !== 1) {
750
+ throw new Error(`zerops: ${hostname} reads back ${after?.maxContainerCount ?? 'no'} container(s) after it was pinned to one`)
751
+ }
752
+ log(` ${hostname} pinned to one container`)
753
+ }
754
+
715
755
  /** What raising a minimum RAM calls; the deploy's collaborators, and the live platform-facts probe of the same path. */
716
756
  export interface MinRamRaise {
717
757
  zerops: Pick<ZeropsCollaborators, 'api' | 'sleep'>