@fabrika/provider-zerops 0.8.1 → 0.9.0-dev.2
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 +15 -14
- package/src/api/client.ts +6 -0
- package/src/api/types.ts +12 -0
- package/src/control.ts +26 -0
- package/src/execution.ts +147 -14
- 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/types/src/api/client.d.ts +5 -0
- package/types/src/api/errors.d.ts +24 -0
- package/types/src/api/polling.d.ts +41 -0
- package/types/src/api/readers.d.ts +977 -0
- package/types/src/api/service-env-limit.d.ts +17 -0
- package/types/src/api/service-env-response.d.ts +23 -0
- package/types/src/api/types.d.ts +638 -0
- package/types/src/api.d.ts +6 -0
- package/types/src/authoring.d.ts +9 -0
- package/types/src/cli-args.d.ts +19 -0
- package/types/src/cli.d.ts +10 -0
- package/types/src/collaborators.d.ts +18 -0
- package/types/src/compile.d.ts +91 -0
- package/types/src/control.d.ts +41 -0
- package/types/src/database-budget.d.ts +38 -0
- package/types/src/descriptor.d.ts +28 -0
- package/types/src/execution.d.ts +137 -0
- package/types/src/exposure.d.ts +47 -0
- package/types/src/index.d.ts +31 -0
- package/types/src/installation/admin-options.d.ts +24 -0
- package/types/src/installation/admin.d.ts +35 -0
- package/types/src/installation/checker-release.d.ts +12 -0
- package/types/src/installation/deploy-options.d.ts +37 -0
- package/types/src/installation/deploy.d.ts +96 -0
- package/types/src/installation/generated/platform-console-schema.d.ts +2 -0
- package/types/src/installation/generated/platform-proxy-manifest.d.ts +2 -0
- package/types/src/installation/hosts.d.ts +38 -0
- package/types/src/installation/index.d.ts +9 -0
- package/types/src/installation/init-options.d.ts +38 -0
- package/types/src/installation/init.d.ts +60 -0
- package/types/src/installation/install-collaborators.d.ts +24 -0
- package/types/src/installation/install-environment.d.ts +70 -0
- package/types/src/installation/install-options.d.ts +49 -0
- package/types/src/installation/install-project.d.ts +13 -0
- package/types/src/installation/install-provisioning.d.ts +58 -0
- package/types/src/installation/install-secrets.d.ts +30 -0
- package/types/src/installation/install-token.d.ts +10 -0
- package/types/src/installation/install.d.ts +23 -0
- package/types/src/installation/installation-source.d.ts +8 -0
- package/types/src/installation/log.d.ts +44 -0
- package/types/src/installation/machine-key.d.ts +19 -0
- package/types/src/installation/mcp.d.ts +18 -0
- package/types/src/installation/placement-options.d.ts +11 -0
- package/types/src/installation/platform-archive.d.ts +7 -0
- package/types/src/installation/platform-source.d.ts +8 -0
- package/types/src/installation/platform-upload.d.ts +3 -0
- package/types/src/installation/proxy-manifest.d.ts +105 -0
- package/types/src/installation/secrets.d.ts +17 -0
- package/types/src/installation/sidecar.d.ts +51 -0
- package/types/src/installation/source-options.d.ts +1 -0
- package/types/src/installation/topology/artifacts.d.ts +8 -0
- package/types/src/installation/topology/index.d.ts +5 -0
- package/types/src/installation/topology/installation-schemas.d.ts +2242 -0
- package/types/src/installation/topology/invariants.d.ts +23 -0
- package/types/src/installation/topology/setups.d.ts +58 -0
- package/types/src/installation/topology/topology.d.ts +148 -0
- package/types/src/installation/topology/validate.d.ts +11 -0
- package/types/src/installation/upgrade-options.d.ts +26 -0
- package/types/src/installation/upgrade-output.d.ts +6 -0
- package/types/src/installation/upgrade.d.ts +88 -0
- package/types/src/managed-env.d.ts +57 -0
- package/types/src/manifest.d.ts +206 -0
- package/types/src/namespace/claims.d.ts +3 -0
- package/types/src/namespace/codec.d.ts +334 -0
- package/types/src/namespace/constants.d.ts +25 -0
- package/types/src/namespace/errors.d.ts +15 -0
- package/types/src/namespace/manifest.d.ts +4 -0
- package/types/src/namespace/operator.d.ts +12 -0
- package/types/src/namespace/provisioning.d.ts +4 -0
- package/types/src/namespace/proxy-attempt.d.ts +36 -0
- package/types/src/namespace/proxy-reads.d.ts +33 -0
- package/types/src/namespace/proxy-secrets.d.ts +8 -0
- package/types/src/namespace/proxy-source.d.ts +74 -0
- package/types/src/namespace/topology.d.ts +9 -0
- package/types/src/namespace/types.d.ts +99 -0
- package/types/src/namespace/update.d.ts +6 -0
- package/types/src/namespace/validation.d.ts +12 -0
- package/types/src/namespace.d.ts +9 -0
- package/types/src/plan.d.ts +12 -0
- package/types/src/provider.d.ts +65 -0
- package/types/src/proxy-manifest-carrier.d.ts +16 -0
- package/types/src/schema.generated.d.ts +376 -0
- package/types/src/service-names.d.ts +4 -0
- package/types/src/service-references.d.ts +13 -0
- package/types/src/source/constants.d.ts +6 -0
- package/types/src/source/port.d.ts +60 -0
- package/types/src/source/types.d.ts +4 -0
- package/types/src/source/validation.d.ts +19 -0
- package/types/src/source.d.ts +2 -0
- package/types/src/types.d.ts +121 -0
- package/types/src/version-attempt.d.ts +8 -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.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,27 +12,28 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"src",
|
|
15
|
-
"!src/**/__tests__/**"
|
|
15
|
+
"!src/**/__tests__/**",
|
|
16
|
+
"types"
|
|
16
17
|
],
|
|
17
18
|
"exports": {
|
|
18
19
|
".": {
|
|
19
20
|
"bun": "./src/index.ts",
|
|
20
|
-
"types": "./src/index.ts",
|
|
21
|
+
"types": "./types/src/index.d.ts",
|
|
21
22
|
"default": "./src/index.ts"
|
|
22
23
|
},
|
|
23
24
|
"./cli": {
|
|
24
25
|
"bun": "./src/cli.ts",
|
|
25
|
-
"types": "./src/cli.ts",
|
|
26
|
+
"types": "./types/src/cli.d.ts",
|
|
26
27
|
"default": "./src/cli.ts"
|
|
27
28
|
},
|
|
28
29
|
"./installation": {
|
|
29
30
|
"bun": "./src/installation/index.ts",
|
|
30
|
-
"types": "./src/installation/index.ts",
|
|
31
|
+
"types": "./types/src/installation/index.d.ts",
|
|
31
32
|
"default": "./src/installation/index.ts"
|
|
32
33
|
},
|
|
33
34
|
"./installation/topology": {
|
|
34
35
|
"bun": "./src/installation/topology/index.ts",
|
|
35
|
-
"types": "./src/installation/topology/index.ts",
|
|
36
|
+
"types": "./types/src/installation/topology/index.d.ts",
|
|
36
37
|
"default": "./src/installation/topology/index.ts"
|
|
37
38
|
}
|
|
38
39
|
},
|
|
@@ -43,14 +44,14 @@
|
|
|
43
44
|
},
|
|
44
45
|
"dependencies": {
|
|
45
46
|
"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.
|
|
47
|
+
"@fabrika/auth": "0.9.0-dev.2",
|
|
48
|
+
"@fabrika/auth-core": "0.9.0-dev.2",
|
|
49
|
+
"@fabrika/email": "0.9.0-dev.2",
|
|
50
|
+
"@fabrika/iam-contract": "0.9.0-dev.2",
|
|
51
|
+
"@fabrika/installation-init": "0.9.0-dev.2",
|
|
52
|
+
"@fabrika/operations-contract": "0.9.0-dev.2",
|
|
53
|
+
"@fabrika/provider-contract": "0.9.0-dev.2",
|
|
54
|
+
"@fabrika/proxy-core": "0.9.0-dev.2",
|
|
54
55
|
"yaml": "^2.9.1"
|
|
55
56
|
}
|
|
56
57
|
}
|
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,15 +262,20 @@ 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
|
+
/**
|
|
269
|
+
* The compiler options of the platform's type check; the application's own `tsconfig.json` and its `paths` are not read.
|
|
270
|
+
* It covers the SPA under `web/` too: Bun bundles the SPA without checking its types.
|
|
271
|
+
*/
|
|
251
272
|
const PLATFORM_TSCONFIG_CONTENTS = JSON.stringify({
|
|
252
273
|
compilerOptions: {
|
|
253
274
|
target: 'ES2022',
|
|
254
275
|
module: 'ESNext',
|
|
255
276
|
moduleResolution: 'bundler',
|
|
256
277
|
lib: ['ES2022', 'DOM', 'DOM.Iterable'],
|
|
278
|
+
jsx: 'react-jsx',
|
|
257
279
|
types: ['bun'],
|
|
258
280
|
strict: true,
|
|
259
281
|
noUncheckedIndexedAccess: true,
|
|
@@ -264,7 +286,7 @@ const PLATFORM_TSCONFIG_CONTENTS = JSON.stringify({
|
|
|
264
286
|
isolatedModules: true,
|
|
265
287
|
noEmit: true,
|
|
266
288
|
},
|
|
267
|
-
include: ['../fabrika.contract.ts', '../src', 'entry.generated.ts'],
|
|
289
|
+
include: ['../fabrika.contract.ts', '../src', '../web', 'entry.generated.ts'],
|
|
268
290
|
})
|
|
269
291
|
|
|
270
292
|
/** One `printf` writing `lines` to `path`. Each line is single-quoted, so it may hold neither `'` nor an expansion. */
|
|
@@ -294,16 +316,119 @@ const verifyScript = (appId: string): readonly string[] => [
|
|
|
294
316
|
`FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db test-isolation`,
|
|
295
317
|
]
|
|
296
318
|
|
|
297
|
-
|
|
319
|
+
// The readiness check is retried no sooner than every 10 s (the platform's floor), and a server is rarely listening at
|
|
320
|
+
// the first attempt, one second after `run.start`. So one attempt waits for the health endpoint itself, for less than
|
|
321
|
+
// the retry period; it passes only on a 2xx, like the `httpGet` it replaces.
|
|
322
|
+
const readinessCommand = (path: string): string => {
|
|
323
|
+
if (!READINESS_PATH_PATTERN.test(path)) throw new Error('zerops: a readiness path must be a literal path of unreserved characters')
|
|
324
|
+
const url = `http://127.0.0.1:${ZEROPS_APPLICATION_PORT}${path}`
|
|
325
|
+
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)"`
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const SERVE_ENTRY = 'fabrika/entry.generated.ts'
|
|
329
|
+
|
|
330
|
+
const startScript: readonly string[] = ['#!/bin/sh', 'set -eu', `cd ${RUNTIME_ROOT}`, `exec bun ${SERVE_ENTRY}`]
|
|
331
|
+
|
|
332
|
+
// A dev environment starts the launcher of the application's own framework release, which runs the serving entry as its
|
|
333
|
+
// child. Double quotes inside: a generated file line may hold no single quote.
|
|
334
|
+
const devStartScript: readonly string[] = [
|
|
335
|
+
'#!/bin/sh',
|
|
336
|
+
'set -eu',
|
|
337
|
+
`cd ${RUNTIME_ROOT}`,
|
|
338
|
+
`exec bun -e "await import(\\"${DEV_LAUNCHER_MODULE}\\").then((dev) => dev.runDevLauncher(process.env))"`,
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
// The init commands of a dev container migrate and verify only what the bootstrap run built. A container that restarts
|
|
342
|
+
// after a stop keeps the files the launcher synced since, and its state file: the launcher reconciles that one itself.
|
|
343
|
+
const devInitScript: readonly string[] = [
|
|
344
|
+
'#!/bin/sh',
|
|
345
|
+
'set -eu',
|
|
346
|
+
`cd ${RUNTIME_ROOT}`,
|
|
347
|
+
`if [ -f ${DEV_LAUNCHER_STATE_FILE} ]; then exit 0; fi`,
|
|
348
|
+
`sh ${RUNTIME_ROOT}/${MIGRATE_SCRIPT}`,
|
|
349
|
+
`sh ${RUNTIME_ROOT}/${VERIFY_SCRIPT}`,
|
|
350
|
+
]
|
|
298
351
|
|
|
299
|
-
/**
|
|
352
|
+
/** One command writing `content` to `path` through base64: the content may hold quotes and expansions, the command none. */
|
|
353
|
+
const writeBase64Command = (path: string, content: string): string => {
|
|
354
|
+
const encoded = btoa(String.fromCharCode(...new TextEncoder().encode(content)))
|
|
355
|
+
return `printf '%s' '${encoded}' | base64 -d > ${path}`
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The build's check chain, in its order, as the dev launcher runs it in the application root on what the container holds. */
|
|
359
|
+
const devChecks = (release: string): DevLauncherConfig['checks'] => [
|
|
360
|
+
{ name: 'tests', command: TESTS_COMMAND },
|
|
361
|
+
{ name: 'tsc', command: `cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${RUNTIME_ROOT}/${PLATFORM_TSCONFIG}` },
|
|
362
|
+
{ name: 'fabrika-check', command: `cd /tmp && bun x --bun -p @fabrika/check@${release} fabrika-check ${RUNTIME_ROOT}` },
|
|
363
|
+
]
|
|
364
|
+
|
|
365
|
+
/** What the dev launcher reads (`DEV_LAUNCHER_CONFIG_FILE`): the platform's, written after the checks. */
|
|
366
|
+
export function devLauncherConfig(execution: ZeropsLockedExecution, authoredSpa: readonly string[]): DevLauncherConfig {
|
|
367
|
+
const dev = execution.dev
|
|
368
|
+
if (dev === undefined) throw new Error('zerops: a dev launcher configuration needs a dev environment')
|
|
369
|
+
if (!/^[0-9a-f]{40}$/.test(dev.head)) throw new Error('zerops: a dev environment builds an exact commit')
|
|
370
|
+
return {
|
|
371
|
+
format: 1,
|
|
372
|
+
appId: execution.appId,
|
|
373
|
+
head: dev.head,
|
|
374
|
+
port: ZEROPS_APPLICATION_PORT,
|
|
375
|
+
healthPath: execution.contract.readinessPath,
|
|
376
|
+
serve: ['bun', SERVE_ENTRY],
|
|
377
|
+
migrate: [FABRIKA_BIN, 'db', 'migrate'],
|
|
378
|
+
spaBuild: authoredSpa,
|
|
379
|
+
checks: devChecks(execution.checkRelease),
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const devLauncherConfigJson = (execution: ZeropsLockedExecution, authoredSpa: readonly string[]): string =>
|
|
384
|
+
JSON.stringify(devLauncherConfig(execution, authoredSpa))
|
|
385
|
+
|
|
386
|
+
/** The scripts the build writes, and the ones every container's init commands run, in this order. */
|
|
300
387
|
const initScripts = (execution: ZeropsLockedExecution): readonly { readonly path: string; readonly lines: readonly string[] }[] =>
|
|
301
388
|
execution.databaseUrl === null
|
|
302
389
|
? []
|
|
303
|
-
: [
|
|
390
|
+
: [
|
|
391
|
+
{ path: MIGRATE_SCRIPT, lines: migrateScript(execution.appId) },
|
|
392
|
+
{ path: VERIFY_SCRIPT, lines: verifyScript(execution.appId) },
|
|
393
|
+
...(execution.dev === undefined ? [] : [{ path: DEV_INIT_SCRIPT, lines: devInitScript }]),
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
const initCommandScripts = (execution: ZeropsLockedExecution): readonly string[] =>
|
|
397
|
+
execution.databaseUrl === null ? [] : execution.dev === undefined ? [MIGRATE_SCRIPT, VERIFY_SCRIPT] : [DEV_INIT_SCRIPT]
|
|
304
398
|
|
|
305
399
|
const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
|
|
306
400
|
|
|
401
|
+
/**
|
|
402
|
+
* What `@fabrika/db/testing` prints after the label of a test file it skips without `FABRIKA_TEST_POSTGRES_URL`. Every
|
|
403
|
+
* framework release prints it, so the platform counts it for any application's release.
|
|
404
|
+
*/
|
|
405
|
+
const DATABASE_TEST_SKIP_MARKER = ' skipped: set FABRIKA_TEST_POSTGRES_URL to run the Postgres-backed tests'
|
|
406
|
+
|
|
407
|
+
/** The prefix of the line the tests command prints after the test run. */
|
|
408
|
+
const TESTS_REPORT_PREFIX = 'fabrika-tests:'
|
|
409
|
+
|
|
410
|
+
/** One `bun -e "…"` running `script`, which may hold none of the characters a double-quoted shell word expands. */
|
|
411
|
+
const bunEvalCommand = (script: readonly string[]): string => {
|
|
412
|
+
const source = script.join(' ')
|
|
413
|
+
if (/["$`\\!]/.test(source)) throw new Error('zerops: a generated bun -e script must not contain a quote, an expansion or a backslash')
|
|
414
|
+
return `bun -e "${source}"`
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// The build has no database, so every Postgres-backed test file skips and the run still passes. This command runs the
|
|
418
|
+
// tests as before and then says how many files did not run, so a green test step does not read as a tested database.
|
|
419
|
+
const TESTS_COMMAND = bunEvalCommand([
|
|
420
|
+
"const child = Bun.spawn([process.execPath, 'test', '--isolate', '--pass-with-no-tests'], { stdout: 'inherit', stderr: 'pipe' });",
|
|
421
|
+
'const decoder = new TextDecoder();',
|
|
422
|
+
"let output = '';",
|
|
423
|
+
'for await (const chunk of child.stderr) { process.stderr.write(chunk); output += decoder.decode(chunk, { stream: true }) }',
|
|
424
|
+
'const code = await child.exited;',
|
|
425
|
+
`const marker = '${DATABASE_TEST_SKIP_MARKER}';`,
|
|
426
|
+
'const skipped = output.split(String.fromCharCode(10)).filter((line) => line.includes(marker)).map((line) => line.slice(0, line.indexOf(marker)).trim());',
|
|
427
|
+
`const prefix = '${TESTS_REPORT_PREFIX}';`,
|
|
428
|
+
"console.warn(skipped.length === 0 ? prefix + ' no Postgres-backed test file skipped' : prefix + ' ' + skipped.length + ' Postgres-backed test files did not run, because the build has no database: ' + skipped.join(', '));",
|
|
429
|
+
'process.exit(code);',
|
|
430
|
+
])
|
|
431
|
+
|
|
307
432
|
// After every check: a map is uploaded only for a build that can deploy. A failed upload never fails the build; the
|
|
308
433
|
// provider reads the state from the report line.
|
|
309
434
|
const sourceMapUpload = (release: string): string => `cd /tmp && bun x --bun -p @fabrika/check@${release} fabrika-source-maps ${SOURCE_ROOT} || true`
|
|
@@ -322,21 +447,25 @@ const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record
|
|
|
322
447
|
/**
|
|
323
448
|
* The generated build commands, in order; the authored SPA commands run before all of them. The application's tests
|
|
324
449
|
* run first: they are authored code, so everything after them is reinstalled, regenerated or checked again. They run
|
|
325
|
-
* `--isolate`, as the application skill runs them: a test file's DOM globals otherwise leak into the next file.
|
|
450
|
+
* `--isolate`, as the application skill runs them: a test file's DOM globals otherwise leak into the next file. `tsc`
|
|
451
|
+
* runs before the checker, as in the skill's loop: the usual quick fix of a type error is a cast or an `any`, which only
|
|
452
|
+
* the checker refuses, so the checker is the last word on the code.
|
|
326
453
|
*/
|
|
327
|
-
export function generatedBuildCommands(execution: ZeropsLockedExecution): readonly string[] {
|
|
454
|
+
export function generatedBuildCommands(execution: ZeropsLockedExecution, authoredSpa: readonly string[] = []): readonly string[] {
|
|
328
455
|
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
456
|
if (!FRAMEWORK_RELEASE_PATTERN.test(execution.checkRelease)) throw new Error('zerops: the checker release must be a release version')
|
|
330
457
|
return [
|
|
331
458
|
INSTALL,
|
|
332
|
-
|
|
459
|
+
TESTS_COMMAND,
|
|
333
460
|
`rm -rf node_modules && ${INSTALL}`,
|
|
334
461
|
`bun -e "await import('@fabrika/app/build').then((build) => build.writeGeneratedEntry('.', 'bun'))"`,
|
|
335
|
-
`cd /tmp && bun x --bun -p @fabrika/check@${execution.checkRelease} fabrika-check ${SOURCE_ROOT}`,
|
|
336
462
|
writeFileCommand(PLATFORM_TSCONFIG, [PLATFORM_TSCONFIG_CONTENTS]),
|
|
337
463
|
`cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${SOURCE_ROOT}/${PLATFORM_TSCONFIG}`,
|
|
464
|
+
`cd /tmp && bun x --bun -p @fabrika/check@${execution.checkRelease} fabrika-check ${SOURCE_ROOT}`,
|
|
338
465
|
...initScripts(execution).map((script) => writeFileCommand(script.path, script.lines)),
|
|
339
|
-
|
|
466
|
+
...(execution.dev === undefined
|
|
467
|
+
? [writeFileCommand(START_SCRIPT, startScript)]
|
|
468
|
+
: [writeFileCommand(START_SCRIPT, devStartScript), writeBase64Command(DEV_LAUNCHER_CONFIG_FILE, devLauncherConfigJson(execution, authoredSpa))]),
|
|
340
469
|
...(execution.sourceMaps === null ? [] : [sourceMapUpload(execution.checkRelease)]),
|
|
341
470
|
]
|
|
342
471
|
}
|
|
@@ -371,7 +500,7 @@ export function generatedInitCommands(execution: ZeropsLockedExecution): string[
|
|
|
371
500
|
// A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
|
|
372
501
|
// them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
|
|
373
502
|
...(execution.contract.scan === true ? [`${FRESHCLAM} || echo 'fabrika: freshclam did not refresh the malware signatures'`] : []),
|
|
374
|
-
...
|
|
503
|
+
...initCommandScripts(execution).map((script) => `sh ${RUNTIME_ROOT}/${script}`),
|
|
375
504
|
]
|
|
376
505
|
}
|
|
377
506
|
|
|
@@ -404,13 +533,13 @@ export function renderLockedSetup(
|
|
|
404
533
|
build: {
|
|
405
534
|
base: authored.base ?? [ZEROPS_APPLICATION_RUNTIME],
|
|
406
535
|
...(authored.os === null ? {} : { os: authored.os }),
|
|
407
|
-
buildCommands: [...authored.commands, ...generatedBuildCommands(execution)],
|
|
536
|
+
buildCommands: [...authored.commands, ...generatedBuildCommands(execution, authored.commands)],
|
|
408
537
|
...(execution.sourceMaps === null ? {} : { envVariables: sourceMapBuildVariables(execution.sourceMaps) }),
|
|
409
538
|
deployFiles: ['./'],
|
|
410
539
|
cache: ['node_modules'],
|
|
411
540
|
},
|
|
412
541
|
deploy: {
|
|
413
|
-
readinessCheck: {
|
|
542
|
+
readinessCheck: { exec: { command: readinessCommand(contract.readinessPath) }, retryPeriod: '10s', failureTimeout: '180s' },
|
|
414
543
|
},
|
|
415
544
|
run: {
|
|
416
545
|
base: ZEROPS_APPLICATION_RUNTIME,
|
|
@@ -418,7 +547,11 @@ export function renderLockedSetup(
|
|
|
418
547
|
...(initCommands.length === 0 ? {} : { initCommands }),
|
|
419
548
|
start: `sh ${RUNTIME_ROOT}/${START_SCRIPT}`,
|
|
420
549
|
ports: [{ port: ZEROPS_APPLICATION_PORT, httpSupport: true }],
|
|
421
|
-
|
|
550
|
+
// A dev container keeps its launcher running while the application it respawns fails: Zerops restarting it
|
|
551
|
+
// would only boot the same failing head again.
|
|
552
|
+
...(execution.dev === undefined
|
|
553
|
+
? { healthCheck: { httpGet: probe, execPeriod: '10s', disconnectTimeout: '30s', recoveryTimeout: '10s', failureTimeout: '120s' } }
|
|
554
|
+
: {}),
|
|
422
555
|
...(crontab.length === 0 ? {} : { crontab }),
|
|
423
556
|
envVariables,
|
|
424
557
|
},
|
|
@@ -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'>
|