@fabrika/provider-zerops 0.2.0 → 0.3.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.
Files changed (83) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +2 -6
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +3 -1
  14. package/src/control.ts +203 -450
  15. package/src/descriptor.ts +179 -0
  16. package/src/index.ts +23 -127
  17. package/src/installation/admin-options.ts +100 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +127 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +390 -0
  25. package/src/installation/init-options.ts +148 -0
  26. package/src/installation/init.ts +394 -0
  27. package/src/installation/install-collaborators.ts +27 -0
  28. package/src/installation/install-environment.ts +193 -0
  29. package/src/installation/install-options.ts +156 -0
  30. package/src/installation/install-project.ts +201 -0
  31. package/src/installation/install-provisioning.ts +185 -0
  32. package/src/installation/install.ts +262 -0
  33. package/src/installation/installation-source.ts +42 -0
  34. package/src/installation/log.ts +77 -0
  35. package/src/installation/machine-key.ts +65 -0
  36. package/src/installation/manifest.ts +95 -0
  37. package/src/installation/placement-options.ts +33 -0
  38. package/src/installation/platform-source.ts +106 -0
  39. package/src/installation/platform-upload.ts +34 -0
  40. package/src/installation/proxy-manifest.ts +113 -0
  41. package/src/installation/secrets.ts +79 -0
  42. package/src/installation/sidecar.ts +125 -0
  43. package/src/installation/source-options.ts +12 -0
  44. package/src/installation/templates/README.md +116 -0
  45. package/src/installation/templates/fabrika.ref +1 -0
  46. package/src/installation/templates/gitignore +3 -0
  47. package/src/installation/templates/platform.yml +144 -0
  48. package/src/installation/topology/artifacts.ts +141 -0
  49. package/src/installation/topology/index.ts +13 -0
  50. package/src/installation/topology/installation-schemas.ts +225 -0
  51. package/src/installation/topology/invariants.ts +86 -0
  52. package/src/installation/topology/setups.ts +442 -0
  53. package/src/installation/topology/topology.ts +501 -0
  54. package/src/installation/topology/validate.ts +26 -0
  55. package/src/installation/upgrade-options.ts +99 -0
  56. package/src/installation/upgrade-output.ts +31 -0
  57. package/src/installation/upgrade.ts +373 -0
  58. package/src/managed-env.ts +113 -0
  59. package/src/manifest.ts +194 -344
  60. package/src/namespace/claims.ts +77 -0
  61. package/src/namespace/codec.ts +179 -0
  62. package/src/namespace/constants.ts +30 -0
  63. package/src/namespace/errors.ts +60 -0
  64. package/src/namespace/manifest.ts +28 -0
  65. package/src/namespace/operator.ts +151 -0
  66. package/src/namespace/provisioning.ts +482 -0
  67. package/src/namespace/proxy-attempt.ts +220 -0
  68. package/src/namespace/proxy-source.ts +22 -0
  69. package/src/namespace/topology.ts +115 -0
  70. package/src/namespace/types.ts +114 -0
  71. package/src/namespace.ts +34 -1271
  72. package/src/plan.ts +21 -23
  73. package/src/provider.ts +266 -241
  74. package/src/source/constants.ts +11 -0
  75. package/src/source/port.ts +49 -0
  76. package/src/source/types.ts +4 -0
  77. package/src/source/validation.ts +72 -0
  78. package/src/source.ts +2 -1220
  79. package/src/types.ts +8 -7
  80. package/src/version-attempt.ts +18 -0
  81. package/src/codec.ts +0 -108
  82. package/src/namespace-command.ts +0 -209
  83. package/src/source-connection.ts +0 -344
package/src/provider.ts CHANGED
@@ -4,12 +4,20 @@
4
4
  // driver's whole side-effect surface is one REST client plus the portable schema reconciler
5
5
  // (`./collaborators.ts`). It never spawns a process and has no `runCommand` to be handed.
6
6
  //
7
- // The interesting step is `await-deploy`: fabrika does not RUN the build, it WATCHES one the platform
8
- // runs. That is the case the run's `AbortSignal` was added for (ADR-0009), and it is honoured three ways —
9
- // the poll loop rechecks it, the sleep between polls is interruptible, and a cancelled run asks Zerops to
10
- // cancel the build it started rather than leaving it running.
7
+ // The interesting step is `await-version`: fabrika does not RUN the build, it WATCHES one the platform
8
+ // runs. Recovery never resumes a deploy (ADR-0044): a version reserved but never triggered is deleted, and
9
+ // one whose build may have been requested is settled only by polling it.
11
10
 
