@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 +9 -9
- package/src/api/client.ts +6 -0
- package/src/api/types.ts +12 -0
- package/src/control.ts +26 -0
- package/src/database-budget.ts +2 -2
- package/src/execution.ts +130 -27
- 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 +63 -7
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/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
|
@@ -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
|
|
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
|
|
285
|
-
// schema
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
const
|
|
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
|
-
|
|
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
|
-
...(
|
|
337
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|