@xemahq/xema 0.39.0 → 0.40.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 (61) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/distribution-assets.manifest.json +2 -70
  3. package/dist/commands/biome-dev.js +2 -3
  4. package/dist/commands/biome-dev.js.map +1 -1
  5. package/dist/commands/dev.js +2 -3
  6. package/dist/commands/dev.js.map +1 -1
  7. package/dist/commands/doctor.d.ts.map +1 -1
  8. package/dist/commands/doctor.js +3 -2
  9. package/dist/commands/doctor.js.map +1 -1
  10. package/dist/commands/init.js +21 -9
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/installation.d.ts.map +1 -1
  13. package/dist/commands/installation.js +7 -5
  14. package/dist/commands/installation.js.map +1 -1
  15. package/dist/commands/license.d.ts.map +1 -1
  16. package/dist/commands/license.js +2 -1
  17. package/dist/commands/license.js.map +1 -1
  18. package/dist/commands/serve.js +1 -1
  19. package/dist/commands/serve.js.map +1 -1
  20. package/dist/commands/up.d.ts.map +1 -1
  21. package/dist/commands/up.js +3 -5
  22. package/dist/commands/up.js.map +1 -1
  23. package/dist/edition/verify-image-evidence.bundle.provenance.json +39 -39
  24. package/dist/runtime/config/init-presets.d.ts.map +1 -1
  25. package/dist/runtime/config/init-presets.js +3 -5
  26. package/dist/runtime/config/init-presets.js.map +1 -1
  27. package/dist/runtime/config/loader.d.ts +2 -2
  28. package/dist/runtime/config/loader.d.ts.map +1 -1
  29. package/dist/runtime/config/loader.js +1 -1
  30. package/dist/runtime/config/loader.js.map +1 -1
  31. package/dist/runtime/config/schema.d.ts +149 -1
  32. package/dist/runtime/config/schema.d.ts.map +1 -1
  33. package/dist/runtime/config/schema.js +17 -4
  34. package/dist/runtime/config/schema.js.map +1 -1
  35. package/dist/runtime/installation/setup-profile.d.ts +1 -1
  36. package/dist/runtime/installation/setup-profile.d.ts.map +1 -1
  37. package/dist/runtime/installation/setup-profile.js +1 -1
  38. package/dist/runtime/installation/setup-profile.js.map +1 -1
  39. package/edition/biome-content.mjs +35 -6
  40. package/edition/build-platform-service-index.mjs +1 -1
  41. package/edition/develop-edition-resolve.mjs +58 -20
  42. package/edition/develop-image-probe.mjs +117 -0
  43. package/edition/develop-seal.mjs +4 -12
  44. package/edition/dispatched-runtime-components.mjs +1 -1
  45. package/edition/dist-check.mjs +1 -1
  46. package/edition/lib.mjs +1 -1
  47. package/edition/map-with-concurrency.mjs +19 -0
  48. package/edition/oras-process.mjs +35 -0
  49. package/edition/oras.mjs +123 -94
  50. package/edition/resolve-chart-set.mjs +12 -8
  51. package/edition/resolve-images.mjs +18 -8
  52. package/edition/verify-image-platforms.mjs +28 -26
  53. package/package.json +2 -2
  54. package/dist/assets/cloud/distribution.lock.json +0 -6447
  55. package/dist/assets/cloud/images.lock.json +0 -1831
  56. package/dist/assets/minimal/distribution.lock.json +0 -3297
  57. package/dist/assets/minimal/images.lock.json +0 -1189
  58. package/dist/assets/oss/distribution.lock.json +0 -6266
  59. package/dist/assets/oss/images.lock.json +0 -1750
  60. package/dist/assets/starter/distribution.lock.json +0 -4165
  61. package/dist/assets/starter/images.lock.json +0 -1454
@@ -25,7 +25,7 @@ import { compareStrings } from './string-order.mjs';
25
25
  * rostered biomes that dispatch it. Every unresolvable edge is refused, naming
26
26
  * the consumer and the target.
27
27
  *
28
- * @param {string} context the edition the refusal names, e.g. `distribution "core"`
28
+ * @param {string} context the edition the refusal names, e.g. `distribution "appliance-base"`
29
29
  * @param {object[]} consumers the rostered index entries
30
30
  * @param {object[]} index the index the targets are resolved against
31
31
  */