12
- import { createProvider, type JsonValue, type ProviderDeploySession, type ProviderModule, type TypedProviderRun } from '@fabrika/provider-contract'
11
+ import {
12
+ type DeployStepResult,
13
+ followLeaseLoss,
14
+ isLeaseLoss,
15
+ type JsonValue,
16
+ ProviderInvocationAbort,
17
+ type ProviderManagedEnvironment,
18
+ type ProviderRunEvents,
19
+ runSteps,
20
+ } from '@fabrika/provider-contract'
13
21
  import {
14
22
  waitForProcess,
15
23
  ZEROPS_ACTIVE,
@@ -21,51 +29,60 @@ import {
21
29
  type ZeropsLogAccess,
22
30
  type ZeropsProcess,
23
31
  } from './api'
24
- import { zeropsTargetCodec } from './codec'
25
- import { defaultSleep, defaultZeropsCollaborators, type ZeropsCollaboratorFactory, type ZeropsCollaborators } from './collaborators'
32
+ import { defaultZeropsCollaborators, type ZeropsCollaboratorFactory, type ZeropsCollaborators } from './collaborators'
26
33
  import { assertZeropsInvariants, renderYaml } from './compile'
27
34
  import {
28
- type FabrikaImportDocument,
29
- type FabrikaManifest,
30
- manifestServiceHostnames,
31
- verifyZeropsArtifactSourceDescriptor,
32
- zeropsArtifactCodec,
33
- } from './manifest'
34
- import { buildZeropsPlan, resolveDeployHostname, type ZeropsJobSpec, type ZeropsPlan } from './plan'
35
+ assertAuthoredManagedDocument,
36
+ assertAuthoredManagedInputs,
37
+ type ManagedValues,
38
+ overlayRelease,
39
+ parseManagedValues,
40
+ SERVICE_MANAGED_KEYS,
41
+ } from './managed-env'
42
+ import { type FabrikaImportDocument, type FabrikaManifest, manifestServiceHostnames, verifyZeropsArtifactSourceDescriptor } from './manifest'
43
+ import { buildZeropsPlan, resolveDeployHostname, type ZeropsStep } from './plan'
35
44
  import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
36
- import type { ZeropsSourceUploadInputV2, ZeropsSourceUploadResult } from './source'
45
+ import { isZeropsUploadDestination } from './source'
37
46
  import type { ZeropsRunState, ZeropsRuntimeSource, ZeropsRuntimeTarget } from './types'
47
+ import { deleteVersion } from './version-attempt'
38
48
 
39
49
  export const CANCELLED = 'deploy cancelled'
40
50
 
