@xemahq/xema 0.54.0 → 0.56.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/edition/verify-image-evidence.bundle.mjs +1996 -1012
- package/dist/edition/verify-image-evidence.bundle.provenance.json +584 -500
- 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 +8 -3
- package/edition/biome-content.mjs +4 -4
- package/edition/candidate-edition.mjs +8 -3
- package/edition/check-committed-image-evidence.mjs +44 -15
- package/edition/customer-edition-verbs.mjs +15 -4
- package/edition/develop-current-resolution.mjs +29 -0
- package/edition/develop-edition-resolve.mjs +18 -10
- package/edition/develop-edition.mjs +30 -9
- package/edition/develop-freeze.mjs +24 -1
- package/edition/develop-git-bundles.mjs +201 -0
- package/edition/develop-graph.mjs +5 -3
- package/edition/develop-image-probe.mjs +6 -4
- package/edition/develop-image-record.mjs +194 -0
- package/edition/develop-record-ancestry.mjs +23 -5
- 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/dist-check.mjs +45 -9
- package/edition/distribution-evidence-set.mjs +14 -9
- package/edition/edition-image-seal.mjs +2 -1
- package/edition/edition-protocol.mjs +6 -20
- package/edition/external-source-walk.mjs +37 -15
- 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 +46 -110
- package/edition/lib.mjs +24 -26
- package/edition/locked-bundle-signer.mjs +52 -8
- package/edition/resolve-customer-edition.mjs +1 -1
- package/edition/resolve-distribution.mjs +84 -21
- package/edition/resolve-edition.mjs +2 -1
- package/edition/resolve-images.mjs +224 -12
- package/edition/seal-image-digests.mjs +24 -13
- package/edition/third-party-bundle-registry.mjs +73 -17
- package/edition/third-party-source-evidence.mjs +39 -14
- package/edition/third-party-source-writer.mjs +2 -18
- package/edition/trust-policy-arming.mjs +17 -6
- package/edition/verify-image-platforms.mjs +2 -0
- package/edition/workspace-sections.mjs +11 -1
- package/package.json +3 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// develop-image-record — the body of `xema edition develop image-record <id> --selection <file> --pins <file> --out <file>`.
|
|
2
|
+
//
|
|
3
|
+
// Writes the edition's image build record (images-from-record.mjs, the file `resolve --images-from` reads) from the
|
|
4
|
+
// records `select-records` selected. Its subjects are per-edition (`<kind>:<id>`), so they come from the checked-out
|
|
5
|
+
// snapshot, which select-records cannot know: the subjects are enumerated as `coverage` enumerates them
|
|
6
|
+
// (resolveDevelopSnapshot, then resolveImages on the normal hashing path, no recorded images).
|
|
7
|
+
//
|
|
8
|
+
// a first-party subject of a currentResolution member (develop-current-resolution.mjs) is probed as resolve probes it
|
|
9
|
+
// (createDevelopImageProbe): the digest `<develop repository>:<contentHash>` resolves to goes in `images`, with the
|
|
10
|
+
// computed hash and the snapshot's source commit.
|
|
11
|
+
// every other first-party subject takes the selection's image named by its develop repository: digest and hash from
|
|
12
|
+
// the selection, source commit the one selection.pins names for the repository, which must be the snapshot's. It goes
|
|
13
|
+
// in `satellites`. Several subjects may share one image.
|
|
14
|
+
// a third-party subject is ignored: it keeps its pinned seal at resolve.
|
|
15
|
+
//
|
|
16
|
+
// Each entry's platforms are read from the registry BY DIGEST (resolvePublishedPlatforms: the index's runnable
|
|
17
|
+
// children, attestations skipped, each proven by its image config; or the single manifest's config). Every problem is
|
|
18
|
+
// collected and refused at once (ImageBuildRecordError); nothing is written then. The output passes parseImageBuildRecord.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
import { writeOnce } from './customer-edition-verbs.mjs';
|
|
22
|
+
import { keepsCurrentResolution } from './develop-current-resolution.mjs';
|
|
23
|
+
import { DEVELOP_EDITION_STEPS, resolveDevelopSnapshot } from './develop-edition-resolve.mjs';
|
|
24
|
+
import { developGraphMembers } from './develop-graph.mjs';
|
|
25
|
+
import { developImageRef } from './develop-image-coordinate.mjs';
|
|
26
|
+
import { createDevelopImageProbe } from './develop-image-probe.mjs';
|
|
27
|
+
import { parseSelectionPins } from './develop-selection-pins.mjs';
|
|
28
|
+
import { editionsRoot } from './edition-workspace.mjs';
|
|
29
|
+
import { firstPartySources } from './first-party-source-ids.mjs';
|
|
30
|
+
import { ImageBuildRecordError, parseImageBuildRecord, requireImageBuildRecordKernel, subjectKeyOf } from './images-from-record.mjs';
|
|
31
|
+
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
32
|
+
import { loadRegistryCredentialDeclarations, splitImageRef } from './registry-credentials.mjs';
|
|
33
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
34
|
+
import { compareStrings } from './string-order.mjs';
|
|
35
|
+
import { registryClientsFor, resolvePublishedPlatforms } from './verify-image-platforms.mjs';
|
|
36
|
+
|
|
37
|
+
const SELECTION_REQUIRED = Object.freeze(['pins', 'snapshot', 'images', 'behind']);
|
|
38
|
+
const IMAGE_TEXT_KEYS = Object.freeze(['name', 'digest', 'contentHash', 'member']);
|
|
39
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A selection `select-records` wrote, checked strictly: its four sections are present and shaped, each image names its
|
|
43
|
+
* owning `member`, and `pins` and `currentResolution` pass the one selection-pins validation (40-hex pins, and
|
|
44
|
+
* `currentResolution` exactly what the graph members declare).
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} document
|
|
47
|
+
* @param {readonly {repo: string}[]} members the develop graph
|
|
48
|
+
* @param {string} source the file, named in the refusal
|
|
49
|
+
*/
|
|
50
|
+
export function parseSelection(document, members, source) {
|
|
51
|
+
if (!isPlainObject(document)) {throw new ImageBuildRecordError(`selection ${source} must be a JSON object`);}
|
|
52
|
+
const problems = SELECTION_REQUIRED.filter((key) => document[key] === undefined).map((key) => `has no "${key}"`);
|
|
53
|
+
for (const key of ['snapshot', 'behind']) {
|
|
54
|
+
if (document[key] !== undefined && !isPlainObject(document[key])) {problems.push(`"${key}" must be an object`);}
|
|
55
|
+
}
|
|
56
|
+
if (document.images !== undefined && !Array.isArray(document.images)) {problems.push('"images" must be an array');}
|
|
57
|
+
(Array.isArray(document.images) ? document.images : []).forEach((image, index) => {
|
|
58
|
+
for (const key of IMAGE_TEXT_KEYS) {
|
|
59
|
+
if (!isPlainObject(image) || typeof image[key] !== 'string') {problems.push(`images[${index}].${key} must be a string`);}
|
|
60
|
+
}
|
|
61
|
+
if (isPlainObject(image) && image.members !== undefined && !Array.isArray(image.members)) {problems.push(`images[${index}].members must be an array`);}
|
|
62
|
+
});
|
|
63
|
+
if (problems.length > 0) {throw new ImageBuildRecordError(`selection ${source} is refused:\n ${problems.join('\n ')}`);}
|
|
64
|
+
parseSelectionPins(document, source, '--selection', members);
|
|
65
|
+
return document;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const platformOf = ({ platform }) => ({ os: platform.os, architecture: platform.architecture, ...(platform.variant === undefined ? {} : { variant: platform.variant }) });
|
|
69
|
+
|
|
70
|
+
/** A cached reader of the platforms a develop image serves, read by digest through one registry client per authority. */
|
|
71
|
+
export function createPlatformReader(clientFor) {
|
|
72
|
+
const cache = new Map();
|
|
73
|
+
return (repository, digest) => {
|
|
74
|
+
const key = `${repository}@${digest}`;
|
|
75
|
+
if (!cache.has(key)) {
|
|
76
|
+
const { authority, repository: path } = splitImageRef(repository);
|
|
77
|
+
cache.set(key, resolvePublishedPlatforms({ client: clientFor(authority), repository: path, digest, coordinate: key })
|
|
78
|
+
.then(({ platforms }) => platforms.map(platformOf)));
|
|
79
|
+
}
|
|
80
|
+
return cache.get(key);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {object} request
|
|
86
|
+
* @param {string} request.id the edition
|
|
87
|
+
* @param {object} request.pins the snapshot pins
|
|
88
|
+
* @param {object} request.selection a parsed selection
|
|
89
|
+
* @param {object} request.steps resolve steps (tests replace them)
|
|
90
|
+
* @param {Function} [request.resolveSnapshot] ({id, pins, steps}) => {inputs, lock}; replaced by tests
|
|
91
|
+
* @param {Function} request.createProbe ({distributionId}) => the develop image probe
|
|
92
|
+
* @param {(repository: string, digest: string) => Promise<object[]>} request.platformsOf
|
|
93
|
+
* @param {object} request.declaredSources `firstPartySources`
|
|
94
|
+
* @param {Function} [request.loadKernel] the kernel loader (tests replace it)
|
|
95
|
+
* @returns {Promise<{schemaVersion: 1, images: object[], satellites: object[]}>}
|
|
96
|
+
*/
|
|
97
|
+
export async function buildImageRecord({ id, pins, selection, steps = DEVELOP_EDITION_STEPS, resolveSnapshot = resolveDevelopSnapshot, createProbe, platformsOf, declaredSources, loadKernel = loadKernelDistributionSchemas }) {
|
|
98
|
+
const kernel = requireImageBuildRecordKernel(await loadKernel());
|
|
99
|
+
const { inputs, lock } = await resolveSnapshot({ id, pins, steps });
|
|
100
|
+
const probe = createProbe({ distributionId: id });
|
|
101
|
+
const isFirstParty = (subject) => Object.hasOwn(declaredSources, subject.repo);
|
|
102
|
+
const { imageLock } = await steps.resolveImages(id, {
|
|
103
|
+
inputs,
|
|
104
|
+
lock,
|
|
105
|
+
onSubjectHashed: (subject) => {
|
|
106
|
+
if (isFirstParty(subject) && keepsCurrentResolution(declaredSources[subject.repo])) {probe.observe(subject);}
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const probed = await probe.settle();
|
|
110
|
+
const problems = [];
|
|
111
|
+
const built = [];
|
|
112
|
+
const satellites = [];
|
|
113
|
+
for (const subject of imageLock.subjects.filter(isFirstParty)) {
|
|
114
|
+
const key = subjectKeyOf(subject);
|
|
115
|
+
const repository = developImageRef(subject.imageRef);
|
|
116
|
+
if (keepsCurrentResolution(declaredSources[subject.repo])) {
|
|
117
|
+
built.push({ subject: key, repository, contentHash: subject.contentHash, digest: probed.get(key), sourceSha: subject.sourceCommit });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const image = selection.images.find(({ name }) => name === repository);
|
|
121
|
+
if (image === undefined) {
|
|
122
|
+
problems.push(`${key} (${subject.repo}): the selection has no image ${repository}`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (image.member !== subject.repo) {
|
|
126
|
+
problems.push(`${key} (${subject.repo}): the selection's image ${repository} belongs to member ${image.member}, not ${subject.repo}`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const selected = selection.pins[image.member];
|
|
130
|
+
if (selected !== subject.sourceCommit) {
|
|
131
|
+
problems.push(`${key} (${subject.repo}): the selection pins ${selected}, not ${subject.sourceCommit}, the commit the snapshot pins`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// The subject's COMPUTED hash is the one recorded: the heads path's probe already finds dev/<image>:<computed hash>, so
|
|
135
|
+
// producer CI and this CLI agree on the hash, and a selected hash that differs is a real mismatch, never overwritten.
|
|
136
|
+
if (image.contentHash !== subject.contentHash) {
|
|
137
|
+
problems.push(`${key} (${subject.repo}): the selection's content hash ${image.contentHash} is not the computed ${subject.contentHash}`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
// A composed image's members[{service, key}] are the build record's own, copied unchanged and never recomputed; the kernel's
|
|
141
|
+
// ImageBuildRecord parse refuses them on any other entry.
|
|
142
|
+
satellites.push({ subject: key, repository, contentHash: subject.contentHash, digest: image.digest, sourceSha: selected, ...(image.members === undefined ? {} : { members: image.members }) });
|
|
143
|
+
}
|
|
144
|
+
const withPlatforms = async (entries) => {
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
try {
|
|
148
|
+
out.push({ ...entry, platforms: await platformsOf(entry.repository, entry.digest) });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
problems.push(`${entry.subject}: the platforms of ${entry.repository}@${entry.digest} could not be read: ${error.message}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return out.sort((a, b) => compareStrings(a.subject, b.subject));
|
|
154
|
+
};
|
|
155
|
+
const record = { schemaVersion: kernel.IMAGE_BUILD_RECORD_SCHEMA_VERSION, images: await withPlatforms(built), satellites: await withPlatforms(satellites) };
|
|
156
|
+
if (problems.length > 0) {
|
|
157
|
+
throw new ImageBuildRecordError(`the image build record of "${id}" cannot be written (${problems.length} problem(s)):\n ${problems.join('\n ')}`);
|
|
158
|
+
}
|
|
159
|
+
return parseImageBuildRecord(record, 'develop image-record', { loadKernel: async () => kernel });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The `image-record` verb. Writes `--out` once, only after every subject is covered. */
|
|
163
|
+
export async function runImageRecord({
|
|
164
|
+
id,
|
|
165
|
+
options,
|
|
166
|
+
env = process.env,
|
|
167
|
+
steps = DEVELOP_EDITION_STEPS,
|
|
168
|
+
members = developGraphMembers(),
|
|
169
|
+
declaredSources = firstPartySources('enumerating the first-party image subjects'),
|
|
170
|
+
createProbe = (request) => createDevelopImageProbe({ ...request, env }),
|
|
171
|
+
resolveSnapshot,
|
|
172
|
+
clientFor,
|
|
173
|
+
write = writeOnce,
|
|
174
|
+
}) {
|
|
175
|
+
const selection = parseSelection(readStrictJsonFile(options['--selection']), members, options['--selection']);
|
|
176
|
+
const declarations = clientFor === undefined ? loadRegistryCredentialDeclarations(editionsRoot()) : null;
|
|
177
|
+
const clients = new Map();
|
|
178
|
+
const registryClient = clientFor ?? ((authority) => {
|
|
179
|
+
if (!clients.has(authority)) {clients.set(authority, registryClientsFor([{ imageRef: `${authority}/x` }], { declarations, env }).get(authority));}
|
|
180
|
+
return clients.get(authority);
|
|
181
|
+
});
|
|
182
|
+
const record = await buildImageRecord({
|
|
183
|
+
id,
|
|
184
|
+
pins: readStrictJsonFile(options['--pins']),
|
|
185
|
+
selection,
|
|
186
|
+
steps,
|
|
187
|
+
...(resolveSnapshot === undefined ? {} : { resolveSnapshot }),
|
|
188
|
+
createProbe,
|
|
189
|
+
platformsOf: createPlatformReader(registryClient),
|
|
190
|
+
declaredSources,
|
|
191
|
+
});
|
|
192
|
+
write(options['--out'], record);
|
|
193
|
+
return record;
|
|
194
|
+
}
|
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
// With a published commit for the member, the records may not take it backwards: behind or equal keeps the published
|
|
10
10
|
// commit (its own record must then exist), ahead selects the records' commit, otherwise the run is refused.
|
|
11
11
|
//
|
|
12
|
+
// The result also carries develop's head (`git rev-parse refs/heads/develop`) and `behind`, the commits the selected
|
|
13
|
+
// one trails that head by (`git rev-list --count <selected>..refs/heads/develop`), both from the same fetch.
|
|
14
|
+
//
|
|
12
15
|
// Refusals (`RecordRefused`): a commit absent from the fetched develop history, two commits that are not
|
|
13
16
|
// ancestor-related. Operational (plain Error): git cannot be spawned, a fetch fails, or merge-base answers
|
|
14
|
-
// anything but 0 (ancestor) or 1 (not an ancestor).
|
|
17
|
+
// anything but 0 (ancestor) or 1 (not an ancestor), or rev-parse or rev-list fails or answers a non-number.
|
|
15
18
|
|
|
16
19
|
import { spawnSync } from 'node:child_process';
|
|
17
20
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
@@ -72,7 +75,8 @@ export function defaultRunGit(args, { cwd, env }) {
|
|
|
72
75
|
* @param {object} request.workspace the workspace config the source hold reads
|
|
73
76
|
* @param {object} request.env
|
|
74
77
|
* @param {Function} [request.runGit] (args, {cwd, env}) => {status, stdout, stderr}; replaced by tests
|
|
75
|
-
* @returns {string} the selected commit
|
|
78
|
+
* @returns {{commit: string, head: string, behind: number}} the selected commit, develop's head in the same fetch, and
|
|
79
|
+
* how many commits the selected one is behind that head (0 when it is the head)
|
|
76
80
|
*/
|
|
77
81
|
export function newestCommitOnDevelop({ member, candidates, published, workspace, env, runGit = defaultRunGit }) {
|
|
78
82
|
const byCommit = new Map();
|
|
@@ -109,10 +113,24 @@ export function newestCommitOnDevelop({ member, candidates, published, workspace
|
|
|
109
113
|
if (!isAncestor(selected.commit, other.commit)) {throw new IncomparableRecordCommitsError(member.repo, selected, other);}
|
|
110
114
|
selected = other;
|
|
111
115
|
}
|
|
112
|
-
|
|
116
|
+
// develop's head and the chosen commit's distance from it, read in the same scratch the choice was made in
|
|
117
|
+
const withDistance = (commit) => {
|
|
118
|
+
const headResult = git(['rev-parse', DEVELOP_REF]);
|
|
119
|
+
const head = headResult.stdout.trim();
|
|
120
|
+
if (headResult.status !== 0 || !/^[0-9a-f]{40}$/.test(head)) {
|
|
121
|
+
throw new Error(`select-records: ${member.repo}: git rev-parse ${DEVELOP_REF} failed (exit ${headResult.status}): ${headResult.stderr.trim() || head}`);
|
|
122
|
+
}
|
|
123
|
+
const counted = git(['rev-list', '--count', `${commit}..${DEVELOP_REF}`]);
|
|
124
|
+
const behind = Number(counted.stdout.trim());
|
|
125
|
+
if (counted.status !== 0 || !/^\d+$/.test(counted.stdout.trim())) {
|
|
126
|
+
throw new Error(`select-records: ${member.repo}: git rev-list --count ${commit}..${DEVELOP_REF} failed (exit ${counted.status}): ${counted.stderr.trim() || counted.stdout.trim()}`);
|
|
127
|
+
}
|
|
128
|
+
return { commit, head, behind };
|
|
129
|
+
};
|
|
130
|
+
if (published === undefined) {return withDistance(selected.commit);}
|
|
113
131
|
assertOnDevelop(published, ['published pin']);
|
|
114
|
-
if (isAncestor(selected.commit, published)) {return published;} // equal, or the records are behind it
|
|
115
|
-
if (isAncestor(published, selected.commit)) {return selected.commit;}
|
|
132
|
+
if (isAncestor(selected.commit, published)) {return withDistance(published);} // equal, or the records are behind it
|
|
133
|
+
if (isAncestor(published, selected.commit)) {return withDistance(selected.commit);}
|
|
116
134
|
throw new DivergedFromPublishedError(member.repo, selected.commit, published);
|
|
117
135
|
});
|
|
118
136
|
} finally {
|
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
// when the records are not ahead of it, and a diverged one is refused; without it the rule applies among the
|
|
14
14
|
// records only (part 07 passes the flag).
|
|
15
15
|
//
|
|
16
|
+
// The output also carries `behind: { <repo>: { head, commits } }`: develop's head and how many commits the selected
|
|
17
|
+
// commit trails it by, from the same fetch (0 when the record is the head). The verb prints one line per member, in
|
|
18
|
+
// repo order, after writing --out, formatted by develop-selection-line.mjs (`<short name>: record <commit>, <N> commits behind HEAD`,
|
|
19
|
+
// always `commits`, never singular; short name = the repo's last path segment, unambiguous across members).
|
|
20
|
+
//
|
|
21
|
+
// A member that declares a currentResolution (develop-current-resolution.mjs) is skipped, its registry never read; the
|
|
22
|
+
// selection lists it as `currentResolution: { <repo>: <ticket> }` and the verb prints `<short name>: current resolution
|
|
23
|
+
// (leaves the edition, <ticket>)` in its place. Every other member without a record still refuses.
|
|
24
|
+
//
|
|
16
25
|
// Every failure is a hard refusal naming the repository, and nothing is written. Two classes: `RecordRefused`
|
|
17
26
|
// (the record is missing, or cosign or the record itself says no) and a plain Error (the registry or cosign
|
|
18
27
|
// could not answer), which the operator retries rather than reads.
|
|
@@ -22,6 +31,7 @@
|
|
|
22
31
|
// backwards. This verb checks nothing against current pins.
|
|
23
32
|
|
|
24
33
|
import { writeOnce } from './customer-edition-verbs.mjs';
|
|
34
|
+
import { currentResolutionOf, keepsCurrentResolution } from './develop-current-resolution.mjs';
|
|
25
35
|
import { developGraphMembers } from './develop-graph.mjs';
|
|
26
36
|
import { PublishedRecordMissingError, newestCommitOnDevelop } from './develop-record-ancestry.mjs';
|
|
27
37
|
import {
|
|
@@ -32,10 +42,11 @@ import {
|
|
|
32
42
|
requireBuildRecordKernel,
|
|
33
43
|
withRecordVerifier,
|
|
34
44
|
} from './develop-record-verify.mjs';
|
|
45
|
+
import { assertShortNamesUnambiguous, formatSelectionLine } from './develop-selection-line.mjs';
|
|
46
|
+
import { readSelectionPins } from './develop-selection-pins.mjs';
|
|
35
47
|
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
36
48
|
import { compareStrings } from './lib.mjs';
|
|
37
49
|
import { RecordRefused } from './record-refused.mjs';
|
|
38
|
-
import { readStrictJsonFile } from './strict-json.mjs';
|
|
39
50
|
|
|
40
51
|
// The record-verification names this verb's tests and callers read from here, owned by the shared core.
|
|
41
52
|
export {
|
|
@@ -64,16 +75,19 @@ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
|
64
75
|
* @param {object} request.workspace the workspace config the source hold reads
|
|
65
76
|
* @param {object} request.env
|
|
66
77
|
* @param {Function} [request.runGit] replaced by tests
|
|
67
|
-
* @returns {Promise<{pins: object, snapshot: object, images: object[]}>}
|
|
78
|
+
* @returns {Promise<{pins: object, snapshot: object, behind: object, images: object[], currentResolution: object}>}
|
|
68
79
|
*/
|
|
69
80
|
export async function selectRecords({ branch, registry, at, members, dataRootRepositoryUrl, kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath, publishedPins, workspace, env, runGit }) {
|
|
70
81
|
requireBuildRecordKernel(kernel);
|
|
82
|
+
assertShortNamesUnambiguous(members);
|
|
71
83
|
recordSignerOrigin({ branch, dataRootRepositoryUrl });
|
|
72
84
|
if (at !== undefined && (!ISO_UTC.test(at) || Number.isNaN(Date.parse(at)))) {
|
|
73
85
|
throw new Error(`--at must be an ISO-8601 UTC time (YYYY-MM-DDTHH:MM:SSZ); got ${JSON.stringify(at)}`);
|
|
74
86
|
}
|
|
75
87
|
const selected = [];
|
|
76
88
|
for (const member of members) {
|
|
89
|
+
// the one declared exception (develop-current-resolution.mjs): no registry read, no record
|
|
90
|
+
if (keepsCurrentResolution(member)) {continue;}
|
|
77
91
|
const { repository, authority, client, runs } = await listBuildRecordRuns({ kernel, clientFor, registry, member, branch });
|
|
78
92
|
// EVERY candidate is read and verified; any that fails refuses the run. None is skipped.
|
|
79
93
|
const verified = [];
|
|
@@ -87,7 +101,7 @@ export async function selectRecords({ branch, registry, at, members, dataRootRep
|
|
|
87
101
|
const eligible = at === undefined ? verified : verified.filter(({ record }) => Date.parse(record.completedAt) <= Date.parse(at));
|
|
88
102
|
if (eligible.length === 0) {throw new RecordRefused(member.repo, `no build record for branch ${branch} completed at or before ${at}`);}
|
|
89
103
|
const published = publishedPins?.[member.repo];
|
|
90
|
-
const commit = newestCommitOnDevelop({
|
|
104
|
+
const { commit, head, behind } = newestCommitOnDevelop({
|
|
91
105
|
member, candidates: eligible.map(({ record, tag }) => ({ commit: record.commit, tag })), published, workspace, env, runGit,
|
|
92
106
|
});
|
|
93
107
|
// records of one commit are re-runs of it: the highest run, then attempt
|
|
@@ -96,46 +110,36 @@ export async function selectRecords({ branch, registry, at, members, dataRootRep
|
|
|
96
110
|
const ofCommit = ofCommitIn(eligible).length > 0 ? ofCommitIn(eligible) : ofCommitIn(verified);
|
|
97
111
|
if (ofCommit.length === 0) {throw new PublishedRecordMissingError(member.repo, commit);}
|
|
98
112
|
const chosen = ofCommit[0];
|
|
99
|
-
selected.push({ member, ...chosen });
|
|
113
|
+
selected.push({ member, head, behind, ...chosen });
|
|
100
114
|
}
|
|
101
|
-
return composeSelection(selected);
|
|
115
|
+
return { ...composeSelection(selected), currentResolution: currentResolutionOf(members) };
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
/** The snapshot a selection writes. A name built by two records is refused: an image has one builder. */
|
|
105
119
|
export function composeSelection(selected) {
|
|
106
120
|
const pins = {};
|
|
107
121
|
const snapshot = {};
|
|
122
|
+
const behind = {};
|
|
108
123
|
const owners = new Map();
|
|
109
124
|
const images = [];
|
|
110
|
-
for (const { member, record, snapshot: run } of selected) {
|
|
125
|
+
for (const { member, record, snapshot: run, head, behind: commits } of selected) {
|
|
111
126
|
pins[member.repo] = record.commit;
|
|
127
|
+
behind[member.repo] = { head, commits };
|
|
112
128
|
snapshot[member.repo] = run;
|
|
113
129
|
for (const image of record.images) {
|
|
114
130
|
if (owners.has(image.name)) {
|
|
115
131
|
throw new RecordRefused(member.repo, `image ${image.name} is also built by ${owners.get(image.name)}; an image has one builder`);
|
|
116
132
|
}
|
|
117
133
|
owners.set(image.name, member.repo);
|
|
118
|
-
images.push({ name: image.name, digest: image.digest, contentHash: image.contentHash });
|
|
134
|
+
images.push({ name: image.name, digest: image.digest, contentHash: image.contentHash, member: member.repo, ...(image.members === undefined ? {} : { members: image.members }) });
|
|
119
135
|
}
|
|
120
136
|
}
|
|
121
137
|
const sorted = (object) => Object.fromEntries(Object.entries(object).sort(([a], [b]) => compareStrings(a, b)));
|
|
122
|
-
return { pins: sorted(pins), snapshot: sorted(snapshot), images: images.sort((a, b) => compareStrings(a.name, b.name)) };
|
|
138
|
+
return { pins: sorted(pins), snapshot: sorted(snapshot), behind: sorted(behind), images: images.sort((a, b) => compareStrings(a.name, b.name)) };
|
|
123
139
|
}
|
|
124
140
|
|
|
125
141
|
/** The `pins` of a file this verb wrote: member repo -> 40-hex commit. Nothing else of the file is read. */
|
|
126
|
-
export
|
|
127
|
-
const document = readStrictJsonFile(path);
|
|
128
|
-
const { pins } = document ?? {};
|
|
129
|
-
if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
|
|
130
|
-
throw new Error(`--published-pins ${path} has no "pins" object`);
|
|
131
|
-
}
|
|
132
|
-
for (const [repo, commit] of Object.entries(pins)) {
|
|
133
|
-
if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) {
|
|
134
|
-
throw new Error(`--published-pins ${path}: pin ${JSON.stringify(repo)} is not a 40-hex commit: ${JSON.stringify(commit)}`);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return pins;
|
|
138
|
-
}
|
|
142
|
+
export const readPublishedPins = (path) => readSelectionPins(path, '--published-pins');
|
|
139
143
|
|
|
140
144
|
/** The `select-records` verb. Writes `--out` once, only after every member was selected. */
|
|
141
145
|
export async function runSelectRecords({
|
|
@@ -144,6 +148,7 @@ export async function runSelectRecords({
|
|
|
144
148
|
members = developGraphMembers(),
|
|
145
149
|
runGit,
|
|
146
150
|
write = writeOnce,
|
|
151
|
+
print = (text) => process.stdout.write(text),
|
|
147
152
|
...verifierInjections
|
|
148
153
|
}) {
|
|
149
154
|
return withRecordVerifier({ cosignBinary: options['--cosign'], env, ...verifierInjections }, async (verifier) => {
|
|
@@ -159,6 +164,9 @@ export async function runSelectRecords({
|
|
|
159
164
|
runGit,
|
|
160
165
|
});
|
|
161
166
|
write(options['--out'], selection);
|
|
167
|
+
for (const member of members) {
|
|
168
|
+
print(`${formatSelectionLine(member, selection)}\n`);
|
|
169
|
+
}
|
|
162
170
|
return selection;
|
|
163
171
|
});
|
|
164
172
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// develop-selection-line — the one stdout line `select-records` prints per member, and the check that lines are unambiguous.
|
|
2
|
+
//
|
|
3
|
+
// <short name>: record <40-hex commit>, <N> commits behind HEAD
|
|
4
|
+
// <short name>: current resolution (leaves the edition, <ticket>)
|
|
5
|
+
//
|
|
6
|
+
// The short name is the repo handle's last path segment. The develop-edition workflow in xema-distributions
|
|
7
|
+
// (develop-edition.yaml) parses these lines with the same pattern; change both together.
|
|
8
|
+
|
|
9
|
+
import { keepsCurrentResolution } from './develop-current-resolution.mjs';
|
|
10
|
+
|
|
11
|
+
export const shortNameOf = (repo) => repo.split('/').at(-1);
|
|
12
|
+
|
|
13
|
+
/** One graph member's line (no newline): its current-resolution line, or its record, commit and distance from develop HEAD. */
|
|
14
|
+
export function formatSelectionLine(member, selection) {
|
|
15
|
+
const { repo } = member;
|
|
16
|
+
if (keepsCurrentResolution(member)) {
|
|
17
|
+
return `${shortNameOf(repo)}: current resolution (leaves the edition, ${member.currentResolution})`;
|
|
18
|
+
}
|
|
19
|
+
return `${shortNameOf(repo)}: record ${selection.pins[repo]}, ${selection.behind[repo].commits} commits behind HEAD`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Two members with one short name would print indistinguishable lines: refused by name. */
|
|
23
|
+
export function assertShortNamesUnambiguous(members) {
|
|
24
|
+
const seen = new Map();
|
|
25
|
+
for (const { repo } of members) {
|
|
26
|
+
const name = shortNameOf(repo);
|
|
27
|
+
if (seen.has(name)) {throw new Error(`select-records: members ${seen.get(name)} and ${repo} share the short name "${name}"; the printed lines would be ambiguous`);}
|
|
28
|
+
seen.set(name, repo);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// develop-selection-pins — the `pins` of a file `select-records` wrote: member repo -> 40-hex commit. Nothing else of
|
|
2
|
+
// the file is read here. `flag` names the option the file came in on (--published-pins, --member-pins), for the refusal.
|
|
3
|
+
|
|
4
|
+
import { currentResolutionProblem } from './develop-current-resolution.mjs';
|
|
5
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {readonly {repo: string}[]} [members] when given, the file's `currentResolution` must also be exactly
|
|
9
|
+
* what these graph members declare (develop-current-resolution.mjs)
|
|
10
|
+
* @returns {Record<string, string>} the file's `pins`, each a 40-hex commit
|
|
11
|
+
*/
|
|
12
|
+
export const readSelectionPins = (path, flag, members) => parseSelectionPins(readStrictJsonFile(path), path, flag, members);
|
|
13
|
+
|
|
14
|
+
/** The same over a document already read: the ONE validation of a selection's `pins` (and `currentResolution`). */
|
|
15
|
+
export function parseSelectionPins(document, path, flag, members) {
|
|
16
|
+
const { pins } = document ?? {};
|
|
17
|
+
if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
|
|
18
|
+
throw new Error(`${flag} ${path} has no "pins" object`);
|
|
19
|
+
}
|
|
20
|
+
for (const [repo, commit] of Object.entries(pins)) {
|
|
21
|
+
if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) {
|
|
22
|
+
throw new Error(`${flag} ${path}: pin ${JSON.stringify(repo)} is not a 40-hex commit: ${JSON.stringify(commit)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (members !== undefined) {
|
|
26
|
+
const problem = currentResolutionProblem(document.currentResolution, members);
|
|
27
|
+
if (problem !== null) {throw new Error(`${flag} ${path}: ${problem}`);}
|
|
28
|
+
}
|
|
29
|
+
return pins;
|
|
30
|
+
}
|
package/edition/dist-check.mjs
CHANGED
|
@@ -50,6 +50,7 @@ import { assertKernelEditionProtocol } from './edition-protocol.mjs';
|
|
|
50
50
|
import { describeEditionHolds } from './edition-source-pins.mjs';
|
|
51
51
|
import { WorkspaceRoot, biomeIndexPath, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule, kernelPackageDir, platformServiceIndexPath, runtimeImageSourceIndexPath, workspaceArgv } from './edition-workspace.mjs';
|
|
52
52
|
import { checkEvidenceTrustPolicy } from './evidence-trust-policy.mjs';
|
|
53
|
+
import { imageSubjectIdentity } from './image-subject-identity.mjs';
|
|
53
54
|
import {
|
|
54
55
|
compareIndexEntries,
|
|
55
56
|
comparableDistributionLock,
|
|
@@ -58,6 +59,7 @@ import {
|
|
|
58
59
|
loadKernelDistributionSchemas,
|
|
59
60
|
parseResolvedDistributionLock,
|
|
60
61
|
serializeJson,
|
|
62
|
+
sha256Hex,
|
|
61
63
|
validateImmutableBiomeIndex,
|
|
62
64
|
validateImmutableDistributionLock,
|
|
63
65
|
validateImmutableImageLock,
|
|
@@ -69,6 +71,8 @@ import {
|
|
|
69
71
|
resolveDistributionDeclaredSources,
|
|
70
72
|
resolveDistributionRequiredPlatforms,
|
|
71
73
|
externalSourceTrees,
|
|
74
|
+
carryComposedForwardStructurally,
|
|
75
|
+
readCommittedPreviousLock,
|
|
72
76
|
resolveImages,
|
|
73
77
|
resolveComposedProcessStructure,
|
|
74
78
|
resolveImageStructure,
|
|
@@ -237,6 +241,24 @@ async function checkAuthorizationLock(id, kernel, index) {
|
|
|
237
241
|
* with the same structure ship the same images and bundle the same web biomes,
|
|
238
242
|
* regardless of current source content (which the build re-hashes anyway).
|
|
239
243
|
*/
|
|
244
|
+
/**
|
|
245
|
+
* The committed lock with a stand-in `imageDigest` on every UNSEALED composed artifact, so the kernel can carry it forward.
|
|
246
|
+
* The kernel's v5 parse refuses two composed artifacts sharing one imageDigest, so each stand-in is unique to its subject:
|
|
247
|
+
* the sha256 of its identity (`imageSubjectIdentity`: `composed-process:<imageRef>:<contentHash>`, unique per lock).
|
|
248
|
+
* It equals a real manifest digest only by a sha256 preimage, and it never reaches a verdict: both sides of every
|
|
249
|
+
* comparison drop `imageDigest` (`withoutSealedDigests`).
|
|
250
|
+
*/
|
|
251
|
+
export function withStandInDigests(lock, schemas) {
|
|
252
|
+
return {
|
|
253
|
+
...lock,
|
|
254
|
+
subjects: lock.subjects.map((subject) =>
|
|
255
|
+
subject.kind === schemas.RuntimeImageSubjectKind.ComposedProcess && subject.imageDigest === undefined
|
|
256
|
+
? { ...subject, imageDigest: `sha256:${sha256Hex(imageSubjectIdentity(subject))}` }
|
|
257
|
+
: subject,
|
|
258
|
+
),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
240
262
|
function imageStructure(lock) {
|
|
241
263
|
return serializeJson({
|
|
242
264
|
distributionId: lock.distributionId,
|
|
@@ -315,12 +337,6 @@ export async function checkImagesLock(id, checkSourceEvidence) {
|
|
|
315
337
|
schemas,
|
|
316
338
|
requiredPlatforms,
|
|
317
339
|
);
|
|
318
|
-
const fresh = {
|
|
319
|
-
distributionId: id,
|
|
320
|
-
contractAuthority: runtimeSourceIndex.contractAuthority,
|
|
321
|
-
...structure,
|
|
322
|
-
subjects: structureSubjects(structure),
|
|
323
|
-
};
|
|
324
340
|
if (!existsSync(imageLockPath)) {
|
|
325
341
|
console.error(
|
|
326
342
|
`[dist:check] ${id}: images.lock.json is missing. Run \`${commandFor('resolve-images.mjs', id)}\`.`,
|
|
@@ -328,9 +344,29 @@ export async function checkImagesLock(id, checkSourceEvidence) {
|
|
|
328
344
|
return false;
|
|
329
345
|
}
|
|
330
346
|
const committed = validateImmutableImageLock(
|
|
331
|
-
|
|
347
|
+
(await readCommittedPreviousLock(id, imageLockPath)),
|
|
332
348
|
schemas,
|
|
333
349
|
);
|
|
350
|
+
// The committed lock is the previous lock of the fresh resolve, so the fresh view carries the composed artifacts forward
|
|
351
|
+
// exactly as a reseal does (generation, member keys, one artifact per live generation). Its digests are compared away
|
|
352
|
+
// below, so a committed lock that predates sealing is given one stand-in digest per subject to be carried.
|
|
353
|
+
const previousLock = withStandInDigests(committed, schemas);
|
|
354
|
+
let freshSubjects;
|
|
355
|
+
try {
|
|
356
|
+
freshSubjects = carryComposedForwardStructurally({ subjects: structureSubjects(structure), previousLock, schemas });
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.error(
|
|
359
|
+
`[dist:check] ${id}: images.lock.json is structurally out of date (its composed artifacts do not cover the declared composed processes: ${error.message}). ` +
|
|
360
|
+
`Run \`${commandFor('resolve-images.mjs', id)}\`.`,
|
|
361
|
+
);
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
const fresh = {
|
|
365
|
+
distributionId: id,
|
|
366
|
+
contractAuthority: runtimeSourceIndex.contractAuthority,
|
|
367
|
+
...structure,
|
|
368
|
+
subjects: freshSubjects,
|
|
369
|
+
};
|
|
334
370
|
if (
|
|
335
371
|
serializeJson(committed.contractAuthority) !==
|
|
336
372
|
serializeJson(runtimeSourceIndex.contractAuthority)
|
|
@@ -350,12 +386,12 @@ export async function checkImagesLock(id, checkSourceEvidence) {
|
|
|
350
386
|
return false;
|
|
351
387
|
}
|
|
352
388
|
if (checkSourceEvidence) {
|
|
353
|
-
const { imageLock: sourceResolved } = await resolveImages(id);
|
|
389
|
+
const { imageLock: sourceResolved } = await resolveImages(id, { previousLock });
|
|
354
390
|
// The same subtraction as the structural view above: the source resolve
|
|
355
391
|
// carries no digest, and the committed lock may.
|
|
356
392
|
if (
|
|
357
393
|
normalize(withoutSealedDigests(committed)) !==
|
|
358
|
-
normalize(sourceResolved)
|
|
394
|
+
normalize(withoutSealedDigests(sourceResolved))
|
|
359
395
|
) {
|
|
360
396
|
console.error(
|
|
361
397
|
`[dist:check] ${id}: images.lock.json source content hashes are out of date. ` +
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
sha256Digest,
|
|
57
57
|
sourceIdentity,
|
|
58
58
|
} from './image-evidence.mjs';
|
|
59
|
+
import { completenessImageKey } from './image-subject-identity.mjs';
|
|
59
60
|
import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
|
|
60
61
|
import { compareStrings } from './lib.mjs';
|
|
61
62
|
import { readBoundedNoFollowFile } from './no-follow-file.mjs';
|
|
@@ -133,7 +134,7 @@ export { compareCompletenessImages as compareEvidenceSubjects };
|
|
|
133
134
|
* Keyed by the same `kind\0id` identity `image-evidence.mjs` uses everywhere
|
|
134
135
|
* else, so two subjects can never collide and one subject can never move.
|
|
135
136
|
*/
|
|
136
|
-
function subjectStem(subject) {
|
|
137
|
+
export function subjectStem(subject) {
|
|
137
138
|
return createHash('sha256')
|
|
138
139
|
.update(sourceIdentity(subject))
|
|
139
140
|
.digest('hex');
|
|
@@ -315,10 +316,9 @@ function indexOneEvidenceDirectory({
|
|
|
315
316
|
});
|
|
316
317
|
continue;
|
|
317
318
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
});
|
|
319
|
+
// Only named in a defect: the record is indexed by the statement's own source coordinate, which already
|
|
320
|
+
// tells the generations of a composed artifact apart (its sourceTreeHash).
|
|
321
|
+
const subjectKey = `${entry.subjectKind}:${entry.subjectId}`;
|
|
322
322
|
const descriptor = entry.evidence;
|
|
323
323
|
let statementBytes;
|
|
324
324
|
let bundleBytes;
|
|
@@ -691,12 +691,17 @@ export function buildDistributionImagesStatement({
|
|
|
691
691
|
// Refusing here as well means an incomplete set can never be SIGNED, rather
|
|
692
692
|
// than being signed and rejected days later at a customer install.
|
|
693
693
|
const expectedIdentities = imageLock.subjects
|
|
694
|
-
.map((subject) =>
|
|
694
|
+
.map((subject) =>
|
|
695
|
+
completenessImageKey({
|
|
696
|
+
subjectKind: subject.kind,
|
|
697
|
+
subjectId: subject.id,
|
|
698
|
+
imageRepository: subject.imageRef,
|
|
699
|
+
manifestDigest: subject.imageDigest,
|
|
700
|
+
}),
|
|
701
|
+
)
|
|
695
702
|
.sort(compareStrings);
|
|
696
703
|
const actualIdentities = images
|
|
697
|
-
.map((image) =>
|
|
698
|
-
sourceIdentity({ kind: image.subjectKind, id: image.subjectId }),
|
|
699
|
-
)
|
|
704
|
+
.map((image) => completenessImageKey(image))
|
|
700
705
|
.sort(compareStrings);
|
|
701
706
|
if (
|
|
702
707
|
new Set(actualIdentities).size !== actualIdentities.length ||
|
|
@@ -19,6 +19,7 @@ import { developImageRef } from './develop-image-coordinate.mjs';
|
|
|
19
19
|
import { notBuiltYetError } from './develop-image-probe.mjs';
|
|
20
20
|
import { EditionChannel } from './edition-channel.mjs';
|
|
21
21
|
import { editionsRoot } from './edition-workspace.mjs';
|
|
22
|
+
import { imageSubjectIdentity } from './image-subject-identity.mjs';
|
|
22
23
|
import { validateImmutableImageLock, loadKernelDistributionSchemas, } from './lib.mjs';
|
|
23
24
|
import { isNotPublishedYet, planDigestSeal } from './seal-image-digests.mjs';
|
|
24
25
|
import { imageSubjectKey, pinnedManifestDigests, thirdPartyImagePins } from './third-party-image-pins.mjs';
|
|
@@ -86,7 +87,7 @@ export async function sealImageSubjects({
|
|
|
86
87
|
const digests = new Map([...writes, ...unchanged].map(({ key, digest }) => [key, digest]));
|
|
87
88
|
return channelSubjects.map((subject) => ({
|
|
88
89
|
...subject,
|
|
89
|
-
imageDigest: digests.get(
|
|
90
|
+
imageDigest: digests.get(imageSubjectIdentity(subject)),
|
|
90
91
|
}));
|
|
91
92
|
}
|
|
92
93
|
|