@fabrika/provider-zerops 0.4.2 → 0.4.4

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 (49) 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 +44 -5
  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 +95 -37
  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 +169 -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/platform-archive.ts +87 -0
  31. package/src/installation/platform-upload.ts +2 -2
  32. package/src/installation/secrets.ts +3 -4
  33. package/src/installation/sidecar.ts +38 -2
  34. package/src/installation/templates/README.md +1 -1
  35. package/src/installation/templates/platform.yml +23 -2
  36. package/src/installation/topology/setups.ts +15 -27
  37. package/src/installation/topology/topology.ts +105 -104
  38. package/src/installation/upgrade.ts +24 -7
  39. package/src/managed-env.ts +11 -0
  40. package/src/manifest.ts +25 -3
  41. package/src/namespace/codec.ts +6 -1
  42. package/src/namespace/errors.ts +3 -1
  43. package/src/namespace/provisioning.ts +18 -5
  44. package/src/namespace/proxy-attempt.ts +56 -14
  45. package/src/namespace/update.ts +50 -8
  46. package/src/provider.ts +57 -13
  47. package/src/proxy-manifest-carrier.ts +32 -0
  48. package/src/source/port.ts +9 -1
  49. package/src/installation/manifest.ts +0 -95
@@ -117,12 +117,11 @@ export interface ProjectTopology {
117
117
  /** The source declaration compiled into the generated topology artifacts. */
118
118
  target: ZeropsSourceTarget
119
119
  /**
120
- * The SAME services with no `project` block, for a project the operator created by hand.
120
+ * The SAME services with no `project` block, for the project `platform install` created or was handed.
121
121
  *
122
122
  * A document carrying `project:` is only valid at `POST /client/{clientId}/project/import`, which
123
123
  * CREATES a project — and Zerops permits duplicate project names, so applying one at an existing
124
- * installation risks a second project rather than failing. Present only on the tiers a bootstrap
125
- * imports into an existing project.
124
+ * installation risks a second project rather than failing. Present on both platform tiers.
126
125
  */
127
126
  servicesTarget?: ZeropsSourceTarget
128
127
  /** The one service allowed to be publicly routed (ADR-0022). Always the proxy. */
@@ -182,6 +181,107 @@ export const platformTopology = (options: TopologyOptions): ProjectTopology => {
182
181
  if (tier === 'light') {
183
182
  return lightPlatformTopology(options, publicAccess)
184
183
  }
184
+ // ONE services function behind both targets, as on the light tier.
185
+ const services = (): ZeropsServiceSpec[] => [
186
+ // ── data, created first (priority is descending) ──────────────────────────────────
187
+ {
188
+ hostname: 'db',
189
+ // HA is encoded in the TYPE. The deprecated `mode` field is not merely avoided here — it
190
+ // is absent from `ZeropsServiceSpec`, so it cannot be written. One database serves both
191
+ // IAM and the control plane; they are separate schemas in it, not separate services,
192
+ // because a second HA Postgres triples the cost of the isolation the private network
193
+ // already gives us.
194
+ type: 'postgresql:ha@18',
195
+ // The identity and control-plane database: every login, every token mint, every run's
196
+ // state. `oltp-production` is also what an HA service gets by DEFAULT — stated here so
197
+ // the bill is a decision rather than a discovery, and because this is the one place the
198
+ // standard tier should pay for it: dedicated CPU is what keeps auth latency off the
199
+ // noisy-neighbour curve.
200
+ profile: 'oltp-production',
201
+ priority: 100,
202
+ },
203
+ {
204
+ hostname: 'storage',
205
+ // S3-compatible object storage: run logs (`FABRIKA_CONTROL_RUN_LOGS_*`) and deploy artifacts.
206
+ // `@fabrika/platform-node`'s `S3BlobStore` speaks to this unchanged — it is the same
207
+ // implementation that speaks to R2.
208
+ type: 'object-storage',
209
+ objectStorageSize: 25,
210
+ // Never `public-read`: a run log can quote a build's environment.
211
+ objectStoragePolicy: 'private',
212
+ // A CDN in front of private run logs would be a cache in front of a thing nobody reads twice.
213
+ enableCdn: false,
214
+ priority: 100,
215
+ },
216
+ {
217
+ hostname: 'operationsdb',
218
+ // Operations has an independent database service. This prevents its high-volume error
219
+ // history and migrations from sharing IAM/control's failure and capacity domain.
220
+ type: 'postgresql:ha@18',
221
+ // NOT `db`'s profile, and that is the point of stating both. `oltp-staging` keeps the
222
+ // same three-container redundancy and the same 8-core/48 GB ceiling, but starts at one
223
+ // SHARED core and 1 GB instead of two DEDICATED cores and 4 GB — measured off a live
224
+ // HA service, see `docs/reference/zerops-platform.md`. Error history is bursty and
225
+ // tolerant of jitter: slow ingest is a queue, not an outage. Holding two dedicated
226
+ // cores idle for it is the half of the default nobody chose.
227
+ profile: 'oltp-staging',
228
+ priority: 100,
229
+ },
230
+ {
231
+ hostname: 'operationsstorage',
232
+ type: 'object-storage',
233
+ objectStorageSize: 25,
234
+ objectStoragePolicy: 'private',
235
+ enableCdn: false,
236
+ priority: 100,
237
+ },
238
+ // ── services ──────────────────────────────────────────────────────────────────────
239
+ runtime({
240
+ hostname: 'iam',
241
+ type: 'alpine/bun@1.3',
242
+ priority: 50,
243
+ // Identity is on the critical path of every login and every cold-path token mint, and the
244
+ // service is stateless (its state is in `db`), so two containers cost little and remove a
245
+ // single point of failure the product cannot afford.
246
+ minContainers: 2,
247
+ maxContainers: 4,
248
+ }),
249
+ runtime({
250
+ hostname: 'operations',
251
+ type: 'alpine/bun@1.3',
252
+ priority: 40,
253
+ minContainers: 1,
254
+ maxContainers: 3,
255
+ }),
256
+ runtime({
257
+ hostname: 'control',
258
+ type: 'alpine/bun@1.3',
259
+ // IAM → Operations → proxy → control is the code deployment order too:
260
+ // control must never start against a missing private dependency.
261
+ priority: 20,
262
+ // Deliberately allowed to be a single container. The control plane is CRASH-SAFE across a
263
+ // deploy by design — it may trigger its own redeploy and die, then reconcile in-flight runs
264
+ // by polling `/app-version` on restart (ADR-0003) — so an outage window here is a delay,
265
+ // not a lost run. Scaling it out would also multiply the queue consumers for no gain.
266
+ minContainers: 1,
267
+ maxContainers: 2,
268
+ }),
269
+ runtime({
270
+ hostname: PROXY_HOSTNAME,
271
+ // Alpine custom runtime: a Caddy binary plus a compiled Bun auth service, no interpreter
272
+ // (ADR-0022). `run.os` is deprecated — the OS is the service type.
273
+ type: 'alpine@3.21',
274
+ priority: 30,
275
+ // THE ONLY PUBLICLY ROUTED SERVICE. Everything above is reachable only over the project's
276
+ // private network; the proxy is what the internet talks to, including for IAM's own OIDC
277
+ // login and the control-plane dashboard.
278
+ public: publicAccess === 'zerops-subdomain',
279
+ // It is on every request to every service in this project, and it is stateless by
280
+ // construction (ADR-0022 insists on that precisely so this line is allowed to exist).
281
+ minContainers: 2,
282
+ maxContainers: 6,
283
+ }),
284
+ ]
185
285
  return {
186
286
  id: 'platform',
187
287
  publicService: PROXY_HOSTNAME,
@@ -193,107 +293,9 @@ export const platformTopology = (options: TopologyOptions): ProjectTopology => {
193
293
  corePackage: 'SERIOUS',
194
294
  tags: ['fabrika', 'platform', options.env],
195
295
  },
196
- services: () => [
197
- // ── data, created first (priority is descending) ──────────────────────────────────
198
- {
199
- hostname: 'db',
200
- // HA is encoded in the TYPE. The deprecated `mode` field is not merely avoided here — it
201
- // is absent from `ZeropsServiceSpec`, so it cannot be written. One database serves both
202
- // IAM and the control plane; they are separate schemas in it, not separate services,
203
- // because a second HA Postgres triples the cost of the isolation the private network
204
- // already gives us.
205
- type: 'postgresql:ha@18',
206
- // The identity and control-plane database: every login, every token mint, every run's
207
- // state. `oltp-production` is also what an HA service gets by DEFAULT — stated here so
208
- // the bill is a decision rather than a discovery, and because this is the one place the
209
- // standard tier should pay for it: dedicated CPU is what keeps auth latency off the
210
- // noisy-neighbour curve.
211
- profile: 'oltp-production',
212
- priority: 100,
213
- },
214
- {
215
- hostname: 'storage',
216
- // S3-compatible object storage: run logs (`FABRIKA_CONTROL_RUN_LOGS_*`) and deploy artifacts.
217
- // `@fabrika/platform-node`'s `S3BlobStore` speaks to this unchanged — it is the same
218
- // implementation that speaks to R2.
219
- type: 'object-storage',
220
- objectStorageSize: 25,
221
- // Never `public-read`: a run log can quote a build's environment.
222
- objectStoragePolicy: 'private',
223
- // A CDN in front of private run logs would be a cache in front of a thing nobody reads twice.
224
- enableCdn: false,
225
- priority: 100,
226
- },
227
- {
228
- hostname: 'operationsdb',
229
- // Operations has an independent database service. This prevents its high-volume error
230
- // history and migrations from sharing IAM/control's failure and capacity domain.
231
- type: 'postgresql:ha@18',
232
- // NOT `db`'s profile, and that is the point of stating both. `oltp-staging` keeps the
233
- // same three-container redundancy and the same 8-core/48 GB ceiling, but starts at one
234
- // SHARED core and 1 GB instead of two DEDICATED cores and 4 GB — measured off a live
235
- // HA service, see `docs/reference/zerops-platform.md`. Error history is bursty and
236
- // tolerant of jitter: slow ingest is a queue, not an outage. Holding two dedicated
237
- // cores idle for it is the half of the default nobody chose.
238
- profile: 'oltp-staging',
239
- priority: 100,
240
- },
241
- {
242
- hostname: 'operationsstorage',
243
- type: 'object-storage',
244
- objectStorageSize: 25,
245
- objectStoragePolicy: 'private',
246
- enableCdn: false,
247
- priority: 100,
248
- },
249
- // ── services ──────────────────────────────────────────────────────────────────────
250
- runtime({
251
- hostname: 'iam',
252
- type: 'alpine/bun@1.3',
253
- priority: 50,
254
- // Identity is on the critical path of every login and every cold-path token mint, and the
255
- // service is stateless (its state is in `db`), so two containers cost little and remove a
256
- // single point of failure the product cannot afford.
257
- minContainers: 2,
258
- maxContainers: 4,
259
- }),
260
- runtime({
261
- hostname: 'operations',
262
- type: 'alpine/bun@1.3',
263
- priority: 40,
264
- minContainers: 1,
265
- maxContainers: 3,
266
- }),
267
- runtime({
268
- hostname: 'control',
269
- type: 'alpine/bun@1.3',
270
- // IAM → Operations → proxy → control is the code deployment order too:
271
- // control must never start against a missing private dependency.
272
- priority: 20,
273
- // Deliberately allowed to be a single container. The control plane is CRASH-SAFE across a
274
- // deploy by design — it may trigger its own redeploy and die, then reconcile in-flight runs
275
- // by polling `/app-version` on restart (ADR-0003) — so an outage window here is a delay,
276
- // not a lost run. Scaling it out would also multiply the queue consumers for no gain.
277
- minContainers: 1,
278
- maxContainers: 2,
279
- }),
280
- runtime({
281
- hostname: PROXY_HOSTNAME,
282
- // Alpine custom runtime: a Caddy binary plus a compiled Bun auth service, no interpreter
283
- // (ADR-0022). `run.os` is deprecated — the OS is the service type.
284
- type: 'alpine@3.21',
285
- priority: 30,
286
- // THE ONLY PUBLICLY ROUTED SERVICE. Everything above is reachable only over the project's
287
- // private network; the proxy is what the internet talks to, including for IAM's own OIDC
288
- // login and the control-plane dashboard.
289
- public: publicAccess === 'zerops-subdomain',
290
- // It is on every request to every service in this project, and it is stateless by
291
- // construction (ADR-0022 insists on that precisely so this line is allowed to exist).
292
- minContainers: 2,
293
- maxContainers: 6,
294
- }),
295
- ],
296
+ services,
296
297
  },
298
+ servicesTarget: { platform: 'zerops', services },
297
299
  }
298
300
  }