41
- type ZeropsRun = TypedProviderRun<ZeropsRuntimeTarget, FabrikaManifest>
42
-
43
- export interface ZeropsSourceTransportBinding {
44
- readonly connectionId: string
45
- readonly installationId: number
46
- readonly transportKind: 'keyed-v2'
51
+ export interface ZeropsDeployRun {
52
+ readonly appId: string
53
+ readonly env: string
54
+ /** Every origin IAM may hand this app a session at; app-wide, because IAM's registry is keyed by app id. */
55
+ readonly returnOrigins?: readonly string[]
56
+ readonly secrets: Readonly<Record<string, string>>
57
+ readonly vars: Readonly<Record<string, string>>
58
+ /** Platform-owned runtime configuration, separate from operator-managed application vars. */
59
+ readonly managedEnvironment: ProviderManagedEnvironment
60
+ readonly target: ZeropsRuntimeTarget
61
+ readonly artifact: FabrikaManifest
62
+ readonly dryRun: boolean
63
+ readonly signal: AbortSignal
64
+ readonly events: ProviderRunEvents
47
65
  }
48
66
 
49
- export interface ZeropsSourceTransportRouting {
50
- bindingForRun(runId: string): ZeropsSourceTransportBinding | undefined
51
- uploadV2(input: ZeropsSourceUploadInputV2): Promise<ZeropsSourceUploadResult>
67
+ export interface ZeropsDeployResult {
68
+ readonly appId: string
69
+ readonly env: string
70
+ readonly status: 'succeeded' | 'failed'
71
+ readonly steps: readonly DeployStepResult[]
52
72
  }
53
73
 
54
- /** How often `await-deploy` asks Zerops for the version's status. */
74
+ /** How often `await-version` asks Zerops for the version's status. */
55
75
  const POLL_INTERVAL_MS = 3000
56
76
 
57
- /** Give up after this long. Zerops' own pipeline limit is 1 hour; we allow a little slack past it. */
58
- const POLL_TIMEOUT_MS = 70 * 60 * 1000
59
-
60
- /** Delay before deciding whether an ambiguous build trigger remained pre-trigger. */
61
- const BUILD_TRIGGER_CONSISTENCY_MS = 10_000
77
+ /** A run stops waiting for its version this long after it started. Zerops' own pipeline limit is 1 hour. */
78
+ export const ZEROPS_VERSION_DEADLINE_MS = 75 * 60 * 1000
62
79
 
63
- /** Source cancellation cannot hold Zerops cleanup hostage. */
64
- const SOURCE_CANCEL_TIMEOUT_MS = 5000
80
+ const RELEASE_KEY = 'FABRIKA_RELEASE'
65
81
 
66
82
  /** Abandon the step if the run was cancelled. Checked before every real mutation and each poll iteration. */
67
83
  const assertRunning = (signal: AbortSignal): void => {
68
84
  if (signal.aborted) {
85
+ if (signal.reason instanceof ProviderInvocationAbort) throw signal.reason
69
86
  throw new Error(CANCELLED)
70
87
  }
71
88
  }
@@ -173,6 +190,8 @@ export function interpolateManifest(
173
190
  values: Record<string, string>,
174
191
  ownHostnames: readonly string[],
175
192
  ): FabrikaImportDocument {
193
+ assertAuthoredManagedDocument(document.services)
194
+ assertAuthoredManagedInputs({ variableNames: [...declared, ...Object.keys(values)], secretNames: [] })
176
195
  const allowed = new Set(declared)
177
196
  const hosts = allowedReferenceHosts(ownHostnames)
178
197
  const resolve = (text: string): string => {
@@ -204,6 +223,7 @@ export function interpolateManifest(
204
223
  assertZeropsInvariants(substituted)
205
224
  assertStructureUnchanged(document, substituted)
206
225
  assertNoPlaceholderLeft(substituted)
226
+ assertAuthoredManagedDocument(substituted.services)
207
227
  return substituted
208
228
  }
209
229
 
@@ -218,36 +238,32 @@ const terminalFailure = (version: ZeropsAppVersion): string => {
218
238
  return `Zerops pipeline failed: app-version ${version.id} is ${version.status ?? 'in an unknown state'}`
219
239
  }
220
240
 
241
+ /**
242
+ * The attempt this invocation is making. `appVersionId` is set once a version is reserved and `triggered`
243
+ * just before the build request is sent; together they decide whether a failure may delete the version.
244
+ */
245
+ interface Attempt {
246
+ appVersionId?: string
247
+ uploadUrl?: string
248
+ processId?: string
249
+ triggered: boolean
250
+ }
251
+
221
252
  /** Everything one step's executor needs, bundled so the per-kind handlers stay small. */
222
253
  interface StepEnv {
223
- run: ZeropsRun
254
+ run: ZeropsDeployRun
224
255
  zerops: ZeropsCollaborators
225
256
  /** The compiled import document + its YAML text, materialized once in `open()`. */
226
257
  compiled: { yaml: string; hostnames: string[] }
227
- /** Hostname of the service the app's code deploys to. */
228
- deployHostname: string
229
258
  log: (line: string) => void
230
259
  signal: AbortSignal
231
260
  dryRun: boolean
232
- sourceTransport?: ZeropsSourceTransportRouting
233
- /** Set by `trigger-deploy`, read by `await-deploy` — the only state that crosses a step boundary. */
234
- state: { appVersionId?: string; processId?: string }
261
+ attempt: Attempt
262
+ managedValues: ManagedValues
263
+ /** `await-version` gives up at this instant. */
264
+ deadlineMs: number
235
265
  }
236
266
 
237
- const runState = (
238
- appVersionId: string,
239
- phase: Exclude<ZeropsRunState['phase'], 'build_triggered'>,
240
- ): Exclude<ZeropsRunState, { phase: 'build_triggered' }> => ({ appVersionId, phase })
241
-
242
- const buildTriggeredState = (
243
- appVersionId: string,
244
- processId?: string,
245
- ): Extract<ZeropsRunState, { phase: 'build_triggered' }> => ({
246
- appVersionId,
247
- phase: 'build_triggered',
248
- ...(processId === undefined ? {} : { processId }),
249
- })
250
-
251
267
  const requireSource = (env: StepEnv): { runtime: ZeropsRuntimeSource; client: NonNullable<ZeropsCollaborators['source']> } => {
252
268
  const runtime = env.run.target.source
253
269
  const client = env.zerops.source
@@ -257,38 +273,15 @@ const requireSource = (env: StepEnv): { runtime: ZeropsRuntimeSource; client: No
257
273
  return { runtime, client }
258
274
  }
259
275
 
260
- const cancelSourceBestEffort = async (env: StepEnv, source: ZeropsRuntimeSource, appVersionId: string): Promise<void> => {
261
- const client = env.zerops.source
262
- if (client === undefined) return
263
- const sourceController = new AbortController()
264
- const timeoutController = new AbortController()
265
- const cancel = Promise.resolve()
266
- .then(() => client.cancel({ runId: source.runId, appVersionId, signal: sourceController.signal }))
267
- .catch(() => {})
268
- const timeout = Promise.resolve()
269
- .then(() => (env.zerops.sourceCancelSleep ?? defaultSleep)(SOURCE_CANCEL_TIMEOUT_MS, timeoutController.signal))
270
- .then(() => sourceController.abort())
271
- .catch(() => {})
272
- await Promise.race([cancel, timeout])
273
- timeoutController.abort()
274
- sourceController.abort()
276
+ const requireVersion = (env: StepEnv, step: string): string => {
277
+ const appVersionId = env.attempt.appVersionId
278
+ if (appVersionId === undefined) throw new Error(`zerops: ${step} has no reserved app-version (reserve-version did not run)`)
279
+ return appVersionId
275
280
  }
276
281
 
277
- /**
278
- * Drop a version that was created and never handed to the platform.
279
- *
280
- * BEST EFFORT ON PURPOSE. Every caller runs this while already failing, so a cleanup that throws would
281
- * replace the reason the deploy failed with the reason the cleanup did — which is what happened live,
282
- * where a real `trigger-deploy` failure surfaced as `delete app-version failed (400)` and the cause was
283
- * gone. The leftover version is visible on the platform; the lost diagnosis was not.
284
- */
285
- const cleanupPreTriggerVersion = async (env: StepEnv, source: ZeropsRuntimeSource, appVersionId: string): Promise<void> => {
286
- await cancelSourceBestEffort(env, source, appVersionId)
287
- try {
288
- await env.zerops.api.deleteAppVersion({ appVersionId, signal: AbortSignal.timeout(5000) })
289
- } catch (error) {
290
- env.log(` [warn] app-version ${appVersionId} was left behind: ${error instanceof Error ? error.message : 'unknown error'}`)
291
- }
282
+ /** Best-effort: the caller is already giving up, so a failed cancellation must not replace the reason. */
283
+ const cancelBuildQuietly = async (env: StepEnv, appVersionId: string): Promise<void> => {
284
+ await env.zerops.api.cancelBuild({ appVersionId, signal: AbortSignal.timeout(5000) }).catch(() => {})
292
285
  }
293
286
 
294
287
  /**
@@ -354,22 +347,28 @@ const openLog = async (env: StepEnv): Promise<ZeropsLogAccess | null> => {
354
347
  * Poll the trigger process and `/app-version` until the version reaches a terminal state, relaying the
355
348
  * build log as it goes. The process is optional because the trigger response may omit its id.
356
349
  *
357
- * A run cancelled here asks Zerops to cancel the BUILD too. That is not tidiness: the platform is running
358
- * the work, so abandoning the poll loop without telling it leaves a build container burning through the
359
- * pipeline's hour.
350
+ * A cancelled run asks Zerops to cancel the BUILD too: the platform is running the work, so abandoning the
351
+ * poll loop without telling it leaves a build container burning through the pipeline's hour. A lost lease
352
+ * gives no such permission (ADR-0044), so it stops without touching the build.
360
353
  */
361
354
  const awaitVersion = async (env: StepEnv, appVersionId: string, processId: string | undefined): Promise<void> => {
362
355
  const { zerops, signal, log } = env
363
356
  const access = await openLog(env)
364
357
  const seen = new Set<string>()
365
- const deadline = Date.now() + POLL_TIMEOUT_MS
366
358
  let previous: string | undefined
359
+ const timedOut = async (): Promise<never> => {
360
+ await cancelBuildQuietly(env, appVersionId)
361
+ throw new Error(`zerops: gave up waiting for app-version ${appVersionId} at the run deadline`)
362
+ }
367
363
 
368
364
  for (;;) {
365
+ // Only a boundary that still holds the lease may cancel the build; a lost lease rejects it as lease_lost.
366
+ const crossed: unknown = await env.run.events.boundary().then(() => null, (error: unknown) => error)
367
+ if (isLeaseLoss(crossed)) throw crossed
369
368
  if (signal.aborted) {
370
- // Best-effort: the run is already abandoned, so a failure to cancel must not mask the reason.
371
- await zerops.api.cancelBuild({ appVersionId, signal: AbortSignal.timeout(5000) }).catch(() => {})
372
- throw new Error(CANCELLED)
369
+ if (isLeaseLoss(signal.reason)) throw signal.reason
370
+ await cancelBuildQuietly(env, appVersionId)
371
+ throw new ProviderInvocationAbort('cancel_requested')
373
372
  }
374
373
  let process: ZeropsProcess | undefined
375
374
  let version: ZeropsAppVersion
@@ -378,12 +377,18 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
378
377
  version = await zerops.api.getAppVersion({ appVersionId, signal })
379
378
  } catch {
380
379
  log(` ${appVersionId}: status observation unavailable; retrying`)
381
- if (Date.now() > deadline) {
382
- throw new Error(`zerops: timed out after ${Math.round(POLL_TIMEOUT_MS / 60000)}m waiting for app-version ${appVersionId}`)
383
- }
380
+ if (Date.now() > env.deadlineMs) await timedOut()
384
381
  await zerops.sleep(POLL_INTERVAL_MS, signal)
385
382
  continue
386
383
  }
384
+ if (signal.aborted) continue
385
+ if (
386
+ version.id !== appVersionId || (version.serviceStackId !== undefined && version.serviceStackId !== env.run.target.serviceId)
387
+ || (processId !== undefined && process?.id !== processId)
388
+ || (process?.appVersionId !== undefined && process.appVersionId !== appVersionId)
389
+ ) {
390
+ throw new Error('zerops: status observation has different coordinates')
391
+ }
387
392
  if (version.status !== previous) {
388
393
  log(` ${appVersionId}: ${version.status ?? 'unknown'}`)
389
394
  previous = version.status
@@ -401,17 +406,52 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
401
406
  }
402
407
  throw new Error(terminalFailure(version))
403
408
  }
404
- if (Date.now() > deadline) {
405
- throw new Error(`zerops: timed out after ${Math.round(POLL_TIMEOUT_MS / 60000)}m waiting for app-version ${appVersionId}`)
406
- }
409
+ if (Date.now() > env.deadlineMs) await timedOut()
407
410
  await zerops.sleep(POLL_INTERVAL_MS, signal)
408
411
  }
409
412
  }
410
413
 
411
- /** Run one step's effect. Resolves on success, throws on failure (caught + recorded by the engine). */
412
- const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
413
- const { run, zerops, compiled, log, signal, dryRun, state } = env
414
+ /** Write the five service-level managed keys; `FABRIKA_RELEASE` is never a service variable (ADR-0044). */
415
+ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
416
+ const { zerops, log, signal, managedValues } = env
417
+ const serviceId = env.run.target.serviceId
418
+ if (env.dryRun) {
419
+ const written = SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] !== null)
420
+ const removed = SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] === null)
421
+ log(` [dry-run] would write service variables on ${serviceId}: ${written.join(', ') || '(none)'}`)
422
+ log(` [dry-run] would remove service variables on ${serviceId} if present: ${[...removed, RELEASE_KEY].join(', ')}`)
423
+ return
424
+ }
425
+ assertRunning(signal)
426
+ // One read: every write below waits for its own user-data sync, so the build request never meets `userDataSyncRunning`.
427
+ const records = await zerops.api.listServiceEnv({ serviceId, signal })
428
+ for (const key of SERVICE_MANAGED_KEYS) {
429
+ const value = managedValues[key]
430
+ const existing = records.find((record) => record.key === key)
431
+ assertRunning(signal)
432
+ if (value !== null && existing?.content !== value) {
433
+ await zerops.api.putServiceEnv({ serviceId, key, value, signal })
434
+ log(` ${key}: updated`)
435
+ } else if (value === null && existing !== undefined) {
436
+ await zerops.api.deleteServiceEnv({ envId: existing.id, signal })
437
+ log(` ${key}: removed`)
438
+ } else {
439
+ log(` ${key}: unchanged`)
440
+ }
441
+ }
442
+ const strayRelease = records.find((record) => record.key === RELEASE_KEY)
443
+ if (strayRelease !== undefined) {
444
+ assertRunning(signal)
445
+ await zerops.api.deleteServiceEnv({ envId: strayRelease.id, signal })
446
+ log(` ${RELEASE_KEY}: removed`)
447
+ }
448
+ }
449
+
450
+ /** Run one step's effect. Resolves on success, throws on failure (caught + recorded by `runSteps`). */
451
+ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
452
+ const { run, zerops, compiled, log, signal, dryRun, attempt } = env
414
453
  const { target, artifact } = run
