@xemahq/xema 0.38.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.
- package/README.md +1 -1
- package/dist/assets/distribution-assets.manifest.json +2 -70
- package/dist/commands/biome-dev.js +2 -3
- package/dist/commands/biome-dev.js.map +1 -1
- package/dist/commands/dev.js +2 -3
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +3 -2
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +21 -9
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/installation.d.ts.map +1 -1
- package/dist/commands/installation.js +7 -5
- package/dist/commands/installation.js.map +1 -1
- package/dist/commands/license.d.ts.map +1 -1
- package/dist/commands/license.js +2 -1
- package/dist/commands/license.js.map +1 -1
- package/dist/commands/serve.js +1 -1
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/up.d.ts.map +1 -1
- package/dist/commands/up.js +3 -5
- package/dist/commands/up.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +14 -2
- package/dist/edition/verify-image-evidence.bundle.provenance.json +41 -41
- package/dist/runtime/config/init-presets.d.ts.map +1 -1
- package/dist/runtime/config/init-presets.js +3 -5
- package/dist/runtime/config/init-presets.js.map +1 -1
- package/dist/runtime/config/loader.d.ts +2 -2
- package/dist/runtime/config/loader.d.ts.map +1 -1
- package/dist/runtime/config/loader.js +1 -1
- package/dist/runtime/config/loader.js.map +1 -1
- package/dist/runtime/config/schema.d.ts +149 -1
- package/dist/runtime/config/schema.d.ts.map +1 -1
- package/dist/runtime/config/schema.js +17 -4
- package/dist/runtime/config/schema.js.map +1 -1
- package/dist/runtime/installation/setup-profile.d.ts +1 -1
- package/dist/runtime/installation/setup-profile.d.ts.map +1 -1
- package/dist/runtime/installation/setup-profile.js +1 -1
- package/dist/runtime/installation/setup-profile.js.map +1 -1
- package/edition/biome-content.mjs +35 -6
- package/edition/build-platform-service-index.mjs +1 -1
- package/edition/develop-edition-resolve.mjs +58 -20
- package/edition/develop-image-probe.mjs +117 -0
- package/edition/develop-seal.mjs +4 -12
- package/edition/dispatched-runtime-components.mjs +1 -1
- package/edition/dist-check.mjs +3 -2
- package/edition/lib.mjs +1 -1
- package/edition/map-with-concurrency.mjs +19 -0
- package/edition/oras-process.mjs +35 -0
- package/edition/oras.mjs +123 -94
- package/edition/resolve-chart-set.mjs +12 -8
- package/edition/resolve-images.mjs +119 -34
- package/edition/verify-image-platforms.mjs +28 -26
- package/edition/workspace-sections.mjs +22 -1
- package/package.json +2 -2
- package/dist/assets/cloud/distribution.lock.json +0 -6447
- package/dist/assets/cloud/images.lock.json +0 -1831
- package/dist/assets/minimal/distribution.lock.json +0 -3297
- package/dist/assets/minimal/images.lock.json +0 -1189
- package/dist/assets/oss/distribution.lock.json +0 -6266
- package/dist/assets/oss/images.lock.json +0 -1750
- package/dist/assets/starter/distribution.lock.json +0 -4165
- 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 "
|
|
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
|
*/
|
package/edition/dist-check.mjs
CHANGED
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
resolveComposedProcessStructure,
|
|
74
74
|
resolveImageStructure,
|
|
75
75
|
sourceTreeLocator,
|
|
76
|
+
supplementalSourcesFor,
|
|
76
77
|
} from './resolve-images.mjs';
|
|
77
78
|
import { withoutSealedDigests } from './seal-image-digests.mjs';
|
|
78
79
|
import { createPinnedSourceAuthority } from './source-authority.mjs';
|
|
@@ -301,7 +302,7 @@ export async function checkImagesLock(id, checkSourceEvidence) {
|
|
|
301
302
|
schemas,
|
|
302
303
|
requiredPlatforms,
|
|
303
304
|
resolveDistributionDeclaredSources(schemas, id),
|
|
304
|
-
{ biomeIndex: pinned.biomeIndex },
|
|
305
|
+
{ biomeIndex: pinned.biomeIndex, supplementalSources: supplementalSourcesFor(runtimeSourceIndex) },
|
|
305
306
|
),
|
|
306
307
|
schemas,
|
|
307
308
|
requiredPlatforms,
|
|
@@ -523,7 +524,7 @@ function discoverDistributionIds() {
|
|
|
523
524
|
*
|
|
524
525
|
* Ownership is a property of the platform, not of what an edition preloads.
|
|
525
526
|
* An earlier design put the claim on each `LockedBiome`, which made a trimmed
|
|
526
|
-
* edition (`
|
|
527
|
+
* edition (`appliance-lean`) leave `kb`/`agent`/… unowned — so an
|
|
527
528
|
* org admin could install the owning biome from the store and THEN bind
|
|
528
529
|
* `kb:page.read@1` to a URL they control, silently shadowing the platform
|
|
529
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
|
-
* (`
|
|
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
|
|
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
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// the
|
|
26
|
-
//
|
|
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 {
|
|
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
|
|
264
|
+
return await runExecutable(prepared.path(), args, {
|
|
258
265
|
input: stdin,
|
|
259
266
|
env: orasEnvironment(prepared.home),
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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
|
|
424
|
-
const
|
|
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
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
if (
|
|
430
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
559
|
-
* its media type is
|
|
560
|
-
* the
|
|
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 #
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|