@fabrika/provider-zerops 0.2.0 → 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.
- package/package.json +18 -5
- package/src/api/client.ts +522 -0
- package/src/api/errors.ts +50 -0
- package/src/api/polling.ts +59 -0
- package/src/api/readers.ts +185 -0
- package/src/api/service-env-response.ts +34 -0
- package/src/api/types.ts +602 -0
- package/src/api.ts +29 -1378
- package/src/authoring.ts +7 -10
- package/src/cli-args.ts +0 -5
- package/src/cli.ts +3 -28
- package/src/collaborators.ts +2 -4
- package/src/compile.ts +3 -1
- package/src/control.ts +203 -450
- package/src/descriptor.ts +135 -0
- package/src/index.ts +23 -127
- package/src/installation/admin-options.ts +128 -0
- package/src/installation/admin.ts +123 -0
- package/src/installation/deploy-options.ts +155 -0
- package/src/installation/deploy.ts +668 -0
- package/src/installation/generated/platform-console-schema.ts +41 -0
- package/src/installation/generated/platform-proxy-manifest.ts +72 -0
- package/src/installation/hosts.ts +117 -0
- package/src/installation/index.ts +366 -0
- package/src/installation/init.ts +521 -0
- package/src/installation/install-collaborators.ts +28 -0
- package/src/installation/install-environment.ts +193 -0
- package/src/installation/install-options.ts +193 -0
- package/src/installation/install-project.ts +201 -0
- package/src/installation/install-provisioning.ts +185 -0
- package/src/installation/install.ts +286 -0
- package/src/installation/installation-source.ts +42 -0
- package/src/installation/log.ts +77 -0
- package/src/installation/machine-key.ts +65 -0
- package/src/installation/manifest.ts +95 -0
- package/src/installation/placement-options.ts +33 -0
- package/src/installation/platform-source.ts +106 -0
- package/src/installation/platform-upload.ts +34 -0
- package/src/installation/proxy-manifest.ts +113 -0
- package/src/installation/secrets.ts +79 -0
- package/src/installation/sidecar.ts +122 -0
- package/src/installation/source-options.ts +12 -0
- package/src/installation/templates/README.md +116 -0
- package/src/installation/templates/fabrika.ref +1 -0
- package/src/installation/templates/gitignore +3 -0
- package/src/installation/templates/platform.yml +144 -0
- package/src/installation/topology/artifacts.ts +141 -0
- package/src/installation/topology/index.ts +13 -0
- package/src/installation/topology/installation-schemas.ts +225 -0
- package/src/installation/topology/invariants.ts +86 -0
- package/src/installation/topology/setups.ts +442 -0
- package/src/installation/topology/topology.ts +501 -0
- package/src/installation/topology/validate.ts +26 -0
- package/src/installation/upgrade-options.ts +108 -0
- package/src/installation/upgrade-output.ts +31 -0
- package/src/installation/upgrade.ts +373 -0
- package/src/managed-env.ts +105 -0
- package/src/manifest.ts +191 -344
- package/src/namespace/claims.ts +77 -0
- package/src/namespace/codec.ts +179 -0
- package/src/namespace/constants.ts +30 -0
- package/src/namespace/errors.ts +60 -0
- package/src/namespace/manifest.ts +28 -0
- package/src/namespace/operator.ts +151 -0
- package/src/namespace/provisioning.ts +482 -0
- package/src/namespace/proxy-attempt.ts +220 -0
- package/src/namespace/proxy-source.ts +22 -0
- package/src/namespace/topology.ts +115 -0
- package/src/namespace/types.ts +114 -0
- package/src/namespace.ts +34 -1271
- package/src/plan.ts +21 -23
- package/src/provider.ts +266 -241
- package/src/source/constants.ts +11 -0
- package/src/source/port.ts +49 -0
- package/src/source/types.ts +4 -0
- package/src/source/validation.ts +72 -0
- package/src/source.ts +2 -1220
- package/src/types.ts +8 -7
- package/src/version-attempt.ts +18 -0
- package/src/codec.ts +0 -108
- package/src/namespace-command.ts +0 -209
- 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-
|
|
8
|
-
// runs.
|
|
9
|
-
//
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
readonly
|
|
46
|
-
readonly
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
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-
|
|
74
|
+
/** How often `await-version` asks Zerops for the version's status. */
|
|
55
75
|
const POLL_INTERVAL_MS = 3000
|
|
56
76
|
|
|
57
|
-
/**
|
|
58
|
-
const
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
261
|
-
const
|
|
262
|
-
if (
|
|
263
|
-
|
|
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
|
-
|
|
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
|
|
358
|
-
*
|
|
359
|
-
*
|
|
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
|
-
|
|
371
|
-
await
|
|
372
|
-
throw new
|
|
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() >
|
|
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() >
|
|
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
|
-
/**
|
|
412
|
-
const
|
|
413
|
-
const {
|
|
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 '
|
|
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
|
|
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
|
-
|
|
453
|
-
|
|
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
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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-
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
600
|
-
):
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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}$/
|