@fabrika/provider-zerops 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +2 -6
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +3 -1
  14. package/src/control.ts +203 -450
  15. package/src/descriptor.ts +179 -0
  16. package/src/index.ts +23 -127
  17. package/src/installation/admin-options.ts +100 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +127 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +390 -0
  25. package/src/installation/init-options.ts +148 -0
  26. package/src/installation/init.ts +394 -0
  27. package/src/installation/install-collaborators.ts +27 -0
  28. package/src/installation/install-environment.ts +193 -0
  29. package/src/installation/install-options.ts +156 -0
  30. package/src/installation/install-project.ts +201 -0
  31. package/src/installation/install-provisioning.ts +185 -0
  32. package/src/installation/install.ts +262 -0
  33. package/src/installation/installation-source.ts +42 -0
  34. package/src/installation/log.ts +77 -0
  35. package/src/installation/machine-key.ts +65 -0
  36. package/src/installation/manifest.ts +95 -0
  37. package/src/installation/placement-options.ts +33 -0
  38. package/src/installation/platform-source.ts +106 -0
  39. package/src/installation/platform-upload.ts +34 -0
  40. package/src/installation/proxy-manifest.ts +113 -0
  41. package/src/installation/secrets.ts +79 -0
  42. package/src/installation/sidecar.ts +125 -0
  43. package/src/installation/source-options.ts +12 -0
  44. package/src/installation/templates/README.md +116 -0
  45. package/src/installation/templates/fabrika.ref +1 -0
  46. package/src/installation/templates/gitignore +3 -0
  47. package/src/installation/templates/platform.yml +144 -0
  48. package/src/installation/topology/artifacts.ts +141 -0
  49. package/src/installation/topology/index.ts +13 -0
  50. package/src/installation/topology/installation-schemas.ts +225 -0
  51. package/src/installation/topology/invariants.ts +86 -0
  52. package/src/installation/topology/setups.ts +442 -0
  53. package/src/installation/topology/topology.ts +501 -0
  54. package/src/installation/topology/validate.ts +26 -0
  55. package/src/installation/upgrade-options.ts +99 -0
  56. package/src/installation/upgrade-output.ts +31 -0
  57. package/src/installation/upgrade.ts +373 -0
  58. package/src/managed-env.ts +113 -0
  59. package/src/manifest.ts +194 -344
  60. package/src/namespace/claims.ts +77 -0
  61. package/src/namespace/codec.ts +179 -0
  62. package/src/namespace/constants.ts +30 -0
  63. package/src/namespace/errors.ts +60 -0
  64. package/src/namespace/manifest.ts +28 -0
  65. package/src/namespace/operator.ts +151 -0
  66. package/src/namespace/provisioning.ts +482 -0
  67. package/src/namespace/proxy-attempt.ts +220 -0
  68. package/src/namespace/proxy-source.ts +22 -0
  69. package/src/namespace/topology.ts +115 -0
  70. package/src/namespace/types.ts +114 -0
  71. package/src/namespace.ts +34 -1271
  72. package/src/plan.ts +21 -23
  73. package/src/provider.ts +266 -241
  74. package/src/source/constants.ts +11 -0
  75. package/src/source/port.ts +49 -0
  76. package/src/source/types.ts +4 -0
  77. package/src/source/validation.ts +72 -0
  78. package/src/source.ts +2 -1220
  79. package/src/types.ts +8 -7
  80. package/src/version-attempt.ts +18 -0
  81. package/src/codec.ts +0 -108
  82. package/src/namespace-command.ts +0 -209
  83. package/src/source-connection.ts +0 -344
