@fabrika/provider-zerops 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,13 +43,13 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "zod": "^3.25.76",
46
- "@fabrika/auth": "^0.4.3",
47
- "@fabrika/auth-core": "^0.4.3",
48
- "@fabrika/iam-contract": "^0.4.3",
49
- "@fabrika/installation-init": "^0.4.3",
50
- "@fabrika/operations-contract": "^0.4.3",
51
- "@fabrika/provider-contract": "^0.4.3",
52
- "@fabrika/proxy-core": "^0.4.3",
46
+ "@fabrika/auth": "^0.4.5",
47
+ "@fabrika/auth-core": "^0.4.5",
48
+ "@fabrika/iam-contract": "^0.4.5",
49
+ "@fabrika/installation-init": "^0.4.5",
50
+ "@fabrika/operations-contract": "^0.4.5",
51
+ "@fabrika/provider-contract": "^0.4.5",
52
+ "@fabrika/proxy-core": "^0.4.5",
53
53
  "yaml": "^2.9.1"
54
54
  }
55
55
  }
package/src/execution.ts CHANGED
@@ -291,14 +291,15 @@ const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record
291
291
 
292
292
  /**
293
293
  * The generated build commands, in order; the authored SPA commands run before all of them. The application's tests
294
- * run first: they are authored code, so everything after them is reinstalled, regenerated or checked again.
294
+ * run first: they are authored code, so everything after them is reinstalled, regenerated or checked again. They run
295
+ * `--isolate`, as the application skill runs them: a test file's DOM globals otherwise leak into the next file.
295
296
  */