299
301
 
@@ -371,7 +373,6 @@ const lightPlatformTopology = (options: TopologyOptions, publicAccess: PublicAcc
371
373
  },
372
374
  services,
373
375
  },
374
- // The light tier is the one an operator installs into a project they created themselves.
375
376
  servicesTarget: { platform: 'zerops', services },
376
377
  }
377
378
  }
@@ -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
 
@@ -59,6 +59,17 @@ export const SERVICE_MANAGED_KEYS: readonly Exclude<ManagedKey, 'FABRIKA_RELEASE
59
59
  'FABRIKA_IAM_RPC_KEY',
60
60
  ]
61
61
 
62
+ /** The managed keys that carry a credential, written sensitive and therefore never compared. */
63
+ export const SENSITIVE_MANAGED_KEYS: ReadonlySet<ManagedKey> = new Set<ManagedKey>([
64
+ 'FABRIKA_OPERATIONS_DSN',
65
+ 'FABRIKA_SERVICE_KEY',
66
+ 'FABRIKA_PROXY_SECRET',
67
+ 'FABRIKA_IAM_RPC_KEY',
68
+ ])
69
+
70
+ /** A workload or connect key is a credential; a connect URL is an origin. */
71
+ export const isSensitiveFamilyVariable = (name: string): boolean => !isConnectUrlVariable(name)
72
+
62
73
  export type ManagedValues = Record<ManagedKey, string | null>
