@fabrika/provider-zerops 0.8.0 → 0.9.0-dev.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.8.0",
3
+ "version": "0.9.0-dev.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,14 +43,14 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "zod": "^3.25.76",
46
- "@fabrika/auth": "0.8.0",
47
- "@fabrika/auth-core": "0.8.0",
48
- "@fabrika/email": "0.8.0",
49
- "@fabrika/iam-contract": "0.8.0",
50
- "@fabrika/installation-init": "0.8.0",
51
- "@fabrika/operations-contract": "0.8.0",
52
- "@fabrika/provider-contract": "0.8.0",
53
- "@fabrika/proxy-core": "0.8.0",
46
+ "@fabrika/auth": "0.9.0-dev.1",
47
+ "@fabrika/auth-core": "0.9.0-dev.1",
48
+ "@fabrika/email": "0.9.0-dev.1",
49
+ "@fabrika/iam-contract": "0.9.0-dev.1",
50
+ "@fabrika/installation-init": "0.9.0-dev.1",
51
+ "@fabrika/operations-contract": "0.9.0-dev.1",
52
+ "@fabrika/provider-contract": "0.9.0-dev.1",
53
+ "@fabrika/proxy-core": "0.9.0-dev.1",
54
54
  "yaml": "^2.9.1"
55
55
  }
56
56
  }
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()
@@ -15,7 +15,7 @@ export const ZEROPS_POSTGRES_MIN_RAM_GB = 0.25
15
15
  export const ZEROPS_POSTGRES_UNAVAILABLE_CONNECTIONS = 7
16
16
 
17
17
  /**
18
- * The most connections one container's start script holds at once: `fabrika db test-isolation` peaks at 4 since the
18
+ * The most connections one container's init commands hold at once: `fabrika db test-isolation` peaks at 4 since the
19
19
  * test kit opens a file's connections together, and at 5 with a `@fabrika/db` before that (measured locally,
20
20
  * 2026-10-16, with the Notes and CRM examples). Migrating and reading the schema take fewer, one after the other.
21
21
  */
