@xemahq/xema 0.28.0 → 0.29.1

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 (55) hide show
  1. package/dist/commands/edition.d.ts.map +1 -1
  2. package/dist/commands/edition.js +8 -7
  3. package/dist/commands/edition.js.map +1 -1
  4. package/dist/edition/verify-image-evidence.bundle.mjs +88 -11
  5. package/dist/edition/verify-image-evidence.bundle.provenance.json +48 -42
  6. package/dist/runtime/edition/verb-map.d.ts +2 -1
  7. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  8. package/dist/runtime/edition/verb-map.js +2 -0
  9. package/dist/runtime/edition/verb-map.js.map +1 -1
  10. package/dist/runtime/runner/chart-set-lock.d.ts +4 -1
  11. package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
  12. package/dist/runtime/runner/chart-set-lock.js +39 -4
  13. package/dist/runtime/runner/chart-set-lock.js.map +1 -1
  14. package/edition/README.md +69 -32
  15. package/edition/build-biome-index.mjs +1 -1
  16. package/edition/build-platform-service-index.mjs +1 -1
  17. package/edition/build-runtime-image-source-index.mjs +64 -20
  18. package/edition/catalogue-fetch.mjs +36 -9
  19. package/edition/catalogue-pin.mjs +71 -0
  20. package/edition/catalogue.mjs +11 -0
  21. package/edition/chart-set-source-commit.mjs +17 -7
  22. package/edition/checkout-indexed-source-repositories.mjs +17 -17
  23. package/edition/customer-flags.mjs +0 -1
  24. package/edition/develop-customer-edition.mjs +23 -5
  25. package/edition/develop-edition.mjs +147 -48
  26. package/edition/develop-freeze.mjs +69 -25
  27. package/edition/develop-seal.mjs +39 -25
  28. package/edition/dist-check.mjs +1 -1
  29. package/edition/edition-artifact.mjs +65 -18
  30. package/edition/edition-biome-manifests.mjs +2 -3
  31. package/edition/edition-chart-set.mjs +59 -6
  32. package/edition/edition-config.mjs +28 -2
  33. package/edition/edition-constants.mjs +10 -4
  34. package/edition/edition-contract-parse.mjs +52 -0
  35. package/edition/edition-contract.json +10 -1
  36. package/edition/edition-workspace.mjs +12 -9
  37. package/edition/evidence-trust-policy.mjs +26 -0
  38. package/edition/external-source-walk.mjs +48 -61
  39. package/edition/git-source-auth.mjs +104 -25
  40. package/edition/image-evidence.mjs +20 -10
  41. package/edition/kernel-contract-authority.mjs +23 -1
  42. package/edition/lib.mjs +13 -24
  43. package/edition/oras.mjs +32 -10
  44. package/edition/pin-source.mjs +6 -1
  45. package/edition/registry-credentials.mjs +11 -1
  46. package/edition/release-edition-artifact.mjs +3 -3
  47. package/edition/resolve-chart-set.mjs +50 -30
  48. package/edition/resolve-customer-edition.mjs +63 -7
  49. package/edition/resolve-distribution.mjs +1 -1
  50. package/edition/resolve-edition.mjs +12 -11
  51. package/edition/resolve-images.mjs +36 -15
  52. package/edition/seal-image-digests.mjs +31 -7
  53. package/edition/source-closure.mjs +1 -2
  54. package/edition/trust-policy-arming-verb.mjs +68 -0
  55. package/package.json +3 -3
package/edition/lib.mjs CHANGED
@@ -24,7 +24,6 @@ import {
24
24
  kernelDistPath,
25
25
  kernelPackageDir,
26
26
  kernelRepoDir,
27
- sourceHashPackageDir,
28
27
  sourcePath,
29
28
  } from './edition-workspace.mjs';
30
29
  import { SHA256_HEX_PATTERN } from './evidence-grammars.mjs';
@@ -149,38 +148,28 @@ export const BIOME_WORKLOAD_KINDS = new Set(['service', 'runtime-component']);
149
148
  export const DEFAULT_BIOME_WORKLOAD_KIND = 'service';
150
149
 
151
150
  /**
152
- * The BUILT `@xemahq/distribution-source-hash` package (CJS dist), loaded from
153
- * the directory the entry states with `--source-hash`.
151
+ * The `@xemahq/distribution-source-hash` package (CJS dist), a caret (0.x minor-locked)
152
+ * dependency of the CLI, loaded by name on first use.
154
153
  *
155
154
  * That package is the SINGLE owner of `xema-source-tree-sha256-v2` — the same
156
155
  * implementation every producing repository's CI uses to derive the
157
156
  * `<contentHash>` release tag. This tooling must never carry a second copy: a
158
157
  * divergence would put a lock and its images at coordinates that silently
159
- * disagree. It is NOT an npm dependency of the CLI: the evidence verifier never
160
- * hashes a source tree, so its bundle must not reach this package; only the
161
- * entries that resolve, build, index or seal state the root.
158
+ * disagree. The evidence verifier never hashes a source tree, so its bundle
159
+ * does not reach this package; only the entries that resolve, build, index or
160
+ * seal load it.
162
161
  */
163
162
  const requireFromLib = createRequire(import.meta.url);
164
163
 
165
- const loadedSourceHash = new Map();
164
+ let loadedSourceHash;
166
165
 
