@fabrika/provider-zerops 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/manifest.ts CHANGED
@@ -3,8 +3,10 @@ import type { JsonValue, ProviderCodec } from '@fabrika/provider-contract'
3
3
  import { compileImport, ENV_ISOLATION, OVERRIDE, renderYaml } from './compile'
4
4
  import type { ZeropsAppConfig, ZeropsNamespaceResourceRequirement, ZeropsProxySpec, ZeropsSharedPostgresBinding } from './types'
5
5
 
6
- export const FABRIKA_MANIFEST_VERSION = 2
6
+ export const FABRIKA_MANIFEST_VERSION = 3
7
+ export const ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES = 256 * 1024
7
8
  export const ZEROPS_SERVICE_HOSTNAME_PATTERN = /^[a-z0-9]{1,25}$/
9
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/
8
10
 
9
11
  /**
10
12
  * The one reference an app may declare for the namespace-owned PostgreSQL service.
@@ -22,6 +24,12 @@ export interface FabrikaImportDocument {
22
24
  readonly services: ReadonlyArray<{ readonly [key: string]: JsonValue }>
23
25
  }
24
26
 
27
+ export interface ZeropsArtifactSourceDescriptor {
28
+ readonly path: 'zerops.yaml'
29
+ readonly contents: string
30
+ readonly sha256: string
31
+ }
32
+
25
33
  export interface FabrikaManifest {
26
34
  manifestVersion: typeof FABRIKA_MANIFEST_VERSION
27
35
  app: {
@@ -35,6 +43,8 @@ export interface FabrikaManifest {
35
43
  }
36
44
  target: {
37
45
  platform: 'zerops'
46
+ /** The exact repository-root build descriptor used for this artifact. */
47
+ sourceDescriptor: ZeropsArtifactSourceDescriptor
38
48
  /** The canonical document used both for resource claims and the Zerops API payload. */
39
49
  importDocument: FabrikaImportDocument
40
50
  deployService: string
@@ -264,6 +274,50 @@ const parseImportDocument = (value: unknown): FabrikaImportDocument => {
264
274
  return project === undefined ? { services } : { project, services }
265
275
  }
266
276
 
277
+ const parseSourceDescriptor = (value: unknown): ZeropsArtifactSourceDescriptor => {
278
+ if (value === undefined) {
279
+ throw new Error('invalid fabrika manifest Zerops source descriptor')
280
+ }
281
+ const raw = jsonObject(value, 'Zerops source descriptor')
282
+ const unknownKeys = Object.keys(raw).filter((key) => key !== 'path' && key !== 'contents' && key !== 'sha256')
283
+ if (unknownKeys.length > 0) {
284
+ throw new Error(`invalid fabrika manifest Zerops source descriptor field \`${unknownKeys[0]}\``)
285
+ }
286
+ const contents = raw['contents']
287
+ const sha256 = raw['sha256']
288
+ if (
289
+ raw['path'] !== 'zerops.yaml'
290
+ || typeof contents !== 'string'
291
+ || contents === ''
292
+ || new TextEncoder().encode(contents).byteLength > ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES
293
+ || typeof sha256 !== 'string'
294
+ || !SHA256_PATTERN.test(sha256)
295
+ ) {
296
+ throw new Error('invalid fabrika manifest Zerops source descriptor')
297
+ }
298
+ return { path: 'zerops.yaml', contents, sha256 }
299
+ }
300
+
301
+ export const createZeropsArtifactSourceDescriptor = async (contents: string): Promise<ZeropsArtifactSourceDescriptor> => {
302
+ const bytes = new TextEncoder().encode(contents)
303
+ if (bytes.byteLength === 0) {
304
+ throw new Error('Zerops source descriptor must not be empty')
305
+ }
306
+ if (bytes.byteLength > ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES) {
307
+ throw new Error(`Zerops source descriptor exceeds ${ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES} bytes`)
308
+ }
309
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
310
+ const sha256 = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')
311
+ return { path: 'zerops.yaml', contents, sha256 }
312
+ }
313
+
314
+ export const verifyZeropsArtifactSourceDescriptor = async (descriptor: ZeropsArtifactSourceDescriptor): Promise<void> => {
315
+ const actual = await createZeropsArtifactSourceDescriptor(descriptor.contents)
316
+ if (actual.sha256 !== descriptor.sha256) {
317
+ throw new Error('Zerops artifact source descriptor digest mismatch')
318
+ }
319
+ }
320
+
267
321
  export const manifestServiceHostnames = (manifest: FabrikaManifest): string[] =>
