@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
@@ -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.
package/src/provider.ts CHANGED
@@ -8,19 +8,22 @@
8
8
  // runs. Recovery never resumes a deploy (ADR-0044): a version reserved but never triggered is deleted, and
9
9
  // one whose build may have been requested is settled only by polling it.
10
10
 
11
- import { appEnvironmentId } from '@fabrika/auth-core'
11
+ import { appEnvironmentId, FABRIKA_SOURCE_MAP_UPLOAD_KEY } from '@fabrika/auth-core'
12
+ import { type OperationsSourceMapUploadState, readSourceMapUploadReport } from '@fabrika/operations-contract/releases'
12
13
  import {
13
14
  type DeployStepResult,
14
15
  followLeaseLoss,
15
16
  isLeaseLoss,
16
17
  type JsonValue,
17
18
  type ProviderApprovedAccess,
19
+ type ProviderDeployInput,
18
20
  ProviderInvocationAbort,
19
21
  type ProviderManagedEnvironment,
20
22
  type ProviderRunEvents,
21
23
  runSteps,
22
24
  } from '@fabrika/provider-contract'
23
25
  import {
26
+ readableContent,
24
27
  waitForProcess,
25
28
  ZEROPS_ACTIVE,
26
29
  ZEROPS_PROCESS_FINISHED,
@@ -47,9 +50,11 @@ import {
47
50
  assertAuthoredManagedDocument,
48
51
  assertAuthoredManagedInputs,
49
52
  isFrameworkFamilyVariable,
53
+ isSensitiveFamilyVariable,
50
54
  type ManagedValues,
51
55
  overlayRelease,
52
56
  parseManagedEnvironment,
57
+ SENSITIVE_MANAGED_KEYS,
53
58
  SERVICE_MANAGED_KEYS,
54
59
  } from './managed-env'
55
60
  import {
@@ -81,6 +86,8 @@ export interface ZeropsDeployRun {
81
86
  readonly artifact: FabrikaManifest
82
87
  /** Control's approved access for the environment (ADR-0050); the lock-down and the IAM schema come from it. */
83
88
  readonly access?: ProviderApprovedAccess
89
+ /** The run-scoped source-map destination Control minted; the build uploads to it. */
90
+ readonly artifactUpload?: ProviderDeployInput['artifactUpload']
84
91
  readonly dryRun: boolean
85
92
  readonly signal: AbortSignal
86
93
  readonly events: ProviderRunEvents
@@ -91,6 +98,8 @@ export interface ZeropsDeployResult {
91
98
  readonly env: string
92
99
  readonly status: 'succeeded' | 'failed'
93
100
  readonly steps: readonly DeployStepResult[]
101
+ /** Whether the build's source maps reached Operations; absent when the run had no upload to make. */
102
+ readonly artifactState?: OperationsSourceMapUploadState
94
103
  }
95
104
 
96
105
  /** How often `await-version` asks Zerops for the version's status. */
@@ -271,6 +280,8 @@ interface Attempt {
271
280
  uploadUrl?: string
272
281
  processId?: string
273
282
  triggered: boolean
283
+ /** What the build's source-map upload reported in its log, once a relayed line said so. */
284
+ sourceMaps?: OperationsSourceMapUploadState
274
285
  }
275
286
 
276
287
  /** Everything one step's executor needs, bundled so the per-kind handlers stay small. */
@@ -353,6 +364,8 @@ const relayLog = async (
353
364
  }
354
365
  seen.add(key)
355
366
  env.log(` │ ${line.message}`)
367
+ const reported = readSourceMapUploadReport(line.message)
368
+ if (reported !== null) env.attempt.sourceMaps = reported
356
369
  }
357
370
  } catch (error) {
358
371
  env.log(` [warn] build-log relay unavailable: ${error instanceof Error ? error.message : 'unknown error'}`)
@@ -452,8 +465,8 @@ const verifyExposure = async (env: StepEnv): Promise<void> => {
452
465
  const created = env.attempt.created ?? new Map<string, string>()
453
466
  const buckets = artifact.target.importDocument.services.filter((service) => isObjectStorageType(service.type)).map((service) => service.hostname)
454
467
  const records = buckets.length === 0 ? [] : await zerops.api.listServiceEnv({ serviceId: target.serviceId, signal })
455
- const marker = records.find((record) => record.key === STORAGE_PROVENANCE_KEY)
456
- const known = new Set([...parseStorageProvenance(marker?.content), ...(target.createdBuckets ?? [])])
468
+ const marker = readableContent(records.find((record) => record.key === STORAGE_PROVENANCE_KEY))
469
+ const known = new Set([...parseStorageProvenance(marker), ...(target.createdBuckets ?? [])])
457
470
  const findings: ExposureFinding[] = []
458
471
  const bucketIds: string[] = []
459
472
  for (const hostname of manifestServiceHostnames(artifact)) {
@@ -502,8 +515,13 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
502
515
  const { zerops, log, signal, managedValues, familyVariables } = env
503
516
  const serviceId = env.run.target.serviceId
504
517
  if (env.dryRun) {
505
- const written = [...SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] !== null), ...Object.keys(familyVariables)]
506
- const removed = SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] === null)
518
+ const upload = env.run.artifactUpload !== undefined
519
+ const written = [
520
+ ...SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] !== null),
521
+ ...Object.keys(familyVariables),
522
+ ...(upload ? [FABRIKA_SOURCE_MAP_UPLOAD_KEY] : []),
523
+ ]
524
+ const removed = [...SERVICE_MANAGED_KEYS.filter((key) => managedValues[key] === null), ...(upload ? [] : [FABRIKA_SOURCE_MAP_UPLOAD_KEY])]
507
525
  log(` [dry-run] would write service variables on ${serviceId}: ${written.join(', ') || '(none)'}`)
