@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/package.json +40 -0
- package/src/api.ts +825 -0
- package/src/authoring.ts +33 -0
- package/src/cli-args.ts +101 -0
- package/src/cli.ts +82 -0
- package/src/codec.ts +50 -0
- package/src/collaborators.ts +47 -0
- package/src/compile.ts +382 -0
- package/src/control.ts +390 -0
- package/src/index.ts +98 -0
- package/src/manifest.ts +442 -0
- package/src/namespace-command.ts +209 -0
- package/src/namespace.ts +1028 -0
- package/src/plan.ts +51 -0
- package/src/provider.ts +311 -0
- package/src/schema.generated.ts +607 -0
- package/src/service-names.ts +38 -0
- package/src/types.ts +125 -0
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
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -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()
|