@@ -53,7 +53,7 @@ export function zeropsPostgresMaxConnections(ramGB: number, profile: string | un
53
53
  const poolHolders = (demand: ZeropsDatabaseDemand): number => 2 * demand.maxContainers + demand.jobs
54
54
 
55
55
  /**
56
- * The connections each process may open so that all of them, with one container's start script, stay within what the
56
+ * The connections each process may open so that all of them, with one container's init commands, stay within what the
57
57
  * application's roles can take; `null` when that is below `MIN_PROCESS_CONNECTIONS`.
58
58
  */
59
59
  export function zeropsProcessConnections(maxConnections: number, demand: ZeropsDatabaseDemand): number | null {
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,
@@ -55,7 +59,7 @@ const FILE_SWEEP_SCHEDULE = '41 * * * *'
55
59
  export const ZEROPS_SCAN_MIN_RAM_GB = 2
56
60
 
57
61
  /**
58
- * The RAM in GB a container of an application with a database starts with. Its start script runs `fabrika db
62
+ * The RAM in GB a container of an application with a database starts with. Its init commands run `fabrika db
59
63
  * test-isolation`, whose CLI and `bun test` child together peak at 161 MiB of anonymous memory for the Notes example
60
64
  * and 225 MiB for the CRM example (measured locally, 2026-10-15), beside Bun's own mapped binary. Zerops' 0.125 GB
61
65
  * floor kills that within a second, faster than vertical autoscaling reacts.
@@ -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
  /**
@@ -243,7 +260,9 @@ const SOURCE_ROOT = '/build/source'
243
260
  const RUNTIME_ROOT = '/var/www'
244
261
  const FABRIKA_BIN = './node_modules/.bin/fabrika'
245
262
  const MIGRATE_SCRIPT = 'fabrika/migrate.generated.sh'
263
+ const VERIFY_SCRIPT = 'fabrika/verify.generated.sh'
246
264
  const START_SCRIPT = 'fabrika/start.generated.sh'
265
+ const DEV_INIT_SCRIPT = 'fabrika/dev-init.generated.sh'
247
266
  const PLATFORM_TSCONFIG = 'fabrika/tsconfig.platform.json'
248
267
 
249
268
  /** The compiler options of the platform's type check; the application's own `tsconfig.json` and its `paths` are not read. */
@@ -281,24 +300,98 @@ const migrateScript = (appId: string): readonly string[] => [
281
300
  `FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db migrate`,
282
301
  ]
283
302
 
284
- // The server starts only after the migrations it expects are applied, the committed schema types match the migrated
285
- // schema and every generated isolation test passed on a throwaway schema, so any failure fails the readiness check,
286
- // whether or not a failed `initCommands` stops the activation (it does, verified live). The build has no
287
- // database, so this is where the types and the generated tests run.
288
- const startScript = (appId: string, database: boolean): readonly string[] => [
303
+ // The committed schema types must match the migrated schema and every generated isolation test must pass on a throwaway
304
+ // schema. The build has no database, so they run at container start, after the migrations, as init commands: a failed
305
+ // init command stops the activation, and the readiness window opens only with `run.start`, after the init commands (both
306
+ // verified live), so a run as long as the CRM's ~3 minutes of generated cases does not race it.
307
+ const verifyScript = (appId: string): readonly string[] => [
289
308
  '#!/bin/sh',
290
309
  'set -eu',
291
310
  `cd ${RUNTIME_ROOT}`,
292
- ...(database
293
- ? [
294
- `sh ${RUNTIME_ROOT}/${MIGRATE_SCRIPT}`,
295
- `FABRIKA_APP_ID=${appId} ${FABRIKA_BIN} db codegen --verify`,
296
- `FABRIKA_APP_ID=${appId} ${FABRIKA_BIN} db test-isolation`,
297
- ]
298
- : []),
299
- 'exec bun fabrika/entry.generated.ts',
311
+ `FABRIKA_APP_ID=${appId} ${FABRIKA_BIN} db codegen --verify`,
312
+ `FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db test-isolation`,
300
313
  ]
301
314
 
315
+ // The readiness check is retried no sooner than every 10 s (the platform's floor), and a server is rarely listening at
316
+ // the first attempt, one second after `run.start`. So one attempt waits for the health endpoint itself, for less than
317
+ // the retry period; it passes only on a 2xx, like the `httpGet` it replaces.
318
+ const readinessCommand = (path: string): string => {
319
+ if (!READINESS_PATH_PATTERN.test(path)) throw new Error('zerops: a readiness path must be a literal path of unreserved characters')
320
+ const url = `http://127.0.0.1:${ZEROPS_APPLICATION_PORT}${path}`
321
+ 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)"`
322
+ }
323
+
324
+ const SERVE_ENTRY = 'fabrika/entry.generated.ts'
325
+
326
+ const startScript: readonly string[] = ['#!/bin/sh', 'set -eu', `cd ${RUNTIME_ROOT}`, `exec bun ${SERVE_ENTRY}`]
327
+
328
+ // A dev environment starts the launcher of the application's own framework release, which runs the serving entry as its
329
+ // child. Double quotes inside: a generated file line may hold no single quote.
330
+ const devStartScript: readonly string[] = [
331
+ '#!/bin/sh',
332
+ 'set -eu',
333
+ `cd ${RUNTIME_ROOT}`,
334
+ `exec bun -e "await import(\\"${DEV_LAUNCHER_MODULE}\\").then((dev) => dev.runDevLauncher(process.env))"`,
335
+ ]
336
+
337
+ // The init commands of a dev container migrate and verify only what the bootstrap run built. A container that restarts
338
+ // after a stop keeps the files the launcher synced since, and its state file: the launcher reconciles that one itself.
339
+ const devInitScript: readonly string[] = [
340
+ '#!/bin/sh',
341
+ 'set -eu',
342
+ `cd ${RUNTIME_ROOT}`,
343
+ `if [ -f ${DEV_LAUNCHER_STATE_FILE} ]; then exit 0; fi`,
344
+ `sh ${RUNTIME_ROOT}/${MIGRATE_SCRIPT}`,
345
+ `sh ${RUNTIME_ROOT}/${VERIFY_SCRIPT}`,
346
+ ]
347
+
348
+ /** One command writing `content` to `path` through base64: the content may hold quotes and expansions, the command none. */
349
+ const writeBase64Command = (path: string, content: string): string => {
350
+ const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(content)))
351
+ return `printf '%s' '${encoded}' | base64 -d > ${path}`
352
+ }
353
+
354
+ /** The build's check chain as the dev launcher runs it in the application root, on what the container holds. */
355
+ const devChecks = (release: string): DevLauncherConfig['checks'] => [
356
+ { name: 'tests', command: 'bun test --isolate --pass-with-no-tests' },
357
+ { name: 'fabrika-check', command: `cd /tmp && bun x --bun -p @fabrika/check@${release} fabrika-check ${RUNTIME_ROOT}` },
358
+ { name: 'tsc', command: `cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${RUNTIME_ROOT}/${PLATFORM_TSCONFIG}` },
359
+ ]
360
+
361
+ /** What the dev launcher reads (`DEV_LAUNCHER_CONFIG_FILE`): the platform's, written after the checks. */
362
+ export function devLauncherConfig(execution: ZeropsLockedExecution, authoredSpa: readonly string[]): DevLauncherConfig {
363
+ const dev = execution.dev
364
+ if (dev === undefined) throw new Error('zerops: a dev launcher configuration needs a dev environment')
365
+ if (!/^[0-9a-f]{40}$/.test(dev.head)) throw new Error('zerops: a dev environment builds an exact commit')
366
+ return {
367
+ format: 1,
368
+ appId: execution.appId,
369
+ head: dev.head,
370
+ port: ZEROPS_APPLICATION_PORT,
371
+ healthPath: execution.contract.readinessPath,
372
+ serve: ['bun', SERVE_ENTRY],
373
+ migrate: [FABRIKA_BIN, 'db', 'migrate'],
374
+ spaBuild: authoredSpa,
375
+ checks: devChecks(execution.checkRelease),
376
+ }
377
+ }
378
+
379
+ const devLauncherConfigJson = (execution: ZeropsLockedExecution, authoredSpa: readonly string[]): string =>
380
+ JSON.stringify(devLauncherConfig(execution, authoredSpa))
381
+
382
+ /** The scripts the build writes, and the ones every container's init commands run, in this order. */
383
+ const initScripts = (execution: ZeropsLockedExecution): readonly { readonly path: string; readonly lines: readonly string[] }[] =>
384
+ execution.databaseUrl === null
385
+ ? []
386
+ : [
387
+ { path: MIGRATE_SCRIPT, lines: migrateScript(execution.appId) },
388
+ { path: VERIFY_SCRIPT, lines: verifyScript(execution.appId) },
389
+ ...(execution.dev === undefined ? [] : [{ path: DEV_INIT_SCRIPT, lines: devInitScript }]),
390
+ ]
391
+
392
+ const initCommandScripts = (execution: ZeropsLockedExecution): readonly string[] =>
393
+ execution.databaseUrl === null ? [] : execution.dev === undefined ? [MIGRATE_SCRIPT, VERIFY_SCRIPT] : [DEV_INIT_SCRIPT]
394
+
302
395
  const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