@@ -524,7 +524,7 @@ function discoverDistributionIds() {
524
524
  *
525
525
  * Ownership is a property of the platform, not of what an edition preloads.
526
526
  * An earlier design put the claim on each `LockedBiome`, which made a trimmed
527
- * edition (`minimal`, `appliance-lean`) leave `kb`/`agent`/… unowned — so an
527
+ * edition (`appliance-lean`) leave `kb`/`agent`/… unowned — so an
528
528
  * org admin could install the owning biome from the store and THEN bind
529
529
  * `kb:page.read@1` to a URL they control, silently shadowing the platform
530
530
  * data plane. The table is now built from the FULL biome index and reserves
package/edition/lib.mjs CHANGED
@@ -2562,7 +2562,7 @@ export function assertDeclaredDatabasesUnowned(entries) {
2562
2562
  *
2563
2563
  * Ownership is a property of the PLATFORM, not of what an edition happens to
2564
2564
  * preload. If the table were built from the selected set, a trimmed edition
2565
- * (`minimal`, `appliance-lean`) that ships without `knowledge-base` would
2565
+ * (`appliance-lean`) that ships without `knowledge-base` would
2566
2566
  * leave `kb` unowned — and an org admin could then install `knowledge-base`
2567
2567
  * from the store and bind `kb:page.read@1` to a URL they control, silently
2568
2568
  * shadowing the platform data plane. Being AUTHORED by a first-party biome is
@@ -0,0 +1,19 @@
1
+ // map-with-concurrency — run an async worker over a list with a bounded number in flight.
2
+
3
+ /**
4
+ * `worker(item, index)` for every item, at most `limit` at a time; the results
5
+ * keep the items' order. The first rejection rejects the whole call.
6
+ */
7
+ export async function mapWithConcurrency(items, limit, worker) {
8
+ const results = new Array(items.length);
9
+ let cursor = 0;
10
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
11
+ while (cursor < items.length) {
12
+ const index = cursor;
13
+ cursor += 1;
14
+ results[index] = await worker(items[index], index);
15
+ }
16
+ });
17
+ await Promise.all(runners);
18
+ return results;
19
+ }
@@ -0,0 +1,35 @@
1
+ // oras-process — run the oras binary WITHOUT blocking the event loop.
2
+ //
3
+ // A blocking `execFileSync` turns every caller's worker pool into a queue of
4
+ // one: six "concurrent" subjects still ran their registry calls one after the
5
+ // other. This runner spawns the child and resolves on its exit, so the pool's
6
+ // width is the real concurrency.
7
+
8
+ import { execFile } from 'node:child_process';
9
+
10
+ /**
11
+ * Run `file` with `args`; resolve with stdout bytes. A failure rejects with the
12
+ * child's stderr text on `error.stderr` (a string), so the caller reads the
13
+ * registry status from what oras itself wrote.
14
+ */
15
+ export function runExecutable(file, args, { input, env, timeoutMs, maxBufferBytes }) {
16
+ return new Promise((resolve, reject) => {
17
+ const child = execFile(
18
+ file,
19
+ args,
20
+ { env, timeout: timeoutMs, maxBuffer: maxBufferBytes, encoding: 'buffer' },
21
+ (error, stdout, stderr) => {
22
+ if (error) {
23
+ error.stderr = stderr.toString('utf8');
24
+ reject(error);
25
+ return;
26
+ }
27
+ resolve(stdout);
28
+ },
29
+ );
30
+ // A child that exits without reading its stdin must not crash the process
31
+ // with EPIPE; the exit status already carries the failure.
32
+ child.stdin.on('error', () => {});
33
+ child.stdin.end(input);
34
+ });
35
+ }
package/edition/oras.mjs CHANGED
@@ -13,30 +13,36 @@
13
13
  // those bytes; its version must be the one this module's parsing is written
14
14
  // against;
15
15
  // * oras runs under a scrubbed environment (no ambient proxy, config or
16
- // credential variable reaches it) and a fresh EMPTY registry config, so
16
+ // credential variable reaches it) and an EMPTY registry config, so
17
17
  // ambient docker/oras credentials are never used and an authenticated
18
18
  // registry never degrades into an anonymous read;
