@fabrika/provider-zerops 0.0.26 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +0 -5
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +75 -20
  14. package/src/control.ts +217 -447
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +24 -128
  17. package/src/installation/admin-options.ts +128 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +155 -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 +366 -0
  25. package/src/installation/init.ts +521 -0
  26. package/src/installation/install-collaborators.ts +28 -0
  27. package/src/installation/install-environment.ts +193 -0
  28. package/src/installation/install-options.ts +193 -0
  29. package/src/installation/install-project.ts +201 -0
  30. package/src/installation/install-provisioning.ts +185 -0
  31. package/src/installation/install.ts +286 -0
  32. package/src/installation/installation-source.ts +42 -0
  33. package/src/installation/log.ts +77 -0
  34. package/src/installation/machine-key.ts +65 -0
  35. package/src/installation/manifest.ts +95 -0
  36. package/src/installation/placement-options.ts +33 -0
  37. package/src/installation/platform-source.ts +106 -0
  38. package/src/installation/platform-upload.ts +34 -0
  39. package/src/installation/proxy-manifest.ts +113 -0
  40. package/src/installation/secrets.ts +79 -0
  41. package/src/installation/sidecar.ts +122 -0
  42. package/src/installation/source-options.ts +12 -0
  43. package/src/installation/templates/README.md +116 -0
  44. package/src/installation/templates/fabrika.ref +1 -0
  45. package/src/installation/templates/gitignore +3 -0
  46. package/src/installation/templates/platform.yml +144 -0
  47. package/src/installation/topology/artifacts.ts +141 -0
  48. package/src/installation/topology/index.ts +13 -0
  49. package/src/installation/topology/installation-schemas.ts +225 -0
  50. package/src/installation/topology/invariants.ts +86 -0
  51. package/src/installation/topology/setups.ts +442 -0
  52. package/src/installation/topology/topology.ts +501 -0
  53. package/src/installation/topology/validate.ts +26 -0
  54. package/src/installation/upgrade-options.ts +108 -0
  55. package/src/installation/upgrade-output.ts +31 -0
  56. package/src/installation/upgrade.ts +373 -0
  57. package/src/managed-env.ts +105 -0
  58. package/src/manifest.ts +208 -315
  59. package/src/namespace/claims.ts +77 -0
  60. package/src/namespace/codec.ts +179 -0
  61. package/src/namespace/constants.ts +30 -0
  62. package/src/namespace/errors.ts +60 -0
  63. package/src/namespace/manifest.ts +28 -0
  64. package/src/namespace/operator.ts +151 -0
  65. package/src/namespace/provisioning.ts +482 -0
  66. package/src/namespace/proxy-attempt.ts +220 -0
  67. package/src/namespace/proxy-source.ts +22 -0
  68. package/src/namespace/topology.ts +115 -0
  69. package/src/namespace/types.ts +114 -0
  70. package/src/namespace.ts +34 -1260
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +399 -254
  73. package/src/service-references.ts +28 -0
  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 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,68 +29,202 @@ 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'
33
+ import { assertZeropsInvariants, renderYaml } from './compile'
26
34
  import {
27
- type FabrikaManifest,
28
- manifestServiceHostnames,
29
- renderFabrikaImportYaml,
30
- verifyZeropsArtifactSourceDescriptor,
31
- zeropsArtifactCodec,
32
- } from './manifest'
33
- import { buildZeropsPlan, resolveDeployHostname, type ZeropsJobSpec, type ZeropsPlan } from './plan'
34
- import type { ZeropsSourceUploadInputV2, ZeropsSourceUploadResult } from './source'
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'
44
+ import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
45
+ import { isZeropsUploadDestination } from './source'
35
46
  import type { ZeropsRunState, ZeropsRuntimeSource, ZeropsRuntimeTarget } from './types'
47
+ import { deleteVersion } from './version-attempt'
36
48
 
37
49
  export const CANCELLED = 'deploy cancelled'
38
50
 
39
- type ZeropsRun = TypedProviderRun<ZeropsRuntimeTarget, FabrikaManifest>
40
-
41
- export interface ZeropsSourceTransportBinding {
42
- readonly connectionId: string
43
- readonly installationId: number
44
- 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
45
65
  }
