@fabrika/provider-zerops 0.8.0 → 0.8.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.8.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.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",
54
54
  "yaml": "^2.9.1"
55
55
  }
56
56
  }
@@ -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
@@ -55,7 +55,7 @@ const FILE_SWEEP_SCHEDULE = '41 * * * *'
55
55
  export const ZEROPS_SCAN_MIN_RAM_GB = 2
56
56
 
57
57
  /**
58
- * The RAM in GB a container of an application with a database starts with. Its start script runs `fabrika db
58
+ * The RAM in GB a container of an application with a database starts with. Its init commands run `fabrika db
59
59
  * test-isolation`, whose CLI and `bun test` child together peak at 161 MiB of anonymous memory for the Notes example
60
60
  * and 225 MiB for the CRM example (measured locally, 2026-10-15), beside Bun's own mapped binary. Zerops' 0.125 GB
61
61
  * floor kills that within a second, faster than vertical autoscaling reacts.
@@ -243,6 +243,7 @@ const SOURCE_ROOT = '/build/source'
243
243
  const RUNTIME_ROOT = '/var/www'
244
244
  const FABRIKA_BIN = './node_modules/.bin/fabrika'
245
245
  const MIGRATE_SCRIPT = 'fabrika/migrate.generated.sh'
246
+ const VERIFY_SCRIPT = 'fabrika/verify.generated.sh'
246
247
  const START_SCRIPT = 'fabrika/start.generated.sh'
247
248
  const PLATFORM_TSCONFIG = 'fabrika/tsconfig.platform.json'
248
249
 
@@ -281,24 +282,26 @@ const migrateScript = (appId: string): readonly string[] => [
281
282
  `FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db migrate`,
282
283
  ]
283
284
 
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[] => [
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[] => [
289
290
  '#!/bin/sh',
290
291
  'set -eu',
291
292
  `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',
293
+ `FABRIKA_APP_ID=${appId} ${FABRIKA_BIN} db codegen --verify`,
294
+ `FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db test-isolation`,
300
295
  ]
301
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
+
302
305
  const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
303
306
 
304
307
  // After every check: a map is uploaded only for a build that can deploy. A failed upload never fails the build; the
@@ -324,7 +327,6 @@ const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record
324
327
  export function generatedBuildCommands(execution: ZeropsLockedExecution): readonly string[] {
325
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-]*')
326
329
  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
330
  return [
329
331
  INSTALL,
330
332
  'bun test --isolate --pass-with-no-tests',
@@ -333,8 +335,8 @@ export function generatedBuildCommands(execution: ZeropsLockedExecution): readon
333
335
  `cd /tmp && bun x --bun -p @fabrika/check@${execution.checkRelease} fabrika-check ${SOURCE_ROOT}`,
334
336
  writeFileCommand(PLATFORM_TSCONFIG, [PLATFORM_TSCONFIG_CONTENTS]),
335
337
  `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)),
338
+ ...initScripts(execution).map((script) => writeFileCommand(script.path, script.lines)),
339
+ writeFileCommand(START_SCRIPT, startScript),
338
340
  ...(execution.sourceMaps === null ? [] : [sourceMapUpload(execution.checkRelease)]),
339
341
  ]
340
342
  }
@@ -363,6 +365,16 @@ const jobCrontab = (contract: ZeropsContractRun): { readonly timing: string; rea
363
365
  /** How many job processes the crontab may run at once, each opening the application's database. */
364
366
  export const zeropsJobProcesses = (contract: ZeropsContractRun): number => jobCrontab(contract).length
365
367
 
368
+ /** The commands every container runs before `run.start`, in order; any failure stops the activation. */
369
+ export function generatedInitCommands(execution: ZeropsLockedExecution): string[] {
370
+ return [
371
+ // A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
372
+ // them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
373
+ ...(execution.contract.scan === true ? [`${FRESHCLAM} || echo 'fabrika: freshclam did not refresh the malware signatures'`] : []),
374
+ ...initScripts(execution).map((script) => `sh ${RUNTIME_ROOT}/${script.path}`),
375
+ ]
376
+ }
377
+
366
378
  /** The generated selected setup: the authored build kept, everything else the platform's. */
367
379
  export function renderLockedSetup(
368
380
  setup: string,
@@ -385,12 +397,7 @@ export function renderLockedSetup(
385
397
  const packages = sortedUnique([...execution.runtimePackages, ...(scan ? [SCANNER_PACKAGE] : [])])
386
398
  // Every container keeps its own signatures; one scan pass at a time holds the job's lease.
387
399
  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
- ]
400
+ const initCommands = generatedInitCommands(execution)
394
401
  const prepareCommands = packages.length === 0 ? [] : [`sudo apk add --no-cache ${packages.join(' ')}`, ...(scan ? [FRESHCLAM] : [])]
395
402
  return {
396
403
  setup,
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",
52
52
  })
