@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.
Files changed (36) hide show
  1. package/package.json +7 -6
  2. package/src/api/client.ts +11 -2
  3. package/src/api/types.ts +13 -0
  4. package/src/cli.ts +21 -4
  5. package/src/collaborators.ts +4 -0
  6. package/src/control.ts +37 -8
  7. package/src/execution.ts +371 -0
  8. package/src/exposure.ts +95 -0
  9. package/src/installation/deploy-options.ts +12 -1
  10. package/src/installation/deploy.ts +42 -18
  11. package/src/installation/generated/platform-console-schema.ts +4 -0
  12. package/src/installation/generated/platform-proxy-manifest.ts +533 -33
  13. package/src/installation/index.ts +11 -0
  14. package/src/installation/install-environment.ts +1 -2
  15. package/src/installation/machine-key.ts +3 -4
  16. package/src/installation/manifest.ts +1 -1
  17. package/src/installation/mcp.ts +102 -0
  18. package/src/installation/placement-options.ts +2 -1
  19. package/src/installation/proxy-manifest.ts +124 -23
  20. package/src/installation/secrets.ts +2 -2
  21. package/src/installation/templates/README.md +20 -11
  22. package/src/installation/templates/platform.yml +2 -0
  23. package/src/installation/topology/setups.ts +5 -5
  24. package/src/managed-env.ts +94 -6
  25. package/src/manifest.ts +248 -14
  26. package/src/namespace/codec.ts +23 -12
  27. package/src/namespace/errors.ts +1 -1
  28. package/src/namespace/manifest.ts +25 -6
  29. package/src/namespace/provisioning.ts +10 -29
  30. package/src/namespace/proxy-attempt.ts +13 -2
  31. package/src/namespace/proxy-secrets.ts +21 -0
  32. package/src/namespace/update.ts +212 -0
  33. package/src/namespace/validation.ts +43 -0
  34. package/src/plan.ts +6 -0
  35. package/src/provider.ts +159 -19
  36. 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.2",
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.3.2",
47
- "@fabrika/auth-core": "^0.3.2",
48
- "@fabrika/installation-init": "^0.3.2",
49
- "@fabrika/provider-contract": "^0.3.2",
50
- "@fabrika/proxy-core": "^0.3.2",
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
- const triggerPipelineSchema = z.object({ process: processSchema.nullish() })
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', triggerPipelineSchema, payload).process ?? null
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 config = await loadConfig(args.config)
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, `${JSON.stringify(manifest, null, '\t')}\n`)
105
+ await Bun.write(output, serializeManifest(manifest))
89
106
  console.info(`wrote ${output} (${manifestServiceHostnames(manifest).length} service(s))`)
90
107
  }
91
108
 
@@ -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 { assertAuthoredManagedInputs, assertAuthoredManagedName, parseManagedValues } from './managed-env'
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) => ({ serviceId: target.serviceId }),
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
- if (artifact.app.schema === undefined || options.propustkaUrl === undefined) return
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: artifact.app.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 managedEnvironment = parseManagedValues(input.managedEnvironment)
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, { serviceId: discovered.id }),
543
+ target: envelope(zeropsStoredTargetCodec, {
544
+ serviceId: discovered.id,
545
+ ...(createdBuckets.length === 0 ? {} : { createdBuckets }),
546
+ }),
518
547
  },
519
548
  })
520
549
  },
@@ -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
+ }