@xemahq/xema 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +8 -7
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +88 -11
- package/dist/edition/verify-image-evidence.bundle.provenance.json +48 -42
- 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/runner/chart-set-lock.d.ts +4 -1
- package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
- package/dist/runtime/runner/chart-set-lock.js +39 -4
- package/dist/runtime/runner/chart-set-lock.js.map +1 -1
- package/edition/README.md +69 -32
- package/edition/build-biome-index.mjs +1 -1
- package/edition/build-platform-service-index.mjs +1 -1
- package/edition/build-runtime-image-source-index.mjs +64 -20
- package/edition/catalogue-fetch.mjs +36 -9
- package/edition/catalogue-pin.mjs +71 -0
- package/edition/catalogue.mjs +11 -0
- package/edition/chart-set-source-commit.mjs +17 -7
- package/edition/checkout-indexed-source-repositories.mjs +17 -17
- package/edition/customer-flags.mjs +0 -1
- package/edition/develop-customer-edition.mjs +23 -5
- package/edition/develop-edition.mjs +147 -48
- package/edition/develop-freeze.mjs +69 -25
- package/edition/develop-seal.mjs +39 -25
- package/edition/dist-check.mjs +1 -1
- package/edition/edition-artifact.mjs +65 -18
- package/edition/edition-biome-manifests.mjs +2 -3
- package/edition/edition-chart-set.mjs +59 -6
- package/edition/edition-config.mjs +28 -2
- package/edition/edition-constants.mjs +10 -4
- package/edition/edition-contract-parse.mjs +52 -0
- package/edition/edition-contract.json +10 -1
- package/edition/edition-workspace.mjs +12 -9
- package/edition/evidence-trust-policy.mjs +26 -0
- package/edition/external-source-walk.mjs +48 -61
- package/edition/git-source-auth.mjs +104 -25
- package/edition/image-evidence.mjs +20 -10
- package/edition/kernel-contract-authority.mjs +23 -1
- package/edition/lib.mjs +13 -24
- package/edition/pin-source.mjs +6 -1
- package/edition/registry-credentials.mjs +11 -1
- package/edition/release-edition-artifact.mjs +3 -3
- package/edition/resolve-chart-set.mjs +50 -30
- package/edition/resolve-customer-edition.mjs +63 -7
- package/edition/resolve-distribution.mjs +1 -1
- package/edition/resolve-edition.mjs +12 -11
- package/edition/resolve-images.mjs +36 -15
- package/edition/seal-image-digests.mjs +31 -7
- package/edition/source-closure.mjs +1 -2
- package/edition/trust-policy-arming-verb.mjs +68 -0
- package/package.json +3 -3
package/edition/lib.mjs
CHANGED
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
kernelDistPath,
|
|
25
25
|
kernelPackageDir,
|
|
26
26
|
kernelRepoDir,
|
|
27
|
-
sourceHashPackageDir,
|
|
28
27
|
sourcePath,
|
|
29
28
|
} from './edition-workspace.mjs';
|
|
30
29
|
import { SHA256_HEX_PATTERN } from './evidence-grammars.mjs';
|
|
@@ -149,38 +148,28 @@ export const BIOME_WORKLOAD_KINDS = new Set(['service', 'runtime-component']);
|
|
|
149
148
|
export const DEFAULT_BIOME_WORKLOAD_KIND = 'service';
|
|
150
149
|
|
|
151
150
|
/**
|
|
152
|
-
* The
|
|
153
|
-
*
|
|
151
|
+
* The `@xemahq/distribution-source-hash` package (CJS dist), a caret (0.x minor-locked)
|
|
152
|
+
* dependency of the CLI, loaded by name on first use.
|
|
154
153
|
*
|
|
155
154
|
* That package is the SINGLE owner of `xema-source-tree-sha256-v2` — the same
|
|
156
155
|
* implementation every producing repository's CI uses to derive the
|
|
157
156
|
* `<contentHash>` release tag. This tooling must never carry a second copy: a
|
|
158
157
|
* divergence would put a lock and its images at coordinates that silently
|
|
159
|
-
* disagree.
|
|
160
|
-
*
|
|
161
|
-
*
|
|
158
|
+
* disagree. The evidence verifier never hashes a source tree, so its bundle
|
|
159
|
+
* does not reach this package; only the entries that resolve, build, index or
|
|
160
|
+
* seal load it.
|
|
162
161
|
*/
|
|
163
162
|
const requireFromLib = createRequire(import.meta.url);
|
|
164
163
|
|
|
165
|
-
|
|
164
|
+
let loadedSourceHash;
|
|
166
165
|
|
|
167
166
|
function distributionSourceHash() {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
try {
|
|
173
|
-
mod = createRequire(join(directory, 'package.json'))(directory);
|
|
174
|
-
} catch (err) {
|
|
175
|
-
throw new Error(
|
|
176
|
-
`Cannot load the built distribution-source-hash package from ${directory}. ` +
|
|
177
|
-
`Build it first (\`pnpm install --frozen-lockfile && pnpm run build\` in its workspace). ` +
|
|
178
|
-
`Underlying error: ${err.message}`,
|
|
179
|
-
);
|
|
167
|
+
if (loadedSourceHash === undefined) {
|
|
168
|
+
const mod = requireFromLib('@xemahq/distribution-source-hash');
|
|
169
|
+
assertSourceHashMatchesKernel(mod);
|
|
170
|
+
loadedSourceHash = mod;
|
|
180
171
|
}
|
|
181
|
-
|
|
182
|
-
loadedSourceHash.set(directory, mod);
|
|
183
|
-
return mod;
|
|
172
|
+
return loadedSourceHash;
|
|
184
173
|
}
|
|
185
174
|
|
|
186
175
|
/**
|
|
@@ -214,9 +203,9 @@ function requireAlgorithmMember(enumObject, enumName, member) {
|
|
|
214
203
|
const value = enumObject?.[member];
|
|
215
204
|
if (typeof value !== 'string' || value.length === 0) {
|
|
216
205
|
throw new Error(
|
|
217
|
-
|
|
206
|
+
`@xemahq/distribution-source-hash does not declare ` +
|
|
218
207
|
`${enumName}.${member}. The installed package is `+
|
|
219
|
-
`stale or the member was renamed;
|
|
208
|
+
`stale or the member was renamed; change the pinned @xemahq/distribution-source-hash version and, if the `+
|
|
220
209
|
`rename was deliberate, move every reader in the same change.`,
|
|
221
210
|
);
|
|
222
211
|
}
|
package/edition/pin-source.mjs
CHANGED
|
@@ -65,13 +65,18 @@ export function sourceNameOfPinKey(key) {
|
|
|
65
65
|
return key.slice(SOURCE_PIN_PREFIX.length);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** Whether `key` is the snapshot key of a declared source (`sources/<name>`); pure, it reads no workspace section. */
|
|
69
|
+
export function isSourcePinKey(key) {
|
|
70
|
+
return key.startsWith(SOURCE_PIN_PREFIX) && key.length > SOURCE_PIN_PREFIX.length;
|
|
71
|
+
}
|
|
72
|
+
|
|
68
73
|
/** Which closed namespace a snapshot key belongs to; refuses a key in none. */
|
|
69
74
|
export function snapshotPinNamespace(key) {
|
|
70
75
|
if (key === dataPinKey()) {return SnapshotPinNamespace.Distributions;}
|
|
71
76
|
if (key === kernelPinKey()) {return SnapshotPinNamespace.Kernel;}
|
|
72
77
|
if (key === deployPinKey()) {return SnapshotPinNamespace.ChartSource;}
|
|
73
78
|
if (key === EDITIONS_ROOT_PIN_KEY) {return SnapshotPinNamespace.EditionsRoot;}
|
|
74
|
-
if (
|
|
79
|
+
if (isSourcePinKey(key)) {
|
|
75
80
|
return SnapshotPinNamespace.Source;
|
|
76
81
|
}
|
|
77
82
|
if (GRAPH_PIN_PATTERN.test(key)) {return SnapshotPinNamespace.Graph;}
|
|
@@ -117,7 +117,17 @@ export function loadRegistryCredentialDeclarations(
|
|
|
117
117
|
distributionsRoot = dataRoot(),
|
|
118
118
|
) {
|
|
119
119
|
const path = join(distributionsRoot, REGISTRY_CREDENTIALS_FILE);
|
|
120
|
-
|
|
120
|
+
let text;
|
|
121
|
+
try {
|
|
122
|
+
text = readFileSync(path, 'utf8');
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error?.code !== 'ENOENT') {throw error;}
|
|
125
|
+
throw new Error(
|
|
126
|
+
`${REGISTRY_CREDENTIALS_FILE} does not exist in ${distributionsRoot}: the editions root declares the registry credentials its images are verified with`,
|
|
127
|
+
{ cause: error },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const raw = JSON.parse(text);
|
|
121
131
|
const keys = Object.keys(raw ?? {}).sort().join(',');
|
|
122
132
|
if (
|
|
123
133
|
typeof raw !== 'object' ||
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// release-edition-artifact — write the OCI artifact of a RELEASE edition from
|
|
3
3
|
// the committed, sealed locks of the editions root.
|
|
4
4
|
//
|
|
5
|
-
// artifact <id> --channel release --registry <
|
|
5
|
+
// artifact <id> --channel release --registry <root> --coordinate <tag>
|
|
6
6
|
// --engine-image <repository>@sha256:<digest> --out <dir> [--local]
|
|
7
7
|
//
|
|
8
8
|
// The artifact has the layout of every edition (edition-artifact.mjs): the
|
|
9
9
|
// descriptor plus the four locks and the offering's catalogue.json, written to <dir>, and the target
|
|
10
|
-
// `<
|
|
10
|
+
// `<root>/<release namespace segment>/xema-edition-<id>:<coordinate>` is printed. The workflow pushes
|
|
11
11
|
// <dir> with oras and signs the manifest DIGEST with cosign; the tag is for
|
|
12
12
|
// humans. A lock set that is not sealed is refused: an unsealed images lock names
|
|
13
13
|
// no image digests, so nothing could be admitted against it.
|
|
@@ -128,7 +128,7 @@ export async function readCommittedEdition(id, root = editionsRoot()) {
|
|
|
128
128
|
|
|
129
129
|
async function run(argv, env) {
|
|
130
130
|
const { id, options } = parseReleaseArtifactArgs(argv);
|
|
131
|
-
const target = editionTarget(id, options['--registry'], options['--coordinate']);
|
|
131
|
+
const target = editionTarget(id, options['--registry'], EditionChannel.Release, options['--coordinate']);
|
|
132
132
|
const workflow = options['--local'] ? LOCAL_WORKFLOW : workflowFromEnv(env);
|
|
133
133
|
const locks = await readCommittedEdition(id);
|
|
134
134
|
// Refuses (EvidenceTrustRequiredError) unless the policy is ARMED on the
|
|
@@ -60,12 +60,18 @@ import { basename, join } from 'node:path';
|
|
|
60
60
|
import { EditionChannel } from './edition-channel.mjs';
|
|
61
61
|
import {
|
|
62
62
|
EDITION_CHART_SET_FILE,
|
|
63
|
+
CHART_SET_LOCK_SCHEMA_VERSION,
|
|
64
|
+
DEVELOP_CHART_VERSION_PREFIX,
|
|
63
65
|
EditionChartSetCheck,
|
|
66
|
+
OCI_DIGEST_PATTERN,
|
|
64
67
|
assertRosteredChartSetLock,
|
|
65
68
|
checkEditionChartSet,
|
|
69
|
+
DEVELOP_CHART_SHA_LENGTH,
|
|
70
|
+
hasDevelopChartVersionPrefix,
|
|
71
|
+
isDevelopChartVersion,
|
|
66
72
|
writeEditionChartSet,
|
|
67
73
|
} from './edition-chart-set.mjs';
|
|
68
|
-
import {
|
|
74
|
+
import { vendorChartSetConfig } from './edition-config.mjs';
|
|
69
75
|
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule } from './edition-workspace.mjs';
|
|
70
76
|
import { serializeJson } from './lib.mjs';
|
|
71
77
|
import { OrasRegistry } from './oras.mjs';
|
|
@@ -95,11 +101,11 @@ export const chartSetLockPath = () => join(dataRoot(), CHART_SET_LOCK_FILE);
|
|
|
95
101
|
* refuses a copy that differs — so the copies cannot disagree
|
|
96
102
|
* (edition-chart-set.mjs).
|
|
97
103
|
*/
|
|
98
|
-
const chartRegistry = () =>
|
|
99
|
-
const chartSetPublishers = () =>
|
|
104
|
+
const chartRegistry = () => vendorChartSetConfig().chartRegistry;
|
|
105
|
+
const chartSetPublishers = () => vendorChartSetConfig().chartPublishers;
|
|
100
106
|
|
|
101
107
|
/** `<host>/<path>` -> the repository path the registry API addresses. */
|
|
102
|
-
function registryRepository(indexName) {
|
|
108
|
+
export function registryRepository(indexName) {
|
|
103
109
|
return `${chartRegistry().replace(/^oci:\/\/[^/]+\//, '')}/${indexName}`;
|
|
104
110
|
}
|
|
105
111
|
|
|
@@ -125,9 +131,9 @@ export function isVersionTag(tag) {
|
|
|
125
131
|
*/
|
|
126
132
|
export function selectNewestIndex(allCandidates) {
|
|
127
133
|
if (!Array.isArray(allCandidates)) {return null;}
|
|
128
|
-
//
|
|
129
|
-
// snapshot, never a release index: it is not a candidate for the release pin.
|
|
130
|
-
const candidates = allCandidates.filter((c) => !c.tag
|
|
134
|
+
// Anything with the develop prefix (`0.0.0-dev.`) is a frozen xema-deploy
|
|
135
|
+
// snapshot or a malformed develop version, never a release index: it is not a candidate for the release pin.
|
|
136
|
+
const candidates = allCandidates.filter((c) => !hasDevelopChartVersionPrefix(c.tag));
|
|
131
137
|
if (candidates.length === 0) {return null;}
|
|
132
138
|
const undated = candidates.filter((c) => !c.createdAt);
|
|
133
139
|
if (undated.length > 0) {
|
|
@@ -142,12 +148,17 @@ export function selectNewestIndex(allCandidates) {
|
|
|
142
148
|
)[0].tag;
|
|
143
149
|
}
|
|
144
150
|
|
|
145
|
-
const DIGEST_PATTERN =
|
|
151
|
+
const DIGEST_PATTERN = OCI_DIGEST_PATTERN;
|
|
146
152
|
|
|
147
153
|
/** The index chart annotation listing the charts the publisher closed the set with. */
|
|
148
154
|
export const INDEX_ROSTER_ANNOTATION = 'xema.dev/chart-set-charts';
|
|
149
155
|
|
|
150
|
-
|
|
156
|
+
/**
|
|
157
|
+
* The lock for `publishers`: each publisher's index version, the manifest digest
|
|
158
|
+
* the registry served for that index (`indexDigestsByIndex`, null for a develop
|
|
159
|
+
* snapshot, which has no index chart), and its sealed roster.
|
|
160
|
+
*/
|
|
161
|
+
export function buildLock(versionsByIndex, rostersByIndex, indexDigestsByIndex, publishers = chartSetPublishers()) {
|
|
151
162
|
// Every publisher's VERSION first: a pin that resolved nothing for one half is
|
|
152
163
|
// the more fundamental refusal, whatever its roster.
|
|
153
164
|
for (const publisher of publishers) {
|
|
@@ -156,10 +167,10 @@ export function buildLock(versionsByIndex, rostersByIndex, publishers = chartSet
|
|
|
156
167
|
}
|
|
157
168
|
}
|
|
158
169
|
return {
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
schemaVersion:
|
|
170
|
+
// Every member is `{version, digest}` (its OCI manifest digest) and each
|
|
171
|
+
// publisher pins its index chart by `indexDigest`, so the lock itself is the
|
|
172
|
+
// trust anchor; `publishers[].version` is per publisher, not one shared version.
|
|
173
|
+
schemaVersion: CHART_SET_LOCK_SCHEMA_VERSION,
|
|
163
174
|
registry: chartRegistry(),
|
|
164
175
|
publishers: publishers.map((publisher) => {
|
|
165
176
|
const version = versionsByIndex[publisher.index];
|
|
@@ -180,7 +191,13 @@ export function buildLock(versionsByIndex, rostersByIndex, publishers = chartSet
|
|
|
180
191
|
}
|
|
181
192
|
charts[name] = { version: member.version, digest: member.digest };
|
|
182
193
|
}
|
|
183
|
-
|
|
194
|
+
// The digest the registry served for the index manifest the roster was read from.
|
|
195
|
+
const indexDigest = indexDigestsByIndex?.[publisher.index];
|
|
196
|
+
const wellFormed = isDevelopChartVersion(version) ? indexDigest === null : DIGEST_PATTERN.test(String(indexDigest));
|
|
197
|
+
if (!wellFormed) {
|
|
198
|
+
throw new Error(`no index manifest digest was read for '${publisher.index}@${version}' (got ${JSON.stringify(indexDigest ?? null)}; a release index needs sha256:<64 hex>, a develop snapshot null)`);
|
|
199
|
+
}
|
|
200
|
+
return { ...publisher, version, indexDigest, charts };
|
|
184
201
|
}),
|
|
185
202
|
};
|
|
186
203
|
}
|
|
@@ -235,19 +252,21 @@ export async function sealRoster(roster, lookup, label) {
|
|
|
235
252
|
return sealed;
|
|
236
253
|
}
|
|
237
254
|
|
|
238
|
-
/** The annotations of
|
|
239
|
-
export async function
|
|
255
|
+
/** The annotations of an index chart's served manifest `served` ({digest, bytes}), read from its OCI config blob (helm stores Chart.yaml's metadata there). */
|
|
256
|
+
export async function manifestAnnotations(client, indexName, version, served) {
|
|
240
257
|
const repository = registryRepository(indexName);
|
|
241
|
-
const manifest = JSON.parse(
|
|
258
|
+
const manifest = JSON.parse(served.bytes.toString('utf8'));
|
|
242
259
|
const configDigest = manifest?.config?.digest;
|
|
243
260
|
if (typeof configDigest !== 'string') {throw new Error(`${indexName}@${version}: manifest names no config blob`);}
|
|
244
261
|
const config = JSON.parse((await client.getBlob(repository, configDigest)).toString('utf8'));
|
|
245
262
|
return config?.annotations ?? {};
|
|
246
263
|
}
|
|
247
264
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
265
|
+
/** The roster and the served manifest digest of the index chart `<indexName>:<version>`, from ONE manifest read. */
|
|
266
|
+
async function indexRosterAndDigest(client, indexName, version) {
|
|
267
|
+
const served = await client.getManifestByTag(registryRepository(indexName), version);
|
|
268
|
+
const annotations = await manifestAnnotations(client, indexName, version, served);
|
|
269
|
+
return { roster: parseIndexRoster(annotations[INDEX_ROSTER_ANNOTATION], `${indexName}@${version}`), indexDigest: served.digest };
|
|
251
270
|
}
|
|
252
271
|
|
|
253
272
|
/** Every edition directory (one holding a distribution.lock.json) under the editions root. */
|
|
@@ -259,11 +278,9 @@ function editionDirs() {
|
|
|
259
278
|
}
|
|
260
279
|
|
|
261
280
|
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
262
|
-
const DEVELOP_CHART_VERSION_PREFIX = '0.0.0-dev.';
|
|
263
|
-
const DEVELOP_SHA_LENGTH = 12;
|
|
264
281
|
|
|
265
282
|
/** The develop chart version xema-deploy publishes for a frozen SHA: `0.0.0-dev.<sha12>`, also the OCI tag. */
|
|
266
|
-
export const developChartVersion = (sha) => `${DEVELOP_CHART_VERSION_PREFIX}${sha.slice(0,
|
|
283
|
+
export const developChartVersion = (sha) => `${DEVELOP_CHART_VERSION_PREFIX}${sha.slice(0, DEVELOP_CHART_SHA_LENGTH)}`;
|
|
267
284
|
|
|
268
285
|
/** Every chart (a directory with a Chart.yaml, library charts included) of an xema-deploy checkout, sorted. */
|
|
269
286
|
function deployChartNames(xemaDeployDir) {
|
|
@@ -312,7 +329,8 @@ export async function resolveDevelopChartSet({ xemaDeployDir, xemaDeploySha, loo
|
|
|
312
329
|
const digestOf = lookup ?? registryManifestDigest(chartRegistryClient());
|
|
313
330
|
const roster = Object.fromEntries(names.map((name) => [name, { version }]));
|
|
314
331
|
const sealed = await sealRoster(roster, digestOf, `${publishers[0].index}@${version}`);
|
|
315
|
-
|
|
332
|
+
// A develop snapshot has no index chart: its roster is the deploy checkout's chart directories.
|
|
333
|
+
const lock = buildLock({ [publishers[0].index]: version }, { [publishers[0].index]: sealed }, { [publishers[0].index]: null }, publishers);
|
|
316
334
|
return assertRosteredChartSetLock(lock, EDITION_CHART_SET_FILE, EditionChannel.Develop);
|
|
317
335
|
}
|
|
318
336
|
|
|
@@ -343,11 +361,12 @@ async function indexCandidates(client, indexName) {
|
|
|
343
361
|
return candidates;
|
|
344
362
|
}
|
|
345
363
|
|
|
346
|
-
|
|
347
|
-
|
|
364
|
+
/** Read every publisher's newest index through `client`: its version, its roster, and the manifest digest the registry served for it. */
|
|
365
|
+
export async function resolveFromRegistry(client = chartRegistryClient()) {
|
|
348
366
|
|
|
349
367
|
const versionsByIndex = {};
|
|
350
368
|
const rostersByIndex = {};
|
|
369
|
+
const indexDigestsByIndex = {};
|
|
351
370
|
for (const { index, repo } of chartSetPublishers()) {
|
|
352
371
|
const candidates = await indexCandidates(client, index);
|
|
353
372
|
const version = selectNewestIndex(candidates);
|
|
@@ -359,7 +378,8 @@ async function resolveFromRegistry() {
|
|
|
359
378
|
);
|
|
360
379
|
}
|
|
361
380
|
versionsByIndex[index] = version;
|
|
362
|
-
const roster = await
|
|
381
|
+
const { roster, indexDigest } = await indexRosterAndDigest(client, index, version);
|
|
382
|
+
indexDigestsByIndex[index] = indexDigest;
|
|
363
383
|
// The registry is asked for every member, so an index digest is checked
|
|
364
384
|
// against it and a member without one still gets sealed.
|
|
365
385
|
rostersByIndex[index] = await sealRoster(
|
|
@@ -368,13 +388,13 @@ async function resolveFromRegistry() {
|
|
|
368
388
|
`${index}@${version}`,
|
|
369
389
|
);
|
|
370
390
|
}
|
|
371
|
-
return { versionsByIndex, rostersByIndex };
|
|
391
|
+
return { versionsByIndex, rostersByIndex, indexDigestsByIndex };
|
|
372
392
|
}
|
|
373
393
|
|
|
374
394
|
async function main(argv) {
|
|
375
395
|
const check = argv.includes('--check');
|
|
376
|
-
const { versionsByIndex, rostersByIndex } = await resolveFromRegistry();
|
|
377
|
-
const next = buildLock(versionsByIndex, rostersByIndex);
|
|
396
|
+
const { versionsByIndex, rostersByIndex, indexDigestsByIndex } = await resolveFromRegistry();
|
|
397
|
+
const next = buildLock(versionsByIndex, rostersByIndex, indexDigestsByIndex);
|
|
378
398
|
assertRosteredChartSetLock(next, CHART_SET_LOCK_FILE, EditionChannel.Release);
|
|
379
399
|
const pinText = serializeJson(next);
|
|
380
400
|
const current = readLock();
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// resolve-customer-edition — resolve a CUSTOMER edition against a catalogue.
|
|
2
2
|
//
|
|
3
|
-
// resolve-edition.mjs <root flags> --definition <file>
|
|
3
|
+
// resolve-edition.mjs <root flags> --definition <file>
|
|
4
4
|
// --channel release|develop --out <dir> [--source-image <source>=<registry>/<prefix>]...
|
|
5
5
|
// [--catalogue-username <name> --catalogue-token-env <VAR>]
|
|
6
6
|
//
|
|
7
|
-
// The catalogue
|
|
7
|
+
// The catalogue of the channel is the one pinned in catalogue.pin.json beside the definition
|
|
8
|
+
// (catalogue-pin.mjs). The catalogue (catalogue.mjs) is the CEILING: it supplies availability, pins,
|
|
8
9
|
// image digests, chart-set rows and the contract authority, and never adds a
|
|
9
10
|
// component. The resolved set is the customer's positive selections, every
|
|
10
11
|
// installationClass core entry of the catalogue and the transitive declared
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
|
|
16
17
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
17
18
|
import { join } from 'node:path';
|
|
19
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
18
20
|
|
|
19
21
|
import { AUTHORIZATION_LOCK_FILENAME, writeAuthorizationLock } from './authorization-base.mjs';
|
|
20
22
|
import { CATALOGUE_FILE, CatalogueError } from './catalogue.mjs';
|
|
@@ -31,7 +33,7 @@ import { validateKernelContractAuthorityFromGit } from './kernel-contract-author
|
|
|
31
33
|
import { compareStrings, loadKernelDistributionSchemas, serializeJson, validateImmutableImageLock } from './lib.mjs';
|
|
32
34
|
import { pinnedSource, snapshotPinSource } from './pin-source.mjs';
|
|
33
35
|
import { composeAuthorizationBase, composeManifest, resolveDistributionWithIndex, UnknownSelectionError } from './resolve-distribution.mjs';
|
|
34
|
-
import {
|
|
36
|
+
import { resolveImageRows } from './resolve-images.mjs';
|
|
35
37
|
|
|
36
38
|
/** A selection or dependency the catalogue does not offer: the catalogue is the ceiling. */
|
|
37
39
|
export class CatalogueCeilingError extends Error {
|
|
@@ -186,7 +188,57 @@ export function projectVendorImages(catalogue, lock, enums) {
|
|
|
186
188
|
};
|
|
187
189
|
}
|
|
188
190
|
|
|
189
|
-
/** The
|
|
191
|
+
/** The first field in which two rows differ (compared by canonical deep equality, key by key), or undefined when they are equal. */
|
|
192
|
+
function firstDifferingField(expected, actual) {
|
|
193
|
+
for (const field of [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort(compareStrings)) {
|
|
194
|
+
if (!isDeepStrictEqual(expected[field], actual[field])) {return field;}
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The vendor rows of the merged images lock ARE the catalogue's rows: every subject, platform-service binding and web
|
|
201
|
+
* biome that is not the customer's own deep-equals the catalogue's row (owners narrowed to the selection by
|
|
202
|
+
* `projectVendorImages`, imageRef re-homed by the declared mirror through `mirrorVendorSubjects`), and every projected
|
|
203
|
+
* catalogue row is present. A difference throws `CatalogueCeilingError`, naming the row and the first differing field.
|
|
204
|
+
*/
|
|
205
|
+
export function assertVendorRowsAreCatalogueRows({ catalogue, imageLock, lock, mirror, enums }) {
|
|
206
|
+
const ownBiomes = new Set(lock.biomes.filter((biome) => biome.source !== undefined).map((biome) => biome.id));
|
|
207
|
+
const projected = projectVendorImages(catalogue, lock, enums);
|
|
208
|
+
const expected = {
|
|
209
|
+
subjects: new Map(mirrorVendorSubjects(projected.subjects, mirror).map((subject) => [subjectKey(subject), subject])),
|
|
210
|
+
platformServiceBindings: new Map(projected.platformServiceBindings.map((binding) => [binding.platformService, binding])),
|
|
211
|
+
webBiomes: new Map(projected.webBiomes.map((biome) => [biome.biomeId, biome])),
|
|
212
|
+
};
|
|
213
|
+
const isOwnSubject = (subject) => subject.owners.length > 0 && subject.owners.every((owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome && ownBiomes.has(owner.biomeId));
|
|
214
|
+
const compare = (label, name, want, got) => {
|
|
215
|
+
if (want === undefined) {
|
|
216
|
+
throw new CatalogueCeilingError(`the merged images lock carries ${label} ${name}, which is neither the customer's own nor the catalogue's`);
|
|
217
|
+
}
|
|
218
|
+
const field = firstDifferingField(want, got);
|
|
219
|
+
if (field !== undefined) {
|
|
220
|
+
throw new CatalogueCeilingError(`the merged images lock's ${label} ${name} differs from the catalogue's row at ${field}`);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const kinds = [
|
|
224
|
+
['image', 'subjects', (row) => subjectKey(row), (row) => `${row.kind}:${row.id}`, (row) => isOwnSubject(row)],
|
|
225
|
+
['platform-service binding', 'platformServiceBindings', (row) => row.platformService, (row) => row.platformService, () => false],
|
|
226
|
+
['web biome', 'webBiomes', (row) => row.biomeId, (row) => row.biomeId, (row) => ownBiomes.has(row.biomeId)],
|
|
227
|
+
];
|
|
228
|
+
for (const [label, field, keyOf, nameOf, isOwn] of kinds) {
|
|
229
|
+
const present = new Set();
|
|
230
|
+
for (const row of imageLock[field]) {
|
|
231
|
+
if (isOwn(row)) {continue;}
|
|
232
|
+
present.add(keyOf(row));
|
|
233
|
+
compare(label, nameOf(row), expected[field].get(keyOf(row)), row);
|
|
234
|
+
}
|
|
235
|
+
for (const [key, want] of expected[field]) {
|
|
236
|
+
if (!present.has(key)) {throw new CatalogueCeilingError(`the merged images lock lacks the catalogue's ${label} ${nameOf(want)}`);}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The customer's image lock: vendor rows copied (and mirrored), own rows resolved (and sealed by `sealOwn`, which takes and returns subjects), the merged lock parsed once. */
|
|
190
242
|
export async function resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn }) {
|
|
191
243
|
const vendor = projectVendorImages(catalogue, lock, schemas);
|
|
192
244
|
const vendorSubjects = mirrorVendorSubjects(vendor.subjects, composed.mirror);
|
|
@@ -202,13 +254,15 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
|
|
|
202
254
|
crossRepoBuildInputs: [],
|
|
203
255
|
buildStepContextDirectories: [],
|
|
204
256
|
};
|
|
205
|
-
own
|
|
206
|
-
|
|
257
|
+
// The own rows are ROWS, not a lock: the one parse is of the merged lock below.
|
|
258
|
+
own = await resolveImageRows(id, { inputs: { ...inputs, runtimeImageSourceIndex, composed }, lock: ownLock });
|
|
259
|
+
// A source that builds no image (a web biome) has no row to seal.
|
|
260
|
+
if (sealOwn !== undefined && own.subjects.length > 0) {own = { ...own, subjects: await sealOwn({ distributionId: id, subjects: own.subjects }) };}
|
|
207
261
|
}
|
|
208
262
|
const seen = new Set(vendorSubjects.map(subjectKey));
|
|
209
263
|
const clash = own.subjects.find((subject) => seen.has(subjectKey(subject)));
|
|
210
264
|
if (clash !== undefined) {throw new Error(`the customer's own image ${clash.kind}:${clash.id} collides with a catalogue image of the same identity`);}
|
|
211
|
-
|
|
265
|
+
const merged = validateImmutableImageLock(
|
|
212
266
|
schemas.parseImageLock({
|
|
213
267
|
schemaVersion: 3,
|
|
214
268
|
sourceHashAlgorithm: catalogue.images.sourceHashAlgorithm,
|
|
@@ -220,6 +274,8 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
|
|
|
220
274
|
}),
|
|
221
275
|
schemas,
|
|
222
276
|
);
|
|
277
|
+
assertVendorRowsAreCatalogueRows({ catalogue, imageLock: merged, lock, mirror: composed.mirror, enums: schemas });
|
|
278
|
+
return merged;
|
|
223
279
|
}
|
|
224
280
|
|
|
225
281
|
/** The kernel at --kernel must carry the contract the catalogue's offering was resolved under. */
|
|
@@ -144,7 +144,7 @@ export function lockedServiceProcessFacts(entry) {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
if (isEntryModule(import.meta.url)) {
|
|
147
|
-
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel
|
|
147
|
+
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel]);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/**
|
|
@@ -24,8 +24,8 @@ import { join } from 'node:path';
|
|
|
24
24
|
|
|
25
25
|
import { AUTHORIZATION_LOCK_FILENAME } from './authorization-base.mjs';
|
|
26
26
|
import { fetchCatalogue } from './catalogue-fetch.mjs';
|
|
27
|
+
import { readCataloguePin } from './catalogue-pin.mjs';
|
|
27
28
|
import {
|
|
28
|
-
CATALOGUE_FLAG,
|
|
29
29
|
CATALOGUE_TOKEN_ENV_FLAG,
|
|
30
30
|
CATALOGUE_USERNAME_FLAG,
|
|
31
31
|
CHANNEL_FLAG,
|
|
@@ -49,17 +49,17 @@ import { writeResolvedImages } from './resolve-images.mjs';
|
|
|
49
49
|
import { withoutSealedDigests } from './seal-image-digests.mjs';
|
|
50
50
|
import { readStrictJsonFile } from './strict-json.mjs';
|
|
51
51
|
|
|
52
|
-
// A customer resolve (`--
|
|
52
|
+
// A customer resolve (`--definition`) has no vendor editions root: `--out` IS its
|
|
53
53
|
// editions root, the directory its `<id>/` locks are written under.
|
|
54
54
|
if (isEntryModule(import.meta.url)) {
|
|
55
|
-
if (process.argv.includes(
|
|
55
|
+
if (process.argv.includes(DEFINITION_FLAG)) {
|
|
56
56
|
const at = process.argv.indexOf('--out');
|
|
57
|
-
if (at < 0 || process.argv[at + 1] === undefined) {throw new Error(`--out <dir> is required with ${
|
|
57
|
+
if (at < 0 || process.argv[at + 1] === undefined) {throw new Error(`--out <dir> is required with ${DEFINITION_FLAG}`);}
|
|
58
58
|
mkdirSync(process.argv[at + 1], { recursive: true });
|
|
59
59
|
process.argv.splice(at, 1, '--editions-root');
|
|
60
|
-
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache,
|
|
60
|
+
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache], { refuseOthers: true });
|
|
61
61
|
} else {
|
|
62
|
-
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache
|
|
62
|
+
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache]);
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -135,7 +135,7 @@ export async function checkResolvedLockSet(id) {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
const CUSTOMER_VALUE_FLAGS = [DEFINITION_FLAG,
|
|
138
|
+
const CUSTOMER_VALUE_FLAGS = [DEFINITION_FLAG, CHANNEL_FLAG, CATALOGUE_USERNAME_FLAG, CATALOGUE_TOKEN_ENV_FLAG];
|
|
139
139
|
|
|
140
140
|
/** The arguments of a customer resolve (the root flags and `--out` are already consumed). */
|
|
141
141
|
export function parseCustomerResolveArgs(args) {
|
|
@@ -145,12 +145,12 @@ export function parseCustomerResolveArgs(args) {
|
|
|
145
145
|
const flag = args[i];
|
|
146
146
|
const value = args[i + 1];
|
|
147
147
|
if (![...CUSTOMER_VALUE_FLAGS, SOURCE_IMAGE_FLAG].includes(flag) || value === undefined || value.startsWith('--')) {
|
|
148
|
-
throw new Error(`unexpected argument ${JSON.stringify(flag)} in a customer resolve (${DEFINITION_FLAG}, ${
|
|
148
|
+
throw new Error(`unexpected argument ${JSON.stringify(flag)} in a customer resolve (${DEFINITION_FLAG}, ${CHANNEL_FLAG}, ${SOURCE_IMAGE_FLAG} take a value)`);
|
|
149
149
|
}
|
|
150
150
|
i += 1;
|
|
151
151
|
if (flag === SOURCE_IMAGE_FLAG) {sourceImages.push(value);} else if (values.has(flag)) {throw new Error(`${flag} given twice`);} else {values.set(flag, value);}
|
|
152
152
|
}
|
|
153
|
-
for (const required of [DEFINITION_FLAG,
|
|
153
|
+
for (const required of [DEFINITION_FLAG, CHANNEL_FLAG]) {
|
|
154
154
|
if (!values.has(required)) {throw new Error(`a customer resolve requires ${required}`);}
|
|
155
155
|
}
|
|
156
156
|
if (!Object.values(EditionChannel).includes(values.get(CHANNEL_FLAG))) {
|
|
@@ -162,7 +162,8 @@ export function parseCustomerResolveArgs(args) {
|
|
|
162
162
|
async function mainCustomer() {
|
|
163
163
|
const { values, sourceImages } = parseCustomerResolveArgs(process.argv.slice(2));
|
|
164
164
|
const { catalogue, digest } = await fetchCatalogue({
|
|
165
|
-
ref: values.get(
|
|
165
|
+
ref: readCataloguePin({ definitionFile: values.get(DEFINITION_FLAG), channel: values.get(CHANNEL_FLAG) }),
|
|
166
|
+
channel: values.get(CHANNEL_FLAG),
|
|
166
167
|
username: values.get(CATALOGUE_USERNAME_FLAG),
|
|
167
168
|
tokenEnv: values.get(CATALOGUE_TOKEN_ENV_FLAG),
|
|
168
169
|
});
|
|
@@ -172,7 +173,7 @@ async function mainCustomer() {
|
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
async function main() {
|
|
175
|
-
if (process.argv.includes(
|
|
176
|
+
if (process.argv.includes(DEFINITION_FLAG)) {return mainCustomer();}
|
|
176
177
|
const { id, check } = parseResolveEditionArgs(process.argv.slice(2));
|
|
177
178
|
assertCanonicalDistributionId(id);
|
|
178
179
|
await (check ? checkResolvedLockSet(id) : writeResolvedLockSet(id));
|
|
@@ -68,7 +68,7 @@ import { composeEdition } from './resolve-distribution.mjs';
|
|
|
68
68
|
import { readStrictJsonFile } from './strict-json.mjs';
|
|
69
69
|
|
|
70
70
|
if (isEntryModule(import.meta.url)) {
|
|
71
|
-
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache
|
|
71
|
+
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache]);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
const firstPartyImageRegistry = () => editionWorkspaceConfig().imageRegistry;
|
|
@@ -1383,7 +1383,15 @@ function snapshotImageInputs(schemas, { inputs, lock }) {
|
|
|
1383
1383
|
};
|
|
1384
1384
|
}
|
|
1385
1385
|
|
|
1386
|
-
|
|
1386
|
+
/**
|
|
1387
|
+
* The image ROWS of one edition: its subjects (hashed at the commits the lock
|
|
1388
|
+
* pins), its platform-service bindings and its bundled web biomes, after every
|
|
1389
|
+
* check that needs no parsed lock. Rows are not a lock: the kernel's image-lock
|
|
1390
|
+
* grammar is the grammar of a COMPLETE lock, and a caller that holds only part
|
|
1391
|
+
* of one (a customer's own rows, merged with the catalogue's) parses the lock
|
|
1392
|
+
* it completes. `resolveImages` is these rows parsed as the whole lock.
|
|
1393
|
+
*/
|
|
1394
|
+
export async function resolveImageRows(id, { inputs, lock } = {}) {
|
|
1387
1395
|
assertCanonicalDistributionId(id);
|
|
1388
1396
|
if ((inputs === undefined) !== (lock === undefined)) {
|
|
1389
1397
|
throw new Error('resolveImages takes the snapshot `inputs` and its distribution `lock` together, or neither');
|
|
@@ -1509,18 +1517,6 @@ export async function resolveImages(id, { inputs, lock } = {}) {
|
|
|
1509
1517
|
...biome,
|
|
1510
1518
|
sourceCommit: commitsBefore.get(biome.repo),
|
|
1511
1519
|
}));
|
|
1512
|
-
const imageLock = validateImmutableImageLock(
|
|
1513
|
-
schemas.parseImageLock({
|
|
1514
|
-
schemaVersion: 3,
|
|
1515
|
-
sourceHashAlgorithm: schemas.ImageSourceHashAlgorithm.ImageInputV2,
|
|
1516
|
-
distributionId: id,
|
|
1517
|
-
contractAuthority: runtimeSourceIndex.contractAuthority,
|
|
1518
|
-
subjects,
|
|
1519
|
-
platformServiceBindings: structure.platformServiceBindings,
|
|
1520
|
-
webBiomes,
|
|
1521
|
-
}),
|
|
1522
|
-
schemas,
|
|
1523
|
-
);
|
|
1524
1520
|
// The SAME locator the hash used. The default locator resolves every repo
|
|
1525
1521
|
// handle to a first-party tree under the aggregator root, so for an edition
|
|
1526
1522
|
// with an external source it looked for `<sourcesRoot()>/<source name>`, found no
|
|
@@ -1533,7 +1529,32 @@ export async function resolveImages(id, { inputs, lock } = {}) {
|
|
|
1533
1529
|
'refusing image-lock write: a source repository commit changed while hashing',
|
|
1534
1530
|
);
|
|
1535
1531
|
}
|
|
1536
|
-
return {
|
|
1532
|
+
return {
|
|
1533
|
+
subjects,
|
|
1534
|
+
platformServiceBindings: structure.platformServiceBindings,
|
|
1535
|
+
webBiomes,
|
|
1536
|
+
contractAuthority: runtimeSourceIndex.contractAuthority,
|
|
1537
|
+
commits: [...commitsBefore],
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/** One edition's COMPLETE image lock: its rows, parsed and validated as the kernel's image lock. */
|
|
1542
|
+
export async function resolveImages(id, { inputs, lock } = {}) {
|
|
1543
|
+
const { subjects, platformServiceBindings, webBiomes, contractAuthority, commits } = await resolveImageRows(id, { inputs, lock });
|
|
1544
|
+
const schemas = await loadKernelDistributionSchemas();
|
|
1545
|
+
const imageLock = validateImmutableImageLock(
|
|
1546
|
+
schemas.parseImageLock({
|
|
1547
|
+
schemaVersion: 3,
|
|
1548
|
+
sourceHashAlgorithm: schemas.ImageSourceHashAlgorithm.ImageInputV2,
|
|
1549
|
+
distributionId: id,
|
|
1550
|
+
contractAuthority,
|
|
1551
|
+
subjects,
|
|
1552
|
+
platformServiceBindings,
|
|
1553
|
+
webBiomes,
|
|
1554
|
+
}),
|
|
1555
|
+
schemas,
|
|
1556
|
+
);
|
|
1557
|
+
return { imageLock, commits };
|
|
1537
1558
|
}
|
|
1538
1559
|
|
|
1539
1560
|
/**
|