19
19
  // * the token travels on stdin only (`--password-stdin`), never in argv;
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
- // * a digest-addressed read whose bytes do not hash to the requested digest
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);
26
- // * descriptor sizes are checked BEFORE the fetch, actual lengths after it;
22
+ // * a manifest or blob is fetched in ONE call (`--descriptor --output <file>`:
23
+ // the descriptor on stdout, the raw content in a scratch file). Its bytes
24
+ // must hash to the digest the registry described (and, for a digest read,
25
+ // to the requested digest), or the read throws; the manifest media type is
26
+ // the descriptor's, never the manifest body's optional `mediaType` (Helm's
27
+ // has none);
28
+ // * declared size, on-disk size and read length are all held to the cap
29
+ // BEFORE the content is read into memory;
30
+ // * oras runs asynchronously, so a caller's worker pool really runs in parallel;
31
+ // * every call of one registry shares one empty registry config for the run;
27
32
  // * a registry status is read only from oras's own error forms; any other
28
33
  // failure carries no status and is thrown as undecided;
29
34
  // * `--plain-http` and `--insecure` are never passed.
30
35
 
31
- import { execFileSync } from 'node:child_process';
32
- import { createHash } from 'node:crypto';
36
+ import { createHash, randomUUID } from 'node:crypto';
33
37
  import {
34
38
  accessSync,
35
39
  constants,
36
40
  mkdtempSync,
41
+ readFileSync,
37
42
  realpathSync,
38
43
  rmSync,
39
44
  statSync,
45
+ unlinkSync,
40
46
  writeFileSync,
41
47
  } from 'node:fs';
42
48
  import { tmpdir } from 'node:os';
@@ -46,6 +52,7 @@ import { editionWorkspaceConfig } from './edition-config.mjs';
46
52
  import { dataRoot as configuredDataRoot } from './edition-workspace.mjs';
