@fabrika/provider-zerops 0.8.1 → 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 +9 -9
- package/src/api/client.ts +6 -0
- package/src/api/types.ts +12 -0
- package/src/control.ts +26 -0
- package/src/execution.ts +105 -9
- package/src/installation/generated/platform-proxy-manifest.ts +20 -0
- package/src/manifest.ts +9 -5
- package/src/namespace/provisioning.ts +4 -2
- package/src/namespace/proxy-attempt.ts +29 -0
- package/src/plan.ts +1 -1
- package/src/provider.ts +40 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "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.
|
|
47
|
-
"@fabrika/auth-core": "0.
|
|
48
|
-
"@fabrika/email": "0.
|
|
49
|
-
"@fabrika/iam-contract": "0.
|
|
50
|
-
"@fabrika/installation-init": "0.
|
|
51
|
-
"@fabrika/operations-contract": "0.
|
|
52
|
-
"@fabrika/provider-contract": "0.
|
|
53
|
-
"@fabrika/proxy-core": "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()
|
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,6 +262,7 @@ 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
268
|
/** The compiler options of the platform's type check; the application's own `tsconfig.json` and its `paths` are not read. */
|
|
@@ -294,13 +312,85 @@ const verifyScript = (appId: string): readonly string[] => [
|
|
|
294
312
|
`FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db test-isolation`,
|
|
295
313
|
]
|
|
296
314
|
|
|
297
|
-
|
|
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
|
+
]
|
|
298
360
|
|
|
299
|
-
/**
|
|
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. */
|
|
300
383
|
const initScripts = (execution: ZeropsLockedExecution): readonly { readonly path: string; readonly lines: readonly string[] }[] =>
|
|
301
384
|
execution.databaseUrl === null
|
|
302
385
|
? []
|
|
303
|
-
: [
|
|
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]
|
|
304
394
|
|
|
305
395
|
const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
|
|
306
396
|
|
|
@@ -324,7 +414,7 @@ const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record
|
|
|
324
414
|
* run first: they are authored code, so everything after them is reinstalled, regenerated or checked again. They run
|
|
325
415
|
* `--isolate`, as the application skill runs them: a test file's DOM globals otherwise leak into the next file.
|
|
326
416
|
*/
|
|
327
|
-
export function generatedBuildCommands(execution: ZeropsLockedExecution): readonly string[] {
|
|
417
|
+
export function generatedBuildCommands(execution: ZeropsLockedExecution, authoredSpa: readonly string[] = []): readonly string[] {
|
|
328
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-]*')
|
|
329
419
|
if (!FRAMEWORK_RELEASE_PATTERN.test(execution.checkRelease)) throw new Error('zerops: the checker release must be a release version')
|
|
330
420
|
return [
|
|
@@ -336,7 +426,9 @@ export function generatedBuildCommands(execution: ZeropsLockedExecution): readon
|
|
|
336
426
|
writeFileCommand(PLATFORM_TSCONFIG, [PLATFORM_TSCONFIG_CONTENTS]),
|
|
337
427
|
`cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${SOURCE_ROOT}/${PLATFORM_TSCONFIG}`,
|
|
338
428
|
...initScripts(execution).map((script) => writeFileCommand(script.path, script.lines)),
|
|
339
|
-
|
|
429
|
+
...(execution.dev === undefined
|
|
430
|
+
? [writeFileCommand(START_SCRIPT, startScript)]
|
|
431
|
+
: [writeFileCommand(START_SCRIPT, devStartScript), writeBase64Command(DEV_LAUNCHER_CONFIG_FILE, devLauncherConfigJson(execution, authoredSpa))]),
|
|
340
432
|
...(execution.sourceMaps === null ? [] : [sourceMapUpload(execution.checkRelease)]),
|
|
341
433
|
]
|
|
342
434
|
}
|
|
@@ -371,7 +463,7 @@ export function generatedInitCommands(execution: ZeropsLockedExecution): string[
|
|
|
371
463
|
// A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
|
|
372
464
|
// them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
|
|
373
465
|
...(execution.contract.scan === true ? [`${FRESHCLAM} || echo 'fabrika: freshclam did not refresh the malware signatures'`] : []),
|
|
374
|
-
...
|
|
466
|
+
...initCommandScripts(execution).map((script) => `sh ${RUNTIME_ROOT}/${script}`),
|
|
375
467
|
]
|
|
376
468
|
}
|
|
377
469
|
|
|
@@ -404,13 +496,13 @@ export function renderLockedSetup(
|
|
|
404
496
|
build: {
|
|
405
497
|
base: authored.base ?? [ZEROPS_APPLICATION_RUNTIME],
|
|
406
498
|
...(authored.os === null ? {} : { os: authored.os }),
|
|
407
|
-
buildCommands: [...authored.commands, ...generatedBuildCommands(execution)],
|
|
499
|
+
buildCommands: [...authored.commands, ...generatedBuildCommands(execution, authored.commands)],
|
|
408
500
|
...(execution.sourceMaps === null ? {} : { envVariables: sourceMapBuildVariables(execution.sourceMaps) }),
|
|
409
501
|
deployFiles: ['./'],
|
|
410
502
|
cache: ['node_modules'],
|
|
411
503
|
},
|
|
412
504
|
deploy: {
|
|
413
|
-
readinessCheck: {
|
|
505
|
+
readinessCheck: { exec: { command: readinessCommand(contract.readinessPath) }, retryPeriod: '10s', failureTimeout: '180s' },
|
|
414
506
|
},
|
|
415
507
|
run: {
|
|
416
508
|
base: ZEROPS_APPLICATION_RUNTIME,
|
|
@@ -418,7 +510,11 @@ export function renderLockedSetup(
|
|
|
418
510
|
...(initCommands.length === 0 ? {} : { initCommands }),
|
|
419
511
|
start: `sh ${RUNTIME_ROOT}/${START_SCRIPT}`,
|
|
420
512
|
ports: [{ port: ZEROPS_APPLICATION_PORT, httpSupport: true }],
|
|
421
|
-
|
|
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
|
+
: {}),
|
|
422
518
|
...(crontab.length === 0 ? {} : { crontab }),
|
|
423
519
|
envVariables,
|
|
424
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(
|
|
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
|
-
/**
|
|
480
|
-
|
|
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'>
|