454
+ await run.events.boundary()
415
455
 
416
456
  switch (spec.kind) {
417
457
  case 'apply-import': {
@@ -440,117 +480,109 @@ const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
440
480
  return
441
481
  }
442
482
 
443
- case 'trigger-deploy': {
483
+ case 'write-service-env': {
484
+ await writeServiceEnv(env)
485
+ return
486
+ }
487
+
488
+ case 'reserve-version': {
444
489
  if (dryRun) {
445
- log(` [dry-run] would create an app version, upload the resolved repository snapshot, and trigger build+deploy for service ${target.serviceId}`)
490
+ log(` [dry-run] would reserve an app version on service ${target.serviceId}`)
446
491
  return
447
492
  }
448
- assertRunning(signal)
449
493
  const source = requireSource(env)
450
494
  await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
495
+ assertRunning(signal)
451
496
  const version = await zerops.api.createAppVersion({ serviceId: target.serviceId, name: source.runtime.runId, signal })
452
- state.appVersionId = version.id
453
- let buildTriggerRequested = false
497
+ attempt.appVersionId = version.id
498
+ attempt.uploadUrl = version.uploadUrl
499
+ const checkpoint: ZeropsRunState = { appVersionId: version.id, triggered: false }
500
+ await run.events.externalId(version.id, { ...checkpoint })
501
+ log(` reserved app-version ${version.id}`)
502
+ return
503
+ }
504
+
505
+ case 'upload-source': {
506
+ if (dryRun) {
507
+ log(' [dry-run] would upload the resolved repository snapshot to the reserved app version')
508
+ return
509
+ }
510
+ const source = requireSource(env)
511
+ const appVersionId = requireVersion(env, 'upload-source')
512
+ const uploadUrl = attempt.uploadUrl
513
+ if (uploadUrl === undefined) throw new Error('zerops: upload-source has no upload URL')
514
+ // The upload URL carries its own credential; it is checked here and never put in an error.
515
+ if (!isZeropsUploadDestination(uploadUrl)) throw new Error('zerops: the reserved app version has an unexpected upload destination')
516
+ assertRunning(signal)
517
+ const binding = source.runtime.privateBinding
518
+ const uploaded = await source.client.upload({
519
+ repository: source.runtime.repository,
520
+ commitSha: source.runtime.commitSha,
521
+ descriptorSha256: artifact.target.sourceDescriptor.sha256,
522
+ archive: {
523
+ kind: 'application',
524
+ ...(binding === undefined ? {} : { privateBinding: { connectionId: binding.connectionId, installationId: binding.installationId } }),
525
+ },
526
+ uploadUrl,
527
+ signal,
528
+ })
529
+ if (
530
+ uploaded.commitSha !== source.runtime.commitSha
531
+ || uploaded.descriptorSha256 !== artifact.target.sourceDescriptor.sha256
532
+ ) {
533
+ throw new Error('zerops: source upload returned different coordinates')
534
+ }
535
+ log(` uploaded ${source.runtime.commitSha} to ${appVersionId}`)
536
+ return
537
+ }
538
+
539
+ case 'trigger-build': {
540
+ if (dryRun) {
541
+ log(' [dry-run] would trigger build+deploy with FABRIKA_RELEASE written into the execution descriptor')
542
+ return
543
+ }
544
+ const appVersionId = requireVersion(env, 'trigger-build')
545
+ await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
546
+ const descriptor = overlayRelease(artifact.target.sourceDescriptor.contents, artifact.target.zeropsSetup, env.managedValues.FABRIKA_RELEASE)
547
+ assertRunning(signal)
548
+ // Recorded BEFORE the request: from here on the version is settled only by polling it (ADR-0044).
549
+ const checkpoint: ZeropsRunState = { appVersionId, triggered: true }
550
+ await run.events.checkpoint({ ...checkpoint })
551
+ attempt.triggered = true
552
+ // A cancel from here on still sends the build, so `cancel()` cancels a real build instead of
553
+ // polling a version that would sit in UPLOADING until the deadline. Only a lost lease stops it.
554
+ const buildSignal = followLeaseLoss(signal)
555
+ buildSignal.throwIfAborted()
556
+ let process: ZeropsProcess
454
557
  try {
455
- await run.events.externalId(version.id, runState(version.id, 'version_created'))
456
- const uploadInput = {
457
- runId: source.runtime.runId,
458
- appVersionId: version.id,
459
- repository: source.runtime.repository,
460
- commitSha: source.runtime.commitSha,
461
- ...(source.runtime.githubInstallationId === undefined
462
- ? {}
463
- : { githubInstallationId: source.runtime.githubInstallationId }),
464
- uploadUrl: version.uploadUrl,
465
- descriptor: {
466
- path: artifact.target.sourceDescriptor.path,
467
- sha256: artifact.target.sourceDescriptor.sha256,
468
- },
469
- signal,
470
- }
471
- const sourceTransport = env.sourceTransport
472
- const binding = sourceTransport?.bindingForRun(source.runtime.runId)
473
- if (binding !== undefined && source.runtime.githubInstallationId !== binding.installationId) {
474
- throw new Error('zerops: source transport binding has different installation coordinates')
475
- }
476
- let uploaded: ZeropsSourceUploadResult
477
- if (binding === undefined) {
478
- uploaded = await source.client.upload(uploadInput)
479
- } else {
480
- if (sourceTransport === undefined) throw new Error('zerops: keyed source transport is unavailable')
481
- uploaded = await sourceTransport.uploadV2({
482
- ...uploadInput,
483
- privateBinding: { connectionId: binding.connectionId, installationId: binding.installationId },
484
- })
485
- }
486
- if (
487
- uploaded.runId !== source.runtime.runId
488
- || uploaded.appVersionId !== version.id
489
- || uploaded.commitSha !== source.runtime.commitSha
490
- || uploaded.descriptorSha256 !== artifact.target.sourceDescriptor.sha256
491
- ) {
492
- throw new Error('zerops: source upload returned different coordinates')
493
- }
494
- await run.events.checkpoint(runState(version.id, 'source_uploaded'))
495
- await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
496
- await run.events.checkpoint(runState(version.id, 'build_trigger_requested'))
497
- buildTriggerRequested = true
498
- let process: ZeropsProcess
499
- try {
500
- process = await zerops.api.buildAndDeployAppVersion({
501
- appVersionId: version.id,
502
- zeropsYaml: artifact.target.sourceDescriptor.contents,
503
- ...(artifact.target.zeropsSetup === undefined ? {} : { zeropsYamlSetup: artifact.target.zeropsSetup }),
504
- signal,
505
- })
506
- } catch (error) {
507
- if (error instanceof ZeropsApiError && error.status >= 400 && error.status < 500) {
508
- await cleanupPreTriggerVersion(env, source.runtime, version.id)
509
- throw error
510
- }
511
- let observed: ZeropsAppVersion | undefined
512
- try {
513
- await zerops.sleep(BUILD_TRIGGER_CONSISTENCY_MS, signal)
514
- observed = await zerops.api.getAppVersion({ appVersionId: version.id, signal })
515
- } catch {
516
- log(` ${version.id}: build-trigger observation unavailable; continuing to poll`)
517
- return
518
- }
519
- if (observed.id === version.id && observed.status === 'UPLOADING') {
520
- await cleanupPreTriggerVersion(env, source.runtime, version.id)
521
- throw error
522
- }
523
- if (observed.id === version.id && observed.status !== undefined) {
524
- await run.events.checkpoint(buildTriggeredState(version.id)).catch(() => {})
525
- log(` recovered accepted build trigger for app-version ${version.id}`)
526
- } else {
527
- log(` ${version.id}: build-trigger state is not known yet; continuing to poll`)
528
- }
529
- return
530
- }
531
- state.processId = process.id
532
- await run.events.checkpoint(buildTriggeredState(version.id, process.id)).catch(() => {})
533
- log(` triggered: app-version ${version.id} (build+deploy is ONE platform-side operation)`)
558
+ process = await zerops.api.buildAndDeployAppVersion({
559
+ appVersionId,
560
+ zeropsYaml: descriptor.contents,
561
+ zeropsYamlSetup: descriptor.setup,
562
+ signal: buildSignal,
563
+ })
534
564
  } catch (error) {
535
- if (!buildTriggerRequested) {
536
- await cleanupPreTriggerVersion(env, source.runtime, version.id)
565
+ buildSignal.throwIfAborted()
566
+ // A 408 says the request timed out, not that the platform refused it.
567
+ if (error instanceof ZeropsApiError && error.status >= 400 && error.status < 500 && error.status !== 408) {
568
+ throw new Error(`zerops: build refused (${error.code === '' ? error.status : error.code})`)
537
569
  }
538
- throw error
570
+ log(' build request outcome unknown; polling the version')
571
+ return
539
572
  }
573
+ attempt.processId = process.id
574
+ log(` triggered app-version ${appVersionId}`)
540
575
  return
541
576
  }
542
577
 
543
- case 'await-deploy': {
578
+ case 'await-version': {
544
579
  if (dryRun) {
545
580
  log(' [dry-run] would poll the pipeline process when available and /app-version until it is ACTIVE, relaying the build log')
546
581
  return
547
582
  }
548
- const appVersionId = state.appVersionId
549
- if (appVersionId === undefined) {
550
- throw new Error('zerops: await-deploy has no app-version to watch (trigger-deploy did not run)')
551
- }
583
+ const appVersionId = requireVersion(env, 'await-version')
552
584
  assertRunning(signal)
553
- await awaitVersion(env, appVersionId, state.processId)
585
+ await awaitVersion(env, appVersionId, attempt.processId)
554
586
  return
555
587
  }
556
588
 
@@ -585,59 +617,52 @@ const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
585
617
  }
586
618
 
587
619
  /**
588
- * Build a Zerops driver over a specific collaborator FACTORY. Production passes the real one; tests pass
620
+ * Build a Zerops deployer over a specific collaborator FACTORY. Production passes the real one; tests pass
589
621
  * `() => fakes`, which is the whole testability property — one seam, all of this driver's effects.
590
622
  *
591
623
  * A factory rather than a bundle because the Zerops client is authenticated with a token that lives on the
592
624
  * run's target, so it cannot be constructed once at module load the way Cloudflare's bundle is.
593
625
  */
594
- export type ZeropsProvider = ProviderModule<'zerops', ZeropsRuntimeTarget, FabrikaManifest>
595
-
596
- /** Construct an independently testable Zerops provider against one collaborator factory. */
597
- export const createZeropsProvider = (
626
+ export const createZeropsDeployer = (
598
627
  collaborators: ZeropsCollaboratorFactory = defaultZeropsCollaborators,
599
- sourceTransport?: ZeropsSourceTransportRouting,
600
- ): ZeropsProvider =>
601
- createProvider({
602
- id: 'zerops',
603
- target: zeropsTargetCodec,
604
- artifact: zeropsArtifactCodec,
605
- open: (run): Promise<ProviderDeploySession> => {
606
- if (run.artifact.app.id !== run.appId) {
607
- throw new Error(`zerops: artifact app drift: expected \`${run.appId}\`, got \`${run.artifact.app.id}\``)
608
- }
609
- if (run.artifact.app.env !== run.env) {
610
- throw new Error(`zerops: artifact environment drift: expected \`${run.env}\`, got \`${run.artifact.app.env}\``)
611
- }
612
- const hostnames = manifestServiceHostnames(run.artifact)
613
- const document = interpolateManifest(run.artifact.target.importDocument, run.artifact.app.pipeline.vars, run.vars, hostnames)
614
- const compiled = { yaml: renderYaml(document), hostnames }
615
- const deployHostname = resolveDeployHostname(run.artifact)
616
- const plan: ZeropsPlan = buildZeropsPlan(run.artifact, run.target, run.env)
617
- const env: StepEnv = {
618
- run,
619
- zerops: collaborators(run.target),
620
- compiled,
621
- deployHostname,
622
- log: run.events.log,
623
- signal: run.signal,
624
- dryRun: run.dryRun,
625
- ...(sourceTransport === undefined ? {} : { sourceTransport }),
626
- state: {},
627
- }
628
- const byId = new Map(plan.steps.map((step): [string, ZeropsJobSpec] => [step.id, step]))
629
-
630
- return Promise.resolve({
631
- plan,
632
- execute: async (stepId: string): Promise<void> => {
633
- const spec = byId.get(stepId)
634
- if (spec === undefined) {
635
- throw new Error(`zerops: no step \`${stepId}\` in this plan`)
636
- }
637
- await runStep(spec, env)
638
- },
639
- })
640
- },
641
- })
642
-
643
- export const zeropsProvider: ZeropsProvider = createZeropsProvider()
628
+ ) =>
629
+ async (run: ZeropsDeployRun): Promise<ZeropsDeployResult> => {
630
+ if (run.artifact.app.id !== run.appId) {
631
+ throw new Error(`zerops: artifact app drift: expected \`${run.appId}\`, got \`${run.artifact.app.id}\``)
632
+ }
633
+ if (run.artifact.app.env !== run.env) {
634
+ throw new Error(`zerops: artifact environment drift: expected \`${run.env}\`, got \`${run.artifact.app.env}\``)
635
+ }
636
+ assertAuthoredManagedInputs({ variableNames: Object.keys(run.vars), secretNames: Object.keys(run.secrets) })
637
+ const managedValues = parseManagedValues(run.managedEnvironment)
638
+ const hostnames = manifestServiceHostnames(run.artifact)
639
+ const document = interpolateManifest(run.artifact.target.importDocument, run.artifact.app.pipeline.vars, run.vars, hostnames)
640
+ const compiled = { yaml: renderYaml(document), hostnames }
641
+ resolveDeployHostname(run.artifact)
642
+ const zerops = collaborators(run.target)
643
+ const env: StepEnv = {
644
+ run,
645
+ zerops,
646
+ compiled,
647
+ log: run.events.log,
648
+ signal: run.signal,
649
+ dryRun: run.dryRun,
650
+ attempt: { triggered: false },
651
+ managedValues,
652
+ deadlineMs: Date.now() + ZEROPS_VERSION_DEADLINE_MS,
653
+ }
654
+ const steps = buildZeropsPlan(run.artifact, run.target)
655
+ const options = { appId: run.appId, env: run.env, dryRun: run.dryRun, signal: run.signal, log: run.events.log }
656
+ const result = await runSteps(steps, options, (step) => runStep(step, env))
657
+ const { appVersionId, triggered } = env.attempt
658
+ // A cancelled run leaves the version to `cancel()`; a lost lease leaves it to the next owner.
659
+ if (result.status === 'failed' && appVersionId !== undefined && !triggered && !run.signal.aborted) {
660
+ try {
661
+ await deleteVersion(zerops.api, appVersionId, run.signal)
662
+ run.events.log(` deleted app-version ${appVersionId}`)
663
+ } catch {
664
+ run.events.log(` [warn] app-version ${appVersionId} could not be deleted; it was never built`)
665
+ }
666
+ }
667
+ return { appId: run.appId, env: run.env, ...result }
668
+ }
@@ -0,0 +1,11 @@
1
+ export const MAX_ID_LENGTH = 128
2
+
3
+ export const SHA256_PATTERN = /^[a-f0-9]{64}$/
4
+
5
+ export const COMMIT_SHA_PATTERN = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/
6
+
7
+ export const ID_PATTERN = /^[A-Za-z0-9._:-]+$/
8
+
9
+ export const GITHUB_OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/
10
+
11
+ export const GITHUB_REPOSITORY_PATTERN = /^[a-z0-9._-]{1,100}$/