63
74
 
64
75
  const managedValuesSchema = z.object({
package/src/manifest.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { appSchemaSchema } from '@fabrika/auth'
2
- import { type AppSchema, isDigest } from '@fabrika/auth-core'
3
- import type { JsonValue, ProviderCodec, ProviderEnvironment } from '@fabrika/provider-contract'
2
+ import { type AppSchema, type ContractArtifact, isDigest } from '@fabrika/auth-core'
3
+ import type { OperationsSourceMapBuildUpload } from '@fabrika/operations-contract/releases'
4
+ import type { JsonValue, ProviderCodec, ProviderDeployInput, ProviderEnvironment } from '@fabrika/provider-contract'
4
5
  import { proxyEndpointRowSchema } from '@fabrika/proxy-core/manifest'
5
6
  import { z } from 'zod'
6
7
  import { compileImport, ENV_ISOLATION, OVERRIDE, renderYaml } from './compile'
@@ -11,6 +12,7 @@ import {
11
12
  LOCKED_APP_ID_PATTERN,
12
13
  lockedAuthoredBuild,
13
14
  ZEROPS_RUNTIME_PACKAGES,
15
+ zeropsAssetRoots,
14
16
  type ZeropsContractRun,
15
17
  zeropsContractRun,
16
18
  type ZeropsLockedExecution,
@@ -420,6 +422,7 @@ export const lockedExecutionOf = (manifest: FabrikaManifest): ZeropsLockedExecut
420
422
  databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
421
423
  storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
422
424
  runtimePackages: manifest.target.runtimePackages ?? [],
425
+ sourceMaps: null,
423
426
  }
424
427
  }
425
428
 
@@ -430,7 +433,12 @@ export type ZeropsAccess = Pick<ProviderEnvironment, 'access'>
430
433
  * The execution descriptor's lock-down at deploy (ADR-0050): Control's approved contract decides it, not the artifact,
431
434
  * so a configuration cannot drop it. Without an approved contract nothing deploys outside a dry run.
432
435
  */
433
- export function lockedExecutionFor(manifest: FabrikaManifest, granted: ZeropsAccess, dryRun: boolean): ZeropsLockedExecution | null {
436
+ export function lockedExecutionFor(
437
+ manifest: FabrikaManifest,
438
+ granted: ZeropsAccess,
439
+ dryRun: boolean,
440
+ artifactUpload?: ProviderDeployInput['artifactUpload'],
441
+ ): ZeropsLockedExecution | null {
434
442
  const access = granted.access
435
443
  if (access === undefined) {
436
444
  if (dryRun) return null
@@ -445,9 +453,23 @@ export function lockedExecutionFor(manifest: FabrikaManifest, granted: ZeropsAcc
445
453
  databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
446
454
  storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
447
455
  runtimePackages: manifest.target.runtimePackages ?? [],
456
+ sourceMaps: artifactUpload === undefined ? null : sourceMapUpload(artifactUpload, access.contract),
448
457
  }
449
458
  }
450
459
 
460
+ /** The build's upload: Control's destination without its bearer, and the approved contract's asset roots. */
461
+ const sourceMapUpload = (upload: NonNullable<ProviderDeployInput['artifactUpload']>, contract: ContractArtifact): OperationsSourceMapBuildUpload => ({
462
+ destination: {
463
+ url: upload.url,
464
+ appId: upload.appId,
465
+ environment: upload.environment,
466
+ serviceKey: upload.serviceKey,
467
+ release: upload.release,
468
+ runId: upload.runId,
469
+ },
470
+ assetRoots: zeropsAssetRoots(contract),
471
+ })
472
+
451
473
  /** The IAM schema a deploy, cancel or reconcile applies: the approved contract's. `undefined` reconciles nothing. */
452
474
  export function approvedSchemaOf(granted: ZeropsAccess): AppSchema | undefined {
453
475
  return granted.access?.schema
@@ -10,6 +10,9 @@ import { commitShaSchema, identifierSchema, rejectExplicitUndefined, repositoryS
10
10
  import { namespaceProxySourceSchema } from './proxy-source'
11
11
  import type { NormalizedZeropsNamespaceTarget, ZeropsNamespaceOptions, ZeropsNamespaceProxySource, ZeropsNamespaceTarget } from './types'
12
12
 
13
+ /** The one value an update may give `proxySource`: re-pin the namespace's proxy to the installation's default. */
14
+ export const ZEROPS_PROXY_SOURCE_CURRENT = 'current'
15
+
13
16
  const encodeSource = (source: ZeropsNamespaceProxySource): JsonValue => ({
14
17
  repository: { ...source.repository },
15
18
  commitSha: source.commitSha,
@@ -73,10 +76,12 @@ const namespaceTargetSchema = namespaceTargetShape.transform((target) => {
73
76
  /**
74
77
  * What an update request's payload may name: any target field, each optional, and no field the target
75
78
  * does not have — at the top level or inside `postgres`. The target schema STRIPS an unknown key, which
76
- * would turn a misspelt field into a silent no-op.
79
+ * would turn a misspelt field into a silent no-op. `proxySource` also takes `current`, the installation's
80
+ * default pin; the plan refuses any other pin than that one (ADR-0045).
77
81
  */
78
82
  export const namespaceUpdatePayloadSchema = namespaceTargetShape.partial().extend({
79
83
  postgres: z.discriminatedUnion('type', [haPostgresSchema.strict(), singlePostgresSchema.strict()]).optional(),
84
+ proxySource: z.union([z.literal(ZEROPS_PROXY_SOURCE_CURRENT), namespaceProxySourceSchema]).optional(),
80
85
  }).strict()
81
86
 
82
87
  export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> = {
@@ -1,5 +1,5 @@
1
1
  import { ProviderNamespaceError } from '@fabrika/provider-contract'
2
- import { ZeropsApiError } from '../api'
2
+ import { ZeropsApiError, ZeropsServiceEnvValueTooLargeError } from '../api'
3
3
 
4
4
  /**
5
5
  * Lifecycle codes this provider defines where the platform has none of its own. They are an
@@ -41,6 +41,8 @@ const splitApiMessage = (message: string, code: string): { summary: string; deta
41
41
  /** Every namespace lifecycle failure leaves this provider as one typed, coded error. */
42
42
  export const asNamespaceError = (cause: unknown, mode: 'provision' | 'reconcile' | 'update'): ProviderNamespaceError => {
43
43
  if (cause instanceof ProviderNamespaceError) return cause
44
+ // The same value is refused again on every retry; only a smaller manifest or a new carrier fixes it.
45
+ if (cause instanceof ZeropsServiceEnvValueTooLargeError) return namespaceError(cause.code, cause.message, false)
44
46
  if (cause instanceof ZeropsApiError) {
45
47
  const { summary, detail } = splitApiMessage(cause.message, cause.code)
46
48
  return namespaceError(
@@ -6,7 +6,16 @@ import {
6
6
  type ProviderNamespaceRepublishInput,
7
7
  type ProviderNamespaceRepublishResult,
8
8
  } from '@fabrika/provider-contract'
9
- import { waitForProcess, ZEROPS_SERVICE_NOT_HTTP, ZEROPS_TERMINAL, ZeropsApiError, type ZeropsProject, type ZeropsService } from '../api'
9
+ import {
10
+ enableSubdomainAccessWhenHttp,
11
+ readableContent,
12
+ waitForProcess,
13
+ ZEROPS_SERVICE_NOT_HTTP,
14
+ ZEROPS_TERMINAL,
15
+ ZeropsApiError,
16
+ type ZeropsProject,
17
+ type ZeropsService,
18
+ } from '../api'
10
19
  import { defaultSleep } from '../collaborators'
11
20
  import { namespaceResourceClaims, registrationResourceClaims } from './claims'
12
21
  import { checkpoint, decodeEnvelope, normalizedNamespace, normalizeTarget } from './codec'
@@ -179,8 +188,8 @@ const readProxyHosts = async (
179
188
  }
180
189
  try {
181
190
  const variables = await options.api.listServiceEnv({ serviceId: proxyServiceId, signal })
182
- const subdomains = variables.find((variable) => variable.key === ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE)
183
- return subdomains === undefined ? [] : parseZeropsProxyHosts(subdomains.content)
191
+ const subdomains = readableContent(variables.find((variable) => variable.key === ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE))
192
+ return subdomains === undefined ? [] : parseZeropsProxyHosts(subdomains)
184
193
  } catch {
185
194
  // A host listing is a convenience; the proxy is already built, deployed and published. Failing the
186
195
  // namespace over it would report a placement that WORKS as broken. Never the cause: it can carry
@@ -217,6 +226,7 @@ const deployProxy = async (
217
226
  let currentNamespace = namespace
218
227
  const manifest = compileNamespaceProxyManifest(namespace.id, input.members)
219
228
  const result = await republishProxy({
229
+ namespaceId: namespace.id,
220
230
  target,
221
231
  manifest,
222
232
  proxySecrets: namespaceProxySecrets(manifest, input.members),
@@ -264,6 +274,9 @@ const republishNamespace = async (
264
274
  *
265
275
  * The decision is the READ-BACK, never the call returning. On an already-published service the platform
266
276
  * answers 2xx and then fails the process it handed back — live-verified — so a 2xx proves nothing.
277
+ *
278
+ * The HTTP ports lag the proxy's ACTIVE version, so `serviceStackIsNotHttp` is retried for a bounded
279
+ * window (`enableSubdomainAccessWhenHttp`) before it fails the namespace.
267
280
  */
268
281
  const ensureSubdomainAccess = async (
269
282
  target: NormalizedZeropsNamespaceTarget,
@@ -279,12 +292,12 @@ const ensureSubdomainAccess = async (
279
292
  return
280
293
  }
281
294
  try {
282
- await options.api.enableSubdomainAccess({ serviceId: proxyServiceId, signal })
295
+ await enableSubdomainAccessWhenHttp({ api: options.api, serviceId: proxyServiceId, sleep: options.sleep ?? defaultSleep, signal })
283
296
  } catch (error) {
284
297
  if (error instanceof ZeropsApiError && error.code === ZEROPS_SERVICE_NOT_HTTP) {
285
298
  throw namespaceError(
286
299
  ZEROPS_SERVICE_NOT_HTTP,
287
- `Zerops namespace proxy ${proxyServiceId} cannot be published on a zerops.app subdomain: it exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP})`,
300
+ `Zerops namespace proxy ${proxyServiceId} cannot be published on a zerops.app subdomain: it still exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP}) minutes after its deploy`,
288
301
  false,
289
302
  )
290
303
  }
@@ -1,8 +1,17 @@
1
1
  import { followLeaseLoss, isLeaseLoss, ProviderNamespaceError } from '@fabrika/provider-contract'
2
2
  import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON, type ProxyManifest } from '@fabrika/proxy-core/manifest'
3
- import { ZEROPS_ACTIVE, ZEROPS_TERMINAL, type ZeropsApi, ZeropsApiError, type ZeropsAppVersion } from '../api'
3
+ import {
4
+ readableContent,
5
+ ZEROPS_ACTIVE,
6
+ ZEROPS_TERMINAL,
7
+ type ZeropsApi,
8
+ ZeropsApiError,
9
+ type ZeropsAppVersion,
10
+ ZeropsServiceEnvValueTooLargeError,
11
+ } from '../api'
4
12
  import type { Sleeper } from '../collaborators'
5
13
  import { prepareZeropsDescriptor } from '../descriptor'
14
+ import { ZEROPS_PROXY_MANIFEST_PATH, type ZeropsProxyManifestCarrier, zeropsProxyManifestCarrier } from '../proxy-manifest-carrier'
6
15
  import { isZeropsUploadDestination } from '../source'
7
16
  import type { ZeropsSourcePort } from '../source/port'
8
17
  import { deleteVersion } from '../version-attempt'
@@ -12,10 +21,18 @@ import type { ZeropsNamespaceProxyBuild, ZeropsNamespaceTarget } from './types'
12
21
 
13
22
  export type ZeropsProxyApi = Pick<
14
23
  ZeropsApi,
15
- 'listServiceEnv' | 'putServiceEnv' | 'createAppVersion' | 'deleteAppVersion' | 'buildAndDeployAppVersion' | 'getAppVersion'
24
+ | 'listServiceEnv'
25
+ | 'putServiceEnv'
26
+ | 'deleteServiceEnv'
27
+ | 'createAppVersion'
28
+ | 'deleteAppVersion'
29
+ | 'buildAndDeployAppVersion'
30
+ | 'getAppVersion'
16
31
  >
17
32
 
18
33
  export interface ZeropsProxyRepublishInput {
34
+ /** The namespace's id, named in the one refusal an operator acts on: re-pin it. */
35
+ readonly namespaceId: string
19
36
  readonly target: ZeropsNamespaceTarget
20
37
  readonly manifest: ProxyManifest
21
38
  readonly source?: ZeropsSourcePort
@@ -46,19 +63,24 @@ const digest = async (text: string): Promise<string> =>
46
63
  '',
47
64
  )
48
65
 
49
- const writeProxyIam = async (input: ZeropsProxyRepublishInput, serviceId: string): Promise<void> => {
66
+ const writeProxyIam = async (input: ZeropsProxyRepublishInput, serviceId: string, carrier: ZeropsProxyManifestCarrier): Promise<void> => {
50
67
  const { api, signal } = input
51
68
  const variables = await api.listServiceEnv({ serviceId, signal })
52
- if (variables.find(variable => variable.key === SESSION_OWNER_VARIABLE)?.content !== 'null') {
53
- await api.putServiceEnv({ serviceId, key: SESSION_OWNER_VARIABLE, value: 'null', signal })
69
+ // The archive carries the manifest now; a copy left in the environment would describe nothing the proxy serves.
70
+ const stale = variables.find(variable => variable.key === FABRIKA_PROXY_MANIFEST_JSON)
71
+ if (carrier === 'archive' && stale !== undefined) {
72
+ await api.deleteServiceEnv({ envId: stale.id, signal })
54
73
  }
55
- if (variables.find(variable => variable.key === ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE)?.content !== input.iamUrl) {
56
- await api.putServiceEnv({ serviceId, key: ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE, value: input.iamUrl, signal })
74
+ if (readableContent(variables.find(variable => variable.key === SESSION_OWNER_VARIABLE)) !== 'null') {
75
+ await api.putServiceEnv({ serviceId, key: SESSION_OWNER_VARIABLE, value: 'null', sensitive: false, signal })
57
76
  }
58
- // Secret read-back is not trustworthy, so overwrite it without inspecting its value.
59
- await api.putServiceEnv({ serviceId, key: ZEROPS_NAMESPACE_IAM_KEY_VARIABLE, value: input.iamKey, signal })
77
+ if (readableContent(variables.find(variable => variable.key === ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE)) !== input.iamUrl) {
78
+ await api.putServiceEnv({ serviceId, key: ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE, value: input.iamUrl, sensitive: false, signal })
79
+ }
80
+ // A secret cannot be read back, so it is overwritten without inspecting its value.
81
+ await api.putServiceEnv({ serviceId, key: ZEROPS_NAMESPACE_IAM_KEY_VARIABLE, value: input.iamKey, sensitive: true, signal })
60
82
  for (const [key, value] of input.proxySecrets ?? []) {
61
- await api.putServiceEnv({ serviceId, key, value, signal })
83
+ await api.putServiceEnv({ serviceId, key, value, sensitive: true, signal })
62
84
  }
63
85
  }
64
86
 
@@ -76,6 +98,8 @@ export async function republishProxy(input: ZeropsProxyRepublishInput): Promise<
76
98
  const serviceId = input.target.proxyServiceId
77
99
  if (serviceId === undefined) throw new Error('Zerops namespace has no proxy service id')
78
100
  await verifyZeropsNamespaceProxySource(pin)
101
+ // The pinned descriptor decides: a namespace kept on an older proxy revision still lifts the variable (ADR-0045).
102
+ const carrier = zeropsProxyManifestCarrier(pin.descriptor.contents)
79
103
  const manifest = encodeProxyManifestJson(input.manifest)
80
104
  // The attempt's identity covers the IAM configuration and the proxy secrets too, so rotating any forces a rebuild.
81
105
  const proxySecrets = input.proxySecrets ?? new Map<string, string>()
@@ -162,8 +186,20 @@ export async function republishProxy(input: ZeropsProxyRepublishInput): Promise<
162
186
  throw new Error('Proxy source resolution identity mismatch')
163
187
  }
164
188
  await input.boundary()
165
- await input.api.putServiceEnv({ serviceId, key: FABRIKA_PROXY_MANIFEST_JSON, value: manifest, signal: input.signal })
166
- await writeProxyIam(input, serviceId)
189
+ if (carrier === 'environment') {
190
+ try {
191
+ await input.api.putServiceEnv({ serviceId, key: FABRIKA_PROXY_MANIFEST_JSON, value: manifest, sensitive: false, signal: input.signal })
192
+ } catch (cause) {
193
+ if (!(cause instanceof ZeropsServiceEnvValueTooLargeError)) throw cause
194
+ throw new ProviderNamespaceError(
195
+ `${cause.message}. The namespace's proxy source predates the manifest file in the build archive; re-pin it with `
196
+ + `\`fabrika control namespaces update --namespace=${input.namespaceId} --proxy-source=current\``,
197
+ cause.code,
198
+ false,
199
+ )
200
+ }
201
+ }
202
+ await writeProxyIam(input, serviceId, carrier)
167
203
  input.signal.throwIfAborted()
168
204
  const reserved = await input.api.createAppVersion({ serviceId, name: runId, signal: input.signal })
169
205
  let build: ZeropsNamespaceProxyBuild = { appVersionId: reserved.id, triggered: false, manifestSha256 }
@@ -176,11 +212,17 @@ export async function republishProxy(input: ZeropsProxyRepublishInput): Promise<
176
212
  repository: pin.repository,
177
213
  commitSha: pin.commitSha,
178
214
  descriptorSha256: pin.descriptor.sha256,
179
- archive: { kind: 'platform' },
215
+ archive: carrier === 'archive'
216
+ ? { kind: 'platform', dataFile: { path: ZEROPS_PROXY_MANIFEST_PATH, contents: manifest } }
217
+ : { kind: 'platform' },
180
218
  uploadUrl: reserved.uploadUrl,
181
219
  signal: input.signal,
182
220
  })
183
- if (uploaded.commitSha !== pin.commitSha || uploaded.descriptorSha256 !== pin.descriptor.sha256) {
221
+ const expectedDataFile = carrier === 'archive' ? await digest(manifest) : undefined
222
+ if (
223
+ uploaded.commitSha !== pin.commitSha || uploaded.descriptorSha256 !== pin.descriptor.sha256
224
+ || uploaded.dataFileSha256 !== expectedDataFile
225
+ ) {
184
226
  throw new Error('Proxy source upload identity mismatch')
185
227
  }
186
228
  const descriptor = prepareZeropsDescriptor(pin.descriptor.contents, 'proxy')