167
166
  function distributionSourceHash() {
168
- const directory = sourceHashPackageDir();
169
- const known = loadedSourceHash.get(directory);
170
- if (known) {return known;}
171
- let mod;
172
- try {
173
- mod = createRequire(join(directory, 'package.json'))(directory);
174
- } catch (err) {
175
- throw new Error(
176
- `Cannot load the built distribution-source-hash package from ${directory}. ` +
177
- `Build it first (\`pnpm install --frozen-lockfile && pnpm run build\` in its workspace). ` +
178
- `Underlying error: ${err.message}`,
179
- );
167
+ if (loadedSourceHash === undefined) {
168
+ const mod = requireFromLib('@xemahq/distribution-source-hash');
169
+ assertSourceHashMatchesKernel(mod);
170
+ loadedSourceHash = mod;
180
171
  }
181
- assertSourceHashMatchesKernel(mod);
182
- loadedSourceHash.set(directory, mod);
183
- return mod;
172
+ return loadedSourceHash;
184
173
  }
185
174
 
186
175
  /**
@@ -214,9 +203,9 @@ function requireAlgorithmMember(enumObject, enumName, member) {
214
203
  const value = enumObject?.[member];
215
204
  if (typeof value !== 'string' || value.length === 0) {
216
205
  throw new Error(
217
- `Built @xemahq/distribution-source-hash does not declare ` +
206
+ `@xemahq/distribution-source-hash does not declare ` +
218
207
  `${enumName}.${member}. The installed package is `+
219
- `stale or the member was renamed; raise the @xemahq/distribution-source-hash range and, if the `+
208
+ `stale or the member was renamed; change the pinned @xemahq/distribution-source-hash version and, if the `+
220
209
  `rename was deliberate, move every reader in the same change.`,
221
210
  );
222
211
  }
package/edition/oras.mjs CHANGED
@@ -20,8 +20,9 @@
20
20
  // * before oras ever receives a credential, the registry's own `/v2/`
21
21
  // challenge is probed and a token realm on another origin is refused;
22
22
  // * a digest-addressed read whose bytes do not hash to the requested digest
23
- // throws; a tag read is resolved to a descriptor first, then read by digest,
24
- // so its bytes are always hash-checked;
23
+ // throws; a tag read and a digest read are both resolved to the registry's
24
+ // descriptor first, and the manifest media type is the descriptor's, never
25
+ // the manifest body's optional `mediaType` (Helm's has none);
25
26
  // * descriptor sizes are checked BEFORE the fetch, actual lengths after it;
26
27
  // * a registry status is read only from oras's own error forms; any other
27
28
  // failure carries no status and is thrown as undecided;
@@ -554,11 +555,11 @@ export class OrasRegistry {
554
555
  }
555
556
 
556
557
  /**
557
- * Fetch a manifest by TAG: resolve the descriptor, then read by digest, so the
558
- * bytes are always hash-checked against what the registry said the tag is.
558
+ * The descriptor the registry reports for a manifest reference, refused when
559
+ * its media type is not an accepted manifest type or it declares more than
560
+ * the manifest size cap.
559
561
  */
560
- async getManifestByTag(repository, tag) {
561
- const ref = `${this.#ref(repository)}:${tag}`;
562
+ async #manifestDescriptor(ref) {
562
563
  const out = await this.#oras(['manifest', 'fetch'], ['--descriptor'], ref);
563
564
  const descriptor = this.#parseDescriptor(out, ref);
564
565
  if (!ACCEPTED_MANIFEST_MEDIA_TYPES.includes(descriptor.mediaType)) {
@@ -569,12 +570,33 @@ export class OrasRegistry {
569
570
  if (descriptor.size > MAX_MANIFEST_BYTES) {
570
571
  throw new Error(`${ref} declares ${descriptor.size} bytes, exceeding ${MAX_MANIFEST_BYTES}`);
571
572
  }
573
+ return descriptor;
574
+ }
575
+
576
+ /**
577
+ * Fetch a manifest by TAG: resolve the descriptor, then read by digest, so the
578
+ * bytes are always hash-checked against what the registry said the tag is.
579
+ */
580
+ async getManifestByTag(repository, tag) {
581
+ const descriptor = await this.#manifestDescriptor(`${this.#ref(repository)}:${tag}`);
572
582
  return this.#manifestByDigest(repository, descriptor.digest, descriptor.mediaType);
573
583
  }
574
584
 
575
- /** Fetch a manifest by DIGEST; the bytes must hash to exactly that digest. */
585
+ /**
586
+ * Fetch a manifest by DIGEST: the media type is the one the registry reports
587
+ * for that digest (a manifest body's own `mediaType` is optional, and Helm's
588
+ * has none), and the bytes must hash to exactly that digest.
589
+ */
576
590
  async getManifestByDigest(repository, digest) {
577
- return this.#manifestByDigest(repository, digest, null);
591
+ if (!SHA256_DIGEST_PATTERN.test(digest)) {
592
+ throw new Error(`"${digest}" is not a canonical lowercase sha256 digest`);
593
+ }
594
+ const ref = `${this.#ref(repository)}@${digest}`;
595
+ const descriptor = await this.#manifestDescriptor(ref);
596
+ if (descriptor.digest !== digest) {
597
+ throw new Error(`${ref} described ${descriptor.digest}, not the requested ${digest}`);
598
+ }
599
+ return this.#manifestByDigest(repository, digest, descriptor.mediaType);
578
600
  }
579
601
 
580
602
  async #manifestByDigest(repository, digest, expectedMediaType) {
@@ -629,10 +651,10 @@ function decodeManifest(bytes, expectedMediaType, ref) {
629
651
  if (typeof document !== 'object' || document === null || Array.isArray(document)) {
630
652
  throw new Error(`${ref} returned a manifest that is not a JSON object`);
631
653
  }
632
- const mediaType = expectedMediaType ?? document.mediaType;
654
+ const mediaType = expectedMediaType;
633
655
  if (!ACCEPTED_MANIFEST_MEDIA_TYPES.includes(mediaType)) {
634
656
  throw new Error(
635
- `${ref} returned unsupported manifest media type "${mediaType || '(none)'}"`,
657
+ `${ref} returned unsupported manifest media type "${mediaType}"`,
636
658
  );
637
659
  }
638
660
  if (typeof document.mediaType === 'string' && document.mediaType !== mediaType) {
@@ -65,13 +65,18 @@ export function sourceNameOfPinKey(key) {
65
65
  return key.slice(SOURCE_PIN_PREFIX.length);
66
66
  }
67
67
 
68
+ /** Whether `key` is the snapshot key of a declared source (`sources/<name>`); pure, it reads no workspace section. */
69
+ export function isSourcePinKey(key) {
70
+ return key.startsWith(SOURCE_PIN_PREFIX) && key.length > SOURCE_PIN_PREFIX.length;
71
+ }
72
+
68
73
  /** Which closed namespace a snapshot key belongs to; refuses a key in none. */
69
74
  export function snapshotPinNamespace(key) {
70
75
  if (key === dataPinKey()) {return SnapshotPinNamespace.Distributions;}
71
76
  if (key === kernelPinKey()) {return SnapshotPinNamespace.Kernel;}
72
77
  if (key === deployPinKey()) {return SnapshotPinNamespace.ChartSource;}
73
78
  if (key === EDITIONS_ROOT_PIN_KEY) {return SnapshotPinNamespace.EditionsRoot;}
74
- if (key.startsWith(SOURCE_PIN_PREFIX) && key.length > SOURCE_PIN_PREFIX.length) {
79
+ if (isSourcePinKey(key)) {
75
80
  return SnapshotPinNamespace.Source;
76
81
  }
77
82
  if (GRAPH_PIN_PATTERN.test(key)) {return SnapshotPinNamespace.Graph;}
@@ -117,7 +117,17 @@ export function loadRegistryCredentialDeclarations(
117
117
  distributionsRoot = dataRoot(),
118
118
  ) {
119
119
  const path = join(distributionsRoot, REGISTRY_CREDENTIALS_FILE);
120
- const raw = JSON.parse(readFileSync(path, 'utf8'));
120
+ let text;
121
+ try {
122
+ text = readFileSync(path, 'utf8');
123
+ } catch (error) {
124
+ if (error?.code !== 'ENOENT') {throw error;}
125
+ throw new Error(
126
+ `${REGISTRY_CREDENTIALS_FILE} does not exist in ${distributionsRoot}: the editions root declares the registry credentials its images are verified with`,
127
+ { cause: error },
128
+ );
129
+ }
130
+ const raw = JSON.parse(text);
121
131
  const keys = Object.keys(raw ?? {}).sort().join(',');
122
132
  if (
123
133
  typeof raw !== 'object' ||
@@ -2,12 +2,12 @@
2
2
  // release-edition-artifact — write the OCI artifact of a RELEASE edition from
3
3
  // the committed, sealed locks of the editions root.
4
4
  //
5
- // artifact <id> --channel release --registry <namespace> --coordinate <tag>
5
+ // artifact <id> --channel release --registry <root> --coordinate <tag>
6
6
  // --engine-image <repository>@sha256:<digest> --out <dir> [--local]
7
7
  //
8
8
  // The artifact has the layout of every edition (edition-artifact.mjs): the
9
9
  // descriptor plus the four locks and the offering's catalogue.json, written to <dir>, and the target
10
- // `<registry>/xema-edition-<id>:<coordinate>` is printed. The workflow pushes
10
+ // `<root>/<release namespace segment>/xema-edition-<id>:<coordinate>` is printed. The workflow pushes
11
11
  // <dir> with oras and signs the manifest DIGEST with cosign; the tag is for
12
12
  // humans. A lock set that is not sealed is refused: an unsealed images lock names
13
13
  // no image digests, so nothing could be admitted against it.
@@ -128,7 +128,7 @@ export async function readCommittedEdition(id, root = editionsRoot()) {
128
128
 
129
129
  async function run(argv, env) {
130
130
  const { id, options } = parseReleaseArtifactArgs(argv);
131
- const target = editionTarget(id, options['--registry'], options['--coordinate']);
131
+ const target = editionTarget(id, options['--registry'], EditionChannel.Release, options['--coordinate']);
132
132
  const workflow = options['--local'] ? LOCAL_WORKFLOW : workflowFromEnv(env);
133
133
  const locks = await readCommittedEdition(id);
134
134
  // Refuses (EvidenceTrustRequiredError) unless the policy is ARMED on the
@@ -60,12 +60,18 @@ import { basename, join } from 'node:path';
60
60
  import { EditionChannel } from './edition-channel.mjs';
61
61
  import {
62
62
  EDITION_CHART_SET_FILE,
63
+ CHART_SET_LOCK_SCHEMA_VERSION,
64
+ DEVELOP_CHART_VERSION_PREFIX,
63
65
  EditionChartSetCheck,
66
+ OCI_DIGEST_PATTERN,
64
67
  assertRosteredChartSetLock,
65
68
  checkEditionChartSet,
69
+ DEVELOP_CHART_SHA_LENGTH,
70
+ hasDevelopChartVersionPrefix,
71
+ isDevelopChartVersion,
66
72
  writeEditionChartSet,
67
73
  } from './edition-chart-set.mjs';
68
- import { editionWorkspaceConfig } from './edition-config.mjs';
74
+ import { vendorChartSetConfig } from './edition-config.mjs';
69
75
  import { WorkspaceRoot, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule } from './edition-workspace.mjs';
70
76
  import { serializeJson } from './lib.mjs';
71
77
  import { OrasRegistry } from './oras.mjs';
@@ -95,11 +101,11 @@ export const chartSetLockPath = () => join(dataRoot(), CHART_SET_LOCK_FILE);
95
101
  * refuses a copy that differs — so the copies cannot disagree
96
102
  * (edition-chart-set.mjs).
97
103
  */
98
- const chartRegistry = () => editionWorkspaceConfig().chartRegistry;
99
- const chartSetPublishers = () => editionWorkspaceConfig().chartPublishers;
104
+ const chartRegistry = () => vendorChartSetConfig().chartRegistry;
105
+ const chartSetPublishers = () => vendorChartSetConfig().chartPublishers;
100
106
 
101
107
  /** `<host>/<path>` -> the repository path the registry API addresses. */
102
- function registryRepository(indexName) {
108
+ export function registryRepository(indexName) {
103
109
  return `${chartRegistry().replace(/^oci:\/\/[^/]+\//, '')}/${indexName}`;
104
110
  }
105
111
 
@@ -125,9 +131,9 @@ export function isVersionTag(tag) {
125
131
  */
126
132
  export function selectNewestIndex(allCandidates) {
127
133
  if (!Array.isArray(allCandidates)) {return null;}
128
- // A develop chart version (`0.0.0-dev.<sha12>`) is a frozen xema-deploy
129
- // snapshot, never a release index: it is not a candidate for the release pin.
130
- const candidates = allCandidates.filter((c) => !c.tag.startsWith(DEVELOP_CHART_VERSION_PREFIX));
134
+ // Anything with the develop prefix (`0.0.0-dev.`) is a frozen xema-deploy
135
+ // snapshot or a malformed develop version, never a release index: it is not a candidate for the release pin.
136
+ const candidates = allCandidates.filter((c) => !hasDevelopChartVersionPrefix(c.tag));
131
137
  if (candidates.length === 0) {return null;}
132
138
  const undated = candidates.filter((c) => !c.createdAt);
133
139
  if (undated.length > 0) {
@@ -142,12 +148,17 @@ export function selectNewestIndex(allCandidates) {
142
148
  )[0].tag;
143
149
  }
144
150
 
145
- const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
151
+ const DIGEST_PATTERN = OCI_DIGEST_PATTERN;
146
152
 
147
153
  /** The index chart annotation listing the charts the publisher closed the set with. */
148
154
  export const INDEX_ROSTER_ANNOTATION = 'xema.dev/chart-set-charts';
149
155
 
150
- export function buildLock(versionsByIndex, rostersByIndex, publishers = chartSetPublishers()) {
156
+ /**
157
+ * The lock for `publishers`: each publisher's index version, the manifest digest
158
+ * the registry served for that index (`indexDigestsByIndex`, null for a develop
159
+ * snapshot, which has no index chart), and its sealed roster.
160
+ */
161
+ export function buildLock(versionsByIndex, rostersByIndex, indexDigestsByIndex, publishers = chartSetPublishers()) {
151
162
  // Every publisher's VERSION first: a pin that resolved nothing for one half is
152
163
  // the more fundamental refusal, whatever its roster.
153
164
  for (const publisher of publishers) {
@@ -156,10 +167,10 @@ export function buildLock(versionsByIndex, rostersByIndex, publishers = chartSet
156
167
  }
157
168
  }
158
169
  return {
159
- // 3: every member is `{version, digest}` (its OCI manifest digest), so the
160
- // lock itself is the trust anchor. 2 added `publishers[].version` (one each)
161
- // in place of one shared version, which would couple the two halves.
162
- schemaVersion: 3,
170
+ // Every member is `{version, digest}` (its OCI manifest digest) and each
171
+ // publisher pins its index chart by `indexDigest`, so the lock itself is the
172
+ // trust anchor; `publishers[].version` is per publisher, not one shared version.
173
+ schemaVersion: CHART_SET_LOCK_SCHEMA_VERSION,
163
174
  registry: chartRegistry(),
164
175
  publishers: publishers.map((publisher) => {
165
176
  const version = versionsByIndex[publisher.index];
@@ -180,7 +191,13 @@ export function buildLock(versionsByIndex, rostersByIndex, publishers = chartSet
180
191
  }
181
192
  charts[name] = { version: member.version, digest: member.digest };
182
193
  }
183
- return { ...publisher, version, charts };
194
+ // The digest the registry served for the index manifest the roster was read from.
195
+ const indexDigest = indexDigestsByIndex?.[publisher.index];
196
+ const wellFormed = isDevelopChartVersion(version) ? indexDigest === null : DIGEST_PATTERN.test(String(indexDigest));
197
+ if (!wellFormed) {
198
+ throw new Error(`no index manifest digest was read for '${publisher.index}@${version}' (got ${JSON.stringify(indexDigest ?? null)}; a release index needs sha256:<64 hex>, a develop snapshot null)`);
199
+ }
200
+ return { ...publisher, version, indexDigest, charts };
184
201
  }),
185
202
  };
186
203
  }
@@ -235,19 +252,21 @@ export async function sealRoster(roster, lookup, label) {
235
252
  return sealed;
236
253
  }
237
254
 
238
- /** The annotations of the index chart `<indexName>:<version>`, read from its OCI config blob (helm stores Chart.yaml's metadata there). */
239
- export async function indexAnnotations(client, indexName, version) {
255
+ /** The annotations of an index chart's served manifest `served` ({digest, bytes}), read from its OCI config blob (helm stores Chart.yaml's metadata there). */
256
+ export async function manifestAnnotations(client, indexName, version, served) {
240
257
  const repository = registryRepository(indexName);
241
- const manifest = JSON.parse((await client.getManifestByTag(repository, version)).bytes.toString('utf8'));
258
+ const manifest = JSON.parse(served.bytes.toString('utf8'));
242
259
  const configDigest = manifest?.config?.digest;
243
260
  if (typeof configDigest !== 'string') {throw new Error(`${indexName}@${version}: manifest names no config blob`);}
244
261
  const config = JSON.parse((await client.getBlob(repository, configDigest)).toString('utf8'));
245
262
  return config?.annotations ?? {};
246
263
  }
247
264
 
248
- async function indexRoster(client, indexName, version) {
249
- const annotations = await indexAnnotations(client, indexName, version);
250
- return parseIndexRoster(annotations[INDEX_ROSTER_ANNOTATION], `${indexName}@${version}`);
265
+ /** The roster and the served manifest digest of the index chart `<indexName>:<version>`, from ONE manifest read. */
266
+ async function indexRosterAndDigest(client, indexName, version) {
267
+ const served = await client.getManifestByTag(registryRepository(indexName), version);
268
+ const annotations = await manifestAnnotations(client, indexName, version, served);
269
+ return { roster: parseIndexRoster(annotations[INDEX_ROSTER_ANNOTATION], `${indexName}@${version}`), indexDigest: served.digest };
251
270
  }
252
271
 
253
272
  /** Every edition directory (one holding a distribution.lock.json) under the editions root. */
@@ -259,11 +278,9 @@ function editionDirs() {
259
278
  }
260
279
 
261
280
  const SHA_PATTERN = /^[0-9a-f]{40}$/;
262
- const DEVELOP_CHART_VERSION_PREFIX = '0.0.0-dev.';
263
- const DEVELOP_SHA_LENGTH = 12;
264
281
 
265
282
  /** The develop chart version xema-deploy publishes for a frozen SHA: `0.0.0-dev.<sha12>`, also the OCI tag. */
266
- export const developChartVersion = (sha) => `${DEVELOP_CHART_VERSION_PREFIX}${sha.slice(0, DEVELOP_SHA_LENGTH)}`;
283
+ export const developChartVersion = (sha) => `${DEVELOP_CHART_VERSION_PREFIX}${sha.slice(0, DEVELOP_CHART_SHA_LENGTH)}`;
267
284
 
268
285
  /** Every chart (a directory with a Chart.yaml, library charts included) of an xema-deploy checkout, sorted. */
269
286
  function deployChartNames(xemaDeployDir) {
@@ -312,7 +329,8 @@ export async function resolveDevelopChartSet({ xemaDeployDir, xemaDeploySha, loo
312
329
  const digestOf = lookup ?? registryManifestDigest(chartRegistryClient());
313
330
  const roster = Object.fromEntries(names.map((name) => [name, { version }]));
314
331
  const sealed = await sealRoster(roster, digestOf, `${publishers[0].index}@${version}`);
315
- const lock = buildLock({ [publishers[0].index]: version }, { [publishers[0].index]: sealed }, publishers);
332
+ // A develop snapshot has no index chart: its roster is the deploy checkout's chart directories.
333
+ const lock = buildLock({ [publishers[0].index]: version }, { [publishers[0].index]: sealed }, { [publishers[0].index]: null }, publishers);
316
334
  return assertRosteredChartSetLock(lock, EDITION_CHART_SET_FILE, EditionChannel.Develop);
317
335
  }
318
336
 
@@ -343,11 +361,12 @@ async function indexCandidates(client, indexName) {
343
361
  return candidates;
344
362
  }
345
363
 
346
- async function resolveFromRegistry() {
347
- const client = chartRegistryClient();
364
+ /** Read every publisher's newest index through `client`: its version, its roster, and the manifest digest the registry served for it. */
365
+ export async function resolveFromRegistry(client = chartRegistryClient()) {
348
366
 
349
367
  const versionsByIndex = {};
350
368
  const rostersByIndex = {};
369
+ const indexDigestsByIndex = {};
351
370
  for (const { index, repo } of chartSetPublishers()) {
352
371
  const candidates = await indexCandidates(client, index);
353
372
  const version = selectNewestIndex(candidates);
@@ -359,7 +378,8 @@ async function resolveFromRegistry() {
359
378
  );
360
379
  }
361
380
  versionsByIndex[index] = version;
362
- const roster = await indexRoster(client, index, version);
381
+ const { roster, indexDigest } = await indexRosterAndDigest(client, index, version);
382
+ indexDigestsByIndex[index] = indexDigest;
363
383
  // The registry is asked for every member, so an index digest is checked
364
384
  // against it and a member without one still gets sealed.
365
385
  rostersByIndex[index] = await sealRoster(
@@ -368,13 +388,13 @@ async function resolveFromRegistry() {
368
388
  `${index}@${version}`,
369
389
  );
370
390
  }
371
- return { versionsByIndex, rostersByIndex };
391
+ return { versionsByIndex, rostersByIndex, indexDigestsByIndex };
372
392
  }
373
393
 
374
394
  async function main(argv) {
375
395
  const check = argv.includes('--check');
376
- const { versionsByIndex, rostersByIndex } = await resolveFromRegistry();
377
- const next = buildLock(versionsByIndex, rostersByIndex);
396
+ const { versionsByIndex, rostersByIndex, indexDigestsByIndex } = await resolveFromRegistry();
397
+ const next = buildLock(versionsByIndex, rostersByIndex, indexDigestsByIndex);
378
398
  assertRosteredChartSetLock(next, CHART_SET_LOCK_FILE, EditionChannel.Release);
379
399
  const pinText = serializeJson(next);
380
400
  const current = readLock();
@@ -1,10 +1,11 @@
1
1
  // resolve-customer-edition — resolve a CUSTOMER edition against a catalogue.
2
2
  //
3
- // resolve-edition.mjs <root flags> --definition <file> --catalogue oci://<repo>@sha256:<digest>
3
+ // resolve-edition.mjs <root flags> --definition <file>
4
4
  // --channel release|develop --out <dir> [--source-image <source>=<registry>/<prefix>]...
5
5
  // [--catalogue-username <name> --catalogue-token-env <VAR>]
6
6
  //
7
- // The catalogue (catalogue.mjs) is the CEILING: it supplies availability, pins,
7
+ // The catalogue of the channel is the one pinned in catalogue.pin.json beside the definition
8
+ // (catalogue-pin.mjs). The catalogue (catalogue.mjs) is the CEILING: it supplies availability, pins,
8
9
  // image digests, chart-set rows and the contract authority, and never adds a
9
10
  // component. The resolved set is the customer's positive selections, every
10
11
  // installationClass core entry of the catalogue and the transitive declared
@@ -15,6 +16,7 @@
15
16
 
16
17
  import { mkdirSync, writeFileSync } from 'node:fs';
17
18
  import { join } from 'node:path';
19
+ import { isDeepStrictEqual } from 'node:util';
18
20
 
19
21
  import { AUTHORIZATION_LOCK_FILENAME, writeAuthorizationLock } from './authorization-base.mjs';
20
22
  import { CATALOGUE_FILE, CatalogueError } from './catalogue.mjs';
@@ -31,7 +33,7 @@ import { validateKernelContractAuthorityFromGit } from './kernel-contract-author
31
33
  import { compareStrings, loadKernelDistributionSchemas, serializeJson, validateImmutableImageLock } from './lib.mjs';
32
34
  import { pinnedSource, snapshotPinSource } from './pin-source.mjs';
33
35
  import { composeAuthorizationBase, composeManifest, resolveDistributionWithIndex, UnknownSelectionError } from './resolve-distribution.mjs';
34
- import { resolveImages } from './resolve-images.mjs';
36
+ import { resolveImageRows } from './resolve-images.mjs';
35
37
 
36
38
  /** A selection or dependency the catalogue does not offer: the catalogue is the ceiling. */
37
39
  export class CatalogueCeilingError extends Error {
@@ -186,7 +188,57 @@ export function projectVendorImages(catalogue, lock, enums) {
186
188
  };
187
189
  }
188
190
 
189
- /** The customer's image lock: vendor rows copied (and mirrored), own rows resolved. */
191
+ /** The first field in which two rows differ (compared by canonical deep equality, key by key), or undefined when they are equal. */
192
+ function firstDifferingField(expected, actual) {
193
+ for (const field of [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort(compareStrings)) {
194
+ if (!isDeepStrictEqual(expected[field], actual[field])) {return field;}
195
+ }
196
+ return undefined;
197
+ }
198
+
199
+ /**
200
+ * The vendor rows of the merged images lock ARE the catalogue's rows: every subject, platform-service binding and web
201
+ * biome that is not the customer's own deep-equals the catalogue's row (owners narrowed to the selection by
202
+ * `projectVendorImages`, imageRef re-homed by the declared mirror through `mirrorVendorSubjects`), and every projected
203
+ * catalogue row is present. A difference throws `CatalogueCeilingError`, naming the row and the first differing field.
204
+ */
205
+ export function assertVendorRowsAreCatalogueRows({ catalogue, imageLock, lock, mirror, enums }) {
206
+ const ownBiomes = new Set(lock.biomes.filter((biome) => biome.source !== undefined).map((biome) => biome.id));
207
+ const projected = projectVendorImages(catalogue, lock, enums);
208
+ const expected = {
209
+ subjects: new Map(mirrorVendorSubjects(projected.subjects, mirror).map((subject) => [subjectKey(subject), subject])),
210
+ platformServiceBindings: new Map(projected.platformServiceBindings.map((binding) => [binding.platformService, binding])),
211
+ webBiomes: new Map(projected.webBiomes.map((biome) => [biome.biomeId, biome])),
212
+ };
213
+ const isOwnSubject = (subject) => subject.owners.length > 0 && subject.owners.every((owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome && ownBiomes.has(owner.biomeId));
214
+ const compare = (label, name, want, got) => {
215
+ if (want === undefined) {
216
+ throw new CatalogueCeilingError(`the merged images lock carries ${label} ${name}, which is neither the customer's own nor the catalogue's`);
217
+ }
218
+ const field = firstDifferingField(want, got);
219
+ if (field !== undefined) {
220
+ throw new CatalogueCeilingError(`the merged images lock's ${label} ${name} differs from the catalogue's row at ${field}`);
221
+ }
222
+ };
223
+ const kinds = [
224
+ ['image', 'subjects', (row) => subjectKey(row), (row) => `${row.kind}:${row.id}`, (row) => isOwnSubject(row)],
225
+ ['platform-service binding', 'platformServiceBindings', (row) => row.platformService, (row) => row.platformService, () => false],
226
+ ['web biome', 'webBiomes', (row) => row.biomeId, (row) => row.biomeId, (row) => ownBiomes.has(row.biomeId)],
227
+ ];
228
+ for (const [label, field, keyOf, nameOf, isOwn] of kinds) {
229
+ const present = new Set();
230
+ for (const row of imageLock[field]) {
231
+ if (isOwn(row)) {continue;}
232
+ present.add(keyOf(row));
233
+ compare(label, nameOf(row), expected[field].get(keyOf(row)), row);
234
+ }
235
+ for (const [key, want] of expected[field]) {
236
+ if (!present.has(key)) {throw new CatalogueCeilingError(`the merged images lock lacks the catalogue's ${label} ${nameOf(want)}`);}
237
+ }
238
+ }
239
+ }
240
+
241
+ /** The customer's image lock: vendor rows copied (and mirrored), own rows resolved (and sealed by `sealOwn`, which takes and returns subjects), the merged lock parsed once. */
190
242
  export async function resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn }) {
191
243
  const vendor = projectVendorImages(catalogue, lock, schemas);
192
244
  const vendorSubjects = mirrorVendorSubjects(vendor.subjects, composed.mirror);
@@ -202,13 +254,15 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
202
254
  crossRepoBuildInputs: [],
203
255
  buildStepContextDirectories: [],
204
256
  };
205
- own = (await resolveImages(id, { inputs: { ...inputs, runtimeImageSourceIndex, composed }, lock: ownLock })).imageLock;
206
- if (sealOwn !== undefined) {own = await sealOwn(own);}
257
+ // The own rows are ROWS, not a lock: the one parse is of the merged lock below.
258
+ own = await resolveImageRows(id, { inputs: { ...inputs, runtimeImageSourceIndex, composed }, lock: ownLock });
259
+ // A source that builds no image (a web biome) has no row to seal.
260
+ if (sealOwn !== undefined && own.subjects.length > 0) {own = { ...own, subjects: await sealOwn({ distributionId: id, subjects: own.subjects }) };}
207
261
  }
208
262
  const seen = new Set(vendorSubjects.map(subjectKey));
209
263
  const clash = own.subjects.find((subject) => seen.has(subjectKey(subject)));
210
264
  if (clash !== undefined) {throw new Error(`the customer's own image ${clash.kind}:${clash.id} collides with a catalogue image of the same identity`);}
211
- return validateImmutableImageLock(
265
+ const merged = validateImmutableImageLock(
212
266
  schemas.parseImageLock({
213
267
  schemaVersion: 3,
214
268
  sourceHashAlgorithm: catalogue.images.sourceHashAlgorithm,
@@ -220,6 +274,8 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
220
274
  }),
221
275
  schemas,
222
276
  );
277
+ assertVendorRowsAreCatalogueRows({ catalogue, imageLock: merged, lock, mirror: composed.mirror, enums: schemas });
278
+ return merged;
223
279
  }
224
280
 
225
281
  /** The kernel at --kernel must carry the contract the catalogue's offering was resolved under. */
@@ -144,7 +144,7 @@ export function lockedServiceProcessFacts(entry) {
144
144
  }
145
145
 
146
146
  if (isEntryModule(import.meta.url)) {
147
- configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.SourceHash]);
147
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel]);
148
148
  }
149
149
 
150
150
  /**
@@ -24,8 +24,8 @@ import { join } from 'node:path';
24
24
 
25
25
  import { AUTHORIZATION_LOCK_FILENAME } from './authorization-base.mjs';
26
26
  import { fetchCatalogue } from './catalogue-fetch.mjs';
27
+ import { readCataloguePin } from './catalogue-pin.mjs';
27
28
  import {
28
- CATALOGUE_FLAG,
29
29
  CATALOGUE_TOKEN_ENV_FLAG,
30
30
  CATALOGUE_USERNAME_FLAG,
31
31
  CHANNEL_FLAG,
@@ -49,17 +49,17 @@ import { writeResolvedImages } from './resolve-images.mjs';
49
49
  import { withoutSealedDigests } from './seal-image-digests.mjs';
50
50
  import { readStrictJsonFile } from './strict-json.mjs';
51
51
 
52
- // A customer resolve (`--catalogue`) has no vendor editions root: `--out` IS its
52
+ // A customer resolve (`--definition`) has no vendor editions root: `--out` IS its
53
53
  // editions root, the directory its `<id>/` locks are written under.
54
54
  if (isEntryModule(import.meta.url)) {
55
- if (process.argv.includes(CATALOGUE_FLAG)) {
55
+ if (process.argv.includes(DEFINITION_FLAG)) {
56
56
  const at = process.argv.indexOf('--out');
57
- if (at < 0 || process.argv[at + 1] === undefined) {throw new Error(`--out <dir> is required with ${CATALOGUE_FLAG}`);}
57
+ if (at < 0 || process.argv[at + 1] === undefined) {throw new Error(`--out <dir> is required with ${DEFINITION_FLAG}`);}
58
58
  mkdirSync(process.argv[at + 1], { recursive: true });
59
59
  process.argv.splice(at, 1, '--editions-root');
60
- configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache, WorkspaceRoot.SourceHash]);
60
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache], { refuseOthers: true });
61
61
  } else {
62
- configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache, WorkspaceRoot.SourceHash]);
62
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache]);
63
63
  }
64
64
  }
65
65
 
@@ -135,7 +135,7 @@ export async function checkResolvedLockSet(id) {
135
135
  }
136
136
  }
137
137
 
138
- const CUSTOMER_VALUE_FLAGS = [DEFINITION_FLAG, CATALOGUE_FLAG, CHANNEL_FLAG, CATALOGUE_USERNAME_FLAG, CATALOGUE_TOKEN_ENV_FLAG];
138
+ const CUSTOMER_VALUE_FLAGS = [DEFINITION_FLAG, CHANNEL_FLAG, CATALOGUE_USERNAME_FLAG, CATALOGUE_TOKEN_ENV_FLAG];
139
139
 
140
140
  /** The arguments of a customer resolve (the root flags and `--out` are already consumed). */
141
141
  export function parseCustomerResolveArgs(args) {
@@ -145,12 +145,12 @@ export function parseCustomerResolveArgs(args) {
145
145
  const flag = args[i];
146
146
  const value = args[i + 1];
147
147
  if (![...CUSTOMER_VALUE_FLAGS, SOURCE_IMAGE_FLAG].includes(flag) || value === undefined || value.startsWith('--')) {
148
- throw new Error(`unexpected argument ${JSON.stringify(flag)} in a customer resolve (${DEFINITION_FLAG}, ${CATALOGUE_FLAG}, ${CHANNEL_FLAG}, ${SOURCE_IMAGE_FLAG} take a value)`);
148
+ throw new Error(`unexpected argument ${JSON.stringify(flag)} in a customer resolve (${DEFINITION_FLAG}, ${CHANNEL_FLAG}, ${SOURCE_IMAGE_FLAG} take a value)`);
149
149
  }
150
150
  i += 1;
151
151
  if (flag === SOURCE_IMAGE_FLAG) {sourceImages.push(value);} else if (values.has(flag)) {throw new Error(`${flag} given twice`);} else {values.set(flag, value);}
152
152
  }
153
- for (const required of [DEFINITION_FLAG, CATALOGUE_FLAG, CHANNEL_FLAG]) {
153
+ for (const required of [DEFINITION_FLAG, CHANNEL_FLAG]) {
154
154
  if (!values.has(required)) {throw new Error(`a customer resolve requires ${required}`);}
155
155
  }
156
156
  if (!Object.values(EditionChannel).includes(values.get(CHANNEL_FLAG))) {
@@ -162,7 +162,8 @@ export function parseCustomerResolveArgs(args) {
162
162
  async function mainCustomer() {
163
163
  const { values, sourceImages } = parseCustomerResolveArgs(process.argv.slice(2));
164
164
  const { catalogue, digest } = await fetchCatalogue({
165
- ref: values.get(CATALOGUE_FLAG),
165
+ ref: readCataloguePin({ definitionFile: values.get(DEFINITION_FLAG), channel: values.get(CHANNEL_FLAG) }),
166
+ channel: values.get(CHANNEL_FLAG),
166
167
  username: values.get(CATALOGUE_USERNAME_FLAG),
167
168
  tokenEnv: values.get(CATALOGUE_TOKEN_ENV_FLAG),
168
169
  });
@@ -172,7 +173,7 @@ async function mainCustomer() {
172
173
  }
173
174
 
174
175
  async function main() {
175
- if (process.argv.includes(CATALOGUE_FLAG)) {return mainCustomer();}
176
+ if (process.argv.includes(DEFINITION_FLAG)) {return mainCustomer();}
176
177
  const { id, check } = parseResolveEditionArgs(process.argv.slice(2));
177
178
  assertCanonicalDistributionId(id);
178
179
  await (check ? checkResolvedLockSet(id) : writeResolvedLockSet(id));