303
396
 
304
397
  // After every check: a map is uploaded only for a build that can deploy. A failed upload never fails the build; the
@@ -321,10 +414,9 @@ const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record
321
414
  * run first: they are authored code, so everything after them is reinstalled, regenerated or checked again. They run
322
415
  * `--isolate`, as the application skill runs them: a test file's DOM globals otherwise leak into the next file.
323
416
  */
324
- export function generatedBuildCommands(execution: ZeropsLockedExecution): readonly string[] {
417
+ export function generatedBuildCommands(execution: ZeropsLockedExecution, authoredSpa: readonly string[] = []): readonly string[] {
325
418
  if (!LOCKED_APP_ID_PATTERN.test(execution.appId)) throw new Error('zerops: a contract application id must match [a-z0-9][a-z0-9-]*')
326
419
  if (!FRAMEWORK_RELEASE_PATTERN.test(execution.checkRelease)) throw new Error('zerops: the checker release must be a release version')
327
- const database = execution.databaseUrl !== null
328
420
  return [
329
421
  INSTALL,
330
422
  'bun test --isolate --pass-with-no-tests',
@@ -333,8 +425,10 @@ export function generatedBuildCommands(execution: ZeropsLockedExecution): readon
333
425
  `cd /tmp && bun x --bun -p @fabrika/check@${execution.checkRelease} fabrika-check ${SOURCE_ROOT}`,
334
426
  writeFileCommand(PLATFORM_TSCONFIG, [PLATFORM_TSCONFIG_CONTENTS]),
335
427
  `cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${SOURCE_ROOT}/${PLATFORM_TSCONFIG}`,
336
- ...(database ? [writeFileCommand(MIGRATE_SCRIPT, migrateScript(execution.appId))] : []),
337
- writeFileCommand(START_SCRIPT, startScript(execution.appId, database)),
428
+ ...initScripts(execution).map((script) => writeFileCommand(script.path, script.lines)),
429
+ ...(execution.dev === undefined
430
+ ? [writeFileCommand(START_SCRIPT, startScript)]
431
+ : [writeFileCommand(START_SCRIPT, devStartScript), writeBase64Command(DEV_LAUNCHER_CONFIG_FILE, devLauncherConfigJson(execution, authoredSpa))]),
338
432
  ...(execution.sourceMaps === null ? [] : [sourceMapUpload(execution.checkRelease)]),
339
433
  ]
340
434
  }
@@ -363,6 +457,16 @@ const jobCrontab = (contract: ZeropsContractRun): { readonly timing: string; rea
363
457
  /** How many job processes the crontab may run at once, each opening the application's database. */
364
458
  export const zeropsJobProcesses = (contract: ZeropsContractRun): number => jobCrontab(contract).length
365
459
 
460
+ /** The commands every container runs before `run.start`, in order; any failure stops the activation. */
461
+ export function generatedInitCommands(execution: ZeropsLockedExecution): string[] {
462
+ return [
463
+ // A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
464
+ // them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
465
+ ...(execution.contract.scan === true ? [`${FRESHCLAM} || echo 'fabrika: freshclam did not refresh the malware signatures'`] : []),
466
+ ...initCommandScripts(execution).map((script) => `sh ${RUNTIME_ROOT}/${script}`),
467
+ ]
468
+ }
469
+
366
470
  /** The generated selected setup: the authored build kept, everything else the platform's. */
367
471
  export function renderLockedSetup(
368
472
  setup: string,
@@ -385,25 +489,20 @@ export function renderLockedSetup(
385
489
  const packages = sortedUnique([...execution.runtimePackages, ...(scan ? [SCANNER_PACKAGE] : [])])
386
490
  // Every container keeps its own signatures; one scan pass at a time holds the job's lease.
387
491
  const crontab = [...jobCrontab(contract), ...(scan ? [{ timing: FRESHCLAM_SCHEDULE, command: FRESHCLAM, allContainers: true }] : [])]
388
- // A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
389
- // them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
390
- const initCommands = [
391
- ...(scan ? [`${FRESHCLAM} || echo 'fabrika: freshclam did not refresh the malware signatures'`] : []),
392
- ...(execution.databaseUrl === null ? [] : [`sh ${RUNTIME_ROOT}/${MIGRATE_SCRIPT}`]),
393
- ]
492
+ const initCommands = generatedInitCommands(execution)
394
493
  const prepareCommands = packages.length === 0 ? [] : [`sudo apk add --no-cache ${packages.join(' ')}`, ...(scan ? [FRESHCLAM] : [])]
395
494
  return {
396
495
  setup,
397
496
  build: {
398
497
  base: authored.base ?? [ZEROPS_APPLICATION_RUNTIME],
399
498
  ...(authored.os === null ? {} : { os: authored.os }),
400
- buildCommands: [...authored.commands, ...generatedBuildCommands(execution)],
499
+ buildCommands: [...authored.commands, ...generatedBuildCommands(execution, authored.commands)],
401
500
  ...(execution.sourceMaps === null ? {} : { envVariables: sourceMapBuildVariables(execution.sourceMaps) }),
402
501
  deployFiles: ['./'],
403
502
  cache: ['node_modules'],
404
503
  },
405
504
  deploy: {
406
- readinessCheck: { httpGet: probe, retryPeriod: '10s', failureTimeout: '180s' },
505
+ readinessCheck: { exec: { command: readinessCommand(contract.readinessPath) }, retryPeriod: '10s', failureTimeout: '180s' },
407
506
  },
408
507
  run: {
409
508
  base: ZEROPS_APPLICATION_RUNTIME,
@@ -411,7 +510,11 @@ export function renderLockedSetup(
411
510
  ...(initCommands.length === 0 ? {} : { initCommands }),
412
511
  start: `sh ${RUNTIME_ROOT}/${START_SCRIPT}`,
413
512
  ports: [{ port: ZEROPS_APPLICATION_PORT, httpSupport: true }],
414
- healthCheck: { httpGet: probe, execPeriod: '10s', disconnectTimeout: '30s', recoveryTimeout: '10s', failureTimeout: '120s' },
513
+ // A dev container keeps its launcher running while the application it respawns fails: Zerops restarting it
514
+ // would only boot the same failing head again.
515
+ ...(execution.dev === undefined
516
+ ? { healthCheck: { httpGet: probe, execPeriod: '10s', disconnectTimeout: '30s', recoveryTimeout: '10s', failureTimeout: '120s' } }
517
+ : {}),
415
518
  ...(crontab.length === 0 ? {} : { crontab }),
416
519
  envVariables,
417
520
  },
@@ -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 start script needs",
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
@@ -280,14 +283,27 @@ export function interpolateManifest(
280
283
  }
281
284
 
282
285
  /** Human-readable failure for a version that finished in a non-`ACTIVE` state. */
283
- const terminalFailure = (version: ZeropsAppVersion): string => {
286
+ const terminalFailure = (version: ZeropsAppVersion, checks: boolean): string => {
284
287
  if (version.status === 'BACKUP') {
285
288
  return `app-version ${version.id} was superseded by a newer version before it went live`
286
289
  }
287
290
  if (version.status === 'CANCELLED') {
288
291
  return `app-version ${version.id} was cancelled`
289
292
  }
290
- return `Zerops pipeline failed: app-version ${version.id} is ${version.status ?? 'in an unknown state'}`
293
+ return `Zerops pipeline failed: app-version ${version.id} is ${version.status ?? 'in an unknown state'}${activationHint(version, checks)}`
294
+ }
295
+
296
+ /**
297
+ * What a version that built but did not activate tells the operator. Zerops' own log of it is the refused readiness
298
+ * attempts, which read as a server that never started whatever stopped it. `checks`: the generated init commands run
299
+ * the migrations, the schema check and the isolation tests, as for a contract application with a database.
300
+ */
301
+ const activationHint = (version: ZeropsAppVersion, checks: boolean): string => {
302
+ if (version.status !== 'DEPLOY_FAILED') return ''
303
+ const failed = ': an init command failed or the readiness check never answered'
304
+ return checks
305
+ ? `${failed}. The init commands run the migrations, the schema check and the generated isolation tests; the runtime lines above show how far they got`
306
+ : failed
291
307
  }
292
308
 
293
309
  /**
@@ -415,7 +431,7 @@ const openLog = async (env: StepEnv): Promise<ZeropsLogAccess | null> => {
415
431
  * poll loop without telling it leaves a build container burning through the pipeline's hour. A lost lease
416
432
  * gives no such permission (ADR-0044), so it stops without touching the build.
417
433
  */
418
- const awaitVersion = async (env: StepEnv, appVersionId: string, processId: string | undefined): Promise<void> => {
434
+ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: string | undefined, checks: boolean): Promise<void> => {
419
435
  const { zerops, signal, log } = env
420
436
  const access = await openLog(env)
421
437
  const seen = new Set<string>()
@@ -461,14 +477,16 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
461
477
 
462
478
  if (process?.status !== undefined && ZEROPS_PROCESS_TERMINAL.has(process.status) && process.status !== ZEROPS_PROCESS_FINISHED) {
463
479
  throw new Error(
464
- `zerops: pipeline process ${process.id} is ${process.status} while app-version ${appVersionId} is ${version.status ?? 'in an unknown state'}`,
480
+ `zerops: pipeline process ${process.id} is ${process.status} while app-version ${appVersionId} is ${version.status ?? 'in an unknown state'}${
481
+ activationHint(version, checks)
482
+ }`,
465
483
  )
466
484
  }
467
485
  if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
468
486
  if (version.status === ZEROPS_ACTIVE) {
469
487
  return
470
488
  }
471
- throw new Error(terminalFailure(version))
489
+ throw new Error(terminalFailure(version, checks))
472
490
  }
473
491
  if (Date.now() > env.deadlineMs) await timedOut()
474
492
  await zerops.sleep(POLL_INTERVAL_MS, signal)
@@ -669,12 +687,13 @@ const withMinRam = (custom: ZeropsServiceAutoscaling, minRam: number): ZeropsSer
669
687
  })
670
688
 
671
689
  /**
672
- * Raises the deploy service's minimum RAM to what the generated start script needs (`zeropsStartMinRam`), before the
690
+ * Raises the deploy service's minimum RAM to what the generated init commands need (`zeropsStartMinRam`), before the
673
691
  * version's containers start. An import cannot: Zerops ignores a changed `verticalAutoscaling` of an existing service,
674
692
  * so an existing service converges here, on every deploy. It only raises, and decides by reading the service back.
675
693
  */
676
694
  const raiseStartMemory = async (env: StepEnv): Promise<void> => {
677
695
  const { run, zerops, log, signal, dryRun } = env
696
+ if (run.dev !== undefined) await pinOneContainer(env)
678
697
  const locked = lockedExecutionFor(run.artifact, run, dryRun)
679
698
  const minRam = locked === null ? null : zeropsStartMinRam(locked)
680
699
  const hostname = run.artifact.target.deployService
@@ -697,6 +716,42 @@ const raiseStartMemory = async (env: StepEnv): Promise<void> => {
697
716
  log(` minimum RAM of ${hostname}: ${before ?? 'the platform default'} → ${after} GB`)
698
717
  }
699
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
+
700
755
  /** What raising a minimum RAM calls; the deploy's collaborators, and the live platform-facts probe of the same path. */
701
756
  export interface MinRamRaise {
702
757
  zerops: Pick<ZeropsCollaborators, 'api' | 'sleep'>
@@ -970,7 +1025,8 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
970
1025
  }
971
1026
  const appVersionId = requireVersion(env, 'await-version')
972
1027
  assertRunning(signal)
973
- await awaitVersion(env, appVersionId, attempt.processId)
1028
+ const locked = lockedExecutionFor(artifact, run, dryRun)
1029
+ await awaitVersion(env, appVersionId, attempt.processId, locked !== null && locked.databaseUrl !== null)
974
1030
  return
975
1031
  }
976
1032