@fabrika/provider-zerops 0.4.2 → 0.4.3

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 (45) hide show
  1. package/package.json +8 -7
  2. package/src/api/client.ts +11 -14
  3. package/src/api/polling.ts +42 -0
  4. package/src/api/readers.ts +9 -2
  5. package/src/api/service-env-limit.ts +29 -0
  6. package/src/api/service-env-response.ts +34 -2
  7. package/src/api/types.ts +39 -9
  8. package/src/api.ts +10 -1
  9. package/src/control.ts +23 -8
  10. package/src/execution.ts +41 -3
  11. package/src/index.ts +5 -0
  12. package/src/installation/admin.ts +4 -2
  13. package/src/installation/checker-release.ts +44 -0
  14. package/src/installation/deploy.ts +66 -22
  15. package/src/installation/generated/platform-console-schema.ts +18 -2
  16. package/src/installation/generated/platform-proxy-manifest.ts +20 -0
  17. package/src/installation/guide.md +164 -52
  18. package/src/installation/index.ts +22 -15
  19. package/src/installation/init-options.ts +2 -2
  20. package/src/installation/init.ts +8 -2
  21. package/src/installation/install-collaborators.ts +4 -0
  22. package/src/installation/install-environment.ts +52 -30
  23. package/src/installation/install-options.ts +20 -19
  24. package/src/installation/install-project.ts +23 -15
  25. package/src/installation/install-provisioning.ts +12 -12
  26. package/src/installation/install-secrets.ts +89 -0
  27. package/src/installation/install-token.ts +37 -0
  28. package/src/installation/install.ts +34 -21
  29. package/src/installation/machine-key.ts +3 -2
  30. package/src/installation/manifest.ts +29 -12
  31. package/src/installation/platform-archive.ts +87 -0
  32. package/src/installation/platform-upload.ts +2 -2
  33. package/src/installation/secrets.ts +3 -4
  34. package/src/installation/topology/setups.ts +15 -27
  35. package/src/installation/topology/topology.ts +105 -104
  36. package/src/managed-env.ts +11 -0
  37. package/src/manifest.ts +25 -3
  38. package/src/namespace/codec.ts +6 -1
  39. package/src/namespace/errors.ts +3 -1
  40. package/src/namespace/provisioning.ts +18 -5
  41. package/src/namespace/proxy-attempt.ts +56 -14
  42. package/src/namespace/update.ts +50 -8
  43. package/src/provider.ts +57 -13
  44. package/src/proxy-manifest-carrier.ts +32 -0
  45. package/src/source/port.ts +9 -1
@@ -11,7 +11,8 @@ import {
11
11
  warn as consoleWarn,
12
12
  } from '@fabrika/installation-init'
13
13
  import type { SchemaReconciler } from '@fabrika/provider-contract'