508
526
  log(` [dry-run] would remove service variables on ${serviceId} if present: ${removed.join(', ') || '(none)'}`)
509
527
  if (Object.keys(familyVariables).length > 0) {
@@ -517,9 +535,10 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
517
535
  for (const key of SERVICE_MANAGED_KEYS) {
518
536
  const value = managedValues[key]
519
537
  const existing = records.find((record) => record.key === key)
538
+ const sensitive = SENSITIVE_MANAGED_KEYS.has(key)
520
539
  assertRunning(signal)
521
- if (value !== null && existing?.content !== value) {
522
- await zerops.api.putServiceEnv({ serviceId, key, value, signal })
540
+ if (value !== null && (sensitive || readableContent(existing) !== value)) {
541
+ await zerops.api.putServiceEnv({ serviceId, key, value, sensitive, signal })
523
542
  log(` ${key}: updated`)
524
543
  } else if (value === null && existing !== undefined) {
525
544
  await zerops.api.deleteServiceEnv({ envId: existing.id, signal })
@@ -530,9 +549,10 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
530
549
  }
531
550
  const provenance = env.attempt.storageProvenance
532
551
  const marker = records.find((record) => record.key === STORAGE_PROVENANCE_KEY)
533
- if (typeof provenance === 'string' && marker?.content !== provenance) {
552
+ if (typeof provenance === 'string' && readableContent(marker) !== provenance) {
534
553
  assertRunning(signal)
535
- await zerops.api.putServiceEnv({ serviceId, key: STORAGE_PROVENANCE_KEY, value: provenance, signal })
554
+ // Not a credential, and the exposure read-back above reads it: it must stay readable.
555
+ await zerops.api.putServiceEnv({ serviceId, key: STORAGE_PROVENANCE_KEY, value: provenance, sensitive: false, signal })
536
556
  log(` ${STORAGE_PROVENANCE_KEY}: updated`)
537
557
  } else if (provenance === null && marker !== undefined) {
538
558
  assertRunning(signal)
@@ -542,9 +562,10 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
542
562
  // A workload key is written like a managed key; one whose workload is gone is removed, so no stale key stays readable.
543
563
  for (const [key, value] of Object.entries(familyVariables)) {
544
564
  const existing = records.find((record) => record.key === key)
565
+ const sensitive = isSensitiveFamilyVariable(key)
545
566
  assertRunning(signal)
546
- if (existing?.content !== value) {
547
- await zerops.api.putServiceEnv({ serviceId, key, value, signal })
567
+ if (sensitive || readableContent(existing) !== value) {
568
+ await zerops.api.putServiceEnv({ serviceId, key, value, sensitive, signal })
548
569
  log(` ${key}: updated`)
549
570
  } else {
550
571
  log(` ${key}: unchanged`)
@@ -557,6 +578,18 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
557
578
  log(` ${record.key}: removed`)
558
579
  }
559
580
  }
581
+ // The build lifts the upload's bearer from here (`${RUNTIME_…}`), so the descriptor never holds it.
582
+ const bearer = env.run.artifactUpload?.bearer ?? null
583
+ const uploadKey = records.find((record) => record.key === FABRIKA_SOURCE_MAP_UPLOAD_KEY)
584
+ if (bearer !== null) {
585
+ assertRunning(signal)
586
+ await zerops.api.putServiceEnv({ serviceId, key: FABRIKA_SOURCE_MAP_UPLOAD_KEY, value: bearer, sensitive: true, signal })
587
+ log(` ${FABRIKA_SOURCE_MAP_UPLOAD_KEY}: updated`)
588
+ } else if (uploadKey !== undefined) {
589
+ assertRunning(signal)
590
+ await zerops.api.deleteServiceEnv({ envId: uploadKey.id, signal })
591
+ log(` ${FABRIKA_SOURCE_MAP_UPLOAD_KEY}: removed`)
592
+ }
560
593
  }
561
594
 
562
595
  /** Run one step's effect. Resolves on success, throws on failure (caught + recorded by `runSteps`). */
@@ -655,7 +688,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
655
688
  }
656
689
 
657
690
  case 'trigger-build': {
658
- const locked = lockedExecutionFor(artifact, run, dryRun)
691
+ const locked = lockedExecutionFor(artifact, run, dryRun, run.artifactUpload)
659
692
  if (dryRun) {
660
693
  log(
661
694
  locked === null
@@ -673,6 +706,7 @@ const runStep = async (spec: ZeropsStep, env: StepEnv): Promise<void> => {
673
706
  : renderLockedExecutionDescriptor(contents, artifact.target.zeropsSetup, release, locked)
674
707
  if (locked !== null) {
675
708
  log(` generated run section; build checks @fabrika/check@${FABRIKA_CHECK_VERSION}, typescript@${PLATFORM_TYPESCRIPT_VERSION}`)
709
+ if (locked.sourceMaps !== null) log(` the build uploads the source maps of ${locked.sourceMaps.assetRoots.length} asset root(s)`)
676
710
  }
677
711
  assertRunning(signal)
678
712
  // Recorded BEFORE the request: from here on the version is settled only by polling it (ADR-0044).
@@ -804,5 +838,15 @@ async (run: ZeropsDeployRun): Promise<ZeropsDeployResult> => {
804
838
  run.events.log(` [warn] app-version ${appVersionId} could not be deleted; it was never built`)
805
839
  }
806
840
  }
807
- return { appId: run.appId, env: run.env, ...result }
841
+ return { appId: run.appId, env: run.env, ...result, ...artifactStateOf(run, result.status, env.attempt) }
842
+ }
843
+
844
+ /** A run that uploads reports `complete` only when its build log said so; a missing or unread report is `incomplete`. */
845
+ const artifactStateOf = (
846
+ run: ZeropsDeployRun,
847
+ status: ZeropsDeployResult['status'],
848
+ attempt: Attempt,
849
+ ): Pick<ZeropsDeployResult, 'artifactState'> => {
850
+ if (run.artifactUpload === undefined || run.dryRun || status !== 'succeeded') return {}
851
+ return { artifactState: attempt.sourceMaps === 'complete' ? 'complete' : 'incomplete' }
808
852
  }
@@ -0,0 +1,32 @@
1
+ import { FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
2
+ import { z } from 'zod'
3
+ import { appliedZeropsSetups, parseZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
4
+
5
+ /** The path of the proxy manifest in the build archive: the archive's one data file (ADR-0045), read by the proxy build. */
6
+ export const ZEROPS_PROXY_MANIFEST_PATH = 'proxy.manifest.json'
7
+
8
+ /**
9
+ * How a proxy build receives its manifest.
10
+ *
11
+ * - `environment`: the build lifts `FABRIKA_PROXY_MANIFEST_JSON` from the service and writes it to the file, as every
12
+ * release before the archive carrier did. A namespace pinned to such a commit keeps its descriptor (ADR-0045), so it
13
+ * gets the variable, bound by the Zerops value limit.
14
+ * - `archive`: the build reads the file the uploaded archive carries.
15
+ */
16
+ export type ZeropsProxyManifestCarrier = 'environment' | 'archive'
17
+
18
+ const buildSectionSchema = z.object({ build: z.object({ envVariables: z.record(z.string()).optional() }).passthrough() }).passthrough()
19
+
20
+ /**
21
+ * The carrier a descriptor's proxy setup reads. Decided by the build section Zerops applies (the selected setup's, else
22
+ * the nearest one it `extends`): a lifted `FABRIKA_PROXY_MANIFEST_JSON` would overwrite an archived file, so it wins.
23
+ */
24
+ export const zeropsProxyManifestCarrier = (descriptorContents: string, setup = 'proxy'): ZeropsProxyManifestCarrier => {
25
+ const parsed = parseZeropsDescriptor(descriptorContents)
26
+ for (const entry of appliedZeropsSetups(parsed, selectZeropsDescriptorSetup(parsed, setup))) {
27
+ const read = buildSectionSchema.safeParse(entry)
28
+ if (!read.success) continue
29
+ return read.data.build.envVariables?.[FABRIKA_PROXY_MANIFEST_JSON] === undefined ? 'archive' : 'environment'
30
+ }
31
+ return 'archive'
32
+ }
@@ -20,10 +20,16 @@ export interface ZeropsSourceResolution {
20
20
  readonly descriptorSha256: string
21
21
  }
22
22
 
23
+ /** ADR-0045: the one generated data file a platform archive carries beside the exact commit, at a path the commit lacks. */
24
+ export interface ZeropsSourceDataFile {
25
+ readonly path: string
26
+ readonly contents: string
27
+ }
28
+
23
29
  /** ADR-0045: platform archives are anonymous, tree-prechecked, keep relative symlinks. */
24
30
  export type ZeropsSourceArchive =
25
31
  | { readonly kind: 'application'; readonly privateBinding?: ZeropsSourcePrivateBinding }
26
- | { readonly kind: 'platform' }
32
+ | { readonly kind: 'platform'; readonly dataFile?: ZeropsSourceDataFile }
27
33
 
28
34
  export interface ZeropsSourceUploadInput {
29
35
  readonly repository: ZeropsSourceRepository
@@ -38,6 +44,8 @@ export interface ZeropsSourceUploadInput {
38
44
  export interface ZeropsSourceUploadResult {
39
45
  readonly commitSha: string
40
46
  readonly descriptorSha256: string
47
+ /** The sha256 of the data file the archive carried; absent without one. */
48
+ readonly dataFileSha256?: string
41
49
  readonly entryCount: number
42
50
  readonly expandedBytes: number
43
51
  }
@@ -1,95 +0,0 @@
1
- // Composing the platform's three apps with whatever else the installation's one proxy already fronts.
2
- //
3
- // ── Why this is a MERGE and not a write ───────────────────────────────────────────────────────────
4
- //
5
- // On the `light` tier an application 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.
11
- //
12
- // So: read the live value, compose, write. That composition is this command's job and nothing else's.
13
- //
14
- // ── What happens to an entry the platform does not know about ─────────────────────────────────────
15
- //
16
- // Carried through byte-for-byte, in its live order, after the platform apps — with ONE exception. A
17
- // host belongs to exactly one app (`parseProxyManifest` refuses two apps claiming one, and rightly:
18
- // route order would otherwise decide which app answers). The platform's three listeners are addresses
19
- // the platform OWNS, so a live entry standing on one of them is superseded rather than carried, and
20
- // the deploy says so out loud.
21
- //
22
- // That exception is not hypothetical: WU1 corrected IAM's app id from `iam-local` to `iam`, so the
23
- // live document's `iam-local` claims the platform's own IAM host under a name the platform no longer
24
- // uses. Carrying it would produce a manifest `parseProxyManifest` rejects, which the proxy build then
25
- // fails on — a fail-closed outcome, but a needlessly confusing one.
26
- //
27
- // Superseding is itself CLOSED: the dropped host stops resolving to anything, it does not fall through
28
- // to some other app. A genuinely misplaced application therefore goes dark and is reported, instead of
29
- // quietly being served by the console.
30
-
31
- import { findDuplicateProxyHost, parseProxyManifestJson, type ProxyManifest } from '@fabrika/proxy-core/manifest'
32
-
33
- /** The composed document plus what happened to every live entry that was not the platform's. */
34
- export interface MergedProxyManifest {
35
- readonly manifest: ProxyManifest
36
- /** App ids carried through from the live manifest, in their live order. */
37
- readonly carried: readonly string[]
38
- /** Live entries the platform's own listeners displaced, with the host that collided. */
39
- readonly superseded: readonly { readonly id: string; readonly host: string }[]
40
- }
41
-
42
- /**
43
- * Compose the platform's apps with the application entries already in a live manifest.
44
- *
45
- * `live` is `null` when the proxy carries no manifest yet (a first deploy) — the result is then the
46
- * platform document alone. An UNPARSEABLE live value is a different thing entirely and throws: it may
47
- * be a manifest this build is too old to read, and silently replacing it would drop applications this
48
- * code cannot see. Refusing is what keeps "must not take a deployed app offline" true in the one case
49
- * where the deploy genuinely cannot tell.
50
- */
51
- export const mergePlatformProxyManifest = (platform: ProxyManifest, live: ProxyManifest | null): MergedProxyManifest => {
52
- const platformIds = new Set(platform.apps.map((app) => app.id))
53
- const platformHosts = new Set(platform.apps.flatMap((app) => app.hosts))
54
- const carried: string[] = []
55
- const superseded: { id: string; host: string }[] = []
56
- const apps = [...platform.apps]
57
- for (const app of live?.apps ?? []) {
58
- if (platformIds.has(app.id)) {
59
- continue
60
- }
61
- const collision = app.hosts.find((host) => platformHosts.has(host))
62
- if (collision !== undefined) {
63
- superseded.push({ id: app.id, host: collision })
64
- continue
65
- }
66
- carried.push(app.id)
67
- apps.push(app)
68
- }
69
- // Both inputs are already parsed manifests, so a duplicate host is the one invariant composing can break.
70
- // A duplicate host between two APPLICATION entries — which this function cannot fix — is refused
71
- // here rather than at the proxy's next build.
72
- if (findDuplicateProxyHost(apps) !== null) {
73
- throw new Error('the composed proxy manifest is not one the proxy would accept — two application entries claim one host')
74
- }
75
- return { manifest: { apps }, carried, superseded }
76
- }
77
-
78
- /**
79
- * The live manifest a proxy service currently carries.
80
- *
81
- * Absent means "no manifest yet". Present-but-unparseable throws: see `mergePlatformProxyManifest`.
82
- */
83
- export const readLiveProxyManifest = (value: string | undefined): ProxyManifest | null => {
84
- if (value === undefined || value.trim() === '') {
85
- return null
86
- }
87
- const manifest = parseProxyManifestJson(value)
88
- if (manifest === null) {
89
- throw new Error(
90
- `the proxy's current FABRIKA_PROXY_MANIFEST_JSON cannot be parsed by this build — refusing to replace a document whose `
91
- + 'application entries cannot be read',
92
- )
93
- }
94
- return manifest
95
- }