@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 +9 -9
- package/src/database-budget.ts +2 -2
- package/src/execution.ts +30 -23
- package/src/plan.ts +1 -1
- package/src/provider.ts +23 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
47
|
-
"@fabrika/auth-core": "0.8.
|
|
48
|
-
"@fabrika/email": "0.8.
|
|
49
|
-
"@fabrika/iam-contract": "0.8.
|
|
50
|
-
"@fabrika/installation-init": "0.8.
|
|
51
|
-
"@fabrika/operations-contract": "0.8.
|
|
52
|
-
"@fabrika/provider-contract": "0.8.
|
|
53
|
-
"@fabrika/proxy-core": "0.8.
|
|
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
|
}
|
package/src/database-budget.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
285
|
-
// schema
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
const
|
|
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
|
-
|
|
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
|
-
...(
|
|
337
|
-
writeFileCommand(START_SCRIPT, startScript
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|