14
- import { createZeropsApi, defaultSleep, type ZeropsNamespaceProxySource } from '../index'
14
+ import { createZeropsApi, defaultSleep, readableContent, type ZeropsNamespaceProxySource } from '../index'
15
+ import { assertCheckerPublished, npmCheckerPublication } from './checker-release'
15
16
  import {
16
17
  deployPlatformService,
17
18
  deployPreparedPlatform,
@@ -26,7 +27,7 @@ import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-man
26
27
  import { derivePlatformHosts, type PlatformHosts, platformOrigin, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
27
28
  import { checkedEnvironmentName } from './init-options'
28
29
  import { assertRunning, type InstallCollaborators, type InstallPrompts } from './install-collaborators'
29
- import { installationServiceEnv, passOneProxyEnv, writeServiceEnv } from './install-environment'
30
+ import { installationServiceEnv, PASS_ONE_PROXY_MANIFEST, passOneProxyEnv, writeServiceEnv } from './install-environment'
30
31
  import type { PlatformInstallInput } from './install-options'
31
32
  import { resolveInstallationProject } from './install-project'
32
33
  import {
@@ -38,6 +39,8 @@ import {
38
39
  resolvePlatformServices,
39
40
  serviceOf,
40
41
  } from './install-provisioning'
42
+ import { renderSecretsFile, writeSecretsFile } from './install-secrets'
43
+ import { explainMintFailure } from './install-token'
41
44
  import { prepareInstallationSource, prepareNamespaceProxySource, validateInstallationSetups } from './installation-source'
42
45
  import type { PreparedPlatformSource } from './platform-source'
43
46
  import { generateInstallationSecrets, type InstallationSecrets } from './secrets'
@@ -61,14 +64,14 @@ const passOne = async (
61
64
  const { log, prompts } = collaborators
62
65
  log.step('Pass 1 — build the proxy so the installation has public hostnames')
63
66
  log.info('An empty manifest is a legal one: the proxy comes up answering 404 and publishes one hostname per HTTP port.')
64
- if (!(await prompts.confirm(`Write 4 variables on \`proxy\` and build commit ${source.commitSha}? (about 4 minutes)`, true))) {
67
+ if (!(await prompts.confirm(`Write 3 variables on \`proxy\` and build commit ${source.commitSha}? (about 4 minutes)`, true))) {
65
68
  throw new Error('declined: no variable was written, and re-running the install is safe')
66
69
  }
67
70
  await writeServiceEnv(proxy, passOneProxyEnv(secrets), collaborators)
68
- await deployPlatformService(proxy, { source, dryRun: false }, collaborators)
71
+ await deployPlatformService(proxy, { source, dryRun: false, proxyManifest: PASS_ONE_PROXY_MANIFEST }, collaborators)
69
72
 
70
73
  const proxyEnv = await readServiceEnv(proxy.id, collaborators)
71
- const subdomains = proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE)
74
+ const subdomains = readableContent(proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE))
72
75
  if (subdomains === undefined || subdomains.trim() === '') {
73
76
  throw new Error('the proxy published no Zerops subdomain after its first deploy — this installation has no address to be named by')
74
77
  }
@@ -88,6 +91,7 @@ export const runInstall = async (input: PlatformInstallInput, collaborators: Ins
88
91
  const source = await collaborators.prepareSource(input.sourceDir, collaborators.signal)
89
92
  try {
90
93
  validateInstallationSetups(source, PLATFORM_DEPLOY_ORDER)
94
+ await assertCheckerPublished(collaborators.checkerPublished, collaborators.signal)
91
95
  const proxySource = await prepareNamespaceProxySource(source)
92
96
  await installPreparedPlatform(input, collaborators, source, proxySource)
93
97
  } finally {
@@ -114,7 +118,7 @@ const installPreparedPlatform = async (
114
118
  log.ok(`project ${project.name} (${project.id})`)
115
119
 
116
120
  log.step('Provision the topology')
117
- const document = provisioningDocument(environment)
121
+ const document = provisioningDocument(environment, input.tier)
118
122
  const declared = document.document.services.map((service) => service.hostname)
119
123
  const existing = existingServices(declared, await collaborators.api.listProjectServices({ projectId: project.id, signal }))
120
124
  assertNoServiceIsBeingCreated(existing)
@@ -143,12 +147,21 @@ const installPreparedPlatform = async (
143
147
  // application at all (ADR-0034). `NO_ACCESS` still keeps every project it did not create out of reach.
144
148
  canCreateProjects: true,
145
149
  signal,
150
+ }).catch((failure: unknown) => {
151
+ throw explainMintFailure(failure, project.id)
146
152
  })
147
153
  if (token.token === '') {
148
154
  throw new Error('Zerops minted an integration token with no value — the control plane cannot deploy anything without one')
149
155
  }
150
156
  log.ok(`integration token ${token.id} minted, ${token.projects.length} project grant(s)`)
151
157
 
158
+ log.step('Store the credentials locally')
159
+ // BEFORE the first one is placed: Zerops keeps them sensitive and will not hand them back.
160
+ await collaborators.storeSecrets(
161
+ renderSecretsFile({ environment, projectId: project.id, clientId: input.clientId, secrets, zeropsAccessToken: token.token }),
162
+ )
163
+ log.ok(`wrote ${input.secretsFile} (mode 0600) — every credential this installation was created with; nothing else holds a readable copy`)
164
+
152
165
  const hosts = await passOne(source, serviceOf(services, PLATFORM_PROXY_SERVICE), secrets, collaborators)
153
166
  log.ok(`iam ${hosts.iam}`)
154
167
  log.ok(`console ${hosts.control}`)
@@ -157,6 +170,7 @@ const installPreparedPlatform = async (
157
170
  log.step('Write every remaining variable')
158
171
  const desired = installationServiceEnv({
159
172
  environment,
173
+ tier: input.tier,
160
174
  scheme: input.scheme,
161
175
  hosts,
162
176
  clientId: input.clientId,
@@ -170,7 +184,7 @@ const installPreparedPlatform = async (
170
184
  throw new Error(
171
185
  'declined: the proxy is built but the installation is not configured. Its generated proxy key is already placed, so '
172
186
  + 're-running `platform install` is refused. Neither `platform deploy` nor `platform init` completes a partial bootstrap. '
173
- + 'Preserve the existing keys and arrange operator-owned recovery, or start a fresh installation in a separate empty project',
187
+ + `Keep ${input.secretsFile} and arrange operator-owned recovery, or start a fresh installation in a separate empty project`,
174
188
  )
175
189
  }
176
190
  for (const hostname of PLATFORM_DEPLOY_ORDER) {
@@ -178,9 +192,9 @@ const installPreparedPlatform = async (
178
192
  }
179
193
 
180
194
  log.step('Pass 2 — deploy the installation')
195
+ const withSecrets = (command: string): string => `( set -a && . ${shellQuoted(input.secretsFile)} && set +a && ${command} )`
181
196
  log.info(
182
- 'Every bootstrap variable is placed. If this deploy fails, read FABRIKA_IAM_PROVISIONING_KEY from `iam` or `control` '
183
- + 'and FABRIKA_ZEROPS_ACCESS_TOKEN from `control` in Zerops, then run `fabrika platform deploy --provider=zerops` with this project and source checkout.',
197
+ `Every bootstrap variable is placed. If this deploy fails, finish it with \`platform deploy\`, which takes its credentials from ${input.secretsFile}.`,
184
198
  )
185
199
  log.info(
186
200
  `\`platform deploy\` owns the whole ordered sequence: ${PLATFORM_CONCURRENT_DEPLOY.join(' + ')} together, then ${
@@ -189,7 +203,7 @@ const installPreparedPlatform = async (
189
203
  )
190
204
  if (!(await prompts.confirm('Deploy the installation now? (about 15 minutes)', true))) {
191
205
  log.action('OPERATOR ACTION — every variable is placed; the installation is one command from running', [
192
- `FABRIKA_ZEROPS_ACCESS_TOKEN=… FABRIKA_IAM_PROVISIONING_KEY=… fabrika platform deploy --provider=zerops --source-dir=. --project-id=${project.id} --env=${environment}`,
206
+ withSecrets(`fabrika platform deploy --provider=zerops --source-dir=. --project-id=${project.id} --env=${environment}`),
193
207
  ])
194
208
  } else {
195
209
  await deployPreparedPlatform(
@@ -209,22 +223,19 @@ const installPreparedPlatform = async (
209
223
  }
210
224
 
211
225
  log.step('Hand off')
212
- // The intentional secret-output exception: init needs the key IAM was seeded with, never a new value.
213
- log.action('CAPTURE THIS NOW — the CLI keeps no copy; recover it from the `iam` or `control` service environment in Zerops', [
214
- `FABRIKA_IAM_PROVISIONING_KEY=${secrets.provisioningKey}`,
215
- ])
216
- log.info('`fabrika platform init --provider=zerops <installation>` asks for two required deployment secrets:')
217
- log.info(' FABRIKA_IAM_PROVISIONING_KEY the key above')
218
- log.info(' FABRIKA_ZEROPS_ACCESS_TOKEN the integration token just minted — read it back from the `control` service, never printed here')
226
+ log.info(`Every credential is in ${input.secretsFile}, and nothing prints one. Keep that file private and backed up:`)
227
+ log.info('it holds the vault key, whose loss is unrecoverable, and Zerops does not hand any of them back.')
228
+ log.info('`platform init` and `control key issue` read their credentials from it under the names it uses.')
219
229
  log.info(`The console answers at ${platformOrigin(input.scheme, hosts.control)}, once someone can sign in: nobody can yet.`)
220
- // Through the SAME boxed path as the key above, deliberately: `action` is already the one output
221
- // that carries a secret value, so the ready-to-run command does not become a second place one is
222
- // printed. `platform admin` is idempotent, so re-running it is safe.
230
+ // `platform admin` is idempotent, so re-running it is safe.
223
231
  log.action('ADMIT YOURSELF — the console has no administrator until this runs', [
224
- `FABRIKA_IAM_PROVISIONING_KEY=${secrets.provisioningKey} fabrika platform admin --provider=zerops --email=<you> --iam-host=${hosts.iam}`,
232
+ withSecrets(`fabrika platform admin --provider=zerops --email=<you> --iam-host=${hosts.iam}`),
225
233
  ])
226
234
  }
227
235
 
236
+ /** A path as one shell word. */
237
+ const shellQuoted = (path: string): string => `'${path.replaceAll("'", "'\\''")}'`
238
+
228
239
  /**
229
240
  * The confirmations stand between a stray invocation and an installation: this command generates six
230
241
  * credentials, mints a Zerops token on the account and deploys.
@@ -242,6 +253,7 @@ export const consoleInstallCollaborators = (input: PlatformInstallInput, reconci
242
253
  const prompts = installPrompts(input, process.stdin, (line) => consoleInfo(line))
243
254
  return {
244
255
  prepareSource: prepareInstallationSource,
256
+ checkerPublished: npmCheckerPublication(),
245
257
  uploadFetch: (url, init) => fetch(url, init),
246
258
  api: createZeropsApi({
247
259
  token: input.accessToken,
@@ -258,5 +270,6 @@ export const consoleInstallCollaborators = (input: PlatformInstallInput, reconci
258
270
  },
259
271
  prompts,
260
272
  signal: new AbortController().signal,
273
+ storeSecrets: (text) => writeSecretsFile(input.secretsFile, text),
261
274
  }
262
275
  }
@@ -50,8 +50,9 @@ export const machineKeyNextStepLines = (step: MachineKeyNextStep = {}): readonly
50
50
  // Never a guessed origin: the control service records the real one, and reading it is one command.
51
51
  ? readFrom('FABRIKA_IAM_RPC_URL', "`control`'s own FABRIKA_IAM_ISSUER, in the Zerops project")
52
52
  : `export FABRIKA_IAM_RPC_URL=${step.iamOrigin}`,
53
- readFrom('FABRIKA_IAM_RPC_KEY', "the `iam` or `control` service's env, in the Zerops project"),
54
- readFrom('FABRIKA_IAM_PROVISIONING_KEY', 'the same env — the copy anyone with the project can read;'),
53
+ // Not the service env: Zerops reads a sensitive value back as `REDACTED` outside sudo mode.
54
+ readFrom('FABRIKA_IAM_RPC_KEY', 'the secrets file `platform install` wrote (--secrets-file)'),
55
+ readFrom('FABRIKA_IAM_PROVISIONING_KEY', 'the same file — the copy an operator can read;'),
55
56
  continued(
56
57
  step.sidecarEnvironment === undefined
57
58
  ? "the sidecar repository's Environment holds the copy the pipeline uses"
@@ -2,14 +2,18 @@
2
2
  //
3
3
  // ── Why this is a MERGE and not a write ───────────────────────────────────────────────────────────
4
4
  //
5
- // On the `light` tier an application shares the platform's project and therefore the platform proxy's
6
- // single `FABRIKA_PROXY_MANIFEST_JSON`. The live document on `fabrika-test` carries a fourth app —
7
- // `notes` → `notesapi:3000` on listener 8084 — beside the three platform ones. `platformProxyManifest`
8
- // deliberately emits only the platform apps (it is generated from the platform's own service contracts and
9
- // knows nothing about applications), so writing it alone would DELETE every application entry and take
10
- // the deployed applications offline at the next proxy build.
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.
11
14
  //
12
- // So: read the live value, compose, write. That composition is this command's job and nothing else's.
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.
13
17
  //
14
18
  // ── What happens to an entry the platform does not know about ─────────────────────────────────────
15
19
  //
@@ -28,7 +32,8 @@
28
32
  // to some other app. A genuinely misplaced application therefore goes dark and is reported, instead of
29
33
  // quietly being served by the console.
30
34
 
31
- import { findDuplicateProxyHost, parseProxyManifestJson, type ProxyManifest } from '@fabrika/proxy-core/manifest'
35
+ import { FABRIKA_PROXY_MANIFEST_JSON, findDuplicateProxyHost, parseProxyManifestJson, type ProxyManifest } from '@fabrika/proxy-core/manifest'
36
+ import type { ZeropsServiceEnv } from '../index'
32
37
 
33
38
  /** The composed document plus what happened to every live entry that was not the platform's. */
34
39
  export interface MergedProxyManifest {
@@ -76,12 +81,24 @@ export const mergePlatformProxyManifest = (platform: ProxyManifest, live: ProxyM
76
81
  }
77
82
 
78
83
  /**
79
- * The live manifest a proxy service currently carries.
84
+ * The application entries the operator holds in the platform proxy's variable, as a manifest.
80
85
  *
81
- * Absent means "no manifest yet". Present-but-unparseable throws: see `mergePlatformProxyManifest`.
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.
82
88
  */
83
- export const readLiveProxyManifest = (value: string | undefined): ProxyManifest | null => {
84
- if (value === undefined || value.trim() === '') {
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() === '') {
85
102
  return null
86
103
  }
87
104
  const manifest = parseProxyManifestJson(value)
@@ -0,0 +1,87 @@
1
+ import type { ZeropsSourceDataFile } from '../source/port'
2
+
3
+ const BLOCK = 512
4
+ const END_OF_ARCHIVE = new Uint8Array(BLOCK * 2)
5
+
6
+ const text = (block: Uint8Array, start: number, length: number): string => {
7
+ const field = block.subarray(start, start + length)
8
+ const end = field.indexOf(0)
9
+ return new TextDecoder().decode(end < 0 ? field : field.subarray(0, end))
10
+ }
11
+
12
+ const octal = (block: Uint8Array, start: number, length: number): number => {
13
+ const raw = text(block, start, length).trim()
14
+ const value = raw === '' ? 0 : Number.parseInt(raw, 8)
15
+ if (!/^[0-7]*$/.test(raw) || !Number.isSafeInteger(value)) throw new Error('the prepared platform archive is not a tar archive')
16
+ return value
17
+ }
18
+
19
+ const blocksOf = (size: number): number => Math.ceil(size / BLOCK) * BLOCK
20
+
21
+ /** The `path` record of a pax extended header, the form `git archive` uses for a name over 100 bytes. */
22
+ const paxPath = (payload: Uint8Array): string | undefined => {
23
+ for (const record of new TextDecoder().decode(payload).split('\n')) {
24
+ const match = /^\d+ path=(.*)$/.exec(record)
25
+ if (match?.[1] !== undefined) return match[1]
26
+ }
27
+ return undefined
28
+ }
29
+
30
+ const header = (path: string, size: number): Uint8Array => {
31
+ const block = new Uint8Array(BLOCK)
32
+ const write = (start: number, value: string): void => void block.set(new TextEncoder().encode(value), start)
33
+ write(0, path)
34
+ write(100, '0000644\0')
35
+ write(108, '0000000\0')
36
+ write(116, '0000000\0')
37
+ write(124, `${size.toString(8).padStart(11, '0')}\0`)
38
+ write(136, '00000000000\0')
39
+ block.fill(0x20, 148, 156)
40
+ write(156, '0')
41
+ write(257, 'ustar\0')
42
+ write(263, '00')
43
+ const checksum = block.reduce((sum, byte) => sum + byte, 0)
44
+ write(148, `${checksum.toString(8).padStart(6, '0')}\0 `)
45
+ return block
46
+ }
47
+
48
+ /**
49
+ * The prepared platform archive plus the one generated data file (ADR-0045): every entry of the commit is copied
50
+ * byte for byte, and the file is appended after the last one. A commit that already holds the path, as a file or
51
+ * as a directory, is refused rather than overridden.
52
+ */
53
+ export const archiveWithDataFile = (compressed: Uint8Array<ArrayBuffer>, file: ZeropsSourceDataFile): Uint8Array<ArrayBuffer> => {
54
+ const path = new TextEncoder().encode(file.path)
55
+ if (path.byteLength === 0 || path.byteLength > 100 || file.path.includes('..') || file.path.startsWith('/')) {
56
+ throw new Error(`the data file path \`${file.path}\` is not one a ustar header holds`)
57
+ }
58
+ const tar = Bun.gunzipSync(compressed)
59
+ let offset = 0
60
+ let pendingPath: string | undefined
61
+ for (;;) {
62
+ const block = tar.subarray(offset, offset + BLOCK)
63
+ if (block.byteLength < BLOCK) throw new Error('the prepared platform archive ends without its end-of-archive blocks')
64
+ if (block.every((byte) => byte === 0)) break
65
+ const size = octal(block, 124, 12)
66
+ const type = text(block, 156, 1)
67
+ const content = tar.subarray(offset + BLOCK, offset + BLOCK + size)
68
+ if (type === 'x') {
69
+ pendingPath = paxPath(content)
70
+ } else if (type !== 'g') {
71
+ const prefix = text(block, 345, 155)
72
+ const named = pendingPath ?? (prefix === '' ? text(block, 0, 100) : `${prefix}/${text(block, 0, 100)}`)
73
+ pendingPath = undefined
74
+ const entry = named.replace(/\/$/, '')
75
+ if (entry === file.path || entry.startsWith(`${file.path}/`)) {
76
+ throw new Error(`the platform source already holds \`${file.path}\`, the proxy manifest's place in the build archive`)
77
+ }
78
+ }
79
+ offset += BLOCK + blocksOf(size)
80
+ }
81
+ const contents = new TextEncoder().encode(file.contents)
82
+ const appended = new Uint8Array(offset + BLOCK + blocksOf(contents.byteLength) + END_OF_ARCHIVE.byteLength)
83
+ appended.set(tar.subarray(0, offset))
84
+ appended.set(header(file.path, contents.byteLength), offset)
85
+ appended.set(contents, offset + BLOCK)
86
+ return Bun.gzipSync(appended)
87
+ }
@@ -10,13 +10,13 @@ export const validatePlatformUploadUrl = (value: string): void => {
10
10
 
11
11
  export const uploadPlatformArchive = async (
12
12
  url: string,
13
- archivePath: string,
13
+ archive: Blob,
14
14
  signal: AbortSignal,
15
15
  uploadFetch: PlatformUploadFetch,
16
16
  ): Promise<void> => {
17
17
  validatePlatformUploadUrl(url)
18
18
  const uploadSignal = AbortSignal.any([signal, AbortSignal.timeout(10 * 60_000)])
19
- const body = Bun.file(archivePath).stream()
19
+ const body = archive.stream()
20
20
  try {
21
21
  const response = await uploadFetch(url, {
22
22
  method: 'PUT',
@@ -1,8 +1,7 @@
1
1
  // The six values `fabrika platform install --provider=zerops` GENERATES, and their shapes.
2
2
  //
3
- // Every one of them is written to a Zerops service and never stored anywhere else — there is no `.env`
4
- // on this path, deliberately (see `init.ts`). Only the provisioning key is ever printed, once, at the
5
- // end of the install, because `platform init` asks the operator for it afterwards.
3
+ // Every one of them is written to a Zerops service as a SENSITIVE variable, which Zerops does not hand
4
+ // back, and to the operator's local secrets file (`install-secrets.ts`) before that. None is printed.
6
5
  //
7
6
  // The shapes are not free choices. Each one is what the consuming code already parses:
8
7
  //
@@ -59,7 +58,7 @@ export interface InstallationSecrets {
59
58
  readonly rpcKey: string
60
59
  /** Authenticates IAM's `/auth/mint/*` surface, which the proxy calls on the cold path. */
61
60
  readonly proxyKey: string
62
- /** The `px_` admin bearer IAM admits as a synthetic global admin. The one value this command prints. */
61
+ /** The `px_` admin bearer IAM admits as a synthetic global admin. `platform init` and `admin` take it. */
63
62
  readonly provisioningKey: string
64
63
  /** The catalog credential control and Operations share. No `px_` prefix — it is not an IAM credential. */
65
64
  readonly operationsSyncKey: string
@@ -89,7 +89,6 @@
89
89
  // resolves nested references (a `RUNTIME_` lift of a variable that is itself `${db_connectionString}`
90
90
  // arrives fully resolved). Only the proxy needs this, and it declares it explicitly below.
91
91
 
92
- import { FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
93
92
  import type { ZeropsHealthCheckSpec, ZeropsReadinessCheckSpec, ZeropsYamlSetupSpec, ZeropsYamlSpec } from '../../index'
94
93
 
95
94
  // ── Readiness and liveness: two mechanisms, two questions ──────────────────────────────────────────
@@ -324,37 +323,28 @@ const operations: ZeropsYamlSetupSpec = {
324
323
  *
325
324
  * Per-service variables (env API): `FABRIKA_IAM_ISSUER` — `http://iam:3000` in the platform project, the
326
325
  * IAM service's PUBLIC origin in an apps project, because there is no private network between projects
327
- * (ADR-0006). And `FABRIKA_PROXY_MANIFEST_JSON`; see below.
326
+ * (ADR-0006). The manifest is not a variable; see below.
328
327
  *
329
328
  * Secret (`envSecrets`): `FABRIKA_IAM_KEY` — the value IAM knows as `FABRIKA_IAM_PROXY_KEY`.
330
329
  *
331
- * ── How the manifest reaches the build, and what is unverified about it ───────────────────────────
330
+ * ── How the manifest reaches the build ───────────────────────────────────────────────────────────
332
331
  *
333
332
  * `proxy.manifest.json` tells the proxy which apps it fronts, at which upstream, under which endpoint table. It
334
333
  * is baked into the artifact at BUILD time, which is what `docs/backlog/08` chose ("redeploy") over
335
- * pushing to Caddy's admin API. But on Zerops fabrika has no filesystem in the deploy path — a Zerops
336
- * deploy is five HTTP calls (ADR-0003) — so the driver cannot write a file into the build the way the
337
- * Cloudflare path can. The only write channel it has is the service-level env API, the same one it uses
338
- * for secrets, so the manifest travels as a service variable and the build materializes it to a file.
334
+ * pushing to Caddy's admin API. It arrives as the one generated data file of the uploaded build archive
335
+ * (ADR-0045), beside the exact public commit, at a path the commit does not hold: Control streams it into a
336
+ * namespace proxy's archive (`republishProxy`, `packages/provider-zerops/src/namespace/proxy-attempt.ts`), and
337
+ * `platform install` / `platform deploy` append it to the platform proxy's (`installation/platform-archive.ts`).
338
+ * It never travels through the service environment, whose values Zerops caps at ~32 KB.
339
339
  *
340
- * The control plane writes `FABRIKA_PROXY_MANIFEST_JSON` through the service-level env API and rolls
341
- * this service before each app deploy (`republishProxy`, `packages/provider-zerops/src/namespace/proxy-attempt.ts`).
340
+ * Releases before this one lifted `FABRIKA_PROXY_MANIFEST_JSON` from the env API into the build instead.
341
+ * A namespace pinned to such a release keeps that descriptor, and Control keeps writing the variable for it
342
+ * (`zeropsProxyManifestCarrier`): never add the lift back, or a pin of this release would get the variable.
342
343
  *
343
- * That live-account fact is now settled, and the answer was the bad one: **a build container sees none
344
- * of its service's env-API variables.** Read directly, `${FABRIKA_PROXY_MANIFEST_JSON}` in a build
345
- * command is the empty string — no error, no warning. Left uncorrected, every proxy build would have
346
- * written an empty manifest, failed `generate-config.ts`, and made this service undeployable on Zerops.
347
- *
348
- * The fix is the `build.envVariables` declaration below rather than the move into `run.initCommands`
349
- * this header used to anticipate: `${RUNTIME_x}` lifts a service's own runtime variable into its build
350
- * context, and it resolves nested references too. The delivery channel and the fail-closed parser are
351
- * unchanged, which is what that contingency promised.
352
- *
353
- * What IS structural: missing or malformed JSON makes `generate-config.ts` exit non-zero, so the
344
+ * What IS structural: a missing or malformed file makes `generate-config.ts` exit non-zero, so the
354
345
  * pipeline fails and the previous version keeps serving. A valid empty app list generates only the
355
346
  * terminal 404 route, and the auth service denies every forwarded request.
356
347
  */
357
- const proxyManifestReference = `\${${FABRIKA_PROXY_MANIFEST_JSON}}`
358
348
 
359
349
  /**
360
350
  * The proxy's public listeners.
@@ -395,19 +385,17 @@ const proxy: ZeropsYamlSetupSpec = {
395
385
  // database, a stronger supply-chain guarantee than a release tarball with a hand-copied hash — and
396
386
  // the toolchain floats. If a future Go release breaks the build, this is the line that moved.
397
387
  base: ['alpine/go@latest', 'alpine/bun@1.3'],
398
- // Runtime values reach the build only through the explicit RUNTIME_ bridge.
388
+ // Runtime values reach the build only through the explicit RUNTIME_ bridge. The manifest is not one:
389
+ // it is the archive's data file (see the header).
399
390
  envVariables: {
400
- [FABRIKA_PROXY_MANIFEST_JSON]: `\${RUNTIME_${FABRIKA_PROXY_MANIFEST_JSON}}`,
401
391
  FABRIKA_PROXY_SESSION_OWNER_JSON: '${RUNTIME_FABRIKA_PROXY_SESSION_OWNER_JSON}',
402
392
  },
403
393
  // CGO off makes the binary static, which is what lets it run on Alpine/musl.
404
394
  prepareCommands: ['CGO_ENABLED=0 GOBIN=/tmp/gobin go install github.com/caddyserver/caddy/v2/cmd/caddy@v2.10.2'],
405
395
  buildCommands: [
406
396
  'bun install --frozen-lockfile',
407
- // See the header: the manifest arrives as a service variable because the driver has no
408
- // filesystem. `printf` rather than `echo` so a leading `-` or a backslash survives intact.
409
- `printf %s "${proxyManifestReference}" > ./proxy.manifest.json`,
410
- // Missing/malformed JSON fails the build; an empty app list emits only deny/404 routes.
397
+ // The archive's data file (see the header). Missing/malformed JSON fails the build; an empty app list
398
+ // emits only deny/404 routes.
411
399
  `bun run packages/proxy/src/generate-config.ts ./proxy.manifest.json ./caddy.json --auth-upstream 127.0.0.1:9000 --listen ${
412
400
  PROXY_PUBLIC_PORTS.map((port) => `:${port}`).join(',')
413
401
  }`,