53
53
  steps.push({
54
54
  id: 'size-database',
package/src/provider.ts CHANGED
@@ -280,14 +280,27 @@ export function interpolateManifest(
280
280
  }
281
281
 
282
282
  /** Human-readable failure for a version that finished in a non-`ACTIVE` state. */
283
- const terminalFailure = (version: ZeropsAppVersion): string => {
283
+ const terminalFailure = (version: ZeropsAppVersion, checks: boolean): string => {
284
284
  if (version.status === 'BACKUP') {
285
285
  return `app-version ${version.id} was superseded by a newer version before it went live`
286
286
  }
287
287
  if (version.status === 'CANCELLED') {
288
288
  return `app-version ${version.id} was cancelled`
289
289
  }
290
- return `Zerops pipeline failed: app-version ${version.id} is ${version.status ?? 'in an unknown state'}`
290
+ return `Zerops pipeline failed: app-version ${version.id} is ${version.status ?? 'in an unknown state'}${activationHint(version, checks)}`
291
+ }
292
+
293
+ /**
294
+ * What a version that built but did not activate tells the operator. Zerops' own log of it is the refused readiness
295
+ * attempts, which read as a server that never started whatever stopped it. `checks`: the generated init commands run
296
+ * the migrations, the schema check and the isolation tests, as for a contract application with a database.
297
+ */
298
+ const activationHint = (version: ZeropsAppVersion, checks: boolean): string => {
299
+ if (version.status !== 'DEPLOY_FAILED') return ''
300
+ const failed = ': an init command failed or the readiness check never answered'
301
+ return checks
302
+ ? `${failed}. The init commands run the migrations, the schema check and the generated isolation tests; the runtime lines above show how far they got`
303
+ : failed
291
304
  }
292
305
 
293
306
  /**
@@ -415,7 +428,7 @@ const openLog = async (env: StepEnv): Promise<ZeropsLogAccess | null> => {
415
428
  * poll loop without telling it leaves a build container burning through the pipeline's hour. A lost lease
416
429
  * gives no such permission (ADR-0044), so it stops without touching the build.
417
430
  */
418
- const awaitVersion = async (env: StepEnv, appVersionId: string, processId: string | undefined): Promise<void> => {
431
+ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: string | undefined, checks: boolean): Promise<void> => {
419
432
  const { zerops, signal, log } = env
420
433
  const access = await openLog(env)
421
434
  const seen = new Set<string>()
@@ -461,14 +474,16 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
461
474
 
462
475
  if (process?.status !== undefined && ZEROPS_PROCESS_TERMINAL.has(process.status) && process.status !== ZEROPS_PROCESS_FINISHED) {
463
476
  throw new Error(
464
- `zerops: pipeline process ${process.id} is ${process.status} while app-version ${appVersionId} is ${version.status ?? 'in an unknown state'}`,
477
+ `zerops: pipeline process ${process.id} is ${process.status} while app-version ${appVersionId} is ${version.status ?? 'in an unknown state'}${
478
+ activationHint(version, checks)
479
+ }`,
465
480
  )
466
481
  }
467
482
  if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
468
483
  if (version.status === ZEROPS_ACTIVE) {
469
484
  return
470
485
  }
471
- throw new Error(terminalFailure(version))
486
+ throw new Error(terminalFailure(version, checks))
472
487
  }
473
488
  if (Date.now() > env.deadlineMs) await timedOut()
474
489
  await zerops.sleep(POLL_INTERVAL_MS, signal)
@@ -669,7 +684,7 @@ const withMinRam = (custom: ZeropsServiceAutoscaling, minRam: number): ZeropsSer
669
684
  })
670
685
 
671
686
  /**
672
- * Raises the deploy service's minimum RAM to what the generated start script needs (`zeropsStartMinRam`), before the
687
+ * Raises the deploy service's minimum RAM to what the generated init commands need (`zeropsStartMinRam`), before the
673
688
  * version's containers start. An import cannot: Zerops ignores a changed `verticalAutoscaling` of an existing service,
674
689
  * so an existing service converges here, on every deploy. It only raises, and decides by reading the service back.
675
690
  */
@@ -970,7 +985,8 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
970
985
  }
971
986
  const appVersionId = requireVersion(env, 'await-version')
972
987
  assertRunning(signal)
973
- await awaitVersion(env, appVersionId, attempt.processId)
988
+ const locked = lockedExecutionFor(artifact, run, dryRun)
989
+ await awaitVersion(env, appVersionId, attempt.processId, locked !== null && locked.databaseUrl !== null)
974
990
  return
975
991
  }
976
992