@fabrika/provider-zerops 0.0.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/src/plan.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { ProviderDeployPlan, ProviderJobSpec } from '@fabrika/provider-contract'
2
+ import { type FabrikaManifest, manifestServiceHostnames } from './manifest'
3
+ import type { ZeropsRuntimeTarget } from './types'
4
+
5
+ export type ZeropsStepKind = 'apply-import' | 'trigger-deploy' | 'await-deploy' | 'reconcile-schema'
6
+
7
+ export interface ZeropsJobSpec extends ProviderJobSpec {
8
+ readonly kind: ZeropsStepKind
9
+ }
10
+
11
+ export interface ZeropsPlan extends ProviderDeployPlan {
12
+ readonly steps: readonly ZeropsJobSpec[]
13
+ }
14
+
15
+ /** Resolve the one declared service that receives this repository's code. */
16
+ export const resolveDeployHostname = (manifest: FabrikaManifest): string => {
17
+ const { deployService } = manifest.target
18
+ const serviceHostnames = manifestServiceHostnames(manifest)
19
+ if (!serviceHostnames.includes(deployService)) {
20
+ throw new Error(`zerops: \`deployService: ${deployService}\` names no declared service (have: ${serviceHostnames.join(', ') || 'none'})`)
21
+ }
22
+ return deployService
23
+ }
24
+
25
+ /** Build the provider-owned deploy order without touching the platform. */
26
+ export const buildZeropsPlan = (
27
+ manifest: FabrikaManifest,
28
+ target: ZeropsRuntimeTarget,
29
+ env: string,
30
+ ): ZeropsPlan => {
31
+ const serviceHostnames = manifestServiceHostnames(manifest)
32
+ const steps: ZeropsJobSpec[] = []
33
+ let previous: string | undefined
34
+ const add = (spec: Omit<ZeropsJobSpec, 'dependsOn'>): void => {
35
+ steps.push(previous === undefined ? spec : { ...spec, dependsOn: [previous] })
36
+ previous = spec.id
37
+ }
38
+
39
+ add({
40
+ id: 'apply-import',
41
+ kind: 'apply-import',
42
+ description: `Apply the Zerops import for ${serviceHostnames.length} service(s): ${serviceHostnames.join(', ')}`,
43
+ })
44
+ add({ id: 'trigger-deploy', kind: 'trigger-deploy', description: 'Trigger the Zerops build+deploy pipeline' })
45
+ add({ id: 'await-deploy', kind: 'await-deploy', description: 'Await the Zerops pipeline and relay its build log' })
46
+ if (manifest.app.schema !== undefined && target.propustkaUrl !== undefined) {
47
+ add({ id: 'reconcile-schema', kind: 'reconcile-schema', description: 'Reconcile authz schema into IAM' })
48
+ }
49
+
50
+ return { appId: manifest.app.id, env, steps }
51
+ }
@@ -0,0 +1,311 @@
1
+ // The Zerops provider owns its plan and every effect needed to execute it.
2
+ //
3
+ // A Zerops deploy is HTTP and nothing else (ADR-0003) — no container, no shell, no filesystem — so this
4
+ // driver's whole side-effect surface is one REST client plus the portable schema reconciler
5
+ // (`./collaborators.ts`). It never spawns a process and has no `runCommand` to be handed.
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.
11
+
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'
14
+ import { zeropsTargetCodec } from './codec'
15
+ import { defaultZeropsCollaborators, type ZeropsCollaboratorFactory, type ZeropsCollaborators } from './collaborators'
16
+ import { type FabrikaManifest, manifestServiceHostnames, renderFabrikaImportYaml, zeropsArtifactCodec } from './manifest'
17
+ import { buildZeropsPlan, resolveDeployHostname, type ZeropsJobSpec, type ZeropsPlan } from './plan'
18
+ import type { ZeropsRuntimeTarget } from './types'
19
+
20
+ export const CANCELLED = 'deploy cancelled'
21
+
22
+ type ZeropsRun = TypedProviderRun<ZeropsRuntimeTarget, FabrikaManifest>
23
+
24
+ /** How often `await-deploy` asks Zerops for the version's status. */
25
+ const POLL_INTERVAL_MS = 3000
26
+
27
+ /** Give up after this long. Zerops' own pipeline limit is 1 hour; we allow a little slack past it. */
28
+ const POLL_TIMEOUT_MS = 70 * 60 * 1000
29
+
30
+ /** Abandon the step if the run was cancelled. Checked before every real mutation and each poll iteration. */
31
+ const assertRunning = (signal: AbortSignal): void => {
32
+ if (signal.aborted) {
33
+ throw new Error(CANCELLED)
34
+ }
35
+ }
36
+
37
+ const PLACEHOLDER = /\$\{([A-Z][A-Z0-9_]*)\}/g
38
+
39
+ /** Resolve only variables declared by the manifest. Unknown or missing placeholders fail the deploy. */
40
+ export function interpolateManifest(source: string, declared: string[], values: Record<string, string>): string {
41
+ const allowed = new Set(declared)
42
+ return source.replace(PLACEHOLDER, (_placeholder, name: string) => {
43
+ if (!allowed.has(name)) {
44
+ throw new Error(`zerops: manifest uses undeclared variable \`${name}\``)
45
+ }
46
+ const value = values[name]
47
+ if (value === undefined) {
48
+ throw new Error(`zerops: manifest variable \`${name}\` has no deploy-time value`)
49
+ }
50
+ return value
51
+ })
52
+ }
53
+
54
+ /** Human-readable failure for a version that finished in a non-`ACTIVE` state. */
55
+ const terminalFailure = (version: ZeropsAppVersion): string => {
56
+ if (version.status === 'BACKUP') {
57
+ return `app-version ${version.id} was superseded by a newer version before it went live`
58
+ }
59
+ if (version.status === 'CANCELLED') {
60
+ return `app-version ${version.id} was cancelled`
61
+ }
62
+ return `Zerops pipeline failed: app-version ${version.id} is ${version.status ?? 'in an unknown state'}`
63
+ }
64
+
65
+ /** Everything one step's executor needs, bundled so the per-kind handlers stay small. */
66
+ interface StepEnv {
67
+ run: ZeropsRun
68
+ zerops: ZeropsCollaborators
69
+ /** The compiled import document + its YAML text, materialized once in `open()`. */
70
+ compiled: { yaml: string; hostnames: string[] }
71
+ /** Hostname of the service the app's code deploys to. */
72
+ deployHostname: string
73
+ log: (line: string) => void
74
+ signal: AbortSignal
75
+ dryRun: boolean
76
+ /** Set by `trigger-deploy`, read by `await-deploy` — the only state that crosses a step boundary. */
77
+ state: { appVersionId?: string }
78
+ }
79
+
80
+ /**
81
+ * Relay whatever build log Zerops will give us into the run's progress sink.
82
+ *
83
+ * Deliberately FAILURE-TOLERANT: the log service's protocol is the one shape in this driver nobody could
84
+ * verify against a document (see `ZeropsApi.readBuildLog`). An unverified endpoint must never be able to
85
+ * 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.
87
+ *
88
+ * Relay is pull-based (ADR-0003), so each poll re-reads a window that overlaps the last one; `seen` keeps
89
+ * the run log from repeating itself. Two genuinely identical lines with no timestamp collapse into one —
90
+ * an acceptable trade for not printing the same window every three seconds.
91
+ */
92
+ const relayLog = async (env: StepEnv, access: ZeropsLogAccess | null, seen: Set<string>): Promise<void> => {
93
+ if (access === null) {
94
+ return
95
+ }
96
+ try {
97
+ const lines = await env.zerops.api.readBuildLog({ access, serviceId: env.run.target.serviceId, signal: env.signal })
98
+ for (const line of lines) {
99
+ const key = `${line.timestamp ?? ''}\u0000${line.message}`
100
+ if (seen.has(key)) {
101
+ continue
102
+ }
103
+ seen.add(key)
104
+ env.log(` │ ${line.message}`)
105
+ }
106
+ } catch (error) {
107
+ env.log(` [warn] build-log relay unavailable: ${error instanceof Error ? error.message : 'unknown error'}`)
108
+ }
109
+ }
110
+
111
+ /** Ask Zerops for a log grant, tolerating its absence for the same reason `relayLog` tolerates a failure. */
112
+ const openLog = async (env: StepEnv): Promise<ZeropsLogAccess | null> => {
113
+ try {
114
+ return await env.zerops.api.getLogAccess({ projectId: env.run.target.projectId, signal: env.signal })
115
+ } catch (error) {
116
+ env.log(` [warn] build-log relay unavailable: ${error instanceof Error ? error.message : 'unknown error'}`)
117
+ return null
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Poll `/app-version` until the version reaches a terminal state, relaying the build log as it goes.
123
+ *
124
+ * A run cancelled here asks Zerops to cancel the BUILD too. That is not tidiness: the platform is running
125
+ * the work, so abandoning the poll loop without telling it leaves a build container burning through the
126
+ * pipeline's hour.
127
+ */
128
+ const awaitVersion = async (env: StepEnv, appVersionId: string): Promise<void> => {
129
+ const { zerops, signal, log } = env
130
+ const access = await openLog(env)
131
+ const seen = new Set<string>()
132
+ const deadline = Date.now() + POLL_TIMEOUT_MS
133
+ let previous: string | undefined
134
+
135
+ for (;;) {
136
+ if (signal.aborted) {
137
+ // Best-effort: the run is already abandoned, so a failure to cancel must not mask the reason.
138
+ await zerops.api.cancelBuild({ appVersionId, signal: AbortSignal.timeout(5000) }).catch(() => {})
139
+ throw new Error(CANCELLED)
140
+ }
141
+ const version = await zerops.api.getAppVersion({ appVersionId, signal })
142
+ if (version.status !== previous) {
143
+ log(` ${appVersionId}: ${version.status ?? 'unknown'}`)
144
+ previous = version.status
145
+ }
146
+ await relayLog(env, access, seen)
147
+
148
+ if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
149
+ if (version.status === ZEROPS_ACTIVE) {
150
+ return
151
+ }
152
+ throw new Error(terminalFailure(version))
153
+ }
154
+ if (Date.now() > deadline) {
155
+ throw new Error(`zerops: timed out after ${Math.round(POLL_TIMEOUT_MS / 60000)}m waiting for app-version ${appVersionId}`)
156
+ }
157
+ await zerops.sleep(POLL_INTERVAL_MS, signal)
158
+ }
159
+ }
160
+
161
+ /** Run one step's effect. Resolves on success, throws on failure (caught + recorded by the engine). */
162
+ const runStep = async (spec: ZeropsJobSpec, env: StepEnv): Promise<void> => {
163
+ const { run, zerops, compiled, log, signal, dryRun, state } = env
164
+ const { target, artifact } = run
165
+
166
+ switch (spec.kind) {
167
+ case 'apply-import': {
168
+ if (dryRun) {
169
+ log(` [dry-run] would POST the import for ${compiled.hostnames.length} service(s) to project ${target.projectId}:`)
170
+ for (const line of compiled.yaml.trimEnd().split('\n')) {
171
+ log(` │ ${line}`)
172
+ }
173
+ return
174
+ }
175
+ assertRunning(signal)
176
+ // `override: true` on every service (written by the compiler) is what makes re-applying safe.
177
+ const result = await zerops.api.importServices({ projectId: target.projectId, yaml: compiled.yaml, signal })
178
+ log(` imported: ${result.services.map((service) => `${service.name} (${service.id})`).join(', ') || '(none reported)'}`)
179
+ return
180
+ }
181
+
182
+ case 'trigger-deploy': {
183
+ const source = target.buildFromGit === undefined ? "the service's configured Git integration" : target.buildFromGit
184
+ if (dryRun) {
185
+ log(` [dry-run] would trigger the Zerops pipeline for service ${target.serviceId} (${env.deployHostname}) from ${source}`)
186
+ return
187
+ }
188
+ 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
+ }
205
+ 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)`)
208
+ return
209
+ }
210
+
211
+ case 'await-deploy': {
212
+ if (dryRun) {
213
+ log(' [dry-run] would poll /app-version until it is ACTIVE and relay the build log')
214
+ return
215
+ }
216
+ const appVersionId = state.appVersionId
217
+ if (appVersionId === undefined) {
218
+ throw new Error('zerops: await-deploy has no app-version to watch (trigger-deploy did not run)')
219
+ }
220
+ assertRunning(signal)
221
+ await awaitVersion(env, appVersionId)
222
+ return
223
+ }
224
+
225
+ case 'reconcile-schema': {
226
+ const schema = artifact.app.schema
227
+ const propustkaUrl = target.propustkaUrl
228
+ if (schema === undefined || propustkaUrl === undefined) {
229
+ return
230
+ }
231
+ // Return origins ride this step because `apps.setReturnOrigins` 404s for an app IAM has never
232
+ // heard of, and the schema reconcile is what registers it. Separate call, same moment.
233
+ const returnOrigins = run.returnOrigins !== undefined && run.returnOrigins.length > 0 ? run.returnOrigins : undefined
234
+ if (dryRun) {
235
+ log(` [dry-run] would reconcile schema for \`${artifact.app.id}\` against ${propustkaUrl}`)
236
+ if (returnOrigins !== undefined) {
237
+ log(` [dry-run] would register return origins for \`${artifact.app.id}\`: ${returnOrigins.join(', ')}`)
238
+ }
239
+ return
240
+ }
241
+ assertRunning(signal)
242
+ await zerops.reconcileSchema({
243
+ url: propustkaUrl,
244
+ app: artifact.app.id,
245
+ schema,
246
+ ...(returnOrigins === undefined ? {} : { returnOrigins }),
247
+ adminKey: target.adminKey,
248
+ signal,
249
+ })
250
+ return
251
+ }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Build a Zerops driver over a specific collaborator FACTORY. Production passes the real one; tests pass
257
+ * `() => fakes`, which is the whole testability property — one seam, all of this driver's effects.
258
+ *
259
+ * A factory rather than a bundle because the Zerops client is authenticated with a token that lives on the
260
+ * run's target, so it cannot be constructed once at module load the way Cloudflare's bundle is.
261
+ */
262
+ export type ZeropsProvider = ProviderModule<'zerops', ZeropsRuntimeTarget, FabrikaManifest>
263
+
264
+ /** Construct an independently testable Zerops provider against one collaborator factory. */
265
+ export const createZeropsProvider = (
266
+ collaborators: ZeropsCollaboratorFactory = defaultZeropsCollaborators,
267
+ ): ZeropsProvider =>
268
+ createProvider({
269
+ id: 'zerops',
270
+ target: zeropsTargetCodec,
271
+ artifact: zeropsArtifactCodec,
272
+ open: (run): Promise<ProviderDeploySession> => {
273
+ if (run.artifact.app.id !== run.appId) {
274
+ throw new Error(`zerops: artifact app drift: expected \`${run.appId}\`, got \`${run.artifact.app.id}\``)
275
+ }
276
+ if (run.artifact.app.env !== run.env) {
277
+ throw new Error(`zerops: artifact environment drift: expected \`${run.env}\`, got \`${run.artifact.app.env}\``)
278
+ }
279
+ const hostnames = manifestServiceHostnames(run.artifact)
280
+ const compiled = {
281
+ yaml: interpolateManifest(renderFabrikaImportYaml(run.artifact), run.artifact.app.pipeline.vars, run.vars),
282
+ hostnames,
283
+ }
284
+ const deployHostname = resolveDeployHostname(run.artifact)
285
+ const plan: ZeropsPlan = buildZeropsPlan(run.artifact, run.target, run.env)
286
+ const env: StepEnv = {
287
+ run,
288
+ zerops: collaborators(run.target),
289
+ compiled,
290
+ deployHostname,
291
+ log: run.events.log,
292
+ signal: run.signal,
293
+ dryRun: run.dryRun,
294
+ state: {},
295
+ }
296
+ const byId = new Map(plan.steps.map((step): [string, ZeropsJobSpec] => [step.id, step]))
297
+
298
+ return Promise.resolve({
299
+ plan,
300
+ execute: async (stepId: string): Promise<void> => {
301
+ const spec = byId.get(stepId)
302
+ if (spec === undefined) {
303
+ throw new Error(`zerops: no step \`${stepId}\` in this plan`)
304
+ }
305
+ await runStep(spec, env)
306
+ },
307
+ })
308
+ },
309
+ })
310
+
311
+ export const zeropsProvider: ZeropsProvider = createZeropsProvider()