@@ -0,0 +1,373 @@
1
+ // `fabrika platform upgrade --provider=zerops --to=<tag>` — roll one installation onto a newer release.
2
+ //
3
+ // ── Why this is a command and not a paragraph in a README ─────────────────────────────────────────
4
+ //
5
+ // [ADR-0025](../../../docs/decisions/0025-the-operator-installs-the-platform-fabrika-deploys-apps.md)
6
+ // puts the deploying pipeline in a repository the OPERATOR owns, pinned to a published fabrika tag in
7
+ // `fabrika.ref`. Rolling forward is therefore five steps in someone else's checkout — edit the pin,
8
+ // commit, push, find the run the push started, watch it — and every one of them is a place to get the
9
+ // spelling wrong. `platform init` already knows the repository, the checkout directory and the tag
10
+ // rule; this command is those five steps with that knowledge applied.
11
+ //
12
+ // ── What it refuses, and why each refusal is here rather than in the workflow ─────────────────────
13
+ //
14
+ // • A ref that is not a published tag — `assertPinnedTag` in `upgrade-options.ts`, before any
15
+ // network call. The generated workflow refuses one again at run time, but only after a queue and a
16
+ // checkout (ADR-0025's two gates).
17
+ // • A tag the public repository does not carry. The release job pushes the tag last, so "I tagged it
18
+ // a minute ago" is exactly the case a local check cannot see. A tag lookup that FAILED is a third
19
+ // answer, never "no such tag": reporting a rate limit as a missing tag sends the operator to look
20
+ // at the wrong repository.
21
+ // • A directory that is not a checkout, and a branch the workflow does not trigger on.
22
+ // • A dirty sidecar tree. The commit stages `fabrika.ref` alone, so anything else the operator was
23
+ // part-way through would be left behind on a branch that just deployed.
24
+ // • A pin already equal to `--to` — but only when it is PUSHED. A pin committed and not pushed is
25
+ // the state a failed push leaves behind, and "already pins it" would be a lie there: the push that
26
+ // IS the trigger never happened, so nothing was deployed.
27
+ //
28
+ // ── The commit carries no `[skip ci]`, deliberately ──────────────────────────────────────────────
29
+ //
30
+ // Both scaffold commits do (`@fabrika/installation-init`'s `scaffold.ts`), because a scaffold push must
31
+ // not deploy before the Environment holding the credentials exists. Here the push IS the trigger: the
32
+ // generated workflow runs on a push touching `fabrika.ref`.
33
+
34
+ import { capture, hasGhCli, run as runStep } from '@fabrika/installation-init'
35
+ import { existsSync } from 'node:fs'
36
+ import { mkdtemp } from 'node:fs/promises'
37
+ import { tmpdir } from 'node:os'
38
+ import { join, resolve } from 'node:path'
39
+ import { defaultSleep, type Sleeper } from '../index'
40
+ import { consoleDeployLog, type DeployLog } from './log'
41
+ import { FABRIKA_REF_FILE, readPinnedTag } from './sidecar'
42
+ import type { PlatformUpgradeInput } from './upgrade-options'
43
+ import { carriesTag, firstRun } from './upgrade-output'
44
+
45
+ /** The public repository whose tags an installation may be pinned to. */
46
+ export const FABRIKA_REPOSITORY = 'contember/fabrika-platform'
47
+
48
+ /** The workflow `platform init` generates, and the only one a roll may be watching. */
49
+ export const SIDECAR_WORKFLOW_FILE = 'platform.yml'
50
+
51
+ /** The branch the generated workflow triggers on (`push: branches: [main]`). */
52
+ export const SIDECAR_BRANCH = 'main'
53
+
54
+ /** The `<owner>/fabrika-zerops-<installation>` naming `platform init` follows, read backwards. */
55
+ const SIDECAR_REPO_PREFIX = 'fabrika-zerops-'
56
+
57
+ /** How often the pushed commit's workflow run is looked for. */
58
+ const RUN_POLL_INTERVAL_MS = 5_000
59
+ /** GitHub registers a run a few seconds after the push; bounded so a run that never appears is an error. */
60
+ const RUN_APPEARS_ATTEMPTS = 12
61
+
62
+ /**
63
+ * What the public repository answered about a tag.
64
+ *
65
+ * THREE answers, not two. `gh` exits non-zero for a rate limit, an expired token and an unreachable
66
+ * network exactly as it would for a repository that does not exist, and reporting any of those as
67
+ * `missing` would send an operator to check a tag that is in fact already published.
68
+ */
69
+ export type TagLookup =
70
+ | { readonly state: 'published' }
71
+ | { readonly state: 'missing' }
72
+ | { readonly state: 'unavailable'; readonly reason: string }
73
+
74
+ /** One workflow run, as `gh run list` describes it. */
75
+ export interface UpgradeRun {
76
+ readonly id: string
77
+ readonly url: string
78
+ /** `queued`, `in_progress`, `completed` — reported so the operator knows why nothing moves yet. */
79
+ readonly status: string
80
+ }
81
+
82
+ /** Where the sidecar's branch stands relative to what the pipeline actually watches. */
83
+ export interface SidecarBranch {
84
+ /** The checked-out branch, or `HEAD` when detached. */
85
+ readonly name: string
86
+ /** Commits the upstream does not have; `undefined` when the branch tracks nothing. */
87
+ readonly ahead: number | undefined
88
+ }
89
+
90
+ /** Everything `runUpgrade` does outside its own process. Injected so the flow can be driven without git or gh. */
91
+ export interface UpgradeEffects {
92
+ /** Is `gh` installed AND logged in? Every other effect here needs both, so it is asked once, first. */
93
+ hasGh(): Promise<boolean>
94
+ /** Does the public repository carry this tag? The one thing a local check cannot answer. */
95
+ verifyTag(tag: string): Promise<TagLookup>
96
+ /** Clone `<owner>/<name>` into a fresh directory and return it. */
97
+ cloneSidecar(repo: string): Promise<string>
98
+ /** The `<owner>/<name>` this checkout pushes to — also the repository the run is looked for in. */
99
+ describeCheckout(dir: string): Promise<string>
100
+ /** Does the checkout carry uncommitted changes to TRACKED files? Untracked ones are not staged here. */
101
+ isDirty(dir: string): Promise<boolean>
102
+ /** Which branch the checkout is on, and how far ahead of its upstream. */
103
+ readBranch(dir: string): Promise<SidecarBranch>
104
+ /** Stage `files`, commit them with `message`, and return the resulting commit SHA. */
105
+ commit(input: { readonly dir: string; readonly files: readonly string[]; readonly message: string }): Promise<string>
106
+ push(dir: string): Promise<void>
107
+ /** The run GitHub registered for `sha`, or undefined while it has not appeared yet. */
108
+ findRun(input: { readonly repo: string; readonly sha: string }): Promise<UpgradeRun | undefined>
109
+ /** Follow a run to its conclusion. Throws when it ends in anything but success. */
110
+ watchRun(input: { readonly repo: string; readonly runId: string }): Promise<void>
111
+ }
112
+
113
+ export interface UpgradeCollaborators {
114
+ readonly log: DeployLog
115
+ readonly effects: UpgradeEffects
116
+ readonly sleep: Sleeper
117
+ readonly signal: AbortSignal
118
+ }
119
+
120
+ /** The fixed commit subject. Fixed so `git log` in a sidecar reads as the installation's release history. */
121
+ export const upgradeCommitMessage = (installation: string, tag: string): string => `chore: roll ${installation} forward to fabrika ${tag}`
122
+
123
+ /**
124
+ * What to call this installation in the commit subject.
125
+ *
126
+ * The operator's own name when they gave one; otherwise the sidecar repository's name with the
127
+ * `fabrika-zerops-` prefix `platform init` adds stripped back off, so `--sidecar=<owner>/<name>` alone
128
+ * still writes the subject a named run would have.
129
+ */
130
+ const installationLabel = (input: PlatformUpgradeInput, repo: string): string => {
131
+ if (input.installation !== undefined) {
132
+ return input.installation
133
+ }
134
+ const name = repo.slice(repo.indexOf('/') + 1)
135
+ return name.startsWith(SIDECAR_REPO_PREFIX) ? name.slice(SIDECAR_REPO_PREFIX.length) : name
136
+ }
137
+
138
+ /** Both ways of pointing this command at a sidecar, named together because a wrong path is the usual mistake. */
139
+ const BOTH_WAYS = 'name the installation to use ./fabrika-zerops-<installation>, point at a checkout with --sidecar=<path>, '
140
+ + 'or let this command clone one with --sidecar=<owner>/<name>'
141
+
142
+ const assertRunning = (signal: AbortSignal): void => {
143
+ if (signal.aborted) {
144
+ throw new Error('the fabrika upgrade was cancelled')
145
+ }
146
+ }
147
+
148
+ /**
149
+ * A local checkout, checked before the first shell-out.
150
+ *
151
+ * `Bun.spawn` on a cwd that does not exist fails with a raw ENOENT naming neither the flag that
152
+ * supplied it nor the alternative — the same reason `scaffold.ts` probes for `.git` itself.
153
+ */
154
+ const assertCheckout = (dir: string): void => {
155
+ if (!existsSync(dir)) {
156
+ throw new Error(`${dir} does not exist — ${BOTH_WAYS}`)
157
+ }
158
+ if (!existsSync(resolve(dir, '.git'))) {
159
+ throw new Error(`${dir} is not a git checkout — ${BOTH_WAYS}`)
160
+ }
161
+ }
162
+
163
+ /** Wait for the run the push started. Bounded: a push that started none is an error naming the repository. */
164
+ const awaitRun = async (repo: string, sha: string, { effects, sleep, signal }: UpgradeCollaborators): Promise<UpgradeRun> => {
165
+ for (let attempt = 0;; attempt += 1) {
166
+ assertRunning(signal)
167
+ const run = await effects.findRun({ repo, sha })
168
+ if (run !== undefined) {
169
+ return run
170
+ }
171
+ if (attempt >= RUN_APPEARS_ATTEMPTS) {
172
+ throw new Error(
173
+ `the pin was pushed to ${repo} but no ${SIDECAR_WORKFLOW_FILE} run appeared for ${sha} on ${SIDECAR_BRANCH} — `
174
+ + "check the repository's Actions tab",
175
+ )
176
+ }
177
+ await sleep(RUN_POLL_INTERVAL_MS, signal)
178
+ }
179
+ }
180
+
181
+ /** The tag check, with the failure case kept apart from the answer. */
182
+ const assertTagPublished = async (tag: string, { log, effects }: UpgradeCollaborators): Promise<void> => {
183
+ const lookup = await effects.verifyTag(tag)
184
+ if (lookup.state === 'unavailable') {
185
+ throw new Error(`${FABRIKA_REPOSITORY} could not be asked whether it carries \`${tag}\`: ${lookup.reason}`)
186
+ }
187
+ if (lookup.state === 'missing') {
188
+ throw new Error(
189
+ `${FABRIKA_REPOSITORY} has no tag \`${tag}\`. A release publishes its packages before it pushes the tag, `
190
+ + 'so a tag that exists locally is not yet one an installation can be pinned to',
191
+ )
192
+ }
193
+ log.info(`${tag} is published`)
194
+ }
195
+
196
+ /**
197
+ * The branch, checked against what the pipeline actually watches, and against a push that never landed.
198
+ *
199
+ * Returns how many commits are waiting to be pushed, because the "already pins it" refusal below reads
200
+ * differently depending on that number.
201
+ */
202
+ const checkedBranch = async (dir: string, repo: string, { log, effects }: UpgradeCollaborators): Promise<number> => {
203
+ const branch = await effects.readBranch(dir)
204
+ if (branch.name !== SIDECAR_BRANCH) {
205
+ throw new Error(
206
+ `${dir} is on \`${branch.name}\`, not \`${SIDECAR_BRANCH}\` — the generated workflow triggers on `
207
+ + `\`push: branches: [${SIDECAR_BRANCH}]\`, so a push from here would deploy nothing`,
208
+ )
209
+ }
210
+ if (branch.ahead === undefined) {
211
+ throw new Error(`${dir} tracks no upstream branch — run \`git -C ${dir} push -u origin ${SIDECAR_BRANCH}\` once, then re-run this`)
212
+ }
213
+ if (branch.ahead > 0) {
214
+ log.warn(`${repo}: ${branch.ahead} commit(s) here have not been pushed yet — this roll's push carries them too`)
215
+ }
216
+ return branch.ahead
217
+ }
218
+
219
+ /**
220
+ * Roll one installation's sidecar onto a published fabrika tag and follow the deploy it triggers.
221
+ *
222
+ * Nothing is written until every refusal has been passed, so a run that stops early leaves the sidecar
223
+ * exactly as it was — and once the push has landed the roll is under way whether or not this process
224
+ * survives, which is why the run URL is printed before anything is watched.
225
+ */
226
+ export const runUpgrade = async (input: PlatformUpgradeInput, collaborators: UpgradeCollaborators): Promise<void> => {
227
+ const { log, effects, signal } = collaborators
228
+ assertRunning(signal)
229
+
230
+ if (!(await effects.hasGh())) {
231
+ throw new Error('`gh` (GitHub CLI) is required — install it and run `gh auth login`.')
232
+ }
233
+
234
+ log.step(`Check that ${FABRIKA_REPOSITORY} carries ${input.to}`)
235
+ await assertTagPublished(input.to, collaborators)
236
+
237
+ log.step('Open the sidecar repository')
238
+ const dir = input.sidecar.kind === 'repo' ? await effects.cloneSidecar(input.sidecar.repo) : resolve(input.sidecar.dir)
239
+ if (input.sidecar.kind === 'repo') {
240
+ log.info(`cloned ${input.sidecar.repo} into ${dir}`)
241
+ }
242
+ assertCheckout(dir)
243
+ const repo = await effects.describeCheckout(dir)
244
+ if (await effects.isDirty(dir)) {
245
+ throw new Error(
246
+ `${dir} has uncommitted changes to tracked files — this command commits \`${FABRIKA_REF_FILE}\` alone, so commit or `
247
+ + 'discard them first (untracked files are left alone)',
248
+ )
249
+ }
250
+ const unpushed = await checkedBranch(dir, repo, collaborators)
251
+ const pinned = await readPinnedTag(dir)
252
+ if (pinned === '') {
253
+ throw new Error(`${dir} has no \`${FABRIKA_REF_FILE}\` — it is not a fabrika sidecar checkout`)
254
+ }
255
+ if (pinned === input.to && unpushed > 0) {
256
+ // What a failed push leaves behind. "Already pins it" would be true of the working tree and false
257
+ // of the installation, which is the one that matters.
258
+ throw new Error(
259
+ `${dir} already pins ${input.to} in a commit that was never pushed, so nothing was deployed — `
260
+ + `run \`git -C ${dir} push\` to trigger the roll`,
261
+ )
262
+ }
263
+ if (pinned === input.to) {
264
+ throw new Error(`${repo} already pins ${input.to}. Nothing to push, and the push is what triggers the deploy`)
265
+ }
266
+ const message = upgradeCommitMessage(installationLabel(input, repo), input.to)
267
+ log.info(`${repo}: ${pinned} → ${input.to}`)
268
+
269
+ if (input.dryRun) {
270
+ log.step('Dry run complete — nothing was written')
271
+ log.info(`would write \`${input.to}\` to ${FABRIKA_REF_FILE} and commit it as \`${message}\``)
272
+ return
273
+ }
274
+
275
+ log.step('Roll the pin forward')
276
+ await Bun.write(resolve(dir, FABRIKA_REF_FILE), `${input.to}\n`)
277
+ const sha = await effects.commit({ dir, files: [FABRIKA_REF_FILE], message })
278
+ await effects.push(dir)
279
+ log.info(`pushed ${sha}`)
280
+
281
+ log.step('Follow the deploy')
282
+ const run = await awaitRun(repo, sha, collaborators)
283
+ // Printed BEFORE the watch: the roll is under way whether or not this terminal stays open, and the
284
+ // generated workflow queues rather than cancels, so a run behind an earlier roll can sit here a while.
285
+ log.info(run.url)
286
+ log.info(`run ${run.id} is ${run.status} — the sidecar workflow queues behind an earlier roll rather than cancelling it`)
287
+ await effects.watchRun({ repo, runId: run.id })
288
+ log.step(`${repo} is running fabrika ${input.to}`)
289
+ }
290
+
291
+ /** A short reason for a failed shell-out. `run`/`capture` throw the command + exit code and no value. */
292
+ const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
293
+
294
+ /** The real `git` + `gh`, through the one shell-out module that keeps argv verbatim and errors short. */
295
+ export const consoleUpgradeCollaborators = (): UpgradeCollaborators => ({
296
+ log: consoleDeployLog(),
297
+ sleep: defaultSleep,
298
+ signal: new AbortController().signal,
299
+ effects: {
300
+ hasGh: () => hasGhCli(),
301
+ verifyTag: async (tag) => {
302
+ try {
303
+ const raw = await capture({
304
+ command: 'gh',
305
+ args: ['api', `repos/${FABRIKA_REPOSITORY}/git/matching-refs/tags/${tag}`],
306
+ cwd: process.cwd(),
307
+ })
308
+ return carriesTag(raw, tag) ? { state: 'published' } : { state: 'missing' }
309
+ } catch (error) {
310
+ return { state: 'unavailable', reason: reasonOf(error) }
311
+ }
312
+ },
313
+ cloneSidecar: async (repo) => {
314
+ const dir = await mkdtemp(join(tmpdir(), 'fabrika-sidecar-'))
315
+ await runStep({ command: 'gh', args: ['repo', 'clone', repo, dir], cwd: process.cwd() })
316
+ return dir
317
+ },
318
+ describeCheckout: async (dir) => {
319
+ const named = (await capture({ command: 'gh', args: ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], cwd: dir })).trim()
320
+ if (named === '') {
321
+ throw new Error(`${dir} names no GitHub repository — \`gh repo view\` found no remote to push to`)
322
+ }
323
+ return named
324
+ },
325
+ // `--untracked-files=no`: the commit stages `fabrika.ref` alone, so an untracked scratch file in the
326
+ // sidecar is nothing this command would carry into the roll.
327
+ isDirty: async (dir) => (await capture({ command: 'git', args: ['status', '--porcelain', '--untracked-files=no'], cwd: dir })).trim() !== '',
328
+ readBranch: async (dir) => {
329
+ const name = (await capture({ command: 'git', args: ['rev-parse', '--abbrev-ref', 'HEAD'], cwd: dir })).trim()
330
+ try {
331
+ const ahead = (await capture({ command: 'git', args: ['rev-list', '--count', '@{u}..HEAD'], cwd: dir })).trim()
332
+ return { name, ahead: Number.parseInt(ahead, 10) }
333
+ } catch {
334
+ // `@{u}` on a branch that tracks nothing is an error, not a count — and it is a real state:
335
+ // a clone made with `git init` and never pushed has no upstream.
336
+ return { name, ahead: undefined }
337
+ }
338
+ },
339
+ commit: async ({ dir, files, message }) => {
340
+ await runStep({ command: 'git', args: ['add', ...files], cwd: dir })
341
+ await runStep({ command: 'git', args: ['commit', '-m', message], cwd: dir })
342
+ return (await capture({ command: 'git', args: ['rev-parse', 'HEAD'], cwd: dir })).trim()
343
+ },
344
+ push: (dir) => runStep({ command: 'git', args: ['push'], cwd: dir }),
345
+ // `--workflow`: a sidecar may carry other workflows an operator added, and a run of one of those on
346
+ // the same commit is not the roll this command started.
347
+ findRun: async ({ repo, sha }) =>
348
+ firstRun(
349
+ await capture({
350
+ command: 'gh',
351
+ args: [
352
+ 'run',
353
+ 'list',
354
+ '--repo',
355
+ repo,
356
+ '--workflow',
357
+ SIDECAR_WORKFLOW_FILE,
358
+ '--branch',
359
+ SIDECAR_BRANCH,
360
+ '--commit',
361
+ sha,
362
+ '--limit',
363
+ '1',
364
+ '--json',
365
+ 'databaseId,status,url',
366
+ ],
367
+ cwd: process.cwd(),
368
+ }),
369
+ ),
370
+ // `--exit-status` is what makes a failed run a failed command; without it `gh run watch` exits 0.
371
+ watchRun: ({ repo, runId }) => runStep({ command: 'gh', args: ['run', 'watch', runId, '--repo', repo, '--exit-status'], cwd: process.cwd() }),
372
+ },
373
+ })
@@ -0,0 +1,113 @@
1
+ import type { JsonValue } from '@fabrika/provider-contract'
2
+ import { z } from 'zod'
3
+ import { renderYaml } from './compile'
4
+ import {
5
+ assertOwnRunWhenExtending,
6
+ executionZeropsDocument,
7
+ parseZeropsDescriptor,
8
+ type PreparedZeropsDescriptor,
9
+ selectZeropsDescriptorSetup,
10
+ } from './descriptor'
11
+
12
+ export type ManagedKey =
13
+ | 'FABRIKA_RELEASE'
14
+ | 'FABRIKA_OPERATIONS_DSN'
15
+ | 'FABRIKA_IAM_ISSUER'
16
+ | 'FABRIKA_APP_ID'
17
+ | 'FABRIKA_ENVIRONMENT'
18
+ | 'FABRIKA_SERVICE_KEY'
19
+
20
+ export const MANAGED_KEYS: readonly ManagedKey[] = [
21
+ 'FABRIKA_RELEASE',
22
+ 'FABRIKA_OPERATIONS_DSN',
23
+ 'FABRIKA_IAM_ISSUER',
24
+ 'FABRIKA_APP_ID',
25
+ 'FABRIKA_ENVIRONMENT',
26
+ 'FABRIKA_SERVICE_KEY',
27
+ ]
28
+
29
+ /** The managed keys written as service variables; `FABRIKA_RELEASE` travels only in the execution descriptor (ADR-0044). */
30
+ export const SERVICE_MANAGED_KEYS: readonly Exclude<ManagedKey, 'FABRIKA_RELEASE'>[] = [
31
+ 'FABRIKA_OPERATIONS_DSN',
32
+ 'FABRIKA_IAM_ISSUER',
33
+ 'FABRIKA_APP_ID',
34
+ 'FABRIKA_ENVIRONMENT',
35
+ 'FABRIKA_SERVICE_KEY',
36
+ ]
37
+
38
+ export type ManagedValues = Record<ManagedKey, string | null>
39
+
40
+ const managedValuesSchema = z.object({
41
+ FABRIKA_RELEASE: z.string().nullable(),
42
+ FABRIKA_OPERATIONS_DSN: z.string().nullable(),
43
+ FABRIKA_IAM_ISSUER: z.string().nullable(),
44
+ FABRIKA_APP_ID: z.string().nullable(),
45
+ FABRIKA_ENVIRONMENT: z.string().nullable(),
46
+ FABRIKA_SERVICE_KEY: z.string().nullable(),
47
+ }).strict()
48
+
49
+ export function parseManagedValues(value: unknown): ManagedValues {
50
+ const result = managedValuesSchema.safeParse(value)
51
+ if (!result.success) throw new Error('zerops: managed environment must name exactly the six managed keys')
52
+ return result.data
53
+ }
54
+
55
+ const isManagedKey = (name: string): boolean => MANAGED_KEYS.some((key) => key === name)
56
+
57
+ const refuse = (): never => {
58
+ throw new Error('zerops: application configuration uses a Fabrika-managed environment name')
59
+ }
60
+
61
+ export function assertAuthoredManagedName(name: string): void {
62
+ if (isManagedKey(name)) refuse()
63
+ }
64
+
65
+ type AuthoredDocumentValue = string | number | boolean | null | readonly AuthoredDocumentValue[] | { readonly [key: string]: AuthoredDocumentValue }
66
+ const isSequence = (value: AuthoredDocumentValue): value is readonly AuthoredDocumentValue[] => Array.isArray(value)
67
+
68
+ /** Refuse a managed name as a key of any `envVariables`/`envSecrets` mapping, at any depth. */
69
+ export function assertAuthoredManagedDocument(value: AuthoredDocumentValue): void {
70
+ const visit = (entry: AuthoredDocumentValue, environmentMapping: boolean): void => {
71
+ if (isSequence(entry)) {
72
+ for (const item of entry) visit(item, false)
73
+ return
74
+ }
75
+ if (typeof entry !== 'object' || entry === null) return
76
+ for (const [key, item] of Object.entries(entry)) {
77
+ if (environmentMapping) assertAuthoredManagedName(key)
78
+ visit(item, key === 'envVariables' || key === 'envSecrets')
79
+ }
80
+ }
81
+ visit(value, false)
82
+ }
83
+
84
+ export function assertAuthoredManagedInputs(input: { variableNames: readonly string[]; secretNames: readonly string[] }): void {
85
+ for (const name of [...input.variableNames, ...input.secretNames]) assertAuthoredManagedName(name)
86
+ }
87
+
88
+ const isMapping = (value: JsonValue | undefined): value is { readonly [key: string]: JsonValue } =>
89
+ typeof value === 'object' && value !== null && !Array.isArray(value)
90
+
91
+ /**
92
+ * Render the execution descriptor: the source descriptor with `FABRIKA_RELEASE` written as a literal into
93
+ * the selected setup's `run.envVariables`, so the release travels with the version (ADR-0044). A null
94
+ * release omits the key. The source descriptor's bytes and digest are untouched.
95
+ */
96
+ export function overlayRelease(contents: string, setup: string | undefined, release: string | null): PreparedZeropsDescriptor {
97
+ // Zerops resolves `${…}` in run variables, so a release carrying one would read another variable.
98
+ if (release?.includes('${') === true) throw new Error('zerops: a release name must not contain `${`')
99
+ const parsed = parseZeropsDescriptor(contents)
100
+ assertAuthoredManagedDocument(parsed.document)
101
+ const selected = selectZeropsDescriptorSetup(parsed, setup)
102
+ assertOwnRunWhenExtending(parsed, selected)
103
+ const execution = executionZeropsDocument(parsed, selected)
104
+ const entries = execution['zerops']
105
+ if (!Array.isArray(entries)) throw new Error('zerops: descriptor requires setup entries')
106
+ const overlaid = release === null ? entries : entries.map((entry) => {
107
+ if (!isMapping(entry) || entry['setup'] !== selected) return entry
108
+ const run = isMapping(entry['run']) ? entry['run'] : {}
109
+ const env = isMapping(run['envVariables']) ? run['envVariables'] : {}
110
+ return { ...entry, run: { ...run, envVariables: { ...env, FABRIKA_RELEASE: release } } }
111
+ })
112
+ return { contents: renderYaml({ ...execution, zerops: overlaid }), setup: selected }
113
+ }