268
322
  manifest.target.importDocument.services.map((service) => {
269
323
  const hostname = service['hostname']
@@ -312,6 +366,7 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
312
366
  }
313
367
 
314
368
  const rawTarget = prop(value, 'target')
369
+ const sourceDescriptor = parseSourceDescriptor(prop(rawTarget, 'sourceDescriptor'))
315
370
  const rawImportDocument = prop(rawTarget, 'importDocument')
316
371
  const deployService = nonEmptyString(rawTarget, 'deployService')
317
372
  if (
@@ -372,6 +427,7 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
372
427
  },
373
428
  target: {
374
429
  platform: 'zerops',
430
+ sourceDescriptor,
375
431
  importDocument,
376
432
  deployService,
377
433
  ...(zeropsSetup !== null ? { zeropsSetup } : {}),
@@ -381,7 +437,7 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
381
437
  }
382
438
  }
383
439
 
384
- export function compileFabrikaManifest(config: ZeropsAppConfig, env: string): FabrikaManifest {
440
+ export function compileFabrikaManifest(config: ZeropsAppConfig, env: string, sourceDescriptor: ZeropsArtifactSourceDescriptor): FabrikaManifest {
385
441
  if (config.target.services === undefined) {
386
442
  throw new Error('fabrika app build requires a source Zerops config')
387
443
  }
@@ -414,6 +470,7 @@ export function compileFabrikaManifest(config: ZeropsAppConfig, env: string): Fa
414
470
  },
415
471
  target: {
416
472
  platform: 'zerops',
473
+ sourceDescriptor,
417
474
  importDocument: parseImportDocument(document),
418
475
  deployService,
419
476
  ...(config.target.zeropsSetup !== undefined ? { zeropsSetup: config.target.zeropsSetup } : {}),
@@ -436,7 +493,7 @@ export function compileFabrikaManifest(config: ZeropsAppConfig, env: string): Fa
436
493
 
437
494
  /** Provider artifact codec. Its envelope version and manifest payload version advance independently. */
438
495
  export const zeropsArtifactCodec: ProviderCodec<FabrikaManifest> = {
439
- version: 2,
496
+ version: 3,
440
497
  encode: jsonValue,
441
498
  decode: (payload) => parseFabrikaManifest(payload),
442
499
  }
package/src/provider.ts CHANGED
@@ -10,12 +10,27 @@
10
10
  // cancel the build it started rather than leaving it running.
11
11
 
12
12
  import { createProvider, type ProviderDeploySession, type ProviderModule, type TypedProviderRun } from '@fabrika/provider-contract'
13
- import { ZEROPS_ACTIVE, ZEROPS_TERMINAL, type ZeropsAppVersion, type ZeropsLogAccess } from './api'
13
+ import {
14
+ ZEROPS_ACTIVE,
15
+ ZEROPS_PROCESS_FINISHED,
16
+ ZEROPS_PROCESS_TERMINAL,
17
+ ZEROPS_TERMINAL,
18
+ ZeropsApiError,
19
+ type ZeropsAppVersion,
20
+ type ZeropsLogAccess,
21
+ type ZeropsProcess,
22
+ } from './api'
14
23
  import { zeropsTargetCodec } from './codec'
15
- import { defaultZeropsCollaborators, type ZeropsCollaboratorFactory, type ZeropsCollaborators } from './collaborators'
16
- import { type FabrikaManifest, manifestServiceHostnames, renderFabrikaImportYaml, zeropsArtifactCodec } from './manifest'
24
+ import { defaultSleep, defaultZeropsCollaborators, type ZeropsCollaboratorFactory, type ZeropsCollaborators } from './collaborators'
25
+ import {
26
+ type FabrikaManifest,
27
+ manifestServiceHostnames,
28
+ renderFabrikaImportYaml,
29
+ verifyZeropsArtifactSourceDescriptor,
30
+ zeropsArtifactCodec,
31
+ } from './manifest'
17
32
  import { buildZeropsPlan, resolveDeployHostname, type ZeropsJobSpec, type ZeropsPlan } from './plan'
18
- import type { ZeropsRuntimeTarget } from './types'
33
+ import type { ZeropsRunState, ZeropsRuntimeSource, ZeropsRuntimeTarget } from './types'
19
34
 
20
35
  export const CANCELLED = 'deploy cancelled'
21
36
 
@@ -27,6 +42,12 @@ const POLL_INTERVAL_MS = 3000
27
42
  /** Give up after this long. Zerops' own pipeline limit is 1 hour; we allow a little slack past it. */
28
43
  const POLL_TIMEOUT_MS = 70 * 60 * 1000
29
44
 
45
+ /** Delay before deciding whether an ambiguous build trigger remained pre-trigger. */
46
+ const BUILD_TRIGGER_CONSISTENCY_MS = 10_000
47
+
48
+ /** Source cancellation cannot hold Zerops cleanup hostage. */
49
+ const SOURCE_CANCEL_TIMEOUT_MS = 5000
50
+
30
51
  /** Abandon the step if the run was cancelled. Checked before every real mutation and each poll iteration. */
31
52
  const assertRunning = (signal: AbortSignal): void => {
32
53
  if (signal.aborted) {
@@ -74,7 +95,52 @@ interface StepEnv {
74
95
  signal: AbortSignal
75
96
  dryRun: boolean
76
97
  /** Set by `trigger-deploy`, read by `await-deploy` — the only state that crosses a step boundary. */
77
- state: { appVersionId?: string }
98
+ state: { appVersionId?: string; processId?: string }
99
+ }
100
+
101
+ const runState = (
102
+ appVersionId: string,
103
+ phase: Exclude<ZeropsRunState['phase'], 'build_triggered'>,
104
+ ): Exclude<ZeropsRunState, { phase: 'build_triggered' }> => ({ appVersionId, phase })
105
+
106
+ const buildTriggeredState = (
107
+ appVersionId: string,
108
+ processId?: string,
109
+ ): Extract<ZeropsRunState, { phase: 'build_triggered' }> => ({
110
+ appVersionId,
111
+ phase: 'build_triggered',
112
+ ...(processId === undefined ? {} : { processId }),
113
+ })
114
+
115
+ const requireSource = (env: StepEnv): { runtime: ZeropsRuntimeSource; client: NonNullable<ZeropsCollaborators['source']> } => {
116
+ const runtime = env.run.target.source
117
+ const client = env.zerops.source
118
+ if (runtime === undefined || client === undefined) {
119
+ throw new Error('zerops: upload-backed deploy requires source coordinates and a source client')
120
+ }
121
+ return { runtime, client }
122
+ }
123
+
124
+ const cancelSourceBestEffort = async (env: StepEnv, source: ZeropsRuntimeSource, appVersionId: string): Promise<void> => {
125
+ const client = env.zerops.source
126
+ if (client === undefined) return
127
+ const sourceController = new AbortController()
128
+ const timeoutController = new AbortController()
129
+ const cancel = Promise.resolve()
130
+ .then(() => client.cancel({ runId: source.runId, appVersionId, signal: sourceController.signal }))
131
+ .catch(() => {})
132
+ const timeout = Promise.resolve()
133
+ .then(() => (env.zerops.sourceCancelSleep ?? defaultSleep)(SOURCE_CANCEL_TIMEOUT_MS, timeoutController.signal))
134
+ .then(() => sourceController.abort())
135
+ .catch(() => {})
136
+ await Promise.race([cancel, timeout])
137
+ timeoutController.abort()
138
+ sourceController.abort()
139
+ }
140
+
141
+ const cleanupPreTriggerVersion = async (env: StepEnv, source: ZeropsRuntimeSource, appVersionId: string): Promise<void> => {
142
+ await cancelSourceBestEffort(env, source, appVersionId)
143
+ await env.zerops.api.deleteAppVersion({ appVersionId, signal: AbortSignal.timeout(5000) })
78
144
  }
79
145
 
80
146
  /**
@@ -83,7 +149,7 @@ interface StepEnv {
83
149
  * Deliberately FAILURE-TOLERANT: the log service's protocol is the one shape in this driver nobody could
84
150
  * verify against a document (see `ZeropsApi.readBuildLog`). An unverified endpoint must never be able to
85
151
  * fail a deploy, so a broken guess degrades to "no lines relayed" and the run still succeeds or fails on
86
- * `/app-version` status alone.
152
+ * the platform's process and app-version statuses.
87
153
  *
88
154
  * Relay is pull-based (ADR-0003), so each poll re-reads a window that overlaps the last one; `seen` keeps
89
155
  * the run log from repeating itself. Two genuinely identical lines with no timestamp collapse into one —
@@ -119,13 +185,14 @@ const openLog = async (env: StepEnv): Promise<ZeropsLogAccess | null> => {
119
185
  }
120
186
 
121
187
  /**
122
- * Poll `/app-version` until the version reaches a terminal state, relaying the build log as it goes.
188
+ * Poll the trigger process and `/app-version` until the version reaches a terminal state, relaying the
189
+ * build log as it goes. The process is optional because the trigger response may omit its id.
123
190
  *
124
191
  * A run cancelled here asks Zerops to cancel the BUILD too. That is not tidiness: the platform is running
125
192
  * the work, so abandoning the poll loop without telling it leaves a build container burning through the
126
193
  * pipeline's hour.
127
194
  */
128
- const awaitVersion = async (env: StepEnv, appVersionId: string): Promise<void> => {
195
+ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: string | undefined): Promise<void> => {
129
196
  const { zerops, signal, log } = env
130
197
  const access = await openLog(env)
131
198
  const seen = new Set<string>()
@@ -138,13 +205,30 @@ const awaitVersion = async (env: StepEnv, appVersionId: string): Promise<void> =
138
205
  await zerops.api.cancelBuild({ appVersionId, signal: AbortSignal.timeout(5000) }).catch(() => {})
139
206
  throw new Error(CANCELLED)
140
207
  }
141
- const version = await zerops.api.getAppVersion({ appVersionId, signal })
208
+ let process: ZeropsProcess | undefined
209
+ let version: ZeropsAppVersion
210
+ try {
211
+ process = processId === undefined ? undefined : await zerops.api.getProcess({ processId, signal })
212
+ version = await zerops.api.getAppVersion({ appVersionId, signal })
213
+ } catch {
214
+ log(` ${appVersionId}: status observation unavailable; retrying`)
215
+ if (Date.now() > deadline) {
216
+ throw new Error(`zerops: timed out after ${Math.round(POLL_TIMEOUT_MS / 60000)}m waiting for app-version ${appVersionId}`)
217
+ }
218
+ await zerops.sleep(POLL_INTERVAL_MS, signal)
219
+ continue
220
+ }
142
221
  if (version.status !== previous) {
143
222
  log(` ${appVersionId}: ${version.status ?? 'unknown'}`)
144
223
  previous = version.status
145
224
  }
146
225
  await relayLog(env, access, seen)
147
226
 
227
+ if (process?.status !== undefined && ZEROPS_PROCESS_TERMINAL.has(process.status) && process.status !== ZEROPS_PROCESS_FINISHED) {
228
+ throw new Error(
229
+ `zerops: pipeline process ${process.id} is ${process.status} while app-version ${appVersionId} is ${version.status ?? 'in an unknown state'}`,
230
+ )
231
+ }
148
232
  if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
149
233
  if (version.status === ZEROPS_ACTIVE) {
150
234
  return
@@ -180,37 +264,93 @@ const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
180
264
  }
181
265
 
182
266
  case 'trigger-deploy': {
183
- const source = target.buildFromGit === undefined ? "the service's configured Git integration" : target.buildFromGit
184
267
  if (dryRun) {
185
- log(` [dry-run] would trigger the Zerops pipeline for service ${target.serviceId} (${env.deployHostname}) from ${source}`)
268
+ log(` [dry-run] would create an app version, upload the resolved repository snapshot, and trigger build+deploy for service ${target.serviceId}`)
186
269
  return
187
270
  }
188
271
  assertRunning(signal)
189
- const zeropsSetup = artifact.target.zeropsSetup
190
- const process = await zerops.api.triggerPipeline({
191
- serviceId: target.serviceId,
192
- buildFromGit: target.buildFromGit,
193
- zeropsSetup,
194
- signal,
195
- })
196
- // The trigger response's app-version id is optional on the wire, so resolve the version we just
197
- // created rather than depending on it — and fail loudly if there is none, since awaiting a
198
- // version we cannot name would silently watch someone else's deploy.
199
- const version = process?.appVersionId !== undefined
200
- ? { id: process.appVersionId }
201
- : await zerops.api.latestAppVersion({ serviceId: target.serviceId, signal })
202
- if (version === null || version.id === '') {
203
- throw new Error(`zerops: pipeline triggered for service ${target.serviceId} but no app-version could be resolved`)
204
- }
272
+ const source = requireSource(env)
273
+ await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
274
+ const version = await zerops.api.createAppVersion({ serviceId: target.serviceId, name: source.runtime.runId, signal })
205
275
  state.appVersionId = version.id
206
- await run.events.externalId(version.id)
207
- log(` triggered: app-version ${version.id} (build+deploy is ONE platform-side operation)`)
276
+ let buildTriggerRequested = false
277
+ try {
278
+ await run.events.externalId(version.id, runState(version.id, 'version_created'))
279
+ const uploaded = await source.client.upload({
280
+ runId: source.runtime.runId,
281
+ appVersionId: version.id,
282
+ repository: source.runtime.repository,
283
+ commitSha: source.runtime.commitSha,
284
+ ...(source.runtime.githubInstallationId === undefined
285
+ ? {}
286
+ : { githubInstallationId: source.runtime.githubInstallationId }),
287
+ uploadUrl: version.uploadUrl,
288
+ descriptor: {
289
+ path: artifact.target.sourceDescriptor.path,
290
+ sha256: artifact.target.sourceDescriptor.sha256,
291
+ },
292
+ signal,
293
+ })
294
+ if (
295
+ uploaded.runId !== source.runtime.runId
296
+ || uploaded.appVersionId !== version.id
297
+ || uploaded.commitSha !== source.runtime.commitSha
298
+ || uploaded.descriptorSha256 !== artifact.target.sourceDescriptor.sha256
299
+ ) {
300
+ throw new Error('zerops: source upload returned different coordinates')
301
+ }
302
+ await run.events.checkpoint(runState(version.id, 'source_uploaded'))
303
+ await verifyZeropsArtifactSourceDescriptor(artifact.target.sourceDescriptor)
304
+ await run.events.checkpoint(runState(version.id, 'build_trigger_requested'))
305
+ buildTriggerRequested = true
306
+ let process: ZeropsProcess
307
+ try {
308
+ process = await zerops.api.buildAndDeployAppVersion({
309
+ appVersionId: version.id,
310
+ zeropsYaml: artifact.target.sourceDescriptor.contents,
311
+ ...(artifact.target.zeropsSetup === undefined ? {} : { zeropsYamlSetup: artifact.target.zeropsSetup }),
312
+ signal,
313
+ })
314
+ } catch (error) {
315
+ if (error instanceof ZeropsApiError && error.status >= 400 && error.status < 500) {
316
+ await cleanupPreTriggerVersion(env, source.runtime, version.id)
317
+ throw error
318
+ }
319
+ let observed: ZeropsAppVersion | undefined
320
+ try {
321
+ await zerops.sleep(BUILD_TRIGGER_CONSISTENCY_MS, signal)
322
+ observed = await zerops.api.getAppVersion({ appVersionId: version.id, signal })
323
+ } catch {
324
+ log(` ${version.id}: build-trigger observation unavailable; continuing to poll`)
325
+ return
326
+ }
327
+ if (observed.id === version.id && observed.status === 'UPLOADING') {
328
+ await cleanupPreTriggerVersion(env, source.runtime, version.id)
329
+ throw error
330
+ }
331
+ if (observed.id === version.id && observed.status !== undefined) {
332
+ await run.events.checkpoint(buildTriggeredState(version.id)).catch(() => {})
333
+ log(` recovered accepted build trigger for app-version ${version.id}`)
334
+ } else {
335
+ log(` ${version.id}: build-trigger state is not known yet; continuing to poll`)
336
+ }
337
+ return
338
+ }
339
+ state.processId = process.id
340
+ await run.events.checkpoint(buildTriggeredState(version.id, process.id)).catch(() => {})
341
+ log(` triggered: app-version ${version.id} (build+deploy is ONE platform-side operation)`)
342
+ } catch (error) {
343
+ if (!buildTriggerRequested) {
344
+ await cleanupPreTriggerVersion(env, source.runtime, version.id)
345
+ }
346
+ throw error
347
+ }
208
348
  return
209
349
  }
210
350
 
211
351
  case 'await-deploy': {
212
352
  if (dryRun) {
213
- log(' [dry-run] would poll /app-version until it is ACTIVE and relay the build log')
353
+ log(' [dry-run] would poll the pipeline process when available and /app-version until it is ACTIVE, relaying the build log')
214
354
  return
215
355
  }
216
356
  const appVersionId = state.appVersionId
@@ -218,7 +358,7 @@ const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
218
358
  throw new Error('zerops: await-deploy has no app-version to watch (trigger-deploy did not run)')
219
359
  }
220
360
  assertRunning(signal)
221
- await awaitVersion(env, appVersionId)
361
+ await awaitVersion(env, appVersionId, state.processId)
222
362
  return
223
363
  }
224
364