@fabrika/provider-zerops 0.4.1 → 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 +319 -0
  18. package/src/installation/index.ts +42 -16
  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
@@ -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
  }
@@ -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')
@@ -9,8 +9,9 @@ import {
9
9
  } from '@fabrika/provider-contract'
10
10
  import { waitForProcess, ZeropsApiError, type ZeropsProcess } from '../api'
11
11
  import { defaultSleep } from '../collaborators'
12
- import { envelope, namespaceUpdatePayloadSchema, normalizeTarget, zeropsNamespaceTargetCodec } from './codec'
12
+ import { envelope, namespaceUpdatePayloadSchema, normalizeTarget, ZEROPS_PROXY_SOURCE_CURRENT, zeropsNamespaceTargetCodec } from './codec'
13
13
  import { asNamespaceError, namespaceError, ZEROPS_NAMESPACE_CANCELLED } from './errors'
14
+ import { verifyZeropsNamespaceProxySource } from './proxy-source'
14
15
  import type { NormalizedZeropsNamespaceTarget, ZeropsNamespaceOptions, ZeropsNamespacePostgres, ZeropsNamespaceTarget } from './types'
15
16
  import { validateService } from './validation'
16
17
 
@@ -21,11 +22,16 @@ const READBACK_INTERVAL_MS = 3_000
21
22
  /** UNVERIFIED: how soon `autoscalingProfileId` reads the new profile after the PUT and its process finish. */
22
23
  const READBACK_ATTEMPTS = 10
23
24
 
24
- type FixedField = Exclude<keyof ZeropsNamespaceTarget, 'postgres'>
25
+ /** The plan field of a re-pin, which moves only the stored target; the reconcile core queues rebuilds the proxy. */
26
+ export const ZEROPS_PROXY_SOURCE_FIELD = 'proxySource'
25
27
 
26
- /** Every target field but `postgres`, whose profile is the one thing an update may move. A new field fails to compile until listed. */
28
+ type FixedField = Exclude<keyof ZeropsNamespaceTarget, 'postgres' | 'proxySource'>
29
+
30
+ /**
31
+ * Every target field but the two an update may move: the PostgreSQL profile, and the proxy source pin, which only an
32
+ * explicit re-pin to the installation's default moves (ADR-0045). A new field fails to compile until listed.
33
+ */
27
34
  const FIXED_FIELDS: { readonly [K in FixedField]: (target: ZeropsNamespaceTarget) => ZeropsNamespaceTarget[K] } = {
28
- proxySource: (target) => target.proxySource,
29
35
  proxyBuild: (target) => target.proxyBuild,
30
36
  proxyDeployment: (target) => target.proxyDeployment,
31
37
  projectName: (target) => target.projectName,
@@ -69,10 +75,17 @@ const mergedTarget = (
69
75
  if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) {
70
76
  throw new Error('Zerops namespace target did not encode to an object')
71
77
  }
72
- return normalizeTarget({ ...namespace, target: { ...requested, payload: { ...stored, ...payload } } }, options)
78
+ if (checked.data.proxySource !== ZEROPS_PROXY_SOURCE_CURRENT) {
79
+ return normalizeTarget({ ...namespace, target: { ...requested, payload: { ...stored, ...payload } } }, options)
80
+ }
81
+ const fields = Object.fromEntries(Object.entries(payload).filter(([key]) => key !== 'proxySource'))
82
+ return {
83
+ ...normalizeTarget({ ...namespace, target: { ...requested, payload: { ...stored, ...fields } } }, options),
84
+ proxySource: options.proxySource,
85
+ }
73
86
  }
74
87
 
75
- /** Refuse every difference except the PostgreSQL profile, naming the fields. */
88
+ /** Refuse every difference except the PostgreSQL profile and a re-pin of the proxy source, naming the fields. */
76
89
  const planUpdate = (
77
90
  namespace: ProviderDeploymentNamespace,
78
91
  requested: ProviderEnvelope,
@@ -84,8 +97,32 @@ const planUpdate = (
84
97
  .filter(([, read]) => JSON.stringify(read(current)) !== JSON.stringify(read(merged)))
85
98
  .map(([field]) => field)
86
99
  if (changed.length > 0) {
87
- throw immutable(`Zerops namespace ${namespace.id} cannot change ${changed.join(', ')} in place; only the PostgreSQL profile can`)
100
+ throw immutable(
101
+ `Zerops namespace ${namespace.id} cannot change ${changed.join(', ')} in place; only the PostgreSQL profile and the proxy source can`,
102
+ )
103
+ }
104
+ if (JSON.stringify(current.proxySource) === JSON.stringify(merged.proxySource)) return planProfile(namespace, current, merged)
105
+ if (JSON.stringify(merged.proxySource) !== JSON.stringify(options.proxySource)) {
106
+ throw new Error(`Zerops namespace ${namespace.id} can re-pin its proxy source only to the installation's default: proxySource "current"`)
107
+ }
108
+ if (JSON.stringify(current.postgres) !== JSON.stringify(merged.postgres)) {
109
+ throw new Error(`Zerops namespace ${namespace.id}: re-pin the proxy source and change the PostgreSQL profile in separate updates`)
110
+ }
111
+ // Rebuilding under a new pin settles an attempt first, and would record that attempt's version as the new pin's.
112
+ if (current.proxyBuild !== undefined) {
113
+ throw new Error(`Zerops namespace ${namespace.id} has a proxy build in flight; reconcile it before re-pinning the proxy source`)
114
+ }
115
+ return {
116
+ namespace: { ...namespace, target: envelope(merged) },
117
+ change: { field: ZEROPS_PROXY_SOURCE_FIELD, from: current.proxySource.commitSha, to: merged.proxySource.commitSha },
88
118
  }
119
+ }
120
+
121
+ const planProfile = (
122
+ namespace: ProviderDeploymentNamespace,
123
+ current: NormalizedZeropsNamespaceTarget,
124
+ merged: NormalizedZeropsNamespaceTarget,
125
+ ): ProviderNamespaceUpdatePlan => {
89
126
  const was = current.postgres
90
127
  const wanted = merged.postgres
91
128
  const unchanged: ProviderNamespaceUpdatePlan = { namespace: { ...namespace, target: envelope(current) }, change: null }
@@ -186,10 +223,15 @@ const applyProfile = async (
186
223
 
187
224
  const applyUpdate = async (input: ProviderNamespaceUpdateInput, options: ZeropsNamespaceOptions): Promise<ProviderDeploymentNamespace> => {
188
225
  const current = normalizeTarget(input.namespace, options)
226
+ const plan = planUpdate(input.namespace, input.next.target, options)
227
+ // A re-pin touches no platform resource: it stores the pin, and the reconcile core queues builds from it.
228
+ if (plan.change?.field === ZEROPS_PROXY_SOURCE_FIELD) {
229
+ await verifyZeropsNamespaceProxySource(normalizeTarget(plan.namespace, options).proxySource)
230
+ return plan.namespace
231
+ }
189
232
  if (!current.ready) {
190
233
  throw new Error(`Zerops namespace ${input.namespace.id} is not ready`)
191
234
  }
192
- const plan = planUpdate(input.namespace, input.next.target, options)
193
235
  const wanted = normalizeTarget(plan.namespace, options).postgres
194
236
  // Checked even with no planned change: after an unconfirmed update, confirming the live service is
195
237
  // what lets core return the namespace to ready.