@xemahq/xema 0.28.0 → 0.29.0

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 (54) 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/pin-source.mjs +6 -1
  44. package/edition/registry-credentials.mjs +11 -1
  45. package/edition/release-edition-artifact.mjs +3 -3
  46. package/edition/resolve-chart-set.mjs +50 -30
  47. package/edition/resolve-customer-edition.mjs +63 -7
  48. package/edition/resolve-distribution.mjs +1 -1
  49. package/edition/resolve-edition.mjs +12 -11
  50. package/edition/resolve-images.mjs +36 -15
  51. package/edition/seal-image-digests.mjs +31 -7
  52. package/edition/source-closure.mjs +1 -2
  53. package/edition/trust-policy-arming-verb.mjs +68 -0
  54. 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
  }
@@ -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));
@@ -68,7 +68,7 @@ import { composeEdition } from './resolve-distribution.mjs';
68
68
  import { readStrictJsonFile } from './strict-json.mjs';
69
69
 
70
70
  if (isEntryModule(import.meta.url)) {
71
- configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache, WorkspaceRoot.SourceHash]);
71
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache]);
72
72
  }
73
73
 
74
74
  const firstPartyImageRegistry = () => editionWorkspaceConfig().imageRegistry;
@@ -1383,7 +1383,15 @@ function snapshotImageInputs(schemas, { inputs, lock }) {
1383
1383
  };
1384
1384
  }
1385
1385
 
1386
- export async function resolveImages(id, { inputs, lock } = {}) {
1386
+ /**
1387
+ * The image ROWS of one edition: its subjects (hashed at the commits the lock
1388
+ * pins), its platform-service bindings and its bundled web biomes, after every
1389
+ * check that needs no parsed lock. Rows are not a lock: the kernel's image-lock
1390
+ * grammar is the grammar of a COMPLETE lock, and a caller that holds only part
1391
+ * of one (a customer's own rows, merged with the catalogue's) parses the lock
1392
+ * it completes. `resolveImages` is these rows parsed as the whole lock.
1393
+ */
1394
+ export async function resolveImageRows(id, { inputs, lock } = {}) {
1387
1395
  assertCanonicalDistributionId(id);
1388
1396
  if ((inputs === undefined) !== (lock === undefined)) {
1389
1397
  throw new Error('resolveImages takes the snapshot `inputs` and its distribution `lock` together, or neither');
@@ -1509,18 +1517,6 @@ export async function resolveImages(id, { inputs, lock } = {}) {
1509
1517
  ...biome,
1510
1518
  sourceCommit: commitsBefore.get(biome.repo),
1511
1519
  }));
1512
- const imageLock = validateImmutableImageLock(
1513
- schemas.parseImageLock({
1514
- schemaVersion: 3,
1515
- sourceHashAlgorithm: schemas.ImageSourceHashAlgorithm.ImageInputV2,
1516
- distributionId: id,
1517
- contractAuthority: runtimeSourceIndex.contractAuthority,
1518
- subjects,
1519
- platformServiceBindings: structure.platformServiceBindings,
1520
- webBiomes,
1521
- }),
1522
- schemas,
1523
- );
1524
1520
  // The SAME locator the hash used. The default locator resolves every repo
1525
1521
  // handle to a first-party tree under the aggregator root, so for an edition
1526
1522
  // with an external source it looked for `<sourcesRoot()>/<source name>`, found no
@@ -1533,7 +1529,32 @@ export async function resolveImages(id, { inputs, lock } = {}) {
1533
1529
  'refusing image-lock write: a source repository commit changed while hashing',
1534
1530
  );
1535
1531
  }
1536
- return { imageLock, commits: [...commitsBefore] };
1532
+ return {
1533
+ subjects,
1534
+ platformServiceBindings: structure.platformServiceBindings,
1535
+ webBiomes,
1536
+ contractAuthority: runtimeSourceIndex.contractAuthority,
1537
+ commits: [...commitsBefore],
1538
+ };
1539
+ }
1540
+
1541
+ /** One edition's COMPLETE image lock: its rows, parsed and validated as the kernel's image lock. */
1542
+ export async function resolveImages(id, { inputs, lock } = {}) {
1543
+ const { subjects, platformServiceBindings, webBiomes, contractAuthority, commits } = await resolveImageRows(id, { inputs, lock });
1544
+ const schemas = await loadKernelDistributionSchemas();
1545
+ const imageLock = validateImmutableImageLock(
1546
+ schemas.parseImageLock({
1547
+ schemaVersion: 3,
1548
+ sourceHashAlgorithm: schemas.ImageSourceHashAlgorithm.ImageInputV2,
1549
+ distributionId: id,
1550
+ contractAuthority,
1551
+ subjects,
1552
+ platformServiceBindings,
1553
+ webBiomes,
1554
+ }),
1555
+ schemas,
1556
+ );
1557
+ return { imageLock, commits };
1537
1558
  }
1538
1559
 
1539
1560
  /**