@xemahq/xema 0.53.0 → 0.55.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 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth.d.ts +9 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +66 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +1 -0
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +2062 -1025
- package/dist/edition/verify-image-evidence.bundle.provenance.json +595 -505
- package/dist/runtime/edition/verb-map.d.ts +2 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +2 -0
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/dist/runtime/lock-loader.d.ts +2 -1
- package/dist/runtime/lock-loader.d.ts.map +1 -1
- package/dist/runtime/lock-loader.js +15 -5
- package/dist/runtime/lock-loader.js.map +1 -1
- package/edition/README.md +61 -4
- package/edition/adapter-bundled-versions.mjs +2 -1
- package/edition/authorization-base.mjs +9 -8
- package/edition/biome-content.mjs +23 -29
- package/edition/candidate-edition.mjs +692 -0
- package/edition/check-committed-image-evidence.mjs +44 -15
- package/edition/checkout-indexed-source-repositories.mjs +6 -8
- package/edition/collected-refusals.mjs +59 -0
- package/edition/contract-authority.mjs +122 -0
- package/edition/cosign-certificate-constraints.mjs +18 -8
- package/edition/customer-edition-verbs.mjs +43 -10
- package/edition/develop-chart-set.mjs +7 -6
- package/edition/develop-current-resolution.mjs +29 -0
- package/edition/develop-edition-invariants.mjs +2 -1
- package/edition/develop-edition-resolve.mjs +110 -31
- package/edition/develop-edition.mjs +73 -11
- package/edition/develop-engine-image.mjs +95 -0
- package/edition/develop-freeze.mjs +24 -1
- package/edition/develop-graph.mjs +5 -3
- package/edition/develop-image-probe.mjs +8 -5
- package/edition/develop-image-record.mjs +194 -0
- package/edition/develop-record-ancestry.mjs +23 -5
- package/edition/develop-resolve-report.mjs +159 -0
- package/edition/develop-select-records.mjs +29 -21
- package/edition/develop-selection-line.mjs +30 -0
- package/edition/develop-selection-pins.mjs +30 -0
- package/edition/develop-snapshot-inputs.mjs +4 -3
- package/edition/develop-verify-image-record.mjs +23 -4
- package/edition/dist-check.mjs +45 -9
- package/edition/distribution-evidence-set.mjs +14 -9
- package/edition/edition-artifact.mjs +17 -7
- package/edition/edition-image-seal.mjs +2 -1
- package/edition/edition-protocol.mjs +8 -19
- package/edition/image-evidence.mjs +6 -2
- package/edition/image-lock-service-rows.mjs +4 -4
- package/edition/image-subject-identity.mjs +24 -0
- package/edition/images-from-record.mjs +298 -0
- package/edition/lib.mjs +25 -45
- package/edition/locked-bundle-signer.mjs +15 -3
- package/edition/operation-grant-admission.mjs +51 -9
- package/edition/resolve-customer-edition.mjs +1 -1
- package/edition/resolve-distribution.mjs +95 -82
- package/edition/resolve-edition.mjs +2 -1
- package/edition/resolve-images.mjs +233 -14
- package/edition/seal-image-digests.mjs +24 -13
- package/edition/shallow-checkout.mjs +18 -0
- package/edition/verify-image-platforms.mjs +2 -0
- package/edition/workspace-sections.mjs +33 -3
- package/package.json +7 -3
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// images-from-record — a develop resolve's image digests taken from a BUILD RECORD, with no registry probe and no rehash.
|
|
2
|
+
//
|
|
3
|
+
// The everyday job builds (or reuses by content-hash tag) the edition's images in the aggregator workspace and hands
|
|
4
|
+
// `edition develop resolve --images-from <file>` the record of what it built. Each entry names one image subject of
|
|
5
|
+
// the edition by the identity images.lock.json and the seal key it by (`<kind>:<id>`), and carries
|
|
6
|
+
// `{repository, contentHash, digest, platforms, sourceSha}`:
|
|
7
|
+
//
|
|
8
|
+
// images what the job built or reused itself. Trusted as the job's own statement: the job signs the edition.
|
|
9
|
+
// satellites a satellite producer's develop digest (a first-party source that is not built in the workspace, such
|
|
10
|
+
// as submodules/xema-host-web). Admitted only after a cosign verify that the producer's own develop
|
|
11
|
+
// ci.yaml build record, signed by the data root's record workflow called by that producer
|
|
12
|
+
// (develop-record-verify.mjs), lists exactly this repository, digest and content hash at sourceSha.
|
|
13
|
+
// The producer is the subject's declared `firstPartySources[repo].repositoryUrl`, never the record's.
|
|
14
|
+
//
|
|
15
|
+
// Which list an entry sits in is not the record's choice: each first-party source declares its `imageOrigin` once in
|
|
16
|
+
// `firstPartySources` (FirstPartyImageOrigin: `workspace` -> `images`, `satellite` -> `satellites`). An entry in the
|
|
17
|
+
// other list, or for a source that declares no origin, is refused, so no satellite is trusted without its verify.
|
|
18
|
+
//
|
|
19
|
+
// Coverage is exact: every first-party subject of the edition comes from exactly one entry, and every entry is a
|
|
20
|
+
// subject of the edition. A missing, duplicated or extra subject, a sourceSha other than the commit the snapshot pins
|
|
21
|
+
// for the subject's repository, a repository other than the subject's develop repository, or a platform set without
|
|
22
|
+
// every required platform refuses the whole resolve, every refusal named at once. There is no fallback to the probe.
|
|
23
|
+
// contentHash and sourceSha are carried into the images lock where the probe path writes them, never recomputed.
|
|
24
|
+
// A third-party subject (a pinned OCI biome's image) is not built by the job: it keeps its pinned seal, and a record
|
|
25
|
+
// entry naming one is an extra subject.
|
|
26
|
+
//
|
|
27
|
+
// The record schema is the kernel's (`ImageBuildRecordSchema` / `parseImageBuildRecord`, @xemahq/contracts, edition
|
|
28
|
+
// protocol v41), loaded through the kernel loader; this module owns no parser of its own.
|
|
29
|
+
|
|
30
|
+
import { canonicalJson } from './canonical-evidence-json.mjs';
|
|
31
|
+
import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
|
|
32
|
+
import { RefusalError } from './collected-refusals.mjs';
|
|
33
|
+
import { developImageRef } from './develop-image-coordinate.mjs';
|
|
34
|
+
import { withRecordVerifier } from './develop-record-verify.mjs';
|
|
35
|
+
import { findVerifiedProducerImageRecord } from './develop-verify-image-record.mjs';
|
|
36
|
+
import { EditionChannel } from './edition-channel.mjs';
|
|
37
|
+
import { sealImageSubjects } from './edition-image-seal.mjs';
|
|
38
|
+
import { firstPartySources } from './first-party-source-ids.mjs';
|
|
39
|
+
import { compareStrings, loadKernelDistributionSchemas, sha256Hex, validateImmutableImageLock } from './lib.mjs';
|
|
40
|
+
import { RecordRefused } from './record-refused.mjs';
|
|
41
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
42
|
+
import { FirstPartyImageOrigin } from './workspace-sections.mjs';
|
|
43
|
+
|
|
44
|
+
/** Which list of the record an entry came from: how far it is trusted. */
|
|
45
|
+
export const RecordedImageOrigin = Object.freeze({
|
|
46
|
+
Built: 'images',
|
|
47
|
+
Satellite: 'satellites',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** The one record list each declared image origin admits. */
|
|
51
|
+
const LIST_OF_ORIGIN = Object.freeze({
|
|
52
|
+
[FirstPartyImageOrigin.Workspace]: RecordedImageOrigin.Built,
|
|
53
|
+
[FirstPartyImageOrigin.Satellite]: RecordedImageOrigin.Satellite,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** A build record that is malformed, or does not cover the edition exactly. */
|
|
57
|
+
export class ImageBuildRecordError extends RefusalError {
|
|
58
|
+
constructor(message) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = 'ImageBuildRecordError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The subject key the image lock, the probe and the seal use. */
|
|
65
|
+
export const subjectKeyOf = (subject) => `${subject.kind}:${subject.id}`;
|
|
66
|
+
|
|
67
|
+
const platformKey = (platform) => [platform.os, platform.architecture, platform.variant ?? ''].join('/');
|
|
68
|
+
|
|
69
|
+
/** The kernel's image-build-record exports, refused as a set when any is absent (a kernel before edition protocol v41). */
|
|
70
|
+
export function requireImageBuildRecordKernel(schemas) {
|
|
71
|
+
const names = ['ImageBuildRecordSchema', 'parseImageBuildRecord', 'IMAGE_BUILD_RECORD_SCHEMA_VERSION'];
|
|
72
|
+
const missing = names.filter((name) => schemas?.[name] === undefined);
|
|
73
|
+
if (missing.length > 0) {
|
|
74
|
+
throw new ImageBuildRecordError(`the kernel's distribution module does not export ${missing.join(', ')}; the image build record needs a kernel at edition protocol v41 or newer`);
|
|
75
|
+
}
|
|
76
|
+
return schemas;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse an image build record with the kernel's own parser (`parseImageBuildRecord`, the ONE contract: both the writer
|
|
81
|
+
* `develop image-record` and this reader go through it): `{schemaVersion: 1, images: [entry], satellites: [entry]}`, every
|
|
82
|
+
* list sorted by subject, no subject twice, no unknown key. A refusal is an `ImageBuildRecordError` carrying the kernel's message.
|
|
83
|
+
*
|
|
84
|
+
* @param {unknown} document
|
|
85
|
+
* @param {string} source where the record came from (named in the refusal)
|
|
86
|
+
* @param {{loadKernel?: () => Promise<object>}} [injections] tests replace the kernel loader
|
|
87
|
+
*/
|
|
88
|
+
export async function parseImageBuildRecord(document, source, { loadKernel = loadKernelDistributionSchemas } = {}) {
|
|
89
|
+
const kernel = requireImageBuildRecordKernel(await loadKernel());
|
|
90
|
+
try {
|
|
91
|
+
return kernel.parseImageBuildRecord(document);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new ImageBuildRecordError(`image build record ${source} is refused: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* sha256 (hex) of the parsed record's canonical JSON (canonical-evidence-json.mjs: keys sorted), every list in its
|
|
99
|
+
* canonical order: entries by subject (parse enforces it) and each entry's platforms by os/architecture/variant. It is
|
|
100
|
+
* the `imageBuildRecord` input of the develop edition's target tag (snapshotEditionTag).
|
|
101
|
+
*/
|
|
102
|
+
export function imageBuildRecordDigest(record) {
|
|
103
|
+
const canonicalEntry = (entry) => ({ ...entry, platforms: [...entry.platforms].sort((a, b) => compareStrings(platformKey(a), platformKey(b))) });
|
|
104
|
+
return sha256Hex(canonicalJson({
|
|
105
|
+
schemaVersion: record.schemaVersion,
|
|
106
|
+
...Object.fromEntries(Object.values(RecordedImageOrigin).map((origin) => [origin, record[origin].map(canonicalEntry)])),
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read and parse the record file `--images-from` names (duplicate JSON keys refused). */
|
|
111
|
+
export async function readImageBuildRecord(path) {
|
|
112
|
+
return parseImageBuildRecord(readStrictJsonFile(path), path);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The record as `resolveImages` consumes it (`recordedImages`): `take` gives each first-party subject its recorded
|
|
117
|
+
* content hash and source commit, and `settle` refuses, all at once, every subject the record does not cover exactly.
|
|
118
|
+
*
|
|
119
|
+
* Each subject's entry must sit in the list its source's declared `imageOrigin` names (LIST_OF_ORIGIN); an entry in the
|
|
120
|
+
* other list, or a source with no declared origin, is one more problem `settle` names.
|
|
121
|
+
*
|
|
122
|
+
* @param {{images: object[], satellites: object[]}} record a parsed record
|
|
123
|
+
* @param {{distributionId: string, declaredSources?: object}} context `declaredSources` is `firstPartySources` (tests pass it)
|
|
124
|
+
*/
|
|
125
|
+
export function createRecordedImages(record, { distributionId, declaredSources = firstPartySources('checking each build record entry against its source\'s declared image origin') }) {
|
|
126
|
+
const entries = new Map();
|
|
127
|
+
for (const origin of Object.values(RecordedImageOrigin)) {
|
|
128
|
+
for (const entry of record[origin]) {entries.set(entry.subject, { origin, entry });}
|
|
129
|
+
}
|
|
130
|
+
const taken = new Map();
|
|
131
|
+
const problems = [];
|
|
132
|
+
let settled = false;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
take(subject) {
|
|
136
|
+
const key = subjectKeyOf(subject);
|
|
137
|
+
const recorded = entries.get(key);
|
|
138
|
+
if (recorded === undefined) {
|
|
139
|
+
problems.push(`${key} (${subject.repo}@${subject.sourceCommit}) is not in the record`);
|
|
140
|
+
return { sourceCommit: subject.sourceCommit, contentHash: null };
|
|
141
|
+
}
|
|
142
|
+
if (taken.has(key)) {throw new Error(`images-from-record: subject ${key} was taken twice`);}
|
|
143
|
+
const { entry, origin } = recorded;
|
|
144
|
+
const listProblem = recordListProblem(key, subject, origin, declaredSources);
|
|
145
|
+
if (listProblem !== null) {problems.push(listProblem);}
|
|
146
|
+
if (entry.sourceSha !== subject.sourceCommit) {
|
|
147
|
+
problems.push(`${key}: the record's sourceSha ${entry.sourceSha} is not ${subject.sourceCommit}, the commit the snapshot pins for ${subject.repo}`);
|
|
148
|
+
}
|
|
149
|
+
const repository = developImageRef(subject.imageRef);
|
|
150
|
+
if (entry.repository !== repository) {
|
|
151
|
+
problems.push(`${key}: the record's repository ${entry.repository} is not its develop repository ${repository}`);
|
|
152
|
+
}
|
|
153
|
+
const recordedPlatforms = new Set(entry.platforms.map(platformKey));
|
|
154
|
+
const absent = subject.requiredPlatforms.map(platformKey).filter((platform) => !recordedPlatforms.has(platform));
|
|
155
|
+
if (absent.length > 0) {problems.push(`${key}: the record's platforms lack the required ${absent.join(', ')}`);}
|
|
156
|
+
taken.set(key, { subject, ...recorded });
|
|
157
|
+
return { sourceCommit: entry.sourceSha, contentHash: entry.contentHash, ...recordedMembers(key, subject, entry, problems) };
|
|
158
|
+
},
|
|
159
|
+
settle() {
|
|
160
|
+
settled = true;
|
|
161
|
+
const extra = [...entries.keys()].filter((key) => !taken.has(key)).map((key) => `${key} (record.${entries.get(key).origin}) is not a first-party image subject of "${distributionId}"`);
|
|
162
|
+
const all = [...problems, ...extra];
|
|
163
|
+
if (all.length > 0) {
|
|
164
|
+
throw new ImageBuildRecordError(`the image build record does not cover "${distributionId}" exactly (${all.length} problem(s); no probe fallback):\n ${all.join('\n ')}`);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
/** The satellite entries taken, each with the subject it covers, for the cosign verify. */
|
|
168
|
+
satelliteClaims() {
|
|
169
|
+
if (!settled) {throw new Error('images-from-record: satellite claims are read only after settle()');}
|
|
170
|
+
return [...taken.values()].filter(({ origin }) => origin === RecordedImageOrigin.Satellite).map(({ subject, entry }) => ({ subject, entry }));
|
|
171
|
+
},
|
|
172
|
+
/** The recorded entry of a subject, or undefined for a subject the record does not cover (a third-party one). */
|
|
173
|
+
entryOf(subject) {
|
|
174
|
+
return taken.get(subjectKeyOf(subject))?.entry;
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A composed subject's members with the keys its record entry carries (`members[{service, key}]`), never computed here.
|
|
181
|
+
* An entry without members, or whose services are not the subject's members, is a problem named once; a subject that is
|
|
182
|
+
* not composed gives nothing.
|
|
183
|
+
*/
|
|
184
|
+
function recordedMembers(key, subject, entry, problems) {
|
|
185
|
+
if (subject.members === undefined) {return {};}
|
|
186
|
+
if (entry.members === undefined) {
|
|
187
|
+
problems.push(`${key}: the record's composed entry carries no members[{service, key}]`);
|
|
188
|
+
return {};
|
|
189
|
+
}
|
|
190
|
+
const keyOf = new Map(entry.members.map((member) => [member.service, member.key]));
|
|
191
|
+
const wanted = subject.members.map((member) => member.service);
|
|
192
|
+
const absent = wanted.filter((service) => !keyOf.has(service));
|
|
193
|
+
const extra = [...keyOf.keys()].filter((service) => !wanted.includes(service));
|
|
194
|
+
if (absent.length > 0 || extra.length > 0) {
|
|
195
|
+
problems.push(`${key}: the record's members differ from the subject's${absent.length > 0 ? ` (no key for ${absent.join(', ')})` : ''}${extra.length > 0 ? ` (not members: ${extra.join(', ')})` : ''}`);
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
return { members: subject.members.map((member) => ({ ...member, key: keyOf.get(member.service) })) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Why the record list `origin` may not carry the subject's entry, or null when its source's declared origin names it. */
|
|
202
|
+
function recordListProblem(key, subject, origin, declaredSources) {
|
|
203
|
+
if (!Object.hasOwn(declaredSources, subject.repo)) {
|
|
204
|
+
return `${key} (record.${origin}): ${subject.repo} is not a declared first-party source`;
|
|
205
|
+
}
|
|
206
|
+
const declared = declaredSources[subject.repo].imageOrigin;
|
|
207
|
+
if (declared === undefined) {
|
|
208
|
+
return `${key} (record.${origin}): firstPartySources["${subject.repo}"] declares no imageOrigin (one of ${Object.values(FirstPartyImageOrigin).join(', ')}); --images-from admits no entry of an unconfigured source`;
|
|
209
|
+
}
|
|
210
|
+
const wanted = LIST_OF_ORIGIN[declared];
|
|
211
|
+
if (origin !== wanted) {
|
|
212
|
+
return `${key} is in record.${origin}, but firstPartySources["${subject.repo}"].imageOrigin is "${declared}": its entry belongs in record.${wanted}`;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The `owner/name` a satellite subject's develop identity is bound to: its repository's declared URL. */
|
|
218
|
+
export function satelliteProducerOf(subject) {
|
|
219
|
+
const sources = firstPartySources('naming a satellite producer');
|
|
220
|
+
if (!Object.hasOwn(sources, subject.repo)) {
|
|
221
|
+
throw new ImageBuildRecordError(`${subjectKeyOf(subject)}: ${subject.repo} is not a declared first-party source; a satellite producer is never named by the record`);
|
|
222
|
+
}
|
|
223
|
+
const context = `firstPartySources["${subject.repo}"].repositoryUrl`;
|
|
224
|
+
return repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(sources[subject.repo].repositoryUrl, context), context);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The cosign verifier of satellite claims: for each, the producer's newest verified develop ci.yaml build record
|
|
229
|
+
* (develop-record-verify.mjs: keyless, offline against the pinned trusted root, issuer
|
|
230
|
+
* https://token.actions.githubusercontent.com, identity the data root's record workflow at refs/heads/develop, called by
|
|
231
|
+
* the producer) whose commit is the claim's sourceSha and which lists its repository, digest and content hash. Every
|
|
232
|
+
* refused claim is named at once; a registry or cosign that could not answer is a plain Error. The registry is read
|
|
233
|
+
* for the records and signatures only: no digest is discovered and nothing is rehashed.
|
|
234
|
+
*
|
|
235
|
+
* @param {object} request
|
|
236
|
+
* @param {string} request.registry the registry root that holds `build-records/<repo>`
|
|
237
|
+
* @param {string} request.cosignBinary absolute path of cosign
|
|
238
|
+
* @param {Function} [request.withVerifier] the pinned record verifier scope (tests replace it, to run offline)
|
|
239
|
+
* @param {Function} [request.findRecord] the newest-first verified record walk (tests replace it)
|
|
240
|
+
* @returns {(claims: {subject: object, entry: object}[]) => Promise<void>}
|
|
241
|
+
*/
|
|
242
|
+
export function createSatelliteVerifier({ registry, cosignBinary, env = process.env, withVerifier = withRecordVerifier, findRecord = findVerifiedProducerImageRecord, ...verifierInjections }) {
|
|
243
|
+
return async (claims) => {
|
|
244
|
+
if (claims.length === 0) {return;}
|
|
245
|
+
await withVerifier({ cosignBinary, env, ...verifierInjections }, async (verifier) => {
|
|
246
|
+
const refused = [];
|
|
247
|
+
for (const { subject, entry } of claims) {
|
|
248
|
+
try {
|
|
249
|
+
await findRecord({
|
|
250
|
+
...verifier,
|
|
251
|
+
registry,
|
|
252
|
+
producer: satelliteProducerOf(subject),
|
|
253
|
+
wanted: `at ${entry.sourceSha} lists ${entry.repository}@${entry.digest} with content hash ${entry.contentHash}`,
|
|
254
|
+
matches: (record) =>
|
|
255
|
+
record.commit === entry.sourceSha &&
|
|
256
|
+
record.images.some((listed) => listed.name === entry.repository && listed.digest === entry.digest && listed.contentHash === entry.contentHash),
|
|
257
|
+
});
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (!(error instanceof RecordRefused)) {throw error;}
|
|
260
|
+
refused.push(`${subjectKeyOf(subject)}: ${error.message}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (refused.length > 0) {
|
|
264
|
+
throw new ImageBuildRecordError(`${refused.length} satellite image(s) of the build record are not verified:\n ${refused.join('\n ')}`);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The develop images lock of a snapshot from the record: the subjects as `resolveImages` structures them, each
|
|
272
|
+
* first-party one with the record's content hash and source commit, coverage settled, satellites verified.
|
|
273
|
+
*
|
|
274
|
+
* @returns {Promise<{imageLock: object, recorded: ReturnType<typeof createRecordedImages>}>}
|
|
275
|
+
*/
|
|
276
|
+
export async function resolveImagesFromRecord({ id, inputs, lock, record, resolveImages, verifySatellites, previousLock = null }) {
|
|
277
|
+
const recorded = createRecordedImages(record, { distributionId: id });
|
|
278
|
+
const { imageLock } = await resolveImages(id, { inputs, lock, recordedImages: recorded, previousLock });
|
|
279
|
+
await verifySatellites(recorded.satelliteClaims());
|
|
280
|
+
return { imageLock, recorded };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Seal the lock from the record: each recorded subject takes the record's develop repository and digest, with no
|
|
285
|
+
* registry read. A third-party subject (the only kind the record does not cover) keeps its pinned seal.
|
|
286
|
+
*/
|
|
287
|
+
export async function sealImagesFromRecord({ imageLock, recorded, thirdPartyPins, ...sealOptions }) {
|
|
288
|
+
const unrecorded = imageLock.subjects.filter((subject) => recorded.entryOf(subject) === undefined);
|
|
289
|
+
const pinned = unrecorded.length === 0 ? [] : await sealImageSubjects({ channel: EditionChannel.Develop, distributionId: imageLock.distributionId, subjects: unrecorded, thirdPartyPins, ...sealOptions });
|
|
290
|
+
const pinnedByKey = new Map(pinned.map((subject) => [subjectKeyOf(subject), subject]));
|
|
291
|
+
const subjects = imageLock.subjects.map((subject) => {
|
|
292
|
+
const entry = recorded.entryOf(subject);
|
|
293
|
+
if (entry === undefined) {return pinnedByKey.get(subjectKeyOf(subject));}
|
|
294
|
+
return { ...subject, imageRef: entry.repository, imageDigest: entry.digest };
|
|
295
|
+
});
|
|
296
|
+
const schemas = await loadKernelDistributionSchemas();
|
|
297
|
+
return validateImmutableImageLock(schemas.parseImageLock({ ...imageLock, subjects }), schemas);
|
|
298
|
+
}
|
package/edition/lib.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
} from './edition-workspace.mjs';
|
|
37
37
|
import { SHA256_HEX_PATTERN } from './evidence-grammars.mjs';
|
|
38
38
|
import { readGitJsonAtCommit } from './git-json.mjs';
|
|
39
|
+
import { imageSubjectIdentity } from './image-subject-identity.mjs';
|
|
39
40
|
import { isKernelCapabilityDomain } from './kernel-capability-domain.mjs';
|
|
40
41
|
import {
|
|
41
42
|
PRINCIPAL_PRIVILEGE_FIELDS,
|
|
@@ -719,23 +720,6 @@ export function assertKernelSealsComposedMembership(schemas, subjects, label) {
|
|
|
719
720
|
);
|
|
720
721
|
}
|
|
721
722
|
|
|
722
|
-
/**
|
|
723
|
-
* The READ side of the same rule, for a kernel before image-lock
|
|
724
|
-
* schemaVersion 4 (no `ImageLockSchemaVersion`): it reads only schemaVersion
|
|
725
|
-
* 3, which names no composed members, so a v3 lock carrying a composed-process
|
|
726
|
-
* subject cannot say which services run from it and is refused (worded as the
|
|
727
|
-
* kernel's own `LegacyComposedImageLockError`).
|
|
728
|
-
*/
|
|
729
|
-
export function assertLegacyLockHasNoComposedSubject(lock) {
|
|
730
|
-
const composed = (lock.subjects ?? []).find((subject) => subject.kind === 'composed-process');
|
|
731
|
-
if (composed === undefined) {return;}
|
|
732
|
-
throw new ComposedProcessNeedsLockV4Error(
|
|
733
|
-
`distribution "${lock.distributionId}": subjects "${composed.id}" is a schemaVersion ` +
|
|
734
|
-
`${String(lock.schemaVersion)} composed-process subject, which names no members: regenerate with a CLI ` +
|
|
735
|
-
'that writes image-lock schemaVersion 4, against a kernel at edition protocol >= v14',
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
723
|
/**
|
|
740
724
|
* The image-lock schemaVersion a writer emits for `subjects`: the loaded
|
|
741
725
|
* kernel's `IMAGE_LOCK_WRITE_SCHEMA_VERSION`. A kernel before edition protocol
|
|
@@ -800,7 +784,10 @@ export async function loadKernelDistributionSchemas() {
|
|
|
800
784
|
parseOciDigest,
|
|
801
785
|
parseGitTreeId,
|
|
802
786
|
parseImageLock,
|
|
803
|
-
|
|
787
|
+
dedicatedServiceImageSubjects,
|
|
788
|
+
carryForwardComposedArtifacts,
|
|
789
|
+
DEFAULT_COMPOSED_IMAGE_CLASS,
|
|
790
|
+
ComposedImageClassSchema,
|
|
804
791
|
IMAGE_LOCK_WRITE_SCHEMA_VERSION,
|
|
805
792
|
ImageLockSchemaVersion,
|
|
806
793
|
parseImageProducerStatement,
|
|
@@ -893,7 +880,10 @@ export async function loadKernelDistributionSchemas() {
|
|
|
893
880
|
typeof parseOciDigest !== 'function' ||
|
|
894
881
|
typeof parseGitTreeId !== 'function' ||
|
|
895
882
|
typeof parseImageLock !== 'function' ||
|
|
896
|
-
typeof
|
|
883
|
+
typeof dedicatedServiceImageSubjects !== 'function' ||
|
|
884
|
+
typeof carryForwardComposedArtifacts !== 'function' ||
|
|
885
|
+
typeof DEFAULT_COMPOSED_IMAGE_CLASS !== 'string' ||
|
|
886
|
+
typeof ComposedImageClassSchema?.safeParse !== 'function' ||
|
|
897
887
|
typeof parseImageProducerStatement !== 'function' ||
|
|
898
888
|
typeof parseDistributionImagesStatement !== 'function' ||
|
|
899
889
|
typeof parseImageDeliveryEntitlementStatement !== 'function' ||
|
|
@@ -997,6 +987,8 @@ export async function loadKernelDistributionSchemas() {
|
|
|
997
987
|
// The build-record exports (absent from a kernel before the build-record contract): carried as-is, NOT required
|
|
998
988
|
// here, so every other verb still loads an older kernel. `select-records` refuses a kernel that lacks them.
|
|
999
989
|
const { BUILD_RECORD_MEDIA_TYPE, BuildRecordSchema, buildRecordTag, parseBuildRecordTag, compareBuildRecordRuns, parseBuildRecord } = mod;
|
|
990
|
+
// The image-build-record exports (edition protocol v41): the same, carried as-is; `develop image-record` and `--images-from` refuse an older kernel.
|
|
991
|
+
const { IMAGE_BUILD_RECORD_SCHEMA_VERSION, ImageBuildRecordSchema, parseImageBuildRecord } = mod;
|
|
1000
992
|
return {
|
|
1001
993
|
BUILD_RECORD_MEDIA_TYPE,
|
|
1002
994
|
BuildRecordSchema,
|
|
@@ -1004,6 +996,9 @@ export async function loadKernelDistributionSchemas() {
|
|
|
1004
996
|
parseBuildRecordTag,
|
|
1005
997
|
compareBuildRecordRuns,
|
|
1006
998
|
parseBuildRecord,
|
|
999
|
+
IMAGE_BUILD_RECORD_SCHEMA_VERSION,
|
|
1000
|
+
ImageBuildRecordSchema,
|
|
1001
|
+
parseImageBuildRecord,
|
|
1007
1002
|
parseDistribution,
|
|
1008
1003
|
parseDistributionLock,
|
|
1009
1004
|
parseDistributionLockForReading,
|
|
@@ -1022,7 +1017,10 @@ export async function loadKernelDistributionSchemas() {
|
|
|
1022
1017
|
parseOciDigest,
|
|
1023
1018
|
parseGitTreeId,
|
|
1024
1019
|
parseImageLock,
|
|
1025
|
-
|
|
1020
|
+
dedicatedServiceImageSubjects,
|
|
1021
|
+
carryForwardComposedArtifacts,
|
|
1022
|
+
DEFAULT_COMPOSED_IMAGE_CLASS,
|
|
1023
|
+
ComposedImageClassSchema,
|
|
1026
1024
|
IMAGE_LOCK_WRITE_SCHEMA_VERSION,
|
|
1027
1025
|
ImageLockSchemaVersion,
|
|
1028
1026
|
parseImageProducerStatement,
|
|
@@ -1981,6 +1979,7 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
|
|
|
1981
1979
|
logicalName: requirement.logicalName,
|
|
1982
1980
|
scope: requirement.scope,
|
|
1983
1981
|
inclusion: requirement.inclusion,
|
|
1982
|
+
...(requirement.sharing === undefined ? {} : { sharing: requirement.sharing }),
|
|
1984
1983
|
});
|
|
1985
1984
|
}
|
|
1986
1985
|
for (const provision of provisions) {
|
|
@@ -2097,25 +2096,6 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
|
|
|
2097
2096
|
providesResources.sort(
|
|
2098
2097
|
(a, b) => compareStrings(a.service, b.service) || compareStrings(a.kind, b.kind),
|
|
2099
2098
|
);
|
|
2100
|
-
// The provider derives a resource's PHYSICAL identity from the owning biome
|
|
2101
|
-
// plus (kind, scope, logicalName). Two consumers of this biome naming the
|
|
2102
|
-
// same tuple would be handed ONE physical resource while each believes it
|
|
2103
|
-
// owns it — so the declaration is refused here, where the biome is the whole
|
|
2104
|
-
// subject, rather than discovered at the provider.
|
|
2105
|
-
const physicalResources = new Map();
|
|
2106
|
-
for (const row of serviceResources) {
|
|
2107
|
-
const identity = `${row.kind}\0${row.scope}\0${row.logicalName}`;
|
|
2108
|
-
const holder = physicalResources.get(identity);
|
|
2109
|
-
if (holder !== undefined && holder !== row.consumerService) {
|
|
2110
|
-
throw new Error(
|
|
2111
|
-
`${rel}: services "${holder}" and "${row.consumerService}" both declare ` +
|
|
2112
|
-
`${row.scope}-scoped ${row.kind} "${row.logicalName}" — biome "${id}" ` +
|
|
2113
|
-
`derives ONE physical resource from that, so two consumers would share ` +
|
|
2114
|
-
`it while each believes it owns it. Give each its own logicalName.`,
|
|
2115
|
-
);
|
|
2116
|
-
}
|
|
2117
|
-
physicalResources.set(identity, row.consumerService);
|
|
2118
|
-
}
|
|
2119
2099
|
// The two arrays partition the biome's images — a name in both would be
|
|
2120
2100
|
// simultaneously rostered and unschedulable.
|
|
2121
2101
|
for (const name of runtimeComponents) {
|
|
@@ -3129,9 +3109,7 @@ export function validateImmutableImageLock(lock, schemas) {
|
|
|
3129
3109
|
throw new Error('trusted kernel distribution schemas are required');
|
|
3130
3110
|
}
|
|
3131
3111
|
assertKernelSealsComposedMembership(schemas, lock.subjects, `distribution "${lock.distributionId}"`);
|
|
3132
|
-
// The revisions the loaded kernel reads (`ImageLockSchemaVersion
|
|
3133
|
-
// composed membership, 4 with it); a kernel before image-lock v4 reads only 3.
|
|
3134
|
-
if (schemas.ImageLockSchemaVersion === undefined) {assertLegacyLockHasNoComposedSubject(lock);}
|
|
3112
|
+
// The revisions the loaded kernel reads (`ImageLockSchemaVersion`).
|
|
3135
3113
|
const readableVersions =
|
|
3136
3114
|
schemas.ImageLockSchemaVersion === undefined
|
|
3137
3115
|
? [PRE_V14_IMAGE_LOCK_SCHEMA_VERSION]
|
|
@@ -3146,11 +3124,13 @@ export function validateImmutableImageLock(lock, schemas) {
|
|
|
3146
3124
|
);
|
|
3147
3125
|
}
|
|
3148
3126
|
const identities = new Set();
|
|
3127
|
+
const namedSubjects = new Set();
|
|
3149
3128
|
for (const subject of lock.subjects ?? []) {
|
|
3150
|
-
|
|
3129
|
+
namedSubjects.add(`${subject.kind}:${subject.id}`);
|
|
3130
|
+
const identity = imageSubjectIdentity(subject);
|
|
3151
3131
|
if (identities.has(identity)) {
|
|
3152
3132
|
throw new Error(
|
|
3153
|
-
`distribution "${lock.distributionId}" has duplicate runtime image subject "${
|
|
3133
|
+
`distribution "${lock.distributionId}" has duplicate runtime image subject "${identity}"`,
|
|
3154
3134
|
);
|
|
3155
3135
|
}
|
|
3156
3136
|
identities.add(identity);
|
|
@@ -3185,7 +3165,7 @@ export function validateImmutableImageLock(lock, schemas) {
|
|
|
3185
3165
|
);
|
|
3186
3166
|
}
|
|
3187
3167
|
platformServices.add(binding.platformService);
|
|
3188
|
-
if (!
|
|
3168
|
+
if (!namedSubjects.has(`${binding.subjectKind}:${binding.subjectId}`)) {
|
|
3189
3169
|
throw new Error(
|
|
3190
3170
|
`distribution "${lock.distributionId}": platform service "${binding.platformService}" binds unknown runtime subject "${binding.subjectKind}:${binding.subjectId}"`,
|
|
3191
3171
|
);
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { createRequire } from 'node:module';
|
|
17
17
|
|
|
18
|
+
import { KernelRefusalName, RefusalError, collectRefusals, isRefusal, joinRefusalMessages } from './collected-refusals.mjs';
|
|
18
19
|
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
19
20
|
import { assertKernelEditionProtocol } from './edition-protocol.mjs';
|
|
20
21
|
import { dataRoot, kernelDistPath, kernelPackageDir } from './edition-workspace.mjs';
|
|
@@ -31,13 +32,16 @@ const PUBLIC_KEY_KIND = 'public-key';
|
|
|
31
32
|
const KERNEL_EXPORTS = Object.freeze(['lockedBundleSignerOf', 'assertLockedBundleSignerMatches', 'LockedBundleSignerUnknownPolicyError', 'LockedBundleSignerAmbiguousPolicyError']);
|
|
32
33
|
|
|
33
34
|
/** A named refusal of the bundle signer plane. */
|
|
34
|
-
class BundleSignerError extends
|
|
35
|
+
export class BundleSignerError extends RefusalError {
|
|
35
36
|
constructor(message) {
|
|
36
37
|
super(message);
|
|
37
38
|
this.name = new.target.name;
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/** Whether `error` is a refusal of the bundle signer plane: this module's own, or one the kernel's signer projection throws. */
|
|
43
|
+
export const isBundleSignerRefusal = (error) => error instanceof BundleSignerError || (isRefusal(error) && Object.values(KernelRefusalName).includes(error.name));
|
|
44
|
+
|
|
41
45
|
/** No trust policy row can be selected for the subject: no pin, no `firstPartyBundleSigner`, or no policy at all. */
|
|
42
46
|
export class BundleSignerPolicyAbsentError extends BundleSignerError {}
|
|
43
47
|
/** The selected row is not of the kind its origin requires. */
|
|
@@ -54,6 +58,14 @@ export class BundleSourceOnGitRowError extends BundleSignerError {}
|
|
|
54
58
|
export class GitSourcedHostModuleBundleError extends BundleSignerError {}
|
|
55
59
|
/** The loaded kernel does not export what the bundle signer plane needs. */
|
|
56
60
|
export class BundleSignerKernelUnsupportedError extends BundleSignerError {}
|
|
61
|
+
/** Several OCI rows were refused in one pass; `.errors` holds each row's own refusal. */
|
|
62
|
+
export class BundleSignerRefusedError extends BundleSignerError {
|
|
63
|
+
/** @param {readonly Error[]} errors */
|
|
64
|
+
constructor(errors) {
|
|
65
|
+
super(`${errors.length} OCI bundle rows are refused:\n\n${joinRefusalMessages(errors)}`);
|
|
66
|
+
this.errors = errors;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
57
69
|
|
|
58
70
|
/** What the kernel must export, validated once: `module` is the loaded kernel `distribution` module. */
|
|
59
71
|
export function bundleSignerKernelFrom(module, location) {
|
|
@@ -234,8 +246,8 @@ export function assertLockBundleSigners({ lock, selection, schemas }) {
|
|
|
234
246
|
const subjects = lockBundleSubjects(lock);
|
|
235
247
|
if (subjects.length === 0) {return;}
|
|
236
248
|
const chosen = selection ?? loadBundleSignerSelection({ dataRoot: dataRoot(), schemas });
|
|
237
|
-
|
|
249
|
+
collectRefusals(subjects, ({ row, subject }) => {
|
|
238
250
|
assertNotGitSourced(subject);
|
|
239
251
|
chosen.assertSealed(subject, row.bundleSigner);
|
|
240
|
-
}
|
|
252
|
+
}, isRefusal, BundleSignerRefusedError);
|
|
241
253
|
}
|
|
@@ -10,13 +10,16 @@
|
|
|
10
10
|
* and the edition's store-workload ceiling are admitted the same way.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { RefusalError, collectRefusals, isRefusal, joinRefusalMessages } from './collected-refusals.mjs';
|
|
14
|
+
|
|
13
15
|
/** Thrown when one or more declared operation requirements are not admitted. */
|
|
14
|
-
export class OperationGrantAdmissionError extends
|
|
16
|
+
export class OperationGrantAdmissionError extends RefusalError {
|
|
15
17
|
/**
|
|
16
18
|
* @param {string} distributionId
|
|
17
19
|
* @param {ReadonlyArray<{code: string, operation: string, message: string}>} refusals
|
|
20
|
+
* @param {ReadonlyArray<{caller: object, operation: string, reason: string}>} [ungrantedOptional] what the kernel left ungranted beside the refusals
|
|
18
21
|
*/
|
|
19
|
-
constructor(distributionId, refusals) {
|
|
22
|
+
constructor(distributionId, refusals, ungrantedOptional = []) {
|
|
20
23
|
super(
|
|
21
24
|
`distribution "${distributionId}" declares operation requirements that are not admitted:\n` +
|
|
22
25
|
refusals.map((refusal) => `[${refusal.code}] ${refusal.message}`).join('\n'),
|
|
@@ -24,6 +27,7 @@ export class OperationGrantAdmissionError extends Error {
|
|
|
24
27
|
this.name = 'OperationGrantAdmissionError';
|
|
25
28
|
this.distributionId = distributionId;
|
|
26
29
|
this.refusals = refusals;
|
|
30
|
+
this.ungrantedOptional = ungrantedOptional;
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -49,7 +53,7 @@ export function assertOperationGrantsAdmitted(schemas, lock, { report } = {}) {
|
|
|
49
53
|
// A platform service's refused requirement fails exactly like a biome component's, through the same error.
|
|
50
54
|
const allRefusals = [...refusals, ...platformServiceRefusals];
|
|
51
55
|
if (allRefusals.length > 0) {
|
|
52
|
-
throw new OperationGrantAdmissionError(lock.distributionId, allRefusals);
|
|
56
|
+
throw new OperationGrantAdmissionError(lock.distributionId, allRefusals, ungrantedOptional);
|
|
53
57
|
}
|
|
54
58
|
if (report !== undefined) {
|
|
55
59
|
for (const line of ungrantedOptionalOperationLines(lock.distributionId, ungrantedOptional)) {report(line);}
|
|
@@ -58,7 +62,7 @@ export function assertOperationGrantsAdmitted(schemas, lock, { report } = {}) {
|
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/** Thrown when one or more declared organization sweeps are not admitted. */
|
|
61
|
-
export class OrganizationSweepAdmissionError extends
|
|
65
|
+
export class OrganizationSweepAdmissionError extends RefusalError {
|
|
62
66
|
/**
|
|
63
67
|
* @param {string} distributionId
|
|
64
68
|
* @param {ReadonlyArray<{code: string, biome: string, component: string, sweep: string, message: string}>} refusals
|
|
@@ -89,7 +93,7 @@ export function assertOrganizationSweepsAdmitted(schemas, lock) {
|
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
/** Thrown when one or more declared exact audiences are not admitted. */
|
|
92
|
-
export class ExactAudienceAdmissionError extends
|
|
96
|
+
export class ExactAudienceAdmissionError extends RefusalError {
|
|
93
97
|
/**
|
|
94
98
|
* @param {string} distributionId
|
|
95
99
|
* @param {ReadonlyArray<{code: string, message: string}>} refusals
|
|
@@ -120,7 +124,7 @@ export function assertExactAudiencesAdmitted(schemas, lock) {
|
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
/** Thrown when one or more requested installed operations are not admitted into the ceiling. */
|
|
123
|
-
export class InstalledOperationCeilingAdmissionError extends
|
|
127
|
+
export class InstalledOperationCeilingAdmissionError extends RefusalError {
|
|
124
128
|
/**
|
|
125
129
|
* @param {string} distributionId
|
|
126
130
|
* @param {ReadonlyArray<{code: string, biome: string, component: string, operation: string, message: string}>} refusals
|
|
@@ -151,7 +155,7 @@ export function assertInstalledOperationCeilingAdmitted(schemas, lock) {
|
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
/** Thrown when one or more domains of the edition's store-workload ceiling are not admitted. */
|
|
154
|
-
export class StoreWorkloadCeilingAdmissionError extends
|
|
158
|
+
export class StoreWorkloadCeilingAdmissionError extends RefusalError {
|
|
155
159
|
/**
|
|
156
160
|
* @param {string} distributionId
|
|
157
161
|
* @param {ReadonlyArray<{code: string, domain: string, message: string}>} refusals
|
|
@@ -182,7 +186,7 @@ export function assertStoreWorkloadCeilingAdmitted(schemas, lock) {
|
|
|
182
186
|
}
|
|
183
187
|
|
|
184
188
|
/** Thrown when one or more declared organization-context relays are not admitted. */
|
|
185
|
-
export class OrgContextRelayAdmissionError extends
|
|
189
|
+
export class OrgContextRelayAdmissionError extends RefusalError {
|
|
186
190
|
/**
|
|
187
191
|
* @param {string} distributionId
|
|
188
192
|
* @param {ReadonlyArray<{code: string, biome: string, component: string, message: string}>} refusals
|
|
@@ -212,7 +216,7 @@ export function assertOrgContextRelaysAdmitted(schemas, lock) {
|
|
|
212
216
|
}
|
|
213
217
|
|
|
214
218
|
/** Thrown when one or more declared event subscription clauses are not admitted. */
|
|
215
|
-
export class EventSubscriptionAdmissionError extends
|
|
219
|
+
export class EventSubscriptionAdmissionError extends RefusalError {
|
|
216
220
|
/**
|
|
217
221
|
* @param {string} distributionId
|
|
218
222
|
* @param {ReadonlyArray<{code: string, biome: string, component: string, message: string}>} refusals
|
|
@@ -241,3 +245,41 @@ export function assertEventSubscriptionsAdmitted(schemas, lock) {
|
|
|
241
245
|
}
|
|
242
246
|
return admission;
|
|
243
247
|
}
|
|
248
|
+
|
|
249
|
+
/** Thrown when several admission categories refuse the same lock; `.errors` holds each category's own error. */
|
|
250
|
+
export class LockAdmissionRefusedError extends RefusalError {
|
|
251
|
+
/** @param {readonly Error[]} errors */
|
|
252
|
+
constructor(errors) {
|
|
253
|
+
super(`the distribution lock is refused by ${errors.length} admission checks:\n\n${joinRefusalMessages(errors)}`);
|
|
254
|
+
this.name = 'LockAdmissionRefusedError';
|
|
255
|
+
this.errors = errors;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Run every admission a lock must pass, in the order the resolve has always run them, and refuse with all of them: one
|
|
261
|
+
* refusal is rethrown unchanged, several are one {@link LockAdmissionRefusedError}. `report` receives the optional
|
|
262
|
+
* operations left ungranted. Nothing here changes `lock`.
|
|
263
|
+
*/
|
|
264
|
+
export function admitDistributionLock(schemas, lock, { report } = {}) {
|
|
265
|
+
// The two ceilings read the lock's own fields, not the outcome of the grants, so they are checked beside them.
|
|
266
|
+
const admissions = [
|
|
267
|
+
() => assertOperationGrantsAdmitted(schemas, lock, { report }),
|
|
268
|
+
() => assertOrganizationSweepsAdmitted(schemas, lock),
|
|
269
|
+
() => assertExactAudiencesAdmitted(schemas, lock),
|
|
270
|
+
() => assertOrgContextRelaysAdmitted(schemas, lock),
|
|
271
|
+
() => assertEventSubscriptionsAdmitted(schemas, lock),
|
|
272
|
+
() => assertInstalledOperationCeilingAdmitted(schemas, lock),
|
|
273
|
+
() => assertStoreWorkloadCeilingAdmitted(schemas, lock),
|
|
274
|
+
];
|
|
275
|
+
collectRefusals(admissions, (admit) => admit(), isRefusal, LockAdmissionRefusedError);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The {@link ungrantedOptionalOperationLines} a refused admission carries: the refusal of the operation grants itself, or the
|
|
280
|
+
* one among several that is. Empty when the grants were not what refused.
|
|
281
|
+
*/
|
|
282
|
+
export function ungrantedOptionalLinesOfRefusal(error) {
|
|
283
|
+
const grantRefusal = [error, ...(error.errors ?? [])].find((candidate) => candidate instanceof OperationGrantAdmissionError);
|
|
284
|
+
return grantRefusal === undefined ? [] : ungrantedOptionalOperationLines(grantRefusal.distributionId, grantRefusal.ungrantedOptional);
|
|
285
|
+
}
|
|
@@ -150,7 +150,7 @@ export function projectVendorImages(catalogue, lock, enums) {
|
|
|
150
150
|
...(biome.runtimeComponents ?? []).map((id) => [enums.RuntimeImageSubjectKind.RuntimeComponent, id]),
|
|
151
151
|
];
|
|
152
152
|
for (const [kind, id] of wanted) {
|
|
153
|
-
if (subjectServingBiome(subjects, biome.id, kind, id, enums) === undefined) {
|
|
153
|
+
if (subjectServingBiome(catalogue.images.schemaVersion, subjects, biome.id, kind, id, enums) === undefined) {
|
|
154
154
|
throw new CatalogueCeilingError(`the catalogue carries no image row for ${kind}:${id} of selected biome "${biome.id}"`);
|
|
155
155
|
}
|
|
156
156
|
}
|