@xemahq/xema 0.40.3 → 0.41.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/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 +281 -9
- package/dist/edition/verify-image-evidence.bundle.provenance.json +65 -47
- package/dist/runtime/edition/verb-map.d.ts +3 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +4 -0
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/edition/README.md +5 -0
- package/edition/catalogue-evidence.mjs +225 -0
- package/edition/catalogue-fetch.mjs +40 -7
- package/edition/customer-edition-verbs.mjs +477 -0
- package/edition/customer-flags.mjs +2 -0
- package/edition/develop-coverage-readers.mjs +212 -0
- package/edition/develop-coverage-run.mjs +48 -0
- package/edition/develop-coverage-verdict.mjs +153 -0
- package/edition/develop-coverage.mjs +120 -0
- package/edition/develop-edition-resolve.mjs +18 -7
- package/edition/develop-edition.mjs +47 -220
- package/edition/develop-freeze.mjs +50 -13
- package/edition/develop-image-probe.mjs +7 -7
- package/edition/edition-artifact.mjs +31 -14
- package/edition/edition-constants.mjs +9 -0
- package/edition/edition-contract.json +2 -1
- package/edition/{develop-seal.mjs → edition-image-seal.mjs} +36 -17
- package/edition/edition-workspace.mjs +4 -3
- package/edition/evidence-trust-pin.mjs +28 -8
- package/edition/evidence-trust-policy.mjs +51 -7
- package/edition/image-evidence.mjs +2 -2
- package/edition/lib.mjs +7 -0
- package/edition/oci-image-source-evidence.mjs +3 -0
- package/edition/oras-process.mjs +3 -3
- package/edition/oras.mjs +47 -5
- package/edition/produce-edition-catalogue-evidence.mjs +252 -0
- package/edition/produce-image-source-evidence.mjs +11 -2
- package/edition/release-edition.mjs +54 -0
- package/edition/repin-evidence-trust.mjs +5 -3
- package/edition/sigstore-bundle-media-type.mjs +4 -0
- package/edition/trust-policy-arming.mjs +14 -1
- package/edition/verify-catalogue-evidence.mjs +245 -0
- package/edition/verify-image-evidence.mjs +15 -1
- package/package.json +2 -2
- package/edition/develop-customer-edition.mjs +0 -149
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
// customer-edition-verbs — the verbs of a CUSTOMER edition, for either channel.
|
|
2
|
+
//
|
|
3
|
+
// xema edition develop <verb> --definition <file> ... channel develop
|
|
4
|
+
// xema edition release <verb> --definition <file> ... channel release
|
|
5
|
+
//
|
|
6
|
+
// ONE parser, ONE frame and ONE set of verbs: the channel is an input, never a mode. A customer
|
|
7
|
+
// definition is resolved against the catalogue pinned for the channel in catalogue.pin.json beside
|
|
8
|
+
// it, frozen at the customer's OWN repository pins. The pins file holds one `sources/<name>` commit
|
|
9
|
+
// per source the definition declares and nothing else (the vendor repositories are the catalogue's,
|
|
10
|
+
// not the customer's). The digest of the catalogue artifact the edition was resolved against is
|
|
11
|
+
// frozen into the descriptor's provenance pins as `catalogue`; the artifact tag (snapshotEditionTag)
|
|
12
|
+
// covers it. The vendor image rows arrive sealed from the catalogue; only the customer's own image
|
|
13
|
+
// rows are sealed, in the repositories of the channel (edition-image-seal.mjs).
|
|
14
|
+
|
|
15
|
+
import { writeFileSync } from 'node:fs';
|
|
16
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { sealLock } from './biome-content.mjs';
|
|
19
|
+
import { assertCatalogueConsumerArmed } from './catalogue-evidence.mjs';
|
|
20
|
+
import { fetchCatalogue, parseCatalogueRef } from './catalogue-fetch.mjs';
|
|
21
|
+
import { readCataloguePin } from './catalogue-pin.mjs';
|
|
22
|
+
import { CATALOGUE_TOKEN_ENV_FLAG, CATALOGUE_USERNAME_FLAG, COSIGN_FLAG, DEFINITION_FLAG, SOURCE_IMAGE_FLAG } from './customer-flags.mjs';
|
|
23
|
+
import { freezeCustomerSources, lsRemoteRef } from './develop-freeze.mjs';
|
|
24
|
+
import { isDevelopImageRef } from './develop-image-coordinate.mjs';
|
|
25
|
+
import { assertCanonicalDistributionId } from './distribution-paths.mjs';
|
|
26
|
+
import { LOCAL_WORKFLOW, assertEngineImage, assertExistingArtifactMatches, snapshotEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
|
|
27
|
+
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
28
|
+
import { CATALOGUE_PIN_KEY, DEFINITION_PIN_KEY, EditionChannel, EditionLockFile } from './edition-constants.mjs';
|
|
29
|
+
import { channelImageRef, sealImageSubjects } from './edition-image-seal.mjs';
|
|
30
|
+
import { WorkspaceRoot } from './edition-workspace.mjs';
|
|
31
|
+
import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
|
|
32
|
+
import { holdSource } from './git-source-auth.mjs';
|
|
33
|
+
import { canonicalJson, sha256Digest } from './image-evidence.mjs';
|
|
34
|
+
import { adoptCatalogueKernel } from './kernel-package.mjs';
|
|
35
|
+
import { loadKernelDistributionSchemas, serializeJson } from './lib.mjs';
|
|
36
|
+
import { isSourcePinKey, sortedSnapshotPins, sourcePinKey } from './pin-source.mjs';
|
|
37
|
+
import { assertSealedImageLock } from './release-edition-artifact.mjs';
|
|
38
|
+
import { assertSourceImagesCoverDefinition, parseSourceImages, resolveCustomerEdition } from './resolve-customer-edition.mjs';
|
|
39
|
+
import { externalSourceTrees, imageRefForSubject, sourceTreeLocator } from './resolve-images.mjs';
|
|
40
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
41
|
+
|
|
42
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
43
|
+
|
|
44
|
+
export class CustomerEditionPinsError extends Error {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'CustomerEditionPinsError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The digest of a customer definition: sha256 of its canonical JSON (keys sorted). It covers everything the definition
|
|
53
|
+
* decides that no other pin fixes: the vendor biomes it selects, its image registry mirror, its Store publisher anchor
|
|
54
|
+
* keys and its declared sources.
|
|
55
|
+
*/
|
|
56
|
+
export function customerDefinitionDigest(rawDefinition) {
|
|
57
|
+
return sha256Digest(Buffer.from(canonicalJson(rawDefinition), 'utf8'));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The pins of a customer edition of `channel`: `sources/<name>` commits for exactly
|
|
62
|
+
* the definition's declared sources, plus `catalogue` = the digest of the
|
|
63
|
+
* catalogue artifact and `definition` = the digest of the definition (customerDefinitionDigest).
|
|
64
|
+
* A `catalogue` or `definition` entry in the file must equal that digest.
|
|
65
|
+
*/
|
|
66
|
+
export function customerEditionPins({ channel, pins, declaredSourceNames, catalogueDigest, definitionDigest }) {
|
|
67
|
+
if (typeof definitionDigest !== 'string') {throw new CustomerEditionPinsError('a customer edition pins the digest of its definition');}
|
|
68
|
+
if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
|
|
69
|
+
throw new CustomerEditionPinsError('--pins must be an object mapping sources/<name> -> 40-hex commit');
|
|
70
|
+
}
|
|
71
|
+
const own = {};
|
|
72
|
+
for (const [key, value] of Object.entries(pins)) {
|
|
73
|
+
if (key === CATALOGUE_PIN_KEY) {
|
|
74
|
+
if (value !== catalogueDigest) {
|
|
75
|
+
throw new CustomerEditionPinsError(`--pins "${CATALOGUE_PIN_KEY}" is ${JSON.stringify(value)}, but the pinned ${channel} catalogue is ${catalogueDigest}`);
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (key === DEFINITION_PIN_KEY) {
|
|
80
|
+
if (value !== definitionDigest) {
|
|
81
|
+
throw new CustomerEditionPinsError(`--pins "${DEFINITION_PIN_KEY}" is ${JSON.stringify(value)}, but the definition is ${definitionDigest}`);
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!isSourcePinKey(key)) {
|
|
86
|
+
throw new CustomerEditionPinsError(`--pins "${key}" is not one of the customer's own repositories: a customer ${channel} pins only sources/<name> (the vendor's pins are the catalogue's)`);
|
|
87
|
+
}
|
|
88
|
+
if (typeof value !== 'string' || !SHA_PATTERN.test(value)) {
|
|
89
|
+
throw new CustomerEditionPinsError(`--pins "${key}" is not a 40-hex commit SHA: ${JSON.stringify(value)}`);
|
|
90
|
+
}
|
|
91
|
+
own[key] = value;
|
|
92
|
+
}
|
|
93
|
+
const declared = new Set(declaredSourceNames.map(sourcePinKey));
|
|
94
|
+
for (const key of declared) {
|
|
95
|
+
if (!(key in own)) {throw new CustomerEditionPinsError(`--pins is missing "${key}": the definition declares that source`);}
|
|
96
|
+
}
|
|
97
|
+
for (const key of Object.keys(own)) {
|
|
98
|
+
if (!declared.has(key)) {throw new CustomerEditionPinsError(`--pins names "${key}", but the definition declares no such source`);}
|
|
99
|
+
}
|
|
100
|
+
return sortedSnapshotPins({ ...own, [CATALOGUE_PIN_KEY]: catalogueDigest, [DEFINITION_PIN_KEY]: definitionDigest });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The source names a raw definition declares (the pins are checked before the definition is composed). */
|
|
104
|
+
export function declaredSourceNamesOf(rawDefinition) {
|
|
105
|
+
const sources = rawDefinition?.sources;
|
|
106
|
+
return Array.isArray(sources) ? sources.map((source) => source?.name) : [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Resolve and seal the customer edition of `channel`; returns the four locks and the frozen provenance pins. */
|
|
110
|
+
export async function resolveCustomerChannelEdition({ channel, rawDefinition, catalogue, catalogueDigest, pins, sourceImages, env = process.env, sealOptions = {}, adoptKernel, materialize }) {
|
|
111
|
+
const frozen = customerEditionPins({ channel, pins, declaredSourceNames: declaredSourceNamesOf(rawDefinition), catalogueDigest, definitionDigest: customerDefinitionDigest(rawDefinition) });
|
|
112
|
+
const resolved = await resolveCustomerEdition({
|
|
113
|
+
rawDefinition,
|
|
114
|
+
catalogue,
|
|
115
|
+
channel,
|
|
116
|
+
sourceImages,
|
|
117
|
+
pins: frozen,
|
|
118
|
+
catalogueDigest,
|
|
119
|
+
...(adoptKernel === undefined ? {} : { adoptKernel }),
|
|
120
|
+
...(materialize === undefined ? {} : { materialize }),
|
|
121
|
+
sealOwn: ({ distributionId, subjects }) => sealImageSubjects({ channel, distributionId, subjects, env, ...sealOptions }),
|
|
122
|
+
});
|
|
123
|
+
// The customer edition is sealed here: every biomeContentHash is recomputed through the
|
|
124
|
+
// same code as the vendor's, vendor biomes on the CATALOGUE's authority (biome-content.mjs).
|
|
125
|
+
const schemas = await loadKernelDistributionSchemas();
|
|
126
|
+
const distributionLock = await sealLock({
|
|
127
|
+
schemas,
|
|
128
|
+
unsealed: resolved.distributionLock,
|
|
129
|
+
imageLock: resolved.imageLock,
|
|
130
|
+
locate: sourceTreeLocator(externalSourceTrees(resolved.distributionLock)),
|
|
131
|
+
vendoredCommit: (repo) => {
|
|
132
|
+
throw new Error(`customer biome source "${repo}" is first-party to no checkout; vendor biomes are taken from the catalogue`);
|
|
133
|
+
},
|
|
134
|
+
catalogue,
|
|
135
|
+
catalogueDigest,
|
|
136
|
+
storePublisherAnchorKeys: resolved.storePublisherAnchorKeys,
|
|
137
|
+
});
|
|
138
|
+
return { ...resolved, distributionLock, pins: frozen };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What a verb of a customer edition of `channel` derives from its definition file and its `--pins` file with no
|
|
143
|
+
* network: the id (the definition's), the pinned catalogue reference and digest, and the frozen
|
|
144
|
+
* provenance pins (the customer's `sources/<name>` commits plus that digest).
|
|
145
|
+
*/
|
|
146
|
+
export function customerEditionFrame({ channel, definitionFile, pinsFile }) {
|
|
147
|
+
const rawDefinition = readStrictJsonFile(definitionFile);
|
|
148
|
+
assertCanonicalDistributionId(rawDefinition?.id);
|
|
149
|
+
const catalogueRef = readCataloguePin({ definitionFile, channel });
|
|
150
|
+
const { digest } = parseCatalogueRef(catalogueRef);
|
|
151
|
+
const definitionDigest = customerDefinitionDigest(rawDefinition);
|
|
152
|
+
const pins = customerEditionPins({ channel, pins: readStrictJsonFile(pinsFile), declaredSourceNames: declaredSourceNamesOf(rawDefinition), catalogueDigest: digest, definitionDigest });
|
|
153
|
+
return { id: rawDefinition.id, rawDefinition, catalogueRef, catalogueDigest: digest, definitionDigest, pins };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
157
|
+
// The command line: one grammar for both channels.
|
|
158
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/** The roots a verb takes in its customer form. The kernel is never one: the catalogue's kernel is downloaded from npm into the cache root. */
|
|
161
|
+
export const CUSTOMER_ROOTS = Object.freeze({
|
|
162
|
+
resolve: [WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Cache],
|
|
163
|
+
target: [],
|
|
164
|
+
'check-existing': [WorkspaceRoot.Data, WorkspaceRoot.Cache],
|
|
165
|
+
'freeze-sources': [WorkspaceRoot.Data],
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/** The roots of a customer verb; an unknown verb is refused naming the verbs there are. */
|
|
169
|
+
export function customerRootsOf(command) {
|
|
170
|
+
const roots = CUSTOMER_ROOTS[command];
|
|
171
|
+
if (roots === undefined) {
|
|
172
|
+
throw new Error(`unknown command ${JSON.stringify(command)}; expected ${Object.keys(CUSTOMER_ROOTS).join(' | ')}`);
|
|
173
|
+
}
|
|
174
|
+
return roots;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Per verb: the vendor form (`positional` id, `values`, `switches`; develop only) and, where the verb has one, the
|
|
178
|
+
// customer form: the flags only it takes, the further flags it requires, and the vendor flags it refuses.
|
|
179
|
+
const CREDENTIAL_FLAGS = Object.freeze([CATALOGUE_USERNAME_FLAG, CATALOGUE_TOKEN_ENV_FLAG]);
|
|
180
|
+
const REPEATABLE = Object.freeze([SOURCE_IMAGE_FLAG]);
|
|
181
|
+
|
|
182
|
+
const COMMANDS = Object.freeze({
|
|
183
|
+
resolve: {
|
|
184
|
+
positional: true,
|
|
185
|
+
values: ['--pins', '--out', '--registry', '--engine-image'],
|
|
186
|
+
switches: ['--local'],
|
|
187
|
+
required: ['--pins', '--out', '--registry', '--engine-image'],
|
|
188
|
+
customer: { values: [DEFINITION_FLAG, ...CREDENTIAL_FLAGS, SOURCE_IMAGE_FLAG, COSIGN_FLAG], required: ['--pins', '--out', '--registry', '--engine-image'], refused: [], cosign: true },
|
|
189
|
+
},
|
|
190
|
+
target: {
|
|
191
|
+
positional: true,
|
|
192
|
+
values: ['--pins', '--registry', '--engine-image'],
|
|
193
|
+
switches: [],
|
|
194
|
+
required: ['--pins', '--registry', '--engine-image'],
|
|
195
|
+
customer: { values: [DEFINITION_FLAG, SOURCE_IMAGE_FLAG], required: ['--pins', '--registry', '--engine-image'], refused: [] },
|
|
196
|
+
},
|
|
197
|
+
'freeze-graph': {
|
|
198
|
+
positional: false,
|
|
199
|
+
values: ['--data-sha', '--kernel-sha', '--deploy-sha', '--editions-root-url', '--overrides', '--out'],
|
|
200
|
+
switches: [],
|
|
201
|
+
required: ['--data-sha', '--kernel-sha', '--deploy-sha', '--out'],
|
|
202
|
+
},
|
|
203
|
+
'freeze-sources': {
|
|
204
|
+
positional: true,
|
|
205
|
+
values: ['--pins', '--overrides', '--out'],
|
|
206
|
+
switches: [],
|
|
207
|
+
required: ['--pins', '--out'],
|
|
208
|
+
customer: { values: [DEFINITION_FLAG], required: ['--out'], refused: ['--pins', '--overrides'] },
|
|
209
|
+
},
|
|
210
|
+
coverage: {
|
|
211
|
+
positional: true,
|
|
212
|
+
values: ['--pins', '--cosign'],
|
|
213
|
+
switches: ['--json'],
|
|
214
|
+
required: ['--pins', '--cosign'],
|
|
215
|
+
},
|
|
216
|
+
'check-overrides': { positional: false, values: ['--overrides'], switches: [], required: ['--overrides'] },
|
|
217
|
+
'check-existing': {
|
|
218
|
+
positional: true,
|
|
219
|
+
values: ['--pins', '--artifact', '--engine-image'],
|
|
220
|
+
switches: [],
|
|
221
|
+
required: ['--pins', '--artifact', '--engine-image'],
|
|
222
|
+
customer: { values: [DEFINITION_FLAG, ...CREDENTIAL_FLAGS, SOURCE_IMAGE_FLAG, COSIGN_FLAG], required: ['--pins', '--artifact', '--engine-image'], refused: [], cosign: true },
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The verbs of a channel. Develop has the vendor form of every verb as well as the customer form; a release edition
|
|
228
|
+
* is a customer edition only (the vendor release is `xema edition artifact`), so release has the customer verbs.
|
|
229
|
+
*/
|
|
230
|
+
function commandsOf(channel) {
|
|
231
|
+
if (channel === EditionChannel.Develop) {return COMMANDS;}
|
|
232
|
+
if (channel === EditionChannel.Release) {
|
|
233
|
+
return Object.fromEntries(Object.entries(COMMANDS).filter(([, spec]) => spec.customer !== undefined));
|
|
234
|
+
}
|
|
235
|
+
throw new Error(`unknown edition channel ${JSON.stringify(channel)}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Parse `<command> [<id>] --flag value ...` of `channel` against the closed vocabulary of the command. */
|
|
239
|
+
export function parseEditionVerbArgs(channel, argv) {
|
|
240
|
+
const commands = commandsOf(channel);
|
|
241
|
+
const [command, ...rest] = argv;
|
|
242
|
+
const spec = commands[command];
|
|
243
|
+
if (spec === undefined) {
|
|
244
|
+
throw new Error(`unknown command ${JSON.stringify(command)}; expected ${Object.keys(commands).join(' | ')}`);
|
|
245
|
+
}
|
|
246
|
+
const customerValues = spec.customer?.values ?? [];
|
|
247
|
+
const options = {};
|
|
248
|
+
const positional = [];
|
|
249
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
250
|
+
const arg = rest[i];
|
|
251
|
+
if (spec.switches.includes(arg)) {
|
|
252
|
+
options[arg] = true;
|
|
253
|
+
} else if (spec.values.includes(arg) || customerValues.includes(arg)) {
|
|
254
|
+
const value = rest[i + 1];
|
|
255
|
+
if (value === undefined || (value.startsWith('--') && value.length > 2)) {
|
|
256
|
+
throw new Error(`${arg} requires a value`);
|
|
257
|
+
}
|
|
258
|
+
if (REPEATABLE.includes(arg)) {
|
|
259
|
+
options[arg] = [...(options[arg] ?? []), value];
|
|
260
|
+
} else if (options[arg] !== undefined) {
|
|
261
|
+
throw new Error(`${arg} given twice`);
|
|
262
|
+
} else {
|
|
263
|
+
options[arg] = value;
|
|
264
|
+
}
|
|
265
|
+
i += 1;
|
|
266
|
+
} else if (arg.startsWith('--')) {
|
|
267
|
+
throw new Error(`unknown argument ${arg} for ${command}`);
|
|
268
|
+
} else {
|
|
269
|
+
positional.push(arg);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const customer = spec.customer !== undefined && customerValues.some((flag) => options[flag] !== undefined);
|
|
273
|
+
// `--cosign` of a customer form verifies a release catalogue; a vendor verb (develop `coverage`) takes its own `--cosign`.
|
|
274
|
+
if (spec.customer?.cosign === true && options[COSIGN_FLAG] !== undefined && channel !== EditionChannel.Release) {
|
|
275
|
+
throw new Error(`${COSIGN_FLAG} verifies a release catalogue's evidence; a ${channel} ${command} takes none`);
|
|
276
|
+
}
|
|
277
|
+
if (channel === EditionChannel.Release && options[DEFINITION_FLAG] === undefined) {
|
|
278
|
+
throw new Error(`a ${channel} ${command} requires ${DEFINITION_FLAG}`);
|
|
279
|
+
}
|
|
280
|
+
if (customer) {
|
|
281
|
+
if (options[DEFINITION_FLAG] === undefined) {throw new Error(`a customer ${command} requires ${DEFINITION_FLAG}`);}
|
|
282
|
+
for (const refused of spec.customer.refused) {
|
|
283
|
+
if (options[refused] !== undefined) {throw new Error(`a customer ${command} takes no ${refused}`);}
|
|
284
|
+
}
|
|
285
|
+
if (positional.length !== 0) {throw new Error(`a customer ${command} takes no <distribution-id>: the id is the definition's`);}
|
|
286
|
+
if (spec.customer.cosign === true && channel === EditionChannel.Release) {
|
|
287
|
+
if (options[COSIGN_FLAG] === undefined) {throw new Error(`a release ${command} requires ${COSIGN_FLAG} <absolute path of the cosign binary>`);}
|
|
288
|
+
if (!isAbsolute(options[COSIGN_FLAG])) {throw new Error(`${COSIGN_FLAG} must be an absolute path, got ${JSON.stringify(options[COSIGN_FLAG])}`);}
|
|
289
|
+
}
|
|
290
|
+
} else if (spec.positional ? positional.length !== 1 : positional.length !== 0) {
|
|
291
|
+
throw new Error(`${command} takes ${spec.positional ? 'exactly one <distribution-id>' : 'no positional argument'}`);
|
|
292
|
+
}
|
|
293
|
+
for (const required of customer ? spec.customer.required : spec.required) {
|
|
294
|
+
if (options[required] === undefined) {throw new Error(`${command} requires ${required}`);}
|
|
295
|
+
}
|
|
296
|
+
if (spec.positional && !customer) {assertCanonicalDistributionId(positional[0]);}
|
|
297
|
+
if (options['--engine-image'] !== undefined) {assertEngineImage(options['--engine-image']);}
|
|
298
|
+
return { command, id: positional[0], options, customer };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
302
|
+
// The verbs.
|
|
303
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/** The ref a channel freezes a source at: develop the `develop` branch of every repository, release the ref the source declares. */
|
|
306
|
+
const FREEZE_REF = Object.freeze({
|
|
307
|
+
[EditionChannel.Develop]: () => 'develop',
|
|
308
|
+
[EditionChannel.Release]: (source) => source.ref,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
/** The head lookup and the hold of one source, for the freeze of `channel`. */
|
|
312
|
+
export function headResolver(channel, env) {
|
|
313
|
+
const workspace = editionWorkspaceConfig();
|
|
314
|
+
return {
|
|
315
|
+
resolveHead: (source) => lsRemoteRef(source, FREEZE_REF[channel](source), { channel, workspace, env }),
|
|
316
|
+
holdSource: (source) => holdSource(source, workspace, env),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The pinned catalogue of a customer definition on `channel`, fetched by its digest. A release edition is resolved only
|
|
322
|
+
* under the CUSTOMER's own evidence trust policy (its data root's, never the vendor's): before any registry call the policy
|
|
323
|
+
* must be declared, the built verifier must be the pinned one, and the policy ARMED on the release channel with the E4 row
|
|
324
|
+
* (`assertCatalogueConsumerArmed`, the one arming rule judged by the pinned bundle with its own kernel). The catalogue's E4
|
|
325
|
+
* evidence is then verified before the catalogue is parsed (`fetchCatalogue`).
|
|
326
|
+
*/
|
|
327
|
+
async function fetchPinnedCatalogue(channel, options, env, fetch = fetchCatalogue, { assertArmed = assertCatalogueConsumerArmed, locateVerifier } = {}) {
|
|
328
|
+
if (channel === EditionChannel.Release) {assertArmed({ channel, ...(locateVerifier === undefined ? {} : { locateVerifier }) });}
|
|
329
|
+
return fetch({
|
|
330
|
+
ref: readCataloguePin({ definitionFile: options[DEFINITION_FLAG], channel }),
|
|
331
|
+
channel,
|
|
332
|
+
username: options[CATALOGUE_USERNAME_FLAG],
|
|
333
|
+
tokenEnv: options[CATALOGUE_TOKEN_ENV_FLAG],
|
|
334
|
+
env,
|
|
335
|
+
cosign: options[COSIGN_FLAG],
|
|
336
|
+
...(locateVerifier === undefined ? {} : { locateVerifier }),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** A customer resolve: the artifact (descriptor and four locks, no catalogue: it is not an offering) goes to --out. */
|
|
341
|
+
async function resolveCustomer(channel, options, env) {
|
|
342
|
+
const { catalogue, digest } = await fetchPinnedCatalogue(channel, options, env);
|
|
343
|
+
const rawDefinition = readStrictJsonFile(options[DEFINITION_FLAG]);
|
|
344
|
+
const sourceImages = customerSourceImages(options, rawDefinition);
|
|
345
|
+
// The catalogue's kernel is adopted inside the resolve, before any kernel schema is loaded; the evidence trust
|
|
346
|
+
// state loads kernel schemas, so it is read after.
|
|
347
|
+
const { id, pins, ...resolved } = await resolveCustomerChannelEdition({
|
|
348
|
+
channel,
|
|
349
|
+
rawDefinition,
|
|
350
|
+
catalogue,
|
|
351
|
+
catalogueDigest: digest,
|
|
352
|
+
pins: readStrictJsonFile(options['--pins']),
|
|
353
|
+
sourceImages,
|
|
354
|
+
env,
|
|
355
|
+
});
|
|
356
|
+
const evidenceTrustPolicy = await descriptorEvidenceTrust({ channel, consumesCatalogue: true });
|
|
357
|
+
const target = snapshotEditionTarget(channel, id, pins, { registry: options['--registry'], engineImage: options['--engine-image'], sourceImages });
|
|
358
|
+
// A release edition is admitted only sealed: every image names the digest the registry serves. (A develop edition is not held to it today.)
|
|
359
|
+
if (channel === EditionChannel.Release) {assertSealedImageLock(resolved.imageLock, `the images lock of "${id}"`);}
|
|
360
|
+
writeEditionArtifact({
|
|
361
|
+
outDir: resolve(options['--out']),
|
|
362
|
+
channel,
|
|
363
|
+
distributionId: id,
|
|
364
|
+
pins,
|
|
365
|
+
workflow: options['--local'] ? LOCAL_WORKFLOW : workflowFromEnv(env),
|
|
366
|
+
evidenceTrustPolicy,
|
|
367
|
+
engineImage: options['--engine-image'],
|
|
368
|
+
distributionLock: resolved.distributionLock,
|
|
369
|
+
imageLock: resolved.imageLock,
|
|
370
|
+
authorizationLock: resolved.authorizationLock,
|
|
371
|
+
chartSetLock: resolved.chartSetLock,
|
|
372
|
+
});
|
|
373
|
+
console.log(target);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** A customer check-existing: the pinned catalogue is fetched to take its kernel; the digest fetched is the digest the artifact is compared with. */
|
|
377
|
+
export async function checkExistingCustomer(channel, options, env, { adopt = adoptCatalogueKernel, fetch = fetchCatalogue, locateVerifier, assertArmed } = {}) {
|
|
378
|
+
const frame = customerEditionFrame({ channel, definitionFile: options[DEFINITION_FLAG], pinsFile: options['--pins'] });
|
|
379
|
+
const sourceImages = customerSourceImages(options, frame.rawDefinition);
|
|
380
|
+
const { catalogue, digest } = await fetchPinnedCatalogue(channel, options, env, fetch, { assertArmed, locateVerifier });
|
|
381
|
+
if (digest !== frame.catalogueDigest) {
|
|
382
|
+
throw new Error(`the fetched catalogue is ${digest}, not the pinned ${frame.catalogueDigest}`);
|
|
383
|
+
}
|
|
384
|
+
await adopt(catalogue);
|
|
385
|
+
assertExistingArtifactMatches({
|
|
386
|
+
artifactDir: resolve(options['--artifact']),
|
|
387
|
+
distributionId: frame.id,
|
|
388
|
+
channel,
|
|
389
|
+
evidenceTrustPolicy: await descriptorEvidenceTrust({ channel, locateVerifier, consumesCatalogue: true }),
|
|
390
|
+
pins: frame.pins,
|
|
391
|
+
engineImage: options['--engine-image'],
|
|
392
|
+
catalogueDigest: frame.catalogueDigest,
|
|
393
|
+
definitionDigest: frame.definitionDigest,
|
|
394
|
+
});
|
|
395
|
+
assertOwnImageRowsMatch({ channel, artifactDir: resolve(options['--artifact']), sourceImages });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The parsed --source-image map of a customer verb, covering exactly the definition's declared sources. Every entry
|
|
400
|
+
* names RELEASE repositories on either channel: a develop edition maps them to the develop namespace itself
|
|
401
|
+
* (developImageRef), and a release edition seals them as they are, so an entry already in the develop namespace is
|
|
402
|
+
* refused here (a release edition would otherwise seal develop images).
|
|
403
|
+
*/
|
|
404
|
+
export function customerSourceImages(options, rawDefinition) {
|
|
405
|
+
const sourceImages = parseSourceImages(options[SOURCE_IMAGE_FLAG] ?? []);
|
|
406
|
+
assertSourceImagesCoverDefinition(rawDefinition, sourceImages);
|
|
407
|
+
for (const [name, { imageRegistry, imageNamePrefix }] of sourceImages) {
|
|
408
|
+
if (isDevelopImageRef(imageRefForSubject('probe', imageRegistry, imageNamePrefix))) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`--source-image ${name}=${imageRegistry}/${imageNamePrefix} names the develop image namespace; ` +
|
|
411
|
+
'a --source-image names the release repositories (a develop edition maps them to develop itself)',
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return sourceImages;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The own image rows of an existing customer artifact of `channel` are the rows this `--source-image` map composes:
|
|
420
|
+
* every subject owned by a biome drawn from a declared source is `<registry>/<prefix><subject>` mapped to its
|
|
421
|
+
* repository on the channel. The tag covers the map, but a tag is mutable registry state, so the pulled bytes are
|
|
422
|
+
* compared too. Throws naming the first row that differs.
|
|
423
|
+
*/
|
|
424
|
+
export function assertOwnImageRowsMatch({ channel, artifactDir, sourceImages }) {
|
|
425
|
+
const lock = readStrictJsonFile(join(artifactDir, EditionLockFile.Distribution));
|
|
426
|
+
const imageLock = readStrictJsonFile(join(artifactDir, EditionLockFile.Images));
|
|
427
|
+
const sourceOf = new Map((lock.biomes ?? []).filter((biome) => biome.source !== undefined).map((biome) => [biome.id, biome.source.name]));
|
|
428
|
+
for (const subject of imageLock.subjects ?? []) {
|
|
429
|
+
const biomeId = (subject.owners ?? []).find((owner) => sourceOf.has(owner.biomeId))?.biomeId;
|
|
430
|
+
if (biomeId === undefined) {continue;}
|
|
431
|
+
const name = sourceOf.get(biomeId);
|
|
432
|
+
const image = sourceImages.get(name);
|
|
433
|
+
if (image === undefined) {
|
|
434
|
+
throw new Error(`the existing artifact draws biome "${biomeId}" from source "${name}", which has no --source-image; refusing to reuse it`);
|
|
435
|
+
}
|
|
436
|
+
const expected = channelImageRef(channel, imageRefForSubject(subject.id, image.imageRegistry, image.imageNamePrefix));
|
|
437
|
+
if (subject.imageRef !== expected) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`the existing artifact's own image row ${subject.kind}:${subject.id} is ${subject.imageRef}, not ${expected} ` +
|
|
440
|
+
'(the --source-image map differs); refusing to reuse it',
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Write a file exclusively (wx): a second run over the same path is refused. */
|
|
447
|
+
export function writeOnce(path, value) {
|
|
448
|
+
writeFileSync(path, serializeJson(value), { flag: 'wx' });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Run one verb in its customer form on `channel`. */
|
|
452
|
+
export async function runCustomerVerb({ channel, command, options, env }) {
|
|
453
|
+
switch (command) {
|
|
454
|
+
case 'target': {
|
|
455
|
+
const frame = customerEditionFrame({ channel, definitionFile: options[DEFINITION_FLAG], pinsFile: options['--pins'] });
|
|
456
|
+
const sourceImages = customerSourceImages(options, frame.rawDefinition);
|
|
457
|
+
console.log(snapshotEditionTarget(channel, frame.id, frame.pins, { registry: options['--registry'], engineImage: options['--engine-image'], sourceImages }));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
case 'resolve':
|
|
461
|
+
return resolveCustomer(channel, options, env);
|
|
462
|
+
case 'check-existing':
|
|
463
|
+
return checkExistingCustomer(channel, options, env);
|
|
464
|
+
case 'freeze-sources':
|
|
465
|
+
writeOnce(
|
|
466
|
+
options['--out'],
|
|
467
|
+
freezeCustomerSources({
|
|
468
|
+
definition: readStrictJsonFile(options[DEFINITION_FLAG]),
|
|
469
|
+
...headResolver(channel, env),
|
|
470
|
+
sourceRepositoryUrlPrefix: editionWorkspaceConfig().sourceRepositoryUrlPrefix,
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
return;
|
|
474
|
+
default:
|
|
475
|
+
throw new Error(`${command} has no customer form`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
@@ -4,3 +4,5 @@ export const CHANNEL_FLAG = '--channel';
|
|
|
4
4
|
export const SOURCE_IMAGE_FLAG = '--source-image';
|
|
5
5
|
export const CATALOGUE_USERNAME_FLAG = '--catalogue-username';
|
|
6
6
|
export const CATALOGUE_TOKEN_ENV_FLAG = '--catalogue-token-env';
|
|
7
|
+
/** The absolute path of the cosign binary that verifies a release catalogue's E4 evidence (release only). */
|
|
8
|
+
export const COSIGN_FLAG = '--cosign';
|