@fabrika/provider-zerops 0.3.2 → 0.4.0
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 +7 -6
- package/src/api/client.ts +11 -2
- package/src/api/types.ts +13 -0
- package/src/cli.ts +21 -4
- package/src/collaborators.ts +4 -0
- package/src/control.ts +37 -8
- package/src/execution.ts +371 -0
- package/src/exposure.ts +95 -0
- package/src/installation/deploy-options.ts +12 -1
- package/src/installation/deploy.ts +42 -18
- package/src/installation/generated/platform-console-schema.ts +4 -0
- package/src/installation/generated/platform-proxy-manifest.ts +533 -33
- package/src/installation/index.ts +11 -0
- package/src/installation/install-environment.ts +1 -2
- package/src/installation/machine-key.ts +3 -4
- package/src/installation/manifest.ts +1 -1
- package/src/installation/mcp.ts +102 -0
- package/src/installation/placement-options.ts +2 -1
- package/src/installation/proxy-manifest.ts +124 -23
- package/src/installation/secrets.ts +2 -2
- package/src/installation/templates/README.md +20 -11
- package/src/installation/templates/platform.yml +2 -0
- package/src/installation/topology/setups.ts +5 -5
- package/src/managed-env.ts +94 -6
- package/src/manifest.ts +248 -14
- package/src/namespace/codec.ts +23 -12
- package/src/namespace/errors.ts +1 -1
- package/src/namespace/manifest.ts +25 -6
- package/src/namespace/provisioning.ts +10 -29
- package/src/namespace/proxy-attempt.ts +13 -2
- package/src/namespace/proxy-secrets.ts +21 -0
- package/src/namespace/update.ts +212 -0
- package/src/namespace/validation.ts +43 -0
- package/src/plan.ts +6 -0
- package/src/provider.ts +159 -19
- package/src/types.ts +14 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,11 +43,12 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"zod": "^3.25.76",
|
|
46
|
-
"@fabrika/auth": "^0.
|
|
47
|
-
"@fabrika/auth-core": "^0.
|
|
48
|
-
"@fabrika/
|
|
49
|
-
"@fabrika/
|
|
50
|
-
"@fabrika/
|
|
46
|
+
"@fabrika/auth": "^0.4.0",
|
|
47
|
+
"@fabrika/auth-core": "^0.4.0",
|
|
48
|
+
"@fabrika/iam-contract": "^0.4.0",
|
|
49
|
+
"@fabrika/installation-init": "^0.4.0",
|
|
50
|
+
"@fabrika/provider-contract": "^0.4.0",
|
|
51
|
+
"@fabrika/proxy-core": "^0.4.0",
|
|
51
52
|
"yaml": "^2.9.1"
|
|
52
53
|
}
|
|
53
54
|
}
|
package/src/api/client.ts
CHANGED
|
@@ -35,7 +35,8 @@ const appVersionReservationSchema = z.object({
|
|
|
35
35
|
uploadUrl: z.string().refine((value) => value.trim() !== ''),
|
|
36
36
|
serviceStackId: z.string().refine((value) => value.trim() !== ''),
|
|
37
37
|
})
|
|
38
|
-
|
|
38
|
+
/** `ResponseProcessNil`: a process, or none. */
|
|
39
|
+
const processNilSchema = z.object({ process: processSchema.nullish() })
|
|
39
40
|
const deleteAppVersionSchema = z.object({ success: z.literal(true) })
|
|
40
41
|
|
|
41
42
|
// ── default (real) implementation ───────────────────────────────────────────────
|
|
@@ -282,7 +283,7 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
282
283
|
body['zeropsSetup'] = zeropsSetup
|
|
283
284
|
}
|
|
284
285
|
const payload = await request('trigger-pipeline', 'PUT', `/service-stack/${serviceId}/trigger-pipeline`, { body, signal })
|
|
285
|
-
return readResponse('trigger-pipeline',
|
|
286
|
+
return readResponse('trigger-pipeline', processNilSchema, payload).process ?? null
|
|
286
287
|
},
|
|
287
288
|
|
|
288
289
|
createAppVersion: async ({ serviceId, name, signal }) => {
|
|
@@ -352,6 +353,14 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
352
353
|
await request('enable subdomain access', 'PUT', `/service-stack/${serviceId}/enable-subdomain-access`, { signal })
|
|
353
354
|
},
|
|
354
355
|
|
|
356
|
+
configureAutoscaling: async ({ serviceId, autoscalingProfileId, signal }) => {
|
|
357
|
+
const payload = await request('configure autoscaling', 'PUT', `/service-stack/${serviceId}/autoscaling`, {
|
|
358
|
+
body: { autoscalingProfileId },
|
|
359
|
+
signal,
|
|
360
|
+
})
|
|
361
|
+
return readResponse('configure autoscaling', processNilSchema, payload).process ?? null
|
|
362
|
+
},
|
|
363
|
+
|
|
355
364
|
getService: async ({ serviceId, signal }) =>
|
|
356
365
|
readResponse('get service', serviceSchema, await request('get service', 'GET', `/service-stack/${serviceId}`, { signal })),
|
|
357
366
|
|
package/src/api/types.ts
CHANGED
|
@@ -410,6 +410,19 @@ export interface ZeropsApi {
|
|
|
410
410
|
*/
|
|
411
411
|
enableSubdomainAccess(input: { serviceId: string; signal: AbortSignal }): Promise<void>
|
|
412
412
|
|
|
413
|
+
// ── service-stack: autoscaling ────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Move a managed service to another autoscaling profile. VERIFIED AS A SHAPE: `PUT
|
|
417
|
+
* /service-stack/{id}/autoscaling` (`ConfigureAutoscaling`), body `RequestAutoscaling`, response
|
|
418
|
+
* `ResponseProcessNil` — `null` when the platform hands back no process.
|
|
419
|
+
*
|
|
420
|
+
* The ONLY way to change a live profile: a re-import with a changed `profile` is silently ignored
|
|
421
|
+
* (`docs/reference/zerops-platform.md`). UNVERIFIED: whether omitting `customAutoscaling` keeps or
|
|
422
|
+
* resets custom overrides, and how soon `getService().autoscalingProfileId` reads the new value.
|
|
423
|
+
*/
|
|
424
|
+
configureAutoscaling(input: { serviceId: string; autoscalingProfileId: string; signal: AbortSignal }): Promise<ZeropsProcess | null>
|
|
425
|
+
|
|
413
426
|
// ── service-stack / project: reading state ────────────────────────────────────
|
|
414
427
|
|
|
415
428
|
/** One service by id. VERIFIED: `GET /service-stack/{id}`. */
|
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { parseZeropsCliArgs } from './cli-args'
|
|
|
8
8
|
import {
|
|
9
9
|
compileFabrikaManifest,
|
|
10
10
|
createZeropsArtifactSourceDescriptor,
|
|
11
|
+
type FabrikaManifest,
|
|
11
12
|
manifestServiceHostnames,
|
|
12
13
|
ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES,
|
|
13
14
|
} from './manifest'
|
|
@@ -69,6 +70,24 @@ export const readZeropsSourceDescriptor = async (root: string = process.cwd()) =
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
export interface ZeropsManifestBuildOptions {
|
|
74
|
+
/** App config path, resolved against the working directory like `--config`. */
|
|
75
|
+
readonly config: string
|
|
76
|
+
readonly env: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const compileManifest = async (options: ZeropsManifestBuildOptions): Promise<FabrikaManifest> => {
|
|
80
|
+
const config = await loadConfig(options.config)
|
|
81
|
+
const sourceDescriptor = await readZeropsSourceDescriptor()
|
|
82
|
+
return compileFabrikaManifest(config, options.env, sourceDescriptor)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const serializeManifest = (manifest: FabrikaManifest): string => `${JSON.stringify(manifest, null, '\t')}\n`
|
|
86
|
+
|
|
87
|
+
/** The exact text `build` writes to `fabrika.manifest.json`, produced in memory and written nowhere. */
|
|
88
|
+
export const buildZeropsManifestText = async (options: ZeropsManifestBuildOptions): Promise<string> =>
|
|
89
|
+
serializeManifest(await compileManifest(options))
|
|
90
|
+
|
|
72
91
|
export const runZeropsCli = async (argv: readonly string[]): Promise<void> => {
|
|
73
92
|
const args = parseZeropsCliArgs(argv)
|
|
74
93
|
if (args.help || args.command === undefined) {
|
|
@@ -81,11 +100,9 @@ export const runZeropsCli = async (argv: readonly string[]): Promise<void> => {
|
|
|
81
100
|
if (args.env === undefined || args.env === '') {
|
|
82
101
|
throw new Error('`build` requires --env=<env>')
|
|
83
102
|
}
|
|
84
|
-
const
|
|
85
|
-
const sourceDescriptor = await readZeropsSourceDescriptor()
|
|
86
|
-
const manifest = compileFabrikaManifest(config, args.env, sourceDescriptor)
|
|
103
|
+
const manifest = await compileManifest({ config: args.config, env: args.env })
|
|
87
104
|
const output = resolve(process.cwd(), args.output)
|
|
88
|
-
await Bun.write(output,
|
|
105
|
+
await Bun.write(output, serializeManifest(manifest))
|
|
89
106
|
console.info(`wrote ${output} (${manifestServiceHostnames(manifest).length} service(s))`)
|
|
90
107
|
}
|
|
91
108
|
|
package/src/collaborators.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { reconcileSchema } from '@fabrika/auth'
|
|
2
2
|
import type { SchemaReconciler } from '@fabrika/provider-contract'
|
|
3
3
|
import { createZeropsApi, type ZeropsApi } from './api'
|
|
4
|
+
import { type ExposureAlerter, sendExposureAlert } from './exposure'
|
|
4
5
|
import type { ZeropsSourcePort } from './source/port'
|
|
5
6
|
import type { ZeropsRuntimeTarget } from './types'
|
|
6
7
|
|
|
@@ -28,6 +29,8 @@ export interface ZeropsCollaborators {
|
|
|
28
29
|
/** Injected by the Zerops control composition; dry runs do not require it. */
|
|
29
30
|
source?: ZeropsSourcePort
|
|
30
31
|
reconcileSchema: SchemaReconciler
|
|
32
|
+
/** Raises the Operations alert for a refused activation; best-effort. */
|
|
33
|
+
alertExposure: ExposureAlerter
|
|
31
34
|
sleep: Sleeper
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -46,5 +49,6 @@ const defaultReconcileSchema: SchemaReconciler = (input) =>
|
|
|
46
49
|
export const defaultZeropsCollaborators: ZeropsCollaboratorFactory = (target) => ({
|
|
47
50
|
api: createZeropsApi({ token: target.accessToken, baseUrl: target.apiBaseUrl }),
|
|
48
51
|
reconcileSchema: defaultReconcileSchema,
|
|
52
|
+
alertExposure: sendExposureAlert,
|
|
49
53
|
sleep: defaultSleep,
|
|
50
54
|
})
|
package/src/control.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appEnvironmentId } from '@fabrika/auth-core'
|
|
1
2
|
import type {
|
|
2
3
|
ControlProvider,
|
|
3
4
|
JsonValue,
|
|
@@ -21,8 +22,10 @@ import { decodeEnvelope, ProviderSecretError } from '@fabrika/provider-contract'
|
|
|
21
22
|
import { z } from 'zod'
|
|
22
23
|
import { createZeropsApi, waitForProcess, ZEROPS_ACTIVE, ZEROPS_TERMINAL, type ZeropsApi, type ZeropsAppVersion } from './api'
|
|
23
24
|
import { defaultSleep, defaultZeropsCollaborators, type Sleeper } from './collaborators'
|
|
24
|
-
import {
|
|
25
|
+
import { createdServices, type ExposureAlerter, isObjectStorageType } from './exposure'
|
|
26
|
+
import { assertAuthoredManagedInputs, assertAuthoredManagedName, assertNoFrameworkReference, parseManagedEnvironment } from './managed-env'
|
|
25
27
|
import {
|
|
28
|
+
approvedSchemaOf,
|
|
26
29
|
assertFabrikaManifestCoordinates,
|
|
27
30
|
type FabrikaManifest,
|
|
28
31
|
manifestServiceHostnames,
|
|
@@ -47,13 +50,21 @@ import { deleteVersion, isVersionNotFound } from './version-attempt'
|
|
|
47
50
|
/** Provider coordinates that are safe to persist. Credentials are composed only for a live run. */
|
|
48
51
|
export interface ZeropsStoredTarget {
|
|
49
52
|
serviceId: string
|
|
53
|
+
/**
|
|
54
|
+
* The object-storage service ids the first registration's collision-refusing import created: the creation evidence
|
|
55
|
+
* the first deploy writes into the provenance marker (ADR-0043). Absent when that import created no bucket.
|
|
56
|
+
*/
|
|
57
|
+
createdBuckets?: string[]
|
|
50
58
|
}
|
|
51
59
|
|
|
52
|
-
const storedTargetSchema = z.object({ serviceId: z.string().min(1) })
|
|
60
|
+
const storedTargetSchema = z.object({ serviceId: z.string().min(1), createdBuckets: z.array(z.string().min(1)).min(1).optional() })
|
|
53
61
|
|
|
54
62
|
export const zeropsStoredTargetCodec: ProviderCodec<ZeropsStoredTarget> = {
|
|
55
63
|
version: 2,
|
|
56
|
-
encode: (target) => ({
|
|
64
|
+
encode: (target) => ({
|
|
65
|
+
serviceId: target.serviceId,
|
|
66
|
+
...(target.createdBuckets === undefined ? {} : { createdBuckets: [...target.createdBuckets] }),
|
|
67
|
+
}),
|
|
57
68
|
decode: (payload) => {
|
|
58
69
|
const result = storedTargetSchema.safeParse(payload)
|
|
59
70
|
if (!result.success) throw new Error('Zerops stored target serviceId must be a non-empty string')
|
|
@@ -75,6 +86,7 @@ export interface ZeropsControlProviderOptions {
|
|
|
75
86
|
readonly api?: ZeropsApi
|
|
76
87
|
readonly source: ZeropsSourcePort
|
|
77
88
|
readonly reconcileSchema?: SchemaReconciler
|
|
89
|
+
readonly alertExposure?: ExposureAlerter
|
|
78
90
|
readonly sleep?: Sleeper
|
|
79
91
|
readonly namespaces?: {
|
|
80
92
|
readonly clientId: string
|
|
@@ -204,6 +216,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
204
216
|
api,
|
|
205
217
|
source: options.source,
|
|
206
218
|
reconcileSchema: options.reconcileSchema ?? defaults.reconcileSchema,
|
|
219
|
+
alertExposure: options.alertExposure ?? defaults.alertExposure,
|
|
207
220
|
sleep: options.sleep ?? defaultSleep,
|
|
208
221
|
}
|
|
209
222
|
})
|
|
@@ -241,7 +254,8 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
241
254
|
signal: AbortSignal,
|
|
242
255
|
): Promise<void> => {
|
|
243
256
|
const artifact = artifactFor(input.environment)
|
|
244
|
-
|
|
257
|
+
const schema = approvedSchemaOf(input.environment)
|
|
258
|
+
if (schema === undefined || options.propustkaUrl === undefined) return
|
|
245
259
|
const placement = resolvedEnvironment(input.environment, { requireReady: false })
|
|
246
260
|
const runtimeTarget: ZeropsRuntimeTarget = {
|
|
247
261
|
projectId: placement.projectId,
|
|
@@ -255,8 +269,8 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
255
269
|
signal.throwIfAborted()
|
|
256
270
|
await reconcileSchema({
|
|
257
271
|
url: options.propustkaUrl,
|
|
258
|
-
app: artifact.app.id,
|
|
259
|
-
schema
|
|
272
|
+
app: appEnvironmentId(artifact.app.id, artifact.app.env),
|
|
273
|
+
schema,
|
|
260
274
|
...(input.returnOrigins === undefined || input.returnOrigins.length === 0 ? {} : { returnOrigins: input.returnOrigins }),
|
|
261
275
|
adminKey: options.adminKey,
|
|
262
276
|
signal,
|
|
@@ -318,7 +332,8 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
318
332
|
deploy: async (input: ProviderDeployInput): Promise<ProviderDeployOutcome> => {
|
|
319
333
|
input.signal.throwIfAborted()
|
|
320
334
|
assertAuthoredManagedInputs({ variableNames: Object.keys(input.vars), secretNames: Object.keys(input.secrets) })
|
|
321
|
-
const
|
|
335
|
+
const handed = parseManagedEnvironment(input.managedEnvironment)
|
|
336
|
+
const managedEnvironment = { ...handed.values, ...handed.familyVariables }
|
|
322
337
|
const binding = privateBinding(input.sourceBinding)
|
|
323
338
|
assertEnvironmentOfApp(input.app, input.environment)
|
|
324
339
|
const placement = resolvedEnvironment(input.environment, { requireReady: true })
|
|
@@ -340,6 +355,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
340
355
|
projectId: placement.projectId,
|
|
341
356
|
serviceId: placement.target.serviceId,
|
|
342
357
|
accessToken: options.accessToken,
|
|
358
|
+
...(placement.target.createdBuckets === undefined ? {} : { createdBuckets: placement.target.createdBuckets }),
|
|
343
359
|
...(source === undefined ? {} : { source }),
|
|
344
360
|
...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}),
|
|
345
361
|
...(options.propustkaUrl !== undefined ? { propustkaUrl: options.propustkaUrl } : {}),
|
|
@@ -354,6 +370,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
354
370
|
managedEnvironment,
|
|
355
371
|
target: runtimeTarget,
|
|
356
372
|
artifact,
|
|
373
|
+
...(input.environment.access === undefined ? {} : { access: input.environment.access }),
|
|
357
374
|
dryRun: input.dryRun,
|
|
358
375
|
signal: input.signal,
|
|
359
376
|
events: input.events,
|
|
@@ -438,6 +455,8 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
438
455
|
secrets: {
|
|
439
456
|
put: async (input) => {
|
|
440
457
|
assertAuthoredManagedName(input.name)
|
|
458
|
+
assertNoFrameworkReference(input.name)
|
|
459
|
+
assertNoFrameworkReference(input.value)
|
|
441
460
|
assertOwnReferencesOnly(input.environment, input.name, input.value)
|
|
442
461
|
const target = decodeEnvironment(input.environment).target
|
|
443
462
|
await api.putServiceEnv({
|
|
@@ -510,11 +529,21 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
510
529
|
if (discovered === null || discovered === undefined || discovered.id === '') {
|
|
511
530
|
throw new Error(`Zerops import did not create deploy service \`${manifest.target.deployService}\``)
|
|
512
531
|
}
|
|
532
|
+
// Only this collision-refusing import proves the platform created the buckets private (ADR-0043). The
|
|
533
|
+
// evidence is persisted with the registration, so no write can fail between it and the first deploy.
|
|
534
|
+
const created = present.length === 0 ? createdServices(imported) : new Map<string, string>()
|
|
535
|
+
const createdBuckets = manifest.target.importDocument.services.flatMap((service) => {
|
|
536
|
+
const id = isObjectStorageType(service.type) ? created.get(service.hostname) : undefined
|
|
537
|
+
return id === undefined ? [] : [id]
|
|
538
|
+
})
|
|
513
539
|
return normalizeRegistration({
|
|
514
540
|
app: input.registration.app,
|
|
515
541
|
environment: {
|
|
516
542
|
...input.registration.environment,
|
|
517
|
-
target: envelope(zeropsStoredTargetCodec, {
|
|
543
|
+
target: envelope(zeropsStoredTargetCodec, {
|
|
544
|
+
serviceId: discovered.id,
|
|
545
|
+
...(createdBuckets.length === 0 ? {} : { createdBuckets }),
|
|
546
|
+
}),
|
|
518
547
|
},
|
|
519
548
|
})
|
|
520
549
|
},
|
package/src/execution.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// The execution descriptor of a CONTRACT application (ADR-0043): the platform, not the repository, decides what
|
|
2
|
+
// runs. Of the authored setup only `build.base`, `build.os` and the SPA's `build.buildCommands` survive; the rest of
|
|
3
|
+
// the build, the deploy gate and the whole `run` section are generated here. PURE — no network, no filesystem.
|
|
4
|
+
|
|
5
|
+
import { type ContractArtifact, contractDigest, FABRIKA_CONTRACT_DIGEST, type FrameworkVariableName } from '@fabrika/auth-core'
|
|
6
|
+
import type { JsonValue } from '@fabrika/provider-contract'
|
|
7
|
+
import packageManifest from '../package.json'
|
|
8
|
+
import { renderYaml } from './compile'
|
|
9
|
+
import { parseZeropsDescriptor, type PreparedZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
|
|
10
|
+
import { assertAuthoredManagedDocument } from './managed-env'
|
|
11
|
+
|
|
12
|
+
/** The port the generated entrypoint listens on (`PORT`), and the one the proxy dials. */
|
|
13
|
+
export const ZEROPS_APPLICATION_PORT = 3000
|
|
14
|
+
|
|
15
|
+
/** The runtime every contract application runs on: the generated entrypoint is a Bun program. */
|
|
16
|
+
export const ZEROPS_APPLICATION_RUNTIME = 'alpine/bun@1.3'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The checker every build runs, pinned to this package's own version: the public packages are released together
|
|
20
|
+
* (`release:validate`), so this is the checker of the installation that renders the build.
|
|
21
|
+
*/
|
|
22
|
+
export const FABRIKA_CHECK_VERSION: string = packageManifest.version
|
|
23
|
+
|
|
24
|
+
/** The compiler the platform type-checks with. It is fetched by version, never taken from the application's lockfile. */
|
|
25
|
+
export const PLATFORM_TYPESCRIPT_VERSION = '5.9.3'
|
|
26
|
+
|
|
27
|
+
/** System packages an application may add to its runtime image, installed by the generated `run.prepareCommands`. */
|
|
28
|
+
export const ZEROPS_RUNTIME_PACKAGES = ['ffmpeg', 'ghostscript', 'imagemagick', 'poppler-utils', 'vips'] as const
|
|
29
|
+
|
|
30
|
+
export type ZeropsRuntimePackage = (typeof ZEROPS_RUNTIME_PACKAGES)[number]
|
|
31
|
+
|
|
32
|
+
export interface ZeropsContractJob {
|
|
33
|
+
readonly name: string
|
|
34
|
+
readonly schedule: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The framework's file scan job (`@fabrika/app`'s `FILE_SCAN_JOB`), run every minute when a collection scans. */
|
|
38
|
+
export const ZEROPS_FILE_SCAN_JOB = 'fabrika-file-scan'
|
|
39
|
+
/** The framework's file sweep job (`@fabrika/app`'s `FILE_SWEEP_JOB` and its schedule), run when the contract has files. */
|
|
40
|
+
export const ZEROPS_FILE_SWEEP_JOB = 'fabrika-file-sweep'
|
|
41
|
+
const FILE_SWEEP_SCHEDULE = '41 * * * *'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The RAM in GB a container of a scanning application starts with: one `clamscan` over a batch peaks at about 1 GB
|
|
45
|
+
* resident while it loads its signatures (measured with ClamAV 1.4), beside the application's own process.
|
|
46
|
+
*/
|
|
47
|
+
export const ZEROPS_SCAN_MIN_RAM_GB = 2
|
|
48
|
+
|
|
49
|
+
/** What a scanning application adds: ClamAV in the image with its first signatures, refreshed at start and daily. */
|
|
50
|
+
const SCANNER_PACKAGE = 'clamav'
|
|
51
|
+
const FRESHCLAM = 'sudo freshclam --stdout --quiet'
|
|
52
|
+
const FRESHCLAM_SCHEDULE = '17 4 * * *'
|
|
53
|
+
|
|
54
|
+
/** What the generated run section needs from a contract; derived from the artifact by `zeropsContractRun`. */
|
|
55
|
+
export interface ZeropsContractRun {
|
|
56
|
+
/** The contract digest the application compares at startup (`FABRIKA_CONTRACT_DIGEST`). */
|
|
57
|
+
readonly digest: string
|
|
58
|
+
/** The path of the contract's health endpoint, which the readiness and health checks request. */
|
|
59
|
+
readonly readinessPath: string
|
|
60
|
+
readonly jobs: readonly ZeropsContractJob[]
|
|
61
|
+
/** Present when the contract declares a file collection: the crontab runs the sweep of removed files. */
|
|
62
|
+
readonly files?: true
|
|
63
|
+
/** Present when a file collection scans its uploads: the build installs ClamAV and the crontab runs the scan. */
|
|
64
|
+
readonly scan?: true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Everything one locked execution descriptor is rendered from. */
|
|
68
|
+
export interface ZeropsLockedExecution {
|
|
69
|
+
readonly appId: string
|
|
70
|
+
readonly contract: ZeropsContractRun
|
|
71
|
+
/** A `${host_…}` reference to the application's PostgreSQL service, or null when it has none. */
|
|
72
|
+
readonly databaseUrl: string | null
|
|
73
|
+
/** The hostname of the application's one object-storage service, or null when it has none. */
|
|
74
|
+
readonly storageHostname: string | null
|
|
75
|
+
readonly runtimePackages: readonly ZeropsRuntimePackage[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The application ids the generated scripts may name: the contract's own id rule, so a script needs no quoting. */
|
|
79
|
+
export const LOCKED_APP_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/
|
|
80
|
+
|
|
81
|
+
export const CONTRACT_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/
|
|
82
|
+
|
|
83
|
+
const byName = <T extends { readonly name: string }>(left: T, right: T): number => left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
|
84
|
+
|
|
85
|
+
/** The run facts of a contract. A contract without a health endpoint has nothing a readiness check can request. */
|
|
86
|
+
export function zeropsContractRun(artifact: ContractArtifact): ZeropsContractRun {
|
|
87
|
+
const health = artifact.endpoints.filter((row) => row.kind === 'health').sort((left, right) => left.id < right.id ? -1 : 1)[0]
|
|
88
|
+
if (health === undefined) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
"fabrika app build: a contract application on Zerops needs a health endpoint for its readiness check (allowed form: health: c.health('/healthz'))",
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
digest: contractDigest(artifact),
|
|
95
|
+
readinessPath: health.path,
|
|
96
|
+
jobs: Object.entries(artifact.jobs).map(([name, job]) => ({ name, schedule: job.schedule })).sort(byName),
|
|
97
|
+
...(Object.keys(artifact.files).length > 0 ? { files: true } : {}),
|
|
98
|
+
...(Object.values(artifact.files).some((collection) => collection.scan === true) ? { scan: true } : {}),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A scanning application's deploy service must start its containers with `ZEROPS_SCAN_MIN_RAM_GB`: below it the
|
|
104
|
+
* scanner is killed for memory, and every upload stays quarantined. Zerops ignores a changed `verticalAutoscaling` of
|
|
105
|
+
* an existing service on a re-import, so an existing service is raised through Zerops itself.
|
|
106
|
+
*/
|
|
107
|
+
export function assertScanMemory(services: readonly { readonly [key: string]: JsonValue }[], deployService: string, run: ZeropsContractRun): void {
|
|
108
|
+
if (run.scan !== true) return
|
|
109
|
+
const service = services.find((candidate) => candidate['hostname'] === deployService)
|
|
110
|
+
const scaling = service?.['verticalAutoscaling']
|
|
111
|
+
const minRam = isMapping(scaling) ? scaling['minRam'] : undefined
|
|
112
|
+
if (typeof minRam !== 'number' || minRam < ZEROPS_SCAN_MIN_RAM_GB) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`fabrika app build: a contract with scan: true scans uploads with ClamAV, which needs ${ZEROPS_SCAN_MIN_RAM_GB} GB of RAM; set verticalAutoscaling: { minRam: ${ZEROPS_SCAN_MIN_RAM_GB} } on the service \`${deployService}\``,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Refusals ─────────────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
const LOCKED_BUILD_FORM = 'only `build.base`, `build.os` and the SPA commands in `build.buildCommands`'
|
|
122
|
+
|
|
123
|
+
const RUN_REASON = 'the platform generates the run section: the entrypoint, `fabrika db migrate`, the readiness check and the crontab'
|
|
124
|
+
|
|
125
|
+
/** Every key the authored selected setup may not carry, with why. Any key not listed and not authorable is refused too. */
|
|
126
|
+
const REFUSED_KEYS: Readonly<Record<string, string>> = {
|
|
127
|
+
extends: 'an inherited setup brings keys the platform generates',
|
|
128
|
+
deploy: 'the platform generates the deploy gate',
|
|
129
|
+
'deploy.temporaryShutdown': 'the platform decides how a version replaces the running one',
|
|
130
|
+
'deploy.readinessCheck': 'the platform generates the readiness check against the contract health endpoint',
|
|
131
|
+
'build.addToRunPrepare': 'a file copied into the runtime image skips the build checks',
|
|
132
|
+
'build.deployFiles': 'the platform deploys the checked build',
|
|
133
|
+
'build.deploy': 'the platform deploys the checked build',
|
|
134
|
+
'build.prepareCommands': 'a build-image command runs before the checks and outside them',
|
|
135
|
+
'build.cache': 'a cached path survives into the next build unchecked',
|
|
136
|
+
'build.envVariables': 'a build variable can redirect the generated checks',
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const AUTHORED_SETUP_KEYS: ReadonlySet<string> = new Set(['setup', 'build'])
|
|
140
|
+
const AUTHORED_BUILD_KEYS: ReadonlySet<string> = new Set(['base', 'os', 'buildCommands'])
|
|
141
|
+
|
|
142
|
+
const isMapping = (value: JsonValue | undefined): value is { readonly [key: string]: JsonValue } =>
|
|
143
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
144
|
+
|
|
145
|
+
const isStringList = (value: JsonValue): value is string[] => Array.isArray(value) && value.every((entry) => typeof entry === 'string')
|
|
146
|
+
|
|
147
|
+
const refuse = (setup: string, key: string, reason: string): never => {
|
|
148
|
+
throw new Error(`invalid fabrika manifest: zerops.yaml setup \`${setup}\` sets \`${key}\`; ${reason} (allowed form: ${LOCKED_BUILD_FORM})`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface AuthoredBuild {
|
|
152
|
+
readonly base: string | string[] | null
|
|
153
|
+
readonly os: string | null
|
|
154
|
+
readonly commands: string[]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Refuse every key of a contract application's setup the platform owns, and return what it keeps. */
|
|
158
|
+
export function lockedAuthoredBuild(entry: { readonly [key: string]: JsonValue }, setup: string): AuthoredBuild {
|
|
159
|
+
for (const key of Object.keys(entry)) {
|
|
160
|
+
if (key === 'run') {
|
|
161
|
+
const run = entry[key]
|
|
162
|
+
const first = isMapping(run) ? Object.keys(run)[0] : undefined
|
|
163
|
+
refuse(setup, first === undefined ? 'run' : `run.${first}`, RUN_REASON)
|
|
164
|
+
}
|
|
165
|
+
if (key === 'deploy') {
|
|
166
|
+
const deploy = entry[key]
|
|
167
|
+
const first = isMapping(deploy) ? Object.keys(deploy)[0] : undefined
|
|
168
|
+
const path = first === undefined ? 'deploy' : `deploy.${first}`
|
|
169
|
+
refuse(setup, path, REFUSED_KEYS[path] ?? REFUSED_KEYS['deploy'] ?? '')
|
|
170
|
+
}
|
|
171
|
+
if (!AUTHORED_SETUP_KEYS.has(key)) refuse(setup, key, REFUSED_KEYS[key] ?? 'the platform generates every other setup key')
|
|
172
|
+
}
|
|
173
|
+
const build = entry['build']
|
|
174
|
+
if (build === undefined) return { base: null, os: null, commands: [] }
|
|
175
|
+
if (!isMapping(build)) return refuse(setup, 'build', 'a build is a mapping')
|
|
176
|
+
for (const key of Object.keys(build)) {
|
|
177
|
+
if (!AUTHORED_BUILD_KEYS.has(key)) refuse(setup, `build.${key}`, REFUSED_KEYS[`build.${key}`] ?? 'the platform generates every other build key')
|
|
178
|
+
}
|
|
179
|
+
const base = build['base']
|
|
180
|
+
const os = build['os']
|
|
181
|
+
const commands = build['buildCommands']
|
|
182
|
+
if (base !== undefined && typeof base !== 'string' && !isStringList(base)) refuse(setup, 'build.base', 'a base is a name or a list of names')
|
|
183
|
+
if (os !== undefined && typeof os !== 'string') refuse(setup, 'build.os', 'an os is a name')
|
|
184
|
+
if (commands !== undefined && !isStringList(commands)) refuse(setup, 'build.buildCommands', 'build commands are a list of strings')
|
|
185
|
+
return {
|
|
186
|
+
base: typeof base === 'string' || (base !== undefined && isStringList(base)) ? base : null,
|
|
187
|
+
os: typeof os === 'string' ? os : null,
|
|
188
|
+
commands: commands !== undefined && isStringList(commands) ? commands : [],
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Generation ───────────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
const SOURCE_ROOT = '/build/source'
|
|
195
|
+
const RUNTIME_ROOT = '/var/www'
|
|
196
|
+
const FABRIKA_BIN = './node_modules/.bin/fabrika'
|
|
197
|
+
const MIGRATE_SCRIPT = 'fabrika/migrate.generated.sh'
|
|
198
|
+
const START_SCRIPT = 'fabrika/start.generated.sh'
|
|
199
|
+
const PLATFORM_TSCONFIG = 'fabrika/tsconfig.platform.json'
|
|
200
|
+
|
|
201
|
+
/** The compiler options of the platform's type check; the application's own `tsconfig.json` and its `paths` are not read. */
|
|
202
|
+
const PLATFORM_TSCONFIG_CONTENTS = JSON.stringify({
|
|
203
|
+
compilerOptions: {
|
|
204
|
+
target: 'ES2022',
|
|
205
|
+
module: 'ESNext',
|
|
206
|
+
moduleResolution: 'bundler',
|
|
207
|
+
lib: ['ES2022', 'DOM', 'DOM.Iterable'],
|
|
208
|
+
types: ['bun'],
|
|
209
|
+
strict: true,
|
|
210
|
+
noUncheckedIndexedAccess: true,
|
|
211
|
+
noImplicitOverride: true,
|
|
212
|
+
forceConsistentCasingInFileNames: true,
|
|
213
|
+
skipLibCheck: true,
|
|
214
|
+
resolveJsonModule: true,
|
|
215
|
+
isolatedModules: true,
|
|
216
|
+
noEmit: true,
|
|
217
|
+
},
|
|
218
|
+
include: ['../fabrika.contract.ts', '../src', 'entry.generated.ts'],
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
/** One `printf` writing `lines` to `path`. Each line is single-quoted, so it may hold neither `'` nor an expansion. */
|
|
222
|
+
const writeFileCommand = (path: string, lines: readonly string[]): string => {
|
|
223
|
+
for (const line of lines) {
|
|
224
|
+
if (line.includes("'") || line.includes('$')) throw new Error('zerops: a generated file line must not contain a quote or an expansion')
|
|
225
|
+
}
|
|
226
|
+
return `printf '%s\\n' ${lines.map((line) => `'${line}'`).join(' ')} > ${path}`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const migrateScript = (appId: string): readonly string[] => [
|
|
230
|
+
'#!/bin/sh',
|
|
231
|
+
'set -eu',
|
|
232
|
+
`cd ${RUNTIME_ROOT}`,
|
|
233
|
+
`FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db migrate`,
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
// The server starts only after the migrations it expects are applied, the committed schema types match the migrated
|
|
237
|
+
// schema and every generated isolation test passed on a throwaway schema, so any failure fails the readiness check,
|
|
238
|
+
// whether or not a failed `initCommands` stops the activation (an unverified platform fact). The build has no
|
|
239
|
+
// database, so this is where the types and the generated tests run.
|
|
240
|
+
const startScript = (appId: string, database: boolean): readonly string[] => [
|
|
241
|
+
'#!/bin/sh',
|
|
242
|
+
'set -eu',
|
|
243
|
+
`cd ${RUNTIME_ROOT}`,
|
|
244
|
+
...(database
|
|
245
|
+
? [
|
|
246
|
+
`sh ${RUNTIME_ROOT}/${MIGRATE_SCRIPT}`,
|
|
247
|
+
`FABRIKA_APP_ID=${appId} ${FABRIKA_BIN} db codegen --verify`,
|
|
248
|
+
`FABRIKA_APP_ID=${appId} ${FABRIKA_BIN} db test-isolation`,
|
|
249
|
+
]
|
|
250
|
+
: []),
|
|
251
|
+
'exec bun fabrika/entry.generated.ts',
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The generated build commands, in order; the authored SPA commands run before all of them. The application's tests
|
|
258
|
+
* run first: they are authored code, so everything after them is reinstalled, regenerated or checked again.
|
|
259
|
+
*/
|
|
260
|
+
export function generatedBuildCommands(execution: ZeropsLockedExecution): readonly string[] {
|
|
261
|
+
if (!LOCKED_APP_ID_PATTERN.test(execution.appId)) throw new Error('zerops: a contract application id must match [a-z0-9][a-z0-9-]*')
|
|
262
|
+
const database = execution.databaseUrl !== null
|
|
263
|
+
return [
|
|
264
|
+
INSTALL,
|
|
265
|
+
'bun test --pass-with-no-tests',
|
|
266
|
+
`rm -rf node_modules && ${INSTALL}`,
|
|
267
|
+
`bun -e "await import('@fabrika/app/build').then((build) => build.writeGeneratedEntry('.', 'bun'))"`,
|
|
268
|
+
`cd /tmp && bun x --bun -p @fabrika/check@${FABRIKA_CHECK_VERSION} fabrika-check ${SOURCE_ROOT}`,
|
|
269
|
+
writeFileCommand(PLATFORM_TSCONFIG, [PLATFORM_TSCONFIG_CONTENTS]),
|
|
270
|
+
`cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${SOURCE_ROOT}/${PLATFORM_TSCONFIG}`,
|
|
271
|
+
...(database ? [writeFileCommand(MIGRATE_SCRIPT, migrateScript(execution.appId))] : []),
|
|
272
|
+
writeFileCommand(START_SCRIPT, startScript(execution.appId, database)),
|
|
273
|
+
]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const sortedUnique = (values: readonly string[]): string[] => [...new Set(values)].sort()
|
|
277
|
+
|
|
278
|
+
type StorageVariable = Extract<FrameworkVariableName, `FABRIKA_APP_STORAGE_${string}`>
|
|
279
|
+
|
|
280
|
+
/** The file collections' object storage, as references Zerops resolves at runtime: no credential passes through Fabrika. */
|
|
281
|
+
const storageVariables = (hostname: string): Record<StorageVariable, string> => ({
|
|
282
|
+
FABRIKA_APP_STORAGE_ENDPOINT: `\${${hostname}_apiUrl}`,
|
|
283
|
+
FABRIKA_APP_STORAGE_BUCKET: `\${${hostname}_bucketName}`,
|
|
284
|
+
FABRIKA_APP_STORAGE_ACCESS_KEY_ID: `\${${hostname}_accessKeyId}`,
|
|
285
|
+
FABRIKA_APP_STORAGE_SECRET_ACCESS_KEY: `\${${hostname}_secretAccessKey}`,
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
/** The generated selected setup: the authored build kept, everything else the platform's. */
|
|
289
|
+
export function renderLockedSetup(
|
|
290
|
+
setup: string,
|
|
291
|
+
authored: AuthoredBuild,
|
|
292
|
+
release: string | null,
|
|
293
|
+
execution: ZeropsLockedExecution,
|
|
294
|
+
): { readonly [key: string]: JsonValue } {
|
|
295
|
+
const { contract } = execution
|
|
296
|
+
if (!CONTRACT_DIGEST_PATTERN.test(contract.digest)) throw new Error('zerops: a contract digest must be sha256:<64 hex digits>')
|
|
297
|
+
const probe = { port: ZEROPS_APPLICATION_PORT, path: contract.readinessPath }
|
|
298
|
+
const envVariables: Record<string, string> = {
|
|
299
|
+
PORT: String(ZEROPS_APPLICATION_PORT),
|
|
300
|
+
[FABRIKA_CONTRACT_DIGEST]: contract.digest,
|
|
301
|
+
...(release === null ? {} : { FABRIKA_RELEASE: release }),
|
|
302
|
+
...(execution.databaseUrl === null ? {} : { FABRIKA_APP_DATABASE_URL: execution.databaseUrl }),
|
|
303
|
+
...(execution.storageHostname === null ? {} : storageVariables(execution.storageHostname)),
|
|
304
|
+
}
|
|
305
|
+
const scan = contract.scan === true
|
|
306
|
+
const packages = sortedUnique([...execution.runtimePackages, ...(scan ? [SCANNER_PACKAGE] : [])])
|
|
307
|
+
const crontab = [
|
|
308
|
+
...contract.jobs.map((job) => ({ timing: job.schedule, command: `${FABRIKA_BIN} job run ${job.name}`, allContainers: false })),
|
|
309
|
+
...(contract.files === true
|
|
310
|
+
? [{ timing: FILE_SWEEP_SCHEDULE, command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SWEEP_JOB}`, allContainers: false }]
|
|
311
|
+
: []),
|
|
312
|
+
// Every container keeps its own signatures; one scan pass at a time holds the job's lease.
|
|
313
|
+
...(scan
|
|
314
|
+
? [
|
|
315
|
+
{ timing: '* * * * *', command: `${FABRIKA_BIN} job run ${ZEROPS_FILE_SCAN_JOB}`, allContainers: false },
|
|
316
|
+
{ timing: FRESHCLAM_SCHEDULE, command: FRESHCLAM, allContainers: true },
|
|
317
|
+
]
|
|
318
|
+
: []),
|
|
319
|
+
]
|
|
320
|
+
// A container starts from the prepared image, whose signatures are as old as its build; a failed refresh leaves
|
|
321
|
+
// them to the daily one, and the scanner refuses signatures older than three days, so the scan fails closed.
|
|
322
|
+
const initCommands = [
|
|
323
|
+
...(scan ? [`${FRESHCLAM} || echo 'fabrika: freshclam did not refresh the malware signatures'`] : []),
|
|
324
|
+
...(execution.databaseUrl === null ? [] : [`sh ${RUNTIME_ROOT}/${MIGRATE_SCRIPT}`]),
|
|
325
|
+
]
|
|
326
|
+
const prepareCommands = packages.length === 0 ? [] : [`sudo apk add --no-cache ${packages.join(' ')}`, ...(scan ? [FRESHCLAM] : [])]
|
|
327
|
+
return {
|
|
328
|
+
setup,
|
|
329
|
+
build: {
|
|
330
|
+
base: authored.base ?? [ZEROPS_APPLICATION_RUNTIME],
|
|
331
|
+
...(authored.os === null ? {} : { os: authored.os }),
|
|
332
|
+
buildCommands: [...authored.commands, ...generatedBuildCommands(execution)],
|
|
333
|
+
deployFiles: ['./'],
|
|
334
|
+
cache: ['node_modules'],
|
|
335
|
+
},
|
|
336
|
+
deploy: {
|
|
337
|
+
readinessCheck: { httpGet: probe, retryPeriod: '10s', failureTimeout: '180s' },
|
|
338
|
+
},
|
|
339
|
+
run: {
|
|
340
|
+
base: ZEROPS_APPLICATION_RUNTIME,
|
|
341
|
+
...(prepareCommands.length === 0 ? {} : { prepareCommands }),
|
|
342
|
+
...(initCommands.length === 0 ? {} : { initCommands }),
|
|
343
|
+
start: `sh ${RUNTIME_ROOT}/${START_SCRIPT}`,
|
|
344
|
+
ports: [{ port: ZEROPS_APPLICATION_PORT, httpSupport: true }],
|
|
345
|
+
healthCheck: { httpGet: probe, execPeriod: '10s', disconnectTimeout: '30s', recoveryTimeout: '10s', failureTimeout: '120s' },
|
|
346
|
+
...(crontab.length === 0 ? {} : { crontab }),
|
|
347
|
+
envVariables,
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The execution descriptor of a contract application: only the selected setup, generated. The source descriptor's
|
|
354
|
+
* bytes and digest stay the artifact's identity; this document is what the build request carries.
|
|
355
|
+
*/
|
|
356
|
+
export function renderLockedExecutionDescriptor(
|
|
357
|
+
contents: string,
|
|
358
|
+
setup: string | undefined,
|
|
359
|
+
release: string | null,
|
|
360
|
+
execution: ZeropsLockedExecution,
|
|
361
|
+
): PreparedZeropsDescriptor {
|
|
362
|
+
if (release?.includes('${') === true) throw new Error('zerops: a release name must not contain `${`')
|
|
363
|
+
const parsed = parseZeropsDescriptor(contents)
|
|
364
|
+
assertAuthoredManagedDocument(parsed.document)
|
|
365
|
+
const selected = selectZeropsDescriptorSetup(parsed, setup)
|
|
366
|
+
const entries = parsed.document['zerops']
|
|
367
|
+
const entry = Array.isArray(entries) ? entries.find((candidate) => isMapping(candidate) && candidate['setup'] === selected) : undefined
|
|
368
|
+
if (!isMapping(entry)) throw new Error('zerops: descriptor requires setup entries')
|
|
369
|
+
const authored = lockedAuthoredBuild(entry, selected)
|
|
370
|
+
return { contents: renderYaml({ zerops: [renderLockedSetup(selected, authored, release, execution)] }), setup: selected }
|
|
371
|
+
}
|