47
53
  import { SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
48
54
  import { readEvidenceTrustPin } from './evidence-trust-pin.mjs';
55
+ import { runExecutable } from './oras-process.mjs';
49
56
  import {
50
57
  OCI_INDEX_MEDIA_TYPES,
51
58
  REGISTRY_CREDENTIALS_FILE,
@@ -248,18 +255,17 @@ export function prepareTrustedOras({ env = process.env, trustedDigests, notice =
248
255
  /** The default runner: the trusted oras of the configured data root, under the scrubbed environment. */
249
256
  function defaultRunner(trust = configuredOrasTrust) {
250
257
  let prepared;
251
- return (args, { stdin } = {}) => {
258
+ return async (args, { stdin } = {}) => {
252
259
  if (prepared === undefined) {
253
260
  prepared = prepareTrustedOras({ trustedDigests: trust() });
254
261
  process.once('exit', prepared.dispose);
255
262
  }
256
263
  try {
257
- return execFileSync(prepared.path(), args, {
264
+ return await runExecutable(prepared.path(), args, {
258
265
  input: stdin,
259
266
  env: orasEnvironment(prepared.home),
260
- timeout: ORAS_RUN_TIMEOUT_MS,
261
- maxBuffer: ORAS_MAX_BUFFER_BYTES,
262
- stdio: ['pipe', 'pipe', 'pipe'],
267
+ timeoutMs: ORAS_RUN_TIMEOUT_MS,
268
+ maxBufferBytes: ORAS_MAX_BUFFER_BYTES,
263
269
  });
264
270
  } catch (error) {
265
271
  const stderr = error?.stderr ? error.stderr.toString('utf8') : '';
@@ -325,6 +331,38 @@ export function statusOfOrasFailure(text, { ref = '' } = {}) {
325
331
  return null;
326
332
  }
327
333
 
334
+ /**
335
+ * ONE oras working directory per registry authority per process: the (empty)
336
+ * registry config every call of that registry points at, and the scratch
337
+ * files a fetch writes its content to. The config never holds a credential
338
+ * (that goes to oras on stdin, per call), so sharing it carries no secret; it
339
+ * replaces a temp directory made and removed around every single call.
340
+ */
341
+ const registrySessions = new Map();
342
+
343
+ function registrySession(authority) {
344
+ let session = registrySessions.get(authority);
345
+ if (session === undefined) {
346
+ const directory = mkdtempSync(join(tmpdir(), 'xema-oras-'));
347
+ const configPath = join(directory, 'registry-config.json');
348
+ writeFileSync(configPath, '');
349
+ session = { directory, configPath };
350
+ registrySessions.set(authority, session);
351
+ if (registrySessions.size === 1) {
352
+ process.once('exit', disposeRegistrySessions);
353
+ }
354
+ }
355
+ return session;
356
+ }
357
+
358
+ /** Remove every registry session directory; called at process exit, and by tests. */
359
+ export function disposeRegistrySessions() {
360
+ for (const { directory } of registrySessions.values()) {
361
+ rmSync(directory, { recursive: true, force: true });
362
+ }
363
+ registrySessions.clear();
364
+ }
365
+
328
366
  /**
329
367
  * A read-only OCI registry client bound to ONE registry authority and ONE
330
368
  * resolved credential, doing every operation with the pinned oras.
@@ -417,28 +455,58 @@ export class OrasRegistry {
417
455
  return this.#token ? text.split(this.#token).join('***') : text;
418
456
  }
419
457
 
420
- /** One oras invocation with an empty registry config; returns stdout bytes. */
458
+ /** One oras invocation under this registry's shared empty config; returns stdout bytes. */
421
459
  async #oras(subcommand, flags, ref) {
422
460
  await this.#ensureRealmIsSameOrigin();
423
- const configDirectory = mkdtempSync(join(tmpdir(), 'xema-oras-'));
424
- const configPath = join(configDirectory, 'registry-config.json');
461
+ const { configPath } = registrySession(this.authority);
462
+ const args = [...subcommand, '--registry-config', configPath];
463
+ let stdin;
464
+ if (this.#credential.access === 'authenticated') {
465
+ args.push('--username', this.#username, '--password-stdin');
466
+ stdin = this.#token;
467
+ }
468
+ args.push(...flags, ref);
469
+ try {
470
+ const out = await this.#run(args, { stdin });
471
+ return Buffer.isBuffer(out) ? out : Buffer.from(out ?? '');
472
+ } catch (error) {
473
+ throw this.#failure(ref, error);
474
+ }
475
+ }
476
+
477
+ /**
478
+ * ONE oras call that returns the descriptor (stdout) AND the raw content
479
+ * (written to a scratch file by `--output`): the call that used to be a
480
+ * descriptor round trip followed by a content round trip. The content is
481
+ * read into memory only after both its declared and its on-disk size are
482
+ * inside `maxBytes`.
483
+ */
484
+ async #fetchContent(subcommand, ref, maxBytes, acceptDescriptor) {
485
+ const { directory } = registrySession(this.authority);
486
+ const outputPath = join(directory, `content-${randomUUID()}`);
425
487
  try {
426
- writeFileSync(configPath, '');
427
- const args = [...subcommand, '--registry-config', configPath];
428
- let stdin;
429
- if (this.#credential.access === 'authenticated') {
430
- args.push('--username', this.#username, '--password-stdin');
431
- stdin = this.#token;
488
+ const described = await this.#oras(subcommand, ['--descriptor', '--output', outputPath], ref);
489
+ const descriptor = this.#parseDescriptor(described, ref);
490
+ acceptDescriptor(descriptor);
491
+ if (descriptor.size > maxBytes) {
492
+ throw new Error(`${ref} declares ${descriptor.size} bytes, exceeding ${maxBytes}`);
432
493
  }
433
- args.push(...flags, ref);
434
- try {
435
- const out = await this.#run(args, { stdin });
436
- return Buffer.isBuffer(out) ? out : Buffer.from(out ?? '');
437
- } catch (error) {
438
- throw this.#failure(ref, error);
494
+ const onDisk = statSync(outputPath).size;
495
+ if (onDisk > maxBytes) {
496
+ throw new Error(`${ref} returned more than ${maxBytes} bytes`);
497
+ }
498
+ const bytes = readFileSync(outputPath);
499
+ const actual = sha256DigestOf(bytes);
500
+ if (actual !== descriptor.digest) {
501
+ throw new Error(`${ref} returned content addressed ${actual}, not the digest ${descriptor.digest} it described`);
439
502
  }
503
+ return { descriptor, bytes };
440
504
  } finally {
441
- rmSync(configDirectory, { recursive: true, force: true });
505
+ try {
506
+ unlinkSync(outputPath);
507
+ } catch {
508
+ // No content file exists when the fetch itself failed; the failure is already thrown.
509
+ }
442
510
  }
443
511
  }
444
512
 
@@ -555,88 +623,49 @@ export class OrasRegistry {
555
623
  }
556
624
 
557
625
  /**
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.
626
+ * Fetch a manifest in ONE call and hold it to everything a separate
627
+ * descriptor call used to: its media type is an accepted manifest type, its
628
+ * size is inside the cap, and its bytes hash to the digest the registry
629
+ * described. `expectedDigest`, when the caller asked by digest, must be that
630
+ * digest. The media type is the descriptor's, never the body's optional
631
+ * `mediaType` (Helm's has none).
561
632
  */
562
- async #manifestDescriptor(ref) {
563
- const out = await this.#oras(['manifest', 'fetch'], ['--descriptor'], ref);
564
- const descriptor = this.#parseDescriptor(out, ref);
565
- if (!ACCEPTED_MANIFEST_MEDIA_TYPES.includes(descriptor.mediaType)) {
566
- throw new Error(
567
- `${ref} returned unsupported manifest media type "${descriptor.mediaType}"`,
568
- );
569
- }
570
- if (descriptor.size > MAX_MANIFEST_BYTES) {
571
- throw new Error(`${ref} declares ${descriptor.size} bytes, exceeding ${MAX_MANIFEST_BYTES}`);
572
- }
573
- return descriptor;
633
+ async #manifest(ref, expectedDigest) {
634
+ const { descriptor, bytes } = await this.#fetchContent(['manifest', 'fetch'], ref, MAX_MANIFEST_BYTES, (described) => {
635
+ if (!ACCEPTED_MANIFEST_MEDIA_TYPES.includes(described.mediaType)) {
636
+ throw new Error(`${ref} returned unsupported manifest media type "${described.mediaType}"`);
637
+ }
638
+ if (expectedDigest !== undefined && described.digest !== expectedDigest) {
639
+ throw new Error(`${ref} described ${described.digest}, not the requested ${expectedDigest}`);
640
+ }
641
+ });
642
+ return { ...decodeManifest(bytes, descriptor.mediaType, ref), digest: descriptor.digest, bytes };
574
643
  }
575
644
 
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
- */
645
+ /** Fetch a manifest by TAG: one call, the content verified against the digest the registry described. */
580
646
  async getManifestByTag(repository, tag) {
581
- const descriptor = await this.#manifestDescriptor(`${this.#ref(repository)}:${tag}`);
582
- return this.#manifestByDigest(repository, descriptor.digest, descriptor.mediaType);
647
+ return this.#manifest(`${this.#ref(repository)}:${tag}`);
583
648
  }
584
649
 
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
- */
650
+ /** Fetch a manifest by DIGEST: one call, the bytes must hash to exactly that digest. */
590
651
  async getManifestByDigest(repository, digest) {
591
652
  if (!SHA256_DIGEST_PATTERN.test(digest)) {
592
653
  throw new Error(`"${digest}" is not a canonical lowercase sha256 digest`);
593
654
  }
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);
600
- }
601
-
602
- async #manifestByDigest(repository, digest, expectedMediaType) {
603
- if (!SHA256_DIGEST_PATTERN.test(digest)) {
604
- throw new Error(`"${digest}" is not a canonical lowercase sha256 digest`);
605
- }
606
- const ref = `${this.#ref(repository)}@${digest}`;
607
- const bytes = await this.#oras(['manifest', 'fetch'], [], ref);
608
- if (bytes.length > MAX_MANIFEST_BYTES) {
609
- throw new Error(`${ref} returned more than ${MAX_MANIFEST_BYTES} bytes`);
610
- }
611
- const actual = sha256DigestOf(bytes);
612
- if (actual !== digest) {
613
- throw new Error(`${ref} returned content addressed ${actual}, not the requested ${digest}`);
614
- }
615
- return { ...decodeManifest(bytes, expectedMediaType, ref), digest, bytes };
655
+ return this.#manifest(`${this.#ref(repository)}@${digest}`, digest);
616
656
  }
617
657
 
618
- /** Fetch a blob by DIGEST; the descriptor size is checked before the fetch, the bytes after. */
658
+ /** Fetch a blob by DIGEST in one call; its declared and actual size are bounded by `maxBytes`, its bytes must hash to that digest. */
619
659
  async getBlob(repository, digest, maxBytes = MAX_CONFIG_BLOB_BYTES) {
620
660
  if (!SHA256_DIGEST_PATTERN.test(digest)) {
621
661
  throw new Error(`"${digest}" is not a canonical lowercase sha256 digest`);
622
662
  }
623
663
  const ref = `${this.#ref(repository)}@${digest}`;
624
- const described = await this.#oras(['blob', 'fetch'], ['--descriptor'], ref);
625
- const descriptor = this.#parseDescriptor(described, ref);
626
- if (descriptor.digest !== digest) {
627
- throw new Error(`${ref} described ${descriptor.digest}, not the requested ${digest}`);
628
- }
629
- if (descriptor.size > maxBytes) {
630
- throw new Error(`${ref} declares ${descriptor.size} bytes, exceeding ${maxBytes}`);
631
- }
632
- const bytes = await this.#oras(['blob', 'fetch'], ['--output', '-'], ref);
633
- if (bytes.length > maxBytes) {
634
- throw new Error(`${ref} returned more than ${maxBytes} bytes`);
635
- }
636
- const actual = sha256DigestOf(bytes);
637
- if (actual !== digest) {
638
- throw new Error(`${ref} returned content addressed ${actual}, not the requested ${digest}`);
639
- }
664
+ const { bytes } = await this.#fetchContent(['blob', 'fetch'], ref, maxBytes, (described) => {
665
+ if (described.digest !== digest) {
666
+ throw new Error(`${ref} described ${described.digest}, not the requested ${digest}`);
667
+ }
668
+ });
640
669
  return bytes;
641
670
  }
642
671
  }
@@ -69,6 +69,7 @@ import {
69
69
  import { vendorChartSetConfig } from './edition-config.mjs';
70
70
  import { WorkspaceRoot, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule } from './edition-workspace.mjs';
71
71
  import { serializeJson } from './lib.mjs';
72
+ import { mapWithConcurrency } from './map-with-concurrency.mjs';
72
73
  import { OrasRegistry } from './oras.mjs';
73
74
  import {
74
75
  loadRegistryCredentialDeclarations,
@@ -141,6 +142,9 @@ export function selectNewestIndex(candidates) {
141
142
 
142
143
  const DIGEST_PATTERN = OCI_DIGEST_PATTERN;
143
144
 
145
+ /** Registry reads in flight at once while reading a chart set. */
146
+ const CHART_FETCH_CONCURRENCY = 6;
147
+
144
148
  /** The index chart annotation listing the charts the publisher closed the set with. */
145
149
  export const INDEX_ROSTER_ANNOTATION = 'xema.dev/chart-set-charts';
146
150
 
@@ -226,8 +230,10 @@ export function parseIndexRoster(annotation, label) {
226
230
  */
227
231
  export async function sealRoster(roster, lookup, label) {
228
232
  const sealed = {};
229
- for (const [name, member] of Object.entries(roster)) {
230
- const registryDigest = await lookup(name, member.version);
233
+ const members = Object.entries(roster);
234
+ const registryDigests = await mapWithConcurrency(members, CHART_FETCH_CONCURRENCY, ([name, member]) => lookup(name, member.version));
235
+ for (const [position, [name, member]] of members.entries()) {
236
+ const registryDigest = registryDigests[position];
231
237
  if (!DIGEST_PATTERN.test(String(registryDigest))) {
232
238
  throw new Error(`${label}: registry returned no sha256 manifest digest for ${name}:${member.version}`);
233
239
  }
@@ -290,16 +296,14 @@ export function readLock(path = chartSetLockPath()) {
290
296
  async function indexCandidates(client, indexName) {
291
297
  const repository = registryRepository(indexName);
292
298
  const tags = (await client.listTags(repository)).filter(isVersionTag);
293
- const candidates = [];
294
- for (const tag of tags) {
299
+ return mapWithConcurrency(tags, CHART_FETCH_CONCURRENCY, async (tag) => {
295
300
  const manifest = await client.getManifestByTag(repository, tag);
296
301
  const document = JSON.parse(manifest.bytes.toString('utf8'));
297
- candidates.push({
302
+ return {
298
303
  tag,
299
304
  createdAt: document?.annotations?.['org.opencontainers.image.created'] ?? null,
300
- });
301
- }
302
- return candidates;
305
+ };
306
+ });
303
307
  }
304
308
 
305
309
  /** Read every publisher's newest index through `client`: its version, its roster, and the manifest digest the registry served for it. */
@@ -35,6 +35,7 @@
35
35
  import { execFileSync } from 'node:child_process';
36
36
  import { writeFileSync } from 'node:fs';
37
37
  import { join, relative, sep } from 'node:path';
38
+ import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
38
39
 
39
40
  import { assertBuildLedgerComplete } from './build-ledger-completeness.mjs';
40
41
  import { BUILD_LEDGER_FILE, readBuildLedger } from './build-ledger.mjs';
@@ -1606,7 +1607,7 @@ export function supplementalSourcesFor(runtimeSourceIndex) {
1606
1607
  * of one (a customer's own rows, merged with the catalogue's) parses the lock
1607
1608
  * it completes. `resolveImages` is these rows parsed as the whole lock.
1608
1609
  */
1609
- export async function resolveImageRows(id, { inputs, lock } = {}) {
1610
+ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed } = {}) {
1610
1611
  assertCanonicalDistributionId(id);
1611
1612
  if ((inputs === undefined) !== (lock === undefined)) {
1612
1613
  throw new Error('resolveImages takes the snapshot `inputs` and its distribution `lock` together, or neither');
@@ -1662,8 +1663,8 @@ export async function resolveImageRows(id, { inputs, lock } = {}) {
1662
1663
  // made that edition-independent quantity depend on the edition, which failed
1663
1664
  // in both directions at once:
1664
1665
  // • it is UNCOMPUTABLE for an edition that does not itself resolve the
1665
- // source repo — `appliance-lean` and `minimal` do not ship the xema-store
1666
- // biome, so re-resolving either one threw here and their locks could not
1666
+ // source repo — `appliance-lean` does not ship the xema-store
1667
+ // biome, so re-resolving it threw here and their locks could not
1667
1668
  // be regenerated at all, even though the image they name is the very one
1668
1669
  // the other six editions resolve fine;
1669
1670
  // • had it not thrown it would have been WORSE, silently minting a second
@@ -1704,14 +1705,15 @@ export async function resolveImageRows(id, { inputs, lock } = {}) {
1704
1705
  ...(crossRepoInputsByConsumer.get(`${subject.repo}\u0000`) ?? []),
1705
1706
  ...(crossRepoInputsByConsumer.get(`${subject.repo}\u0000${subject.id}`) ?? []),
1706
1707
  ];
1707
- const subjects = structure.subjects.map((subject) => {
1708
+ const subjects = [];
1709
+ for (const subject of structure.subjects) {
1708
1710
  const sourceCommit = commitsBefore.get(subject.repo);
1709
1711
  const repoDirectory = locate(subject.repo).dir;
1710
1712
  const servicePath = subjectServicePath(subject, sourceCommit, locate);
1711
1713
  const { sourceManifestPath: ignoredSourceManifestPath, ...lockedSubject } =
1712
1714
  subject;
1713
1715
  void ignoredSourceManifestPath;
1714
- return {
1716
+ const hashed = {
1715
1717
  ...lockedSubject,
1716
1718
  sourceCommit,
1717
1719
  contentHash: exactImageBuildInputHash(
@@ -1727,7 +1729,15 @@ export async function resolveImageRows(id, { inputs, lock } = {}) {
1727
1729
  imageInputSourceHashAlgorithm(),
1728
1730
  ),
1729
1731
  };
1730
- });
1732
+ subjects.push(hashed);
1733
+ // The hash is the last thing a registry probe waits for: hand the subject
1734
+ // over now, and let the event loop run so the probes of the subjects
1735
+ // already hashed progress while the rest hash (hashing is synchronous).
1736
+ if (onSubjectHashed !== undefined) {
1737
+ onSubjectHashed(hashed);
1738
+ await yieldToEventLoop();
1739
+ }
1740
+ }
1731
1741
  // Release lane only: a develop snapshot or customer overlay is not covered by
1732
1742
  // the committed ledger.
1733
1743
  if (inputs === undefined) {
@@ -1765,8 +1775,8 @@ export async function resolveImageRows(id, { inputs, lock } = {}) {
1765
1775
  }
1766
1776
 
1767
1777
  /** One edition's COMPLETE image lock: its rows, parsed and validated as the kernel's image lock. */
1768
- export async function resolveImages(id, { inputs, lock } = {}) {
1769
- const { subjects, platformServiceBindings, webBiomes, contractAuthority, commits } = await resolveImageRows(id, { inputs, lock });
1778
+ export async function resolveImages(id, { inputs, lock, onSubjectHashed } = {}) {
1779
+ const { subjects, platformServiceBindings, webBiomes, contractAuthority, commits } = await resolveImageRows(id, { inputs, lock, onSubjectHashed });
1770
1780
  const schemas = await loadKernelDistributionSchemas();
1771
1781
  const imageLock = validateImmutableImageLock(
1772
1782
  schemas.parseImageLock({
@@ -46,6 +46,7 @@ import {
46
46
  loadKernelDistributionSchemas,
47
47
  validateImmutableImageLock,
48
48
  } from './lib.mjs';
49
+ import { mapWithConcurrency } from './map-with-concurrency.mjs';
49
50
  import { OrasRegistry } from './oras.mjs';
50
51
  import {
51
52
  REGISTRY_CREDENTIALS_FILE,
@@ -284,18 +285,20 @@ function missingPlatformMessage({ distributionId, subject, coordinate, missing,
284
285
  );
285
286
  }
286
287
 
287
- async function mapWithConcurrency(items, limit, worker) {
288
- const results = new Array(items.length);
289
- let cursor = 0;
290
- const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
291
- while (cursor < items.length) {
292
- const index = cursor;
293
- cursor += 1;
294
- results[index] = await worker(items[index], index);
295
- }
296
- });
297
- await Promise.all(runners);
298
- return results;
288
+ /**
289
+ * One `OrasRegistry` per registry authority of `subjects`, every credential
290
+ * resolved BEFORE the first request: a run that would die on subject 300 for a
291
+ * missing token must die on subject 0 instead.
292
+ */
293
+ export function registryClientsFor(subjects, { declarations, env, fetchImpl, run }) {
294
+ const clients = new Map();
295
+ for (const subject of subjects) {
296
+ const { authority } = splitImageRef(subject.imageRef);
297
+ if (clients.has(authority)) {continue;}
298
+ const credential = resolveRegistryCredential({ authority, declarations, env });
299
+ clients.set(authority, new OrasRegistry({ credential, fetchImpl, ...(run ? { run } : {}) }));
300
+ }
301
+ return clients;
299
302
  }
300
303
 
301
304
  /**
@@ -335,30 +338,29 @@ export async function verifyDistributionPlatforms({
335
338
  // successes, so a caller can name every missing coordinate at once. It never
336
339
  // swallows one: the caller decides what each failure means.
337
340
  collectFailures = false,
341
+ // `(line) => void`: one line per verified subject, with its elapsed ms; null is silent.
342
+ progress = null,
338
343
  }) {
339
344
  const declarations = loadRegistryCredentialDeclarations(distributionsRoot);
340
345
 
341
- // Resolve every credential BEFORE the first request. A run that would die on
342
- // subject 300 for a missing token must die on subject 0 instead.
343
- const clients = new Map();
344
- for (const subject of imageLock.subjects) {
345
- const { authority } = splitImageRef(subject.imageRef);
346
- if (clients.has(authority)) {continue;}
347
- const credential = resolveRegistryCredential({ authority, declarations, env });
348
- clients.set(
349
- authority,
350
- new OrasRegistry({ credential, fetchImpl, ...(run ? { run } : {}) }),
351
- );
352
- }
346
+ const clients = registryClientsFor(imageLock.subjects, { declarations, env, fetchImpl, run });
353
347
 
354
348
  const failures = [];
355
349
  const verified = await mapWithConcurrency(
356
350
  imageLock.subjects,
357
351
  concurrency,
358
- async (subject) => {
352
+ async (subject, index) => {
353
+ const startedAt = Date.now();
359
354
  try {
360
- return await verifySubject(subject);
355
+ const verifiedSubject = await verifySubject(subject);
356
+ progress?.(
357
+ `[seal] ${index + 1}/${imageLock.subjects.length} ${subject.kind}:${subject.id} ok ${Date.now() - startedAt}ms`,
358
+ );
359
+ return verifiedSubject;
361
360
  } catch (error) {
361
+ progress?.(
362
+ `[seal] ${index + 1}/${imageLock.subjects.length} ${subject.kind}:${subject.id} FAILED ${Date.now() - startedAt}ms`,
363
+ );
362
364
  if (!collectFailures) {throw error;}
363
365
  failures.push({ subject, error });
364
366
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/xema",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/xema-dev/xema-cli.git",
@@ -92,6 +92,6 @@
92
92
  "typecheck": "tsc --noEmit"
93
93
  },
94
94
  "xemaRelease": {
95
- "sourceDigest": "sha256:7df2765700a493e798389fee5d75e5a405b33634922715a3d6bf56990bf15cd4"
95
+ "sourceDigest": "sha256:e3d77e6ac0d871437aec5ec5749446091e4a65c86ce04df7900b22954ae71308"
96
96
  }
97
97
  }