296
297
  export function generatedBuildCommands(execution: ZeropsLockedExecution): readonly string[] {
297
298
  if (!LOCKED_APP_ID_PATTERN.test(execution.appId)) throw new Error('zerops: a contract application id must match [a-z0-9][a-z0-9-]*')
298
299
  const database = execution.databaseUrl !== null
299
300
  return [
300
301
  INSTALL,
301
- 'bun test --pass-with-no-tests',
302
+ 'bun test --isolate --pass-with-no-tests',
302
303
  `rm -rf node_modules && ${INSTALL}`,
303
304
  `bun -e "await import('@fabrika/app/build').then((build) => build.writeGeneratedEntry('.', 'bun'))"`,
304
305
  `cd /tmp && bun x --bun -p @fabrika/check@${FABRIKA_CHECK_VERSION} fabrika-check ${SOURCE_ROOT}`,
@@ -19,9 +19,9 @@
19
19
  //
20
20
  // ── What this command writes, and what it deliberately does not ───────────────────────────────────
21
21
  //
22
- // It writes the composed proxy manifest, the exact namespace planning source default, and the
23
- // per-installation configuration derived from the resolved public hosts (each service's own origin,
24
- // the origins it must know about, and the installation's environment name). **It writes no credential at all** — every
22
+ // It composes the proxy manifest and ships it in the proxy's build archive. It writes the exact namespace
23
+ // planning source default and the per-installation configuration derived from the resolved public hosts
24
+ // (each service's own origin, the origins it must know about, and the installation's environment name). **It writes no credential at all** — every
25
25
  // secret an installation holds is `platform init`'s to place, and a deploy that cannot write one cannot
26
26
  // leak one. The two credentials this command HOLDS are the Zerops access token and the IAM admin key,
27
27
  // both from the environment, neither ever logged.
@@ -68,7 +68,6 @@ import {
68
68
  validateInstallationSetups,
69
69
  } from './installation-source'
70
70
  import type { DeployLog } from './log'
71
- import { mergePlatformProxyManifest, readLiveProxyManifest } from './manifest'
72
71
  import { type IamOAuthConfig, platformIamOAuthConfig } from './mcp'
73
72
  import { archiveWithDataFile } from './platform-archive'
74
73
  import type { PreparedPlatformSource } from './platform-source'
@@ -388,6 +387,26 @@ const applyServiceEnv = async (
388
387
  return written
389
388
  }
390
389
 
390
+ /**
391
+ * Delete, unread, the manifest copy older releases wrote sensitive: nothing reads it, as each proxy version serves
392
+ * the file its own build wrote, and the platform proxy fronts only the platform's apps (ADR-0034). True if one existed.
393
+ */
394
+ const removeLegacyProxyManifest = async (
395
+ proxyEnv: LiveServiceEnv,
396
+ input: PlatformDeployInput,
397
+ collaborators: PlatformDeployCollaborators,
398
+ ): Promise<boolean> => {
399
+ const legacy = proxyEnv.get(FABRIKA_PROXY_MANIFEST_JSON)
400
+ if (legacy === undefined) {
401
+ return false
402
+ }
403
+ if (!input.dryRun) {
404
+ assertRunning(collaborators.signal)
405
+ await collaborators.api.deleteServiceEnv({ envId: legacy.id, signal: collaborators.signal })
406
+ }
407
+ return true
408
+ }
409
+
391
410
  /** Only a known rejection permits another request against the same reservation. */
392
411
  const triggerPipeline = async (
393
412
  service: ResolvedService,
@@ -647,18 +666,7 @@ export const deployPreparedPlatform = async (
647
666
  ? 'MCP authoring: off'
648
667
  : `MCP authoring: on, ${oauthConfig.clients.length} client(s), resource(s) ${oauthConfig.resources.map((entry) => entry.resource).join(', ')}`,
649
668
  )
650
- // The variable is the application entries the platform proxy carries, held by the operator; nothing here writes it.
651
- const merged = mergePlatformProxyManifest(platformManifest, readLiveProxyManifest(proxyEnv.get(FABRIKA_PROXY_MANIFEST_JSON)))
652
- log.info(`${merged.manifest.apps.length} app(s): ${merged.manifest.apps.map((app) => app.id).join(', ')}`)
653
- if (merged.carried.length > 0) {
654
- log.info(`carried through from the live manifest: ${merged.carried.join(', ')}`)
655
- }
656
- for (const displaced of merged.superseded) {
657
- log.warn(
658
- `the live manifest entry \`${displaced.id}\` stood on the platform host ${displaced.host} and was replaced — `
659
- + 'that host now answers for the platform app that owns it',
660
- )
661
- }
669
+ log.info(`${platformManifest.apps.length} app(s): ${platformManifest.apps.map((app) => app.id).join(', ')}`)
662
670
 
663
671
  log.step('Write the service environment')
664
672
  const controlTableDigest = platformServiceTableDigest(platformManifest, platformProxyAppFor(PLATFORM_PROXY_MANIFEST_TEMPLATE, 'control').id)
@@ -679,13 +687,19 @@ export const deployPreparedPlatform = async (
679
687
  : `${hostname}: ${input.dryRun ? 'would write' : 'wrote'} ${written.join(', ')}`,
680
688
  )
681
689
  }
690
+ if (await removeLegacyProxyManifest(proxyEnv, input, collaborators)) {
691
+ log.info(
692
+ `${PLATFORM_PROXY_SERVICE}: ${input.dryRun ? 'would remove' : 'removed'} ${FABRIKA_PROXY_MANIFEST_JSON}, the copy of the manifest a `
693
+ + 'release before this one kept there',
694
+ )
695
+ }
682
696
 
683
697
  // EVERY deploy is after EVERY write, and the manifest rides in the proxy's own archive. That is what makes
684
698
  // the sequence fail closed: a proxy build that does not go ACTIVE leaves the previous manifest in front of
685
699
  // the previous code, because control builds only after it — and `ENVIRONMENT` is in place before any
686
700
  // service that refuses `local` restarts.
687
701
  log.step(`Deploy ${PLATFORM_CONCURRENT_DEPLOY.join(' + ')} together, then ${PLATFORM_SEQUENTIAL_DEPLOY.join(' → ')}`)
688
- const buildInput = { source, dryRun: input.dryRun, proxyManifest: encodeProxyManifestJson(merged.manifest) }
702
+ const buildInput = { source, dryRun: input.dryRun, proxyManifest: encodeProxyManifestJson(platformManifest) }
689
703
  await deployConcurrently(services, buildInput, collaborators)
690
704
  for (const hostname of PLATFORM_SEQUENTIAL_DEPLOY) {
691
705
  await deployPlatformService(serviceOf(services, hostname), buildInput, collaborators)
@@ -293,7 +293,12 @@ fabrika platform upgrade --provider=zerops --to=<newer tag> --sidecar=<sidecar c
293
293
  ```
294
294
 
295
295
  It commits the tag to the sidecar's `fabrika.ref`, pushes, and follows the workflow run it triggers. It
296
- needs only the `gh` login. Existing namespaces keep their proxy version. To move one to the proxy of the
296
+ needs only the `gh` login. When the sidecar's `.github/workflows/platform.yml` differs from the one this CLI
297
+ generates, the same commit rewrites it, so run `upgrade` with the newest CLI: a hand edit of that file does not
298
+ survive. The workflow installs the Bun release the pinned tag names in its `.bun-version` (1.3.14 for a tag
299
+ that has none), never the newest one. From 0.4.2 or earlier, skip 0.4.3: its deploy stops at "Compose the proxy
300
+ manifest" because the proxy's `FABRIKA_PROXY_MANIFEST_JSON` is sensitive. Do not re-save that variable. Upgrade to a
301
+ later tag: its deploy deletes the variable, and needs no sudo mode. Existing namespaces keep their proxy version. To move one to the proxy of the
297
302
  new release, after the run succeeded:
298
303
 
299
304
  ```bash
@@ -24,6 +24,7 @@
24
24
  // Cloudflare builds a runner image and needs a GitHub App; Zerops has no runner at all (ADR-0003).
25
25
 
26
26
  import { resolve } from 'node:path'
27
+ import { z } from 'zod'
27
28
 
28
29
  const TEMPLATES = resolve(import.meta.dir, 'templates')
29
30
 
@@ -33,6 +34,9 @@ export const SIDECAR_FILES = ['.gitignore', 'README.md', 'fabrika.ref', '.github
33
34
  /** The file that pins which published fabrika tag this installation runs. */
34
35
  export const FABRIKA_REF_FILE = 'fabrika.ref'
35
36
 
37
+ /** The generated pipeline. CLI-owned: `init` and `upgrade` both rewrite it. */
38
+ export const SIDECAR_WORKFLOW_PATH = '.github/workflows/platform.yml'
39
+
36
40
  /** What a rendered sidecar repository is named after. */
37
41
  export interface SidecarValues {
38
42
  /** The operator's name for this installation. The GitHub Environment is named after it too. */
@@ -75,6 +79,37 @@ const render = async (name: string, values: SidecarValues): Promise<string> => {
75
79
  .replaceAll('{{FABRIKA_REF}}', values.fabrikaRef)
76
80
  }
77
81
 
82
+ /** The generated pipeline for one installation. Its only placeholder is the installation, which names its GitHub Environment. */
83
+ export const renderSidecarWorkflow = async (installation: string): Promise<string> =>
84
+ (await Bun.file(resolve(TEMPLATES, 'platform.yml')).text()).replaceAll('{{INSTALLATION}}', installation)
85
+
86
+ const sidecarWorkflowSchema = z.object({ jobs: z.object({ deploy: z.object({ environment: z.string().trim().min(1) }) }) })
87
+
88
+ /**
89
+ * The installation a sidecar's current workflow was rendered for, read back from its GitHub Environment.
90
+ *
91
+ * Not from the repository name: `init --repo` accepts any name, and a workflow rendered for the wrong
92
+ * Environment deploys with no credentials.
93
+ */
94
+ export const readSidecarWorkflowInstallation = async (dir: string): Promise<string> => {
95
+ const regenerate = 'run `fabrika platform init --provider=zerops <installation>` to regenerate it'
96
+ const file = Bun.file(resolve(dir, SIDECAR_WORKFLOW_PATH))
97
+ if (!(await file.exists())) {
98
+ throw new Error(`${dir} has no ${SIDECAR_WORKFLOW_PATH} — ${regenerate}`)
99
+ }
100
+ let document: unknown
101
+ try {
102
+ document = Bun.YAML.parse(await file.text())
103
+ } catch {
104
+ throw new Error(`${dir}/${SIDECAR_WORKFLOW_PATH} is not valid YAML — ${regenerate}`)
105
+ }
106
+ const parsed = sidecarWorkflowSchema.safeParse(document)
107
+ if (!parsed.success) {
108
+ throw new Error(`${dir}/${SIDECAR_WORKFLOW_PATH} names no \`jobs.deploy.environment\` — ${regenerate}`)
109
+ }
110
+ return parsed.data.jobs.deploy.environment
111
+ }
112
+
78
113
  const write = async (dir: string, rel: string, content: string): Promise<void> => {
79
114
  await Bun.write(resolve(dir, rel), content)
80
115
  }
@@ -83,7 +118,8 @@ const write = async (dir: string, rel: string, content: string): Promise<void> =
83
118
  * Write the sidecar files into `dir`.
84
119
  *
85
120
  * The workflow, the README and the `.gitignore` are CLI-owned and refreshed on every run, so a fix to
86
- * the generated pipeline reaches every installation on its operator's next `init`. `fabrika.ref` is
121
+ * the generated pipeline reaches every installation on its operator's next `init` (and the workflow on
122
+ * the next `upgrade`). `fabrika.ref` is
87
123
  * written ONLY when absent: bumping it is how an operator rolls the installation forward, and a refresh
88
124
  * that reset it would silently roll them back.
89
125
  *
@@ -99,7 +135,7 @@ export const materializeSidecarScaffold = async (dir: string, values: SidecarVal
99
135
  }
100
136
  await write(dir, '.gitignore', await Bun.file(resolve(TEMPLATES, 'gitignore')).text())
101
137
  await write(dir, 'README.md', await render('README.md', values))
102
- await write(dir, '.github/workflows/platform.yml', await render('platform.yml', values))
138
+ await write(dir, SIDECAR_WORKFLOW_PATH, await renderSidecarWorkflow(values.installation))
103
139
  if (existing === undefined) {
104
140
  await write(dir, FABRIKA_REF_FILE, await render('fabrika.ref', values))
105
141
  }
@@ -122,4 +122,4 @@ Only a change to `fabrika.ref` or to the workflow triggers a push-driven run; ed
122
122
  not deploy.
123
123
 
124
124
  `fabrika.ref` is operator-owned: `fabrika platform init` writes it once and never overwrites a later
125
- version pin.
125
+ version pin. The workflow is not: `init` and `upgrade` both rewrite it to the one their CLI generates.
@@ -21,7 +21,8 @@ name: platform
21
21
  #
22
22
  # contember/fabrika-platform is public and checked out tokenless at the TAG pinned in `fabrika.ref`.
23
23
  # Bump that tag and push to roll this installation forward — `fabrika platform upgrade --provider=zerops
24
- # --to=<tag> {{INSTALLATION}}` does exactly that and then follows the run this file starts.
24
+ # --to=<tag> {{INSTALLATION}}` does exactly that, rewrites this file to the one its CLI generates in the
25
+ # same commit, and then follows the run this file starts. `init` rewrites it too: an edit here does not last.
25
26
 
26
27
  on:
27
28
  workflow_dispatch:
@@ -87,9 +88,29 @@ jobs:
87
88
  working-directory: fabrika-platform
88
89
  run: echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
89
90
 
91
+ - name: Resolve the Bun release of the pinned tag
92
+ id: bun
93
+ working-directory: fabrika-platform
94
+ env:
95
+ PINNED_REF: ${{ steps.ref.outputs.fabrika }}
96
+ # Exactly the Bun the tag was built and released with, never `latest`: a newer Bun rewrites the
97
+ # lockfile and `--frozen-lockfile` refuses it. Tags up to v0.4.3 carry no `.bun-version` and were
98
+ # all released with Bun 1.3.14.
99
+ run: |
100
+ if [ -f .bun-version ]; then
101
+ version="$(tr -d '[:space:]' < .bun-version)"
102
+ else
103
+ version=1.3.14
104
+ fi
105
+ if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
106
+ echo "fabrika-platform@$PINNED_REF names no exact Bun release in .bun-version: '$version'." >&2
107
+ exit 1
108
+ fi
109
+ echo "version=$version" >> "$GITHUB_OUTPUT"
110
+
90
111
  - uses: oven-sh/setup-bun@v2
91
112
  with:
92
- bun-version: latest
113
+ bun-version: ${{ steps.bun.outputs.version }}
93
114
 
94
115
  - name: Install fabrika
95
116
  working-directory: fabrika-platform
@@ -19,12 +19,20 @@
19
19
  // answer, never "no such tag": reporting a rate limit as a missing tag sends the operator to look
20
20
  // at the wrong repository.
21
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.
22
+ // • A dirty sidecar tree. The commit stages `fabrika.ref` and the workflow alone, so anything else the
23
+ // operator was part-way through would be left behind on a branch that just deployed.
24
24
  // • A pin already equal to `--to` — but only when it is PUSHED. A pin committed and not pushed is
25
25
  // the state a failed push leaves behind, and "already pins it" would be a lie there: the push that
26
26
  // IS the trigger never happened, so nothing was deployed.
27
27
  //
28
+ // ── The workflow rides along with the pin ─────────────────────────────────────────────────────────
29
+ //
30
+ // The workflow is CLI-owned (`sidecar.ts`), and a sidecar created by an older CLI keeps its old pipeline
31
+ // until something rewrites it. `init` does, but a roll is the one command every operator runs, so the
32
+ // roll re-renders it too, into the same commit: the pipeline that deploys the new tag is the current one.
33
+ // It is rendered for the GitHub Environment the existing workflow names, never for a name guessed from
34
+ // the repository.
35
+ //
28
36
  // ── The commit carries no `[skip ci]`, deliberately ──────────────────────────────────────────────
29
37
  //
30
38
  // Both scaffold commits do (`@fabrika/installation-init`'s `scaffold.ts`), because a scaffold push must
@@ -38,7 +46,7 @@ import { tmpdir } from 'node:os'
38
46
  import { join, resolve } from 'node:path'
39
47
  import { defaultSleep, type Sleeper } from '../index'
40
48
  import { consoleDeployLog, type DeployLog } from './log'
41
- import { FABRIKA_REF_FILE, readPinnedTag } from './sidecar'
49
+ import { FABRIKA_REF_FILE, readPinnedTag, readSidecarWorkflowInstallation, renderSidecarWorkflow, SIDECAR_WORKFLOW_PATH } from './sidecar'
42
50
  import type { PlatformUpgradeInput } from './upgrade-options'
43
51
  import { carriesTag, firstRun } from './upgrade-output'
44
52
 
@@ -243,8 +251,8 @@ export const runUpgrade = async (input: PlatformUpgradeInput, collaborators: Upg
243
251
  const repo = await effects.describeCheckout(dir)
244
252
  if (await effects.isDirty(dir)) {
245
253
  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)',
254
+ `${dir} has uncommitted changes to tracked files — this command commits \`${FABRIKA_REF_FILE}\` and \`${SIDECAR_WORKFLOW_PATH}\` `
255
+ + 'alone, so commit or discard them first (untracked files are left alone)',
248
256
  )
249
257
  }
250
258
  const unpushed = await checkedBranch(dir, repo, collaborators)
@@ -263,18 +271,27 @@ export const runUpgrade = async (input: PlatformUpgradeInput, collaborators: Upg
263
271
  if (pinned === input.to) {
264
272
  throw new Error(`${repo} already pins ${input.to}. Nothing to push, and the push is what triggers the deploy`)
265
273
  }
274
+ const workflow = await renderSidecarWorkflow(await readSidecarWorkflowInstallation(dir))
275
+ const workflowChanged = (await Bun.file(resolve(dir, SIDECAR_WORKFLOW_PATH)).text()) !== workflow
276
+ const files = workflowChanged ? [FABRIKA_REF_FILE, SIDECAR_WORKFLOW_PATH] : [FABRIKA_REF_FILE]
266
277
  const message = upgradeCommitMessage(installationLabel(input, repo), input.to)
267
278
  log.info(`${repo}: ${pinned} → ${input.to}`)
279
+ if (workflowChanged) {
280
+ log.info(`${SIDECAR_WORKFLOW_PATH} differs from the one this CLI generates — the roll rewrites it`)
281
+ }
268
282
 
269
283
  if (input.dryRun) {
270
284
  log.step('Dry run complete — nothing was written')
271
- log.info(`would write \`${input.to}\` to ${FABRIKA_REF_FILE} and commit it as \`${message}\``)
285
+ log.info(`would write \`${input.to}\` to ${FABRIKA_REF_FILE} and commit ${files.join(' and ')} as \`${message}\``)
272
286
  return
273
287
  }
274
288
 
275
289
  log.step('Roll the pin forward')
276
290
  await Bun.write(resolve(dir, FABRIKA_REF_FILE), `${input.to}\n`)
277
- const sha = await effects.commit({ dir, files: [FABRIKA_REF_FILE], message })
291
+ if (workflowChanged) {
292
+ await Bun.write(resolve(dir, SIDECAR_WORKFLOW_PATH), workflow)
293
+ }
294
+ const sha = await effects.commit({ dir, files, message })
278
295
  await effects.push(dir)
279
296
  log.info(`pushed ${sha}`)
280
297
 
package/src/manifest.ts CHANGED
@@ -400,7 +400,9 @@ const applicationDatabaseUrl = (
400
400
  }
401
401
  if (shared !== undefined) return shared.connectionString
402
402
  const host = own[0]
403
- return host === undefined ? null : `\${${host}_connectionTlsString}/\${${host}_dbName}?sslmode=require`
403
+ // The direct port, not `portTls`: the application's own roles (`app_<id>`, `run_<id>`) are refused by the TLS endpoint,
404
+ // which admits only the service's own users (measured 2026-10-15). `prefer` is TLS on `single`; `ha` offers none there.
405
+ return host === undefined ? null : `\${${host}_connectionString}/\${${host}_dbName}?sslmode=prefer`
404
406
  }
405
407
 
406
408
  /** The hostname the `FABRIKA_APP_STORAGE_*` references name: the application's one object-storage service. */
@@ -1,112 +0,0 @@
1
- // Composing the platform's three apps with whatever else the installation's one proxy already fronts.
2
- //
3
- // ── Why this is a MERGE and not a write ───────────────────────────────────────────────────────────
4
- //
5
- // On the `light` tier an application can share the platform's project and therefore the platform proxy.
6
- // Its entry is held by the operator in the proxy's `FABRIKA_PROXY_MANIFEST_JSON` variable — on `fabrika-test`
7
- // a fourth app, `notes` → `notesapi:3000` on listener 8084, beside the three platform ones. The variable is
8
- // no longer the carrier: the manifest travels as the data file of the proxy's build archive (ADR-0045), and
9
- // nothing in fabrika writes the variable any more; releases before that wrote the whole composed document
10
- // into it, which is why it may still hold platform entries. `platformProxyManifest` deliberately emits only
11
- // the platform apps (it is generated from the platform's own service contracts and knows nothing about
12
- // applications), so building it alone would DELETE every application entry and take the deployed
13
- // applications offline.
14
- //
15
- // So: read the variable, compose, and ship the result in the archive. That composition is this command's
16
- // job and nothing else's.
17
- //
18
- // ── What happens to an entry the platform does not know about ─────────────────────────────────────
19
- //
20
- // Carried through byte-for-byte, in its live order, after the platform apps — with ONE exception. A
21
- // host belongs to exactly one app (`parseProxyManifest` refuses two apps claiming one, and rightly:
22
- // route order would otherwise decide which app answers). The platform's three listeners are addresses
23
- // the platform OWNS, so a live entry standing on one of them is superseded rather than carried, and
24
- // the deploy says so out loud.
25
- //
26
- // That exception is not hypothetical: WU1 corrected IAM's app id from `iam-local` to `iam`, so the
27
- // live document's `iam-local` claims the platform's own IAM host under a name the platform no longer
28
- // uses. Carrying it would produce a manifest `parseProxyManifest` rejects, which the proxy build then
29
- // fails on — a fail-closed outcome, but a needlessly confusing one.
30
- //
31
- // Superseding is itself CLOSED: the dropped host stops resolving to anything, it does not fall through
32
- // to some other app. A genuinely misplaced application therefore goes dark and is reported, instead of
33
- // quietly being served by the console.
34
-
35
- import { FABRIKA_PROXY_MANIFEST_JSON, findDuplicateProxyHost, parseProxyManifestJson, type ProxyManifest } from '@fabrika/proxy-core/manifest'
36
- import type { ZeropsServiceEnv } from '../index'
37
-
38
- /** The composed document plus what happened to every live entry that was not the platform's. */
39
- export interface MergedProxyManifest {
40
- readonly manifest: ProxyManifest
41
- /** App ids carried through from the live manifest, in their live order. */
42
- readonly carried: readonly string[]
43
- /** Live entries the platform's own listeners displaced, with the host that collided. */
44
- readonly superseded: readonly { readonly id: string; readonly host: string }[]
45
- }
46
-
47
- /**
48
- * Compose the platform's apps with the application entries already in a live manifest.
49
- *
50
- * `live` is `null` when the proxy carries no manifest yet (a first deploy) — the result is then the
51
- * platform document alone. An UNPARSEABLE live value is a different thing entirely and throws: it may
52
- * be a manifest this build is too old to read, and silently replacing it would drop applications this
53
- * code cannot see. Refusing is what keeps "must not take a deployed app offline" true in the one case
54
- * where the deploy genuinely cannot tell.
55
- */
56
- export const mergePlatformProxyManifest = (platform: ProxyManifest, live: ProxyManifest | null): MergedProxyManifest => {
57
- const platformIds = new Set(platform.apps.map((app) => app.id))
58
- const platformHosts = new Set(platform.apps.flatMap((app) => app.hosts))
59
- const carried: string[] = []
60
- const superseded: { id: string; host: string }[] = []
61
- const apps = [...platform.apps]
62
- for (const app of live?.apps ?? []) {
63
- if (platformIds.has(app.id)) {
64
- continue
65
- }
66
- const collision = app.hosts.find((host) => platformHosts.has(host))
67
- if (collision !== undefined) {
68
- superseded.push({ id: app.id, host: collision })
69
- continue
70
- }
71
- carried.push(app.id)
72
- apps.push(app)
73
- }
74
- // Both inputs are already parsed manifests, so a duplicate host is the one invariant composing can break.
75
- // A duplicate host between two APPLICATION entries — which this function cannot fix — is refused
76
- // here rather than at the proxy's next build.
77
- if (findDuplicateProxyHost(apps) !== null) {
78
- throw new Error('the composed proxy manifest is not one the proxy would accept — two application entries claim one host')
79
- }
80
- return { manifest: { apps }, carried, superseded }
81
- }
82
-
83
- /**
84
- * The application entries the operator holds in the platform proxy's variable, as a manifest.
85
- *
86
- * Absent means "none". Present-but-unparseable throws: see `mergePlatformProxyManifest`. So
87
- * does a SENSITIVE record, which an older release wrote: Zerops will not hand its value back.
88
- */
89
- export const readLiveProxyManifest = (record: ZeropsServiceEnv | undefined): ProxyManifest | null => {
90
- if (record === undefined) {
91
- return null
92
- }
93
- if (record.sensitive) {
94
- throw new Error(
95
- `the proxy's ${FABRIKA_PROXY_MANIFEST_JSON} is stored as a SENSITIVE variable, which Zerops returns as \`REDACTED\`, so the `
96
- + 'application entries it may carry cannot be read and are not replaced. A release before this one wrote it that way. '
97
- + 'Re-save it in the Zerops GUI as a non-sensitive variable with the same value, then run this again',
98
- )
99
- }
100
- const value = record.content
101
- if (value.trim() === '') {
102
- return null
103
- }
104
- const manifest = parseProxyManifestJson(value)
105
- if (manifest === null) {
106
- throw new Error(
107
- `the proxy's current FABRIKA_PROXY_MANIFEST_JSON cannot be parsed by this build — refusing to replace a document whose `
108
- + 'application entries cannot be read',
109
- )
110
- }
111
- return manifest
112
- }