46
66
 
47
- export interface ZeropsSourceTransportRouting {
48
- bindingForRun(runId: string): ZeropsSourceTransportBinding | undefined
49
- 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[]
50
72
  }
51
73
 
52
- /** How often `await-deploy` asks Zerops for the version's status. */
74
+ /** How often `await-version` asks Zerops for the version's status. */
53
75
  const POLL_INTERVAL_MS = 3000
54
76
 
55
- /** Give up after this long. Zerops' own pipeline limit is 1 hour; we allow a little slack past it. */
56
- const POLL_TIMEOUT_MS = 70 * 60 * 1000
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
57
79
 
58
- /** Delay before deciding whether an ambiguous build trigger remained pre-trigger. */
59
- const BUILD_TRIGGER_CONSISTENCY_MS = 10_000
60
-
61
- /** Source cancellation cannot hold Zerops cleanup hostage. */
62
- const SOURCE_CANCEL_TIMEOUT_MS = 5000
80
+ const RELEASE_KEY = 'FABRIKA_RELEASE'
63
81
 
64
82
  /** Abandon the step if the run was cancelled. Checked before every real mutation and each poll iteration. */
65
83
  const assertRunning = (signal: AbortSignal): void => {
66
84
  if (signal.aborted) {
85
+ if (signal.reason instanceof ProviderInvocationAbort) throw signal.reason
67
86
  throw new Error(CANCELLED)
68
87
  }
69
88
  }
70
89
 
71
90
  const PLACEHOLDER = /\$\{([A-Z][A-Z0-9_]*)\}/g
72
91
 
73
- /** Resolve only variables declared by the manifest. Unknown or missing placeholders fail the deploy. */
74
- export function interpolateManifest(source: string, declared: string[], values: Record<string, string>): string {
75
- const allowed = new Set(declared)
76
- return source.replace(PLACEHOLDER, (_placeholder, name: string) => {
77
- if (!allowed.has(name)) {
78
- throw new Error(`zerops: manifest uses undeclared variable \`${name}\``)
92
+ /** Fields that decide WHAT a service is: a placeholder there would let a value redefine the service. */
93
+ const STRUCTURAL_FIELDS: readonly string[] = ['hostname', 'type', 'envIsolation', 'override']
94
+
95
+ /** Rebuild the tree with every string leaf resolved — arrays and nested objects included. */
96
+ const substituteLeaves = (value: JsonValue, resolve: (text: string) => string): JsonValue => {
97
+ if (typeof value === 'string') {
98
+ return resolve(value)
99
+ }
100
+ if (Array.isArray(value)) {
101
+ return value.map((item) => substituteLeaves(item, resolve))
102
+ }
103
+ if (value !== null && typeof value === 'object') {
104
+ return substituteRecord(value, resolve)
105
+ }
106
+ return value
107
+ }
108
+
109
+ const substituteRecord = (
110
+ record: { readonly [key: string]: JsonValue },
111
+ resolve: (text: string) => string,
112
+ ): { [key: string]: JsonValue } => {
113
+ const substituted: { [key: string]: JsonValue } = {}
114
+ for (const [key, entry] of Object.entries(record)) {
115
+ const planted = key.match(PLACEHOLDER)
116
+ if (planted !== null) {
117
+ throw new Error(`zerops: placeholder \`${String(planted[0])}\` stands in an import document key; a variable may only fill a value`)
118
+ }
119
+ // Defined rather than assigned: a literal `__proto__` key would otherwise set the prototype.
120
+ Object.defineProperty(substituted, key, {
121
+ value: substituteLeaves(entry, resolve),
122
+ enumerable: true,
123
+ writable: true,
124
+ configurable: true,
125
+ })
126
+ }
127
+ return substituted
128
+ }
129
+
130
+ /** A structural field that moved means a placeholder sat where only the compiler may write. */
131
+ const assertStructureUnchanged = (before: FabrikaImportDocument, after: FabrikaImportDocument): void => {
132
+ if (JSON.stringify(before.project) !== JSON.stringify(after.project)) {
133
+ throw new Error('zerops: variable substitution changed the project entry; a variable may not stand in the project fabrika creates')
134
+ }
135
+ for (const [index, service] of after.services.entries()) {
136
+ const original = before.services[index]
137
+ for (const field of STRUCTURAL_FIELDS) {
138
+ if (original?.[field] !== service[field]) {
139
+ throw new Error(`zerops: variable substitution changed service field \`${field}\`; a variable may not stand in a service's identity`)
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ /** Substitution is a single pass, so a placeholder left in the document came out of a variable's VALUE. */
146
+ const assertNoPlaceholderLeft = (document: FabrikaImportDocument): void => {
147
+ const refuse = (text: string): void => {
148
+ const left = text.match(PLACEHOLDER)
149
+ if (left !== null) {
150
+ throw new Error(`zerops: placeholder \`${String(left[0])}\` survived substitution; a variable value may not carry one`)
151
+ }
152
+ }
153
+ const visitRecord = (record: { readonly [key: string]: JsonValue }): void => {
154
+ for (const [key, entry] of Object.entries(record)) {
155
+ refuse(key)
156
+ visit(entry)
157
+ }
158
+ }
159
+ const visit = (value: JsonValue): void => {
160
+ if (typeof value === 'string') {
161
+ refuse(value)
162
+ return
163
+ }
164
+ if (Array.isArray(value)) {
165
+ for (const item of value) visit(item)
166
+ return
167
+ }
168
+ if (value !== null && typeof value === 'object') {
169
+ visitRecord(value)
79
170
  }
80
- const value = values[name]
81
- if (value === undefined) {
82
- throw new Error(`zerops: manifest variable \`${name}\` has no deploy-time value`)
171
+ }
172
+ if (document.project !== undefined) {
173
+ visitRecord(document.project)
174
+ }
175
+ for (const service of document.services) {
176
+ visitRecord(service)
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Resolve the manifest's declared variables INTO THE DOCUMENT — never into the rendered YAML, where a
182
+ * value carrying a quote or a newline would be read as structure and could add a service or turn
183
+ * `envIsolation` off. Only variables the manifest declares resolve; every other placeholder fails the
184
+ * deploy, and so does a value that names a service outside the app (Zerops resolves `${host_KEY}`
185
+ * regardless of isolation). The document is re-checked here because this is the last point it is an object.
186
+ */
187
+ export function interpolateManifest(
188
+ document: FabrikaImportDocument,
189
+ declared: readonly string[],
190
+ values: Record<string, string>,
191
+ ownHostnames: readonly string[],
192
+ ): FabrikaImportDocument {
193
+ assertAuthoredManagedDocument(document.services)
194
+ assertAuthoredManagedInputs({ variableNames: [...declared, ...Object.keys(values)], secretNames: [] })
195
+ const allowed = new Set(declared)
196
+ const hosts = allowedReferenceHosts(ownHostnames)
197
+ const resolve = (text: string): string => {
198
+ const substituted = text.replace(PLACEHOLDER, (_placeholder, name: string) => {
199
+ if (!allowed.has(name)) {
200
+ throw new Error(`zerops: manifest uses undeclared variable \`${name}\``)
201
+ }
202
+ const value = values[name]
203
+ if (value === undefined) {
204
+ throw new Error(`zerops: manifest variable \`${name}\` has no deploy-time value`)
205
+ }
206
+ const foreign = foreignReferenceHost(value, hosts)
207
+ if (foreign !== null) {
208
+ throw new Error(`zerops: manifest variable \`${name}\` references service \`${foreign}\`, which this app does not own`)
209
+ }
210
+ return value
211
+ })
212
+ // Adjacent values can spell a reference neither of them carries alone.
213
+ const foreign = substituted === text ? null : foreignReferenceHost(substituted, hosts)
214
+ if (foreign !== null) {
215
+ throw new Error(`zerops: a substituted value references service \`${foreign}\`, which this app does not own`)
83
216
  }
84
- return value
85
- })
217
+ return substituted
218
+ }
219
+ const services = document.services.map((service) => substituteRecord(service, resolve))
220
+ const substituted: FabrikaImportDocument = document.project === undefined
221
+ ? { services }
222
+ : { project: substituteRecord(document.project, resolve), services }
223
+ assertZeropsInvariants(substituted)
224
+ assertStructureUnchanged(document, substituted)
225
+ assertNoPlaceholderLeft(substituted)
226
+ assertAuthoredManagedDocument(substituted.services)
227
+ return substituted
86
228
  }
87
229
 
88
230
  /** Human-readable failure for a version that finished in a non-`ACTIVE` state. */
@@ -96,36 +238,32 @@ const terminalFailure = (version: ZeropsAppVersion): string => {
96
238
  return `Zerops pipeline failed: app-version ${version.id} is ${version.status ?? 'in an unknown state'}`
97
239
  }
98
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
+
99
252
  /** Everything one step's executor needs, bundled so the per-kind handlers stay small. */
100
253
  interface StepEnv {
101
- run: ZeropsRun
254
+ run: ZeropsDeployRun
102
255
  zerops: ZeropsCollaborators
103
256
  /** The compiled import document + its YAML text, materialized once in `open()`. */
104
257
  compiled: { yaml: string; hostnames: string[] }
105
- /** Hostname of the service the app's code deploys to. */
106
- deployHostname: string
107
258
  log: (line: string) => void
108
259
  signal: AbortSignal
109
260
  dryRun: boolean
110
- sourceTransport?: ZeropsSourceTransportRouting
111
- /** Set by `trigger-deploy`, read by `await-deploy` — the only state that crosses a step boundary. */
112
- state: { appVersionId?: string; processId?: string }
261
+ attempt: Attempt
262
+ managedValues: ManagedValues
263
+ /** `await-version` gives up at this instant. */
264
+ deadlineMs: number
113
265
  }
114
266
 
115
- const runState = (
116
- appVersionId: string,
117
- phase: Exclude<ZeropsRunState['phase'], 'build_triggered'>,
118
- ): Exclude<ZeropsRunState, { phase: 'build_triggered' }> => ({ appVersionId, phase })
119
-
120
- const buildTriggeredState = (
121
- appVersionId: string,
122
- processId?: string,
123
- ): Extract<ZeropsRunState, { phase: 'build_triggered' }> => ({
124
- appVersionId,
125
- phase: 'build_triggered',
126
- ...(processId === undefined ? {} : { processId }),
127
- })
128
-
129
267
  const requireSource = (env: StepEnv): { runtime: ZeropsRuntimeSource; client: NonNullable<ZeropsCollaborators['source']> } => {
130
268
  const runtime = env.run.target.source
131
269
  const client = env.zerops.source
@@ -135,38 +273,15 @@ const requireSource = (env: StepEnv): { runtime: ZeropsRuntimeSource; client: No
135
273
  return { runtime, client }
136
274
  }
137
275
 
138
- const cancelSourceBestEffort = async (env: StepEnv, source: ZeropsRuntimeSource, appVersionId: string): Promise<void> => {
139
- const client = env.zerops.source
140
- if (client === undefined) return
141
- const sourceController = new AbortController()
142
- const timeoutController = new AbortController()
143
- const cancel = Promise.resolve()
144
- .then(() => client.cancel({ runId: source.runId, appVersionId, signal: sourceController.signal }))
145
- .catch(() => {})
146
- const timeout = Promise.resolve()
147
- .then(() => (env.zerops.sourceCancelSleep ?? defaultSleep)(SOURCE_CANCEL_TIMEOUT_MS, timeoutController.signal))
148
- .then(() => sourceController.abort())
149
- .catch(() => {})
150
- await Promise.race([cancel, timeout])
151
- timeoutController.abort()
152
- 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
153
280
  }
154
281
 
155
- /**
156
- * Drop a version that was created and never handed to the platform.
157
- *
158
- * BEST EFFORT ON PURPOSE. Every caller runs this while already failing, so a cleanup that throws would
159
- * replace the reason the deploy failed with the reason the cleanup did — which is what happened live,
160
- * where a real `trigger-deploy` failure surfaced as `delete app-version failed (400)` and the cause was
161
- * gone. The leftover version is visible on the platform; the lost diagnosis was not.
162
- */
163
- const cleanupPreTriggerVersion = async (env: StepEnv, source: ZeropsRuntimeSource, appVersionId: string): Promise<void> => {
164
- await cancelSourceBestEffort(env, source, appVersionId)
165
- try {
166
- await env.zerops.api.deleteAppVersion({ appVersionId, signal: AbortSignal.timeout(5000) })
167
- } catch (error) {
168
- env.log(` [warn] app-version ${appVersionId} was left behind: ${error instanceof Error ? error.message : 'unknown error'}`)
169
- }
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(() => {})
170
285
  }
171
286
 
172
287
  /**
@@ -232,22 +347,28 @@ const openLog = async (env: StepEnv): Promise<ZeropsLogAccess | null> => {
232
347
  * Poll the trigger process and `/app-version` until the version reaches a terminal state, relaying the
233
348
  * build log as it goes. The process is optional because the trigger response may omit its id.
234
349
  *
235
- * A run cancelled here asks Zerops to cancel the BUILD too. That is not tidiness: the platform is running
236
- * the work, so abandoning the poll loop without telling it leaves a build container burning through the
237
- * 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.
238
353
  */
239
354
  const awaitVersion = async (env: StepEnv, appVersionId: string, processId: string | undefined): Promise<void> => {
240
355
  const { zerops, signal, log } = env
241
356
  const access = await openLog(env)
242
357
  const seen = new Set<string>()
243
- const deadline = Date.now() + POLL_TIMEOUT_MS
244
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
+ }
245
363
 
246
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
247
368
  if (signal.aborted) {
248
- // Best-effort: the run is already abandoned, so a failure to cancel must not mask the reason.
249
- await zerops.api.cancelBuild({ appVersionId, signal: AbortSignal.timeout(5000) }).catch(() => {})
250
- throw new Error(CANCELLED)
369
+ if (isLeaseLoss(signal.reason)) throw signal.reason
370
+ await cancelBuildQuietly(env, appVersionId)
371
+ throw new ProviderInvocationAbort('cancel_requested')
251
372
  }
252
373
  let process: ZeropsProcess | undefined
253
374
  let version: ZeropsAppVersion
@@ -256,12 +377,18 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
256
377
  version = await zerops.api.getAppVersion({ appVersionId, signal })
257
378
  } catch {
258
379
  log(` ${appVersionId}: status observation unavailable; retrying`)
259
- if (Date.now() > deadline) {
260
- throw new Error(`zerops: timed out after ${Math.round(POLL_TIMEOUT_MS / 60000)}m waiting for app-version ${appVersionId}`)
261
- }
380
+ if (Date.now() > env.deadlineMs) await timedOut()
262
381
  await zerops.sleep(POLL_INTERVAL_MS, signal)
263
382
  continue
264
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
+ }
265
392
  if (version.status !== previous) {
266
393
  log(` ${appVersionId}: ${version.status ?? 'unknown'}`)
267
394
  previous = version.status
@@ -279,17 +406,52 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
279
406
  }
280
407
  throw new Error(terminalFailure(version))
281
408
  }
282
- if (Date.now() > deadline) {
283
- throw new Error(`zerops: timed out after ${Math.round(POLL_TIMEOUT_MS / 60000)}m waiting for app-version ${appVersionId}`)
284
- }
409
+ if (Date.now() > env.deadlineMs) await timedOut()
285
410
  await zerops.sleep(POLL_INTERVAL_MS, signal)
286
411
  }
287
412
  }
288
413
 
289
- /** Run one step's effect. Resolves on success, throws on failure (caught + recorded by the engine). */
290
- const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
291
- 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
292
453
  const { target, artifact } = run
454
+ await run.events.boundary()
293
455
 
294
456
  switch (spec.kind) {
295
457
  case 'apply-import': {
@@ -318,117 +480,109 @@ const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
318
480
  return
319
481
  }
320
482
 
321
- case 'trigger-deploy': {
483
+ case 'write-service-env': {
484
+ await writeServiceEnv(env)
485
+ return
486
+ }
487
+
488
+ case 'reserve-version': {
322
489
  if (dryRun) {
323
- 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}`)
324
491
  return
325
492
  }
326
- assertRunning(signal)
327
493
  const source = requireSource(env)
328
494
  await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
495
+ assertRunning(signal)
329
496
  const version = await zerops.api.createAppVersion({ serviceId: target.serviceId, name: source.runtime.runId, signal })
330
- state.appVersionId = version.id
331
- 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
332
557
  try {
333
- await run.events.externalId(version.id, runState(version.id, 'version_created'))
334
- const uploadInput = {
335
- runId: source.runtime.runId,
336
- appVersionId: version.id,
337
- repository: source.runtime.repository,
338
- commitSha: source.runtime.commitSha,
339
- ...(source.runtime.githubInstallationId === undefined
340
- ? {}
341
- : { githubInstallationId: source.runtime.githubInstallationId }),
342
- uploadUrl: version.uploadUrl,
343
- descriptor: {
344
- path: artifact.target.sourceDescriptor.path,
345
- sha256: artifact.target.sourceDescriptor.sha256,
346
- },
347
- signal,
348
- }
349
- const sourceTransport = env.sourceTransport
350
- const binding = sourceTransport?.bindingForRun(source.runtime.runId)
351
- if (binding !== undefined && source.runtime.githubInstallationId !== binding.installationId) {
352
- throw new Error('zerops: source transport binding has different installation coordinates')
353
- }
354
- let uploaded: ZeropsSourceUploadResult
355
- if (binding === undefined) {
356
- uploaded = await source.client.upload(uploadInput)
357
- } else {
358
- if (sourceTransport === undefined) throw new Error('zerops: keyed source transport is unavailable')
359
- uploaded = await sourceTransport.uploadV2({
360
- ...uploadInput,
361
- privateBinding: { connectionId: binding.connectionId, installationId: binding.installationId },
362
- })
363
- }
364
- if (
365
- uploaded.runId !== source.runtime.runId
366
- || uploaded.appVersionId !== version.id
367
- || uploaded.commitSha !== source.runtime.commitSha
368
- || uploaded.descriptorSha256 !== artifact.target.sourceDescriptor.sha256
369
- ) {
370
- throw new Error('zerops: source upload returned different coordinates')
371
- }
372
- await run.events.checkpoint(runState(version.id, 'source_uploaded'))
373
- await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
374
- await run.events.checkpoint(runState(version.id, 'build_trigger_requested'))
375
- buildTriggerRequested = true
376
- let process: ZeropsProcess
377
- try {
378
- process = await zerops.api.buildAndDeployAppVersion({
379
- appVersionId: version.id,
380
- zeropsYaml: artifact.target.sourceDescriptor.contents,
381
- ...(artifact.target.zeropsSetup === undefined ? {} : { zeropsYamlSetup: artifact.target.zeropsSetup }),
382
- signal,
383
- })
384
- } catch (error) {
385
- if (error instanceof ZeropsApiError && error.status >= 400 && error.status < 500) {
386
- await cleanupPreTriggerVersion(env, source.runtime, version.id)
387
- throw error
388
- }
389
- let observed: ZeropsAppVersion | undefined
390
- try {
391
- await zerops.sleep(BUILD_TRIGGER_CONSISTENCY_MS, signal)
392
- observed = await zerops.api.getAppVersion({ appVersionId: version.id, signal })
393
- } catch {
394
- log(` ${version.id}: build-trigger observation unavailable; continuing to poll`)
395
- return
396
- }
397
- if (observed.id === version.id && observed.status === 'UPLOADING') {
398
- await cleanupPreTriggerVersion(env, source.runtime, version.id)
399
- throw error
400
- }
401
- if (observed.id === version.id && observed.status !== undefined) {
402
- await run.events.checkpoint(buildTriggeredState(version.id)).catch(() => {})
403
- log(` recovered accepted build trigger for app-version ${version.id}`)
404
- } else {
405
- log(` ${version.id}: build-trigger state is not known yet; continuing to poll`)
406
- }
407
- return
408
- }
409
- state.processId = process.id
410
- await run.events.checkpoint(buildTriggeredState(version.id, process.id)).catch(() => {})
411
- 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
+ })
412
564
  } catch (error) {
413
- if (!buildTriggerRequested) {
414
- 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})`)
415
569
  }
416
- throw error
570
+ log(' build request outcome unknown; polling the version')
571
+ return
417
572
  }
573
+ attempt.processId = process.id
574
+ log(` triggered app-version ${appVersionId}`)
418
575
  return
419
576
  }
420
577
 
421
- case 'await-deploy': {
578
+ case 'await-version': {
422
579
  if (dryRun) {
423
580
  log(' [dry-run] would poll the pipeline process when available and /app-version until it is ACTIVE, relaying the build log')
424
581
  return
425
582
  }
426
- const appVersionId = state.appVersionId
427
- if (appVersionId === undefined) {
428
- throw new Error('zerops: await-deploy has no app-version to watch (trigger-deploy did not run)')
429
- }
583
+ const appVersionId = requireVersion(env, 'await-version')
430
584
  assertRunning(signal)
431
- await awaitVersion(env, appVersionId, state.processId)
585
+ await awaitVersion(env, appVersionId, attempt.processId)
432
586
  return
433
587
  }
434
588
 
@@ -463,61 +617,52 @@ const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
463
617
  }
464
618
 
465
619
  /**
466
- * 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
467
621
  * `() => fakes`, which is the whole testability property — one seam, all of this driver's effects.
468
622
  *
469
623
  * A factory rather than a bundle because the Zerops client is authenticated with a token that lives on the
470
624
  * run's target, so it cannot be constructed once at module load the way Cloudflare's bundle is.
471
625
  */
472
- export type ZeropsProvider = ProviderModule<'zerops', ZeropsRuntimeTarget, FabrikaManifest>
473
-
474
- /** Construct an independently testable Zerops provider against one collaborator factory. */
475
- export const createZeropsProvider = (
626
+ export const createZeropsDeployer = (
476
627
  collaborators: ZeropsCollaboratorFactory = defaultZeropsCollaborators,
477
- sourceTransport?: ZeropsSourceTransportRouting,
478
- ): ZeropsProvider =>
479
- createProvider({
480
- id: 'zerops',
481
- target: zeropsTargetCodec,
482
- artifact: zeropsArtifactCodec,
483
- open: (run): Promise<ProviderDeploySession> => {
484
- if (run.artifact.app.id !== run.appId) {
485
- throw new Error(`zerops: artifact app drift: expected \`${run.appId}\`, got \`${run.artifact.app.id}\``)
486
- }
487
- if (run.artifact.app.env !== run.env) {
488
- throw new Error(`zerops: artifact environment drift: expected \`${run.env}\`, got \`${run.artifact.app.env}\``)
489
- }
490
- const hostnames = manifestServiceHostnames(run.artifact)
491
- const compiled = {
492
- yaml: interpolateManifest(renderFabrikaImportYaml(run.artifact), run.artifact.app.pipeline.vars, run.vars),
493
- hostnames,
494
- }
495
- const deployHostname = resolveDeployHostname(run.artifact)
496
- const plan: ZeropsPlan = buildZeropsPlan(run.artifact, run.target, run.env)
497
- const env: StepEnv = {
498
- run,
499
- zerops: collaborators(run.target),
500
- compiled,
501
- deployHostname,
502
- log: run.events.log,
503
- signal: run.signal,
504
- dryRun: run.dryRun,
505
- ...(sourceTransport === undefined ? {} : { sourceTransport }),
506
- state: {},
507
- }
508
- const byId = new Map(plan.steps.map((step): [string, ZeropsJobSpec] => [step.id, step]))
509
-
510
- return Promise.resolve({
511
- plan,
512
- execute: async (stepId: string): Promise<void> => {
513
- const spec = byId.get(stepId)
514
- if (spec === undefined) {
515
- throw new Error(`zerops: no step \`${stepId}\` in this plan`)
516
- }
517
- await runStep(spec, env)
518
- },
519
- })
520
- },
521
- })
522
-
523
- 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
+ }