@xemahq/edition-tooling 0.0.0-stage → 0.1.1
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/LICENSE +175 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +47 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/edition.d.ts +4 -0
- package/dist/commands/edition.d.ts.map +1 -0
- package/dist/commands/edition.js +58 -0
- package/dist/commands/edition.js.map +1 -0
- package/dist/edition/verify-image-evidence.bundle.mjs +72104 -0
- package/dist/edition/verify-image-evidence.bundle.provenance.json +4119 -0
- package/dist/lib/exit-codes.d.ts +6 -0
- package/dist/lib/exit-codes.d.ts.map +1 -0
- package/dist/lib/exit-codes.js +21 -0
- package/dist/lib/exit-codes.js.map +1 -0
- package/dist/runtime/edition/run-entry.d.ts +10 -0
- package/dist/runtime/edition/run-entry.d.ts.map +1 -0
- package/dist/runtime/edition/run-entry.js +49 -0
- package/dist/runtime/edition/run-entry.js.map +1 -0
- package/dist/runtime/edition/verb-map.d.ts +70 -0
- package/dist/runtime/edition/verb-map.d.ts.map +1 -0
- package/dist/runtime/edition/verb-map.js +166 -0
- package/dist/runtime/edition/verb-map.js.map +1 -0
- package/edition/README.md +291 -0
- package/edition/adapter-bundled-versions.mjs +128 -0
- package/edition/adapter-host-versions.mjs +76 -0
- package/edition/assemble-producer-input.mjs +1423 -0
- package/edition/assemble-third-party-input.mjs +348 -0
- package/edition/authored-source-check.mjs +141 -0
- package/edition/authority-binding.mjs +23 -0
- package/edition/authorization-base.mjs +357 -0
- package/edition/biome-content.mjs +327 -0
- package/edition/build-biome-index.mjs +180 -0
- package/edition/build-ledger-completeness.mjs +67 -0
- package/edition/build-ledger.mjs +107 -0
- package/edition/build-platform-service-index.mjs +292 -0
- package/edition/build-runtime-image-source-index.mjs +1197 -0
- package/edition/built-verifier.mjs +55 -0
- package/edition/candidate-edition.mjs +735 -0
- package/edition/canonical-evidence-json.mjs +82 -0
- package/edition/canonical-repository-url.mjs +61 -0
- package/edition/catalogue-edition.mjs +49 -0
- package/edition/catalogue-evidence.mjs +228 -0
- package/edition/catalogue-fetch.mjs +124 -0
- package/edition/catalogue-lock-field.mjs +41 -0
- package/edition/catalogue-pin.mjs +71 -0
- package/edition/catalogue.mjs +237 -0
- package/edition/chart-set-source-commit.mjs +97 -0
- package/edition/check-committed-image-evidence.mjs +412 -0
- package/edition/checkout-indexed-source-repositories.mjs +283 -0
- package/edition/collected-refusals.mjs +59 -0
- package/edition/comment-blanker.mjs +75 -0
- package/edition/connector-source-authority.mjs +60 -0
- package/edition/contract-authority.mjs +122 -0
- package/edition/cosign-certificate-constraints.mjs +50 -0
- package/edition/cross-repo-build-inputs.mjs +158 -0
- package/edition/customer-definition.mjs +72 -0
- package/edition/customer-edition-verbs.mjs +610 -0
- package/edition/customer-flags.mjs +15 -0
- package/edition/definition-checkout.mjs +96 -0
- package/edition/develop-build-record.mjs +122 -0
- package/edition/develop-catalogue-free-resolve.mjs +282 -0
- package/edition/develop-catalogue-free-vendor.mjs +118 -0
- package/edition/develop-channel-evidence.mjs +23 -0
- package/edition/develop-channel-record.mjs +49 -0
- package/edition/develop-channel-satellites.mjs +65 -0
- package/edition/develop-chart-roster.mjs +96 -0
- package/edition/develop-chart-set.mjs +147 -0
- package/edition/develop-current-resolution.mjs +29 -0
- package/edition/develop-edition-invariants.mjs +142 -0
- package/edition/develop-edition-resolve.mjs +250 -0
- package/edition/develop-edition.mjs +332 -0
- package/edition/develop-engine-image.mjs +97 -0
- package/edition/develop-freeze.mjs +304 -0
- package/edition/develop-git-bundles.mjs +296 -0
- package/edition/develop-graph.mjs +34 -0
- package/edition/develop-image-coordinate.mjs +85 -0
- package/edition/develop-image-probe.mjs +122 -0
- package/edition/develop-image-record.mjs +260 -0
- package/edition/develop-resolve-report.mjs +159 -0
- package/edition/develop-select-records.mjs +90 -0
- package/edition/develop-selection-line.mjs +29 -0
- package/edition/develop-selection-pins.mjs +36 -0
- package/edition/develop-snapshot-inputs.mjs +112 -0
- package/edition/develop-store-root.mjs +20 -0
- package/edition/dispatched-runtime-components.mjs +172 -0
- package/edition/dist-check.mjs +1174 -0
- package/edition/distribution-evidence-set.mjs +1064 -0
- package/edition/distribution-paths.mjs +191 -0
- package/edition/edition-artifact.mjs +529 -0
- package/edition/edition-biome-manifests.mjs +85 -0
- package/edition/edition-ceiling.mjs +182 -0
- package/edition/edition-channel.mjs +27 -0
- package/edition/edition-chart-set.mjs +104 -0
- package/edition/edition-config.mjs +374 -0
- package/edition/edition-constants.mjs +110 -0
- package/edition/edition-contract-parse.mjs +179 -0
- package/edition/edition-contract.json +100 -0
- package/edition/edition-image-seal.mjs +115 -0
- package/edition/edition-kind.mjs +10 -0
- package/edition/edition-list.mjs +35 -0
- package/edition/edition-locks.mjs +78 -0
- package/edition/edition-pin-verbs.mjs +183 -0
- package/edition/edition-pin.mjs +17 -0
- package/edition/edition-pinned-inputs.mjs +116 -0
- package/edition/edition-protocol.mjs +285 -0
- package/edition/edition-publisher.mjs +21 -0
- package/edition/edition-seal.mjs +257 -0
- package/edition/edition-source-pins.mjs +211 -0
- package/edition/edition-unpin.mjs +16 -0
- package/edition/edition-workspace.mjs +205 -0
- package/edition/evidence-checkout.mjs +439 -0
- package/edition/evidence-grammars.mjs +18 -0
- package/edition/evidence-input-mode.mjs +136 -0
- package/edition/evidence-trust-pin.mjs +392 -0
- package/edition/evidence-trust-policy.mjs +374 -0
- package/edition/external-source-walk.mjs +486 -0
- package/edition/first-party-source-ids.mjs +62 -0
- package/edition/fleet-coordinate-finality.mjs +363 -0
- package/edition/fulcio-certificate-extensions.mjs +278 -0
- package/edition/git-json.mjs +54 -0
- package/edition/git-source-auth.mjs +131 -0
- package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
- package/edition/image-evidence-replay-state.mjs +242 -0
- package/edition/image-evidence.mjs +1328 -0
- package/edition/image-lock-service-rows.mjs +26 -0
- package/edition/image-subject-identity.mjs +19 -0
- package/edition/images-from-record.mjs +216 -0
- package/edition/indexes-at-pins.mjs +13 -0
- package/edition/infra-images-lock.mjs +105 -0
- package/edition/kernel-capability-domain.mjs +30 -0
- package/edition/kernel-contract-authority.mjs +526 -0
- package/edition/kernel-contract-source-path-set.mjs +96 -0
- package/edition/kernel-package.mjs +237 -0
- package/edition/kernel-pin-source.mjs +63 -0
- package/edition/kernel-protocol-choice.mjs +239 -0
- package/edition/kernel-signer-kind.mjs +23 -0
- package/edition/lib.mjs +3322 -0
- package/edition/locked-bundle-signer.mjs +297 -0
- package/edition/map-with-concurrency.mjs +19 -0
- package/edition/no-follow-file.mjs +94 -0
- package/edition/oci-evidence-verbs.mjs +421 -0
- package/edition/oci-image-source-evidence.mjs +377 -0
- package/edition/operation-grant-admission.mjs +285 -0
- package/edition/operation-requirements.mjs +282 -0
- package/edition/oras-process.mjs +35 -0
- package/edition/oras.mjs +664 -0
- package/edition/pin-source.mjs +242 -0
- package/edition/pinned-cosign.mjs +86 -0
- package/edition/plan-image-evidence-refresh.mjs +428 -0
- package/edition/platform-lock-readability.mjs +903 -0
- package/edition/platform-service-class.mjs +85 -0
- package/edition/platform-service-ownership.mjs +91 -0
- package/edition/platform-service-source-authority.mjs +105 -0
- package/edition/positive-closure.mjs +162 -0
- package/edition/previous-image-lock.mjs +25 -0
- package/edition/produce-distribution-completeness-evidence.mjs +317 -0
- package/edition/produce-edition-catalogue-evidence.mjs +252 -0
- package/edition/produce-image-delivery-entitlement-evidence.mjs +302 -0
- package/edition/produce-image-source-evidence.mjs +788 -0
- package/edition/producer-evidence-bundle.mjs +201 -0
- package/edition/producer-evidence-index-file.mjs +4 -0
- package/edition/record-refused.mjs +11 -0
- package/edition/registry-client-factory.mjs +21 -0
- package/edition/registry-credentials.mjs +225 -0
- package/edition/release-catalogue.mjs +48 -0
- package/edition/release-channel-guard.mjs +108 -0
- package/edition/release-edition-artifact.mjs +152 -0
- package/edition/release-edition.mjs +54 -0
- package/edition/remedy-command.mjs +89 -0
- package/edition/repin-evidence-trust.mjs +109 -0
- package/edition/required-resource-provider.mjs +58 -0
- package/edition/resolve-chart-set.mjs +376 -0
- package/edition/resolve-customer-edition.mjs +374 -0
- package/edition/resolve-distribution.mjs +2642 -0
- package/edition/resolve-edition.mjs +192 -0
- package/edition/resolve-images.mjs +2344 -0
- package/edition/resolve-infra-images.mjs +88 -0
- package/edition/seal-image-digests.mjs +954 -0
- package/edition/sealed-distribution-lock.mjs +22 -0
- package/edition/sealed-evidence-accept.mjs +82 -0
- package/edition/shallow-checkout.mjs +18 -0
- package/edition/sigstore-bundle-media-type.mjs +4 -0
- package/edition/source-authority.mjs +713 -0
- package/edition/source-closure.mjs +115 -0
- package/edition/store-publisher-trust.mjs +40 -0
- package/edition/strict-json.mjs +121 -0
- package/edition/string-order.mjs +5 -0
- package/edition/third-party-admission.mjs +118 -0
- package/edition/third-party-bundle-registry.mjs +349 -0
- package/edition/third-party-certificate-extensions.mjs +58 -0
- package/edition/third-party-image-pins.mjs +41 -0
- package/edition/third-party-pins-trusted.mjs +24 -0
- package/edition/third-party-producer-evidence.mjs +76 -0
- package/edition/third-party-source-check.mjs +28 -0
- package/edition/third-party-source-context.mjs +41 -0
- package/edition/third-party-source-evidence.mjs +326 -0
- package/edition/third-party-source-flags.mjs +148 -0
- package/edition/third-party-source-pins.mjs +270 -0
- package/edition/third-party-source-uniqueness.mjs +54 -0
- package/edition/third-party-source-walk.mjs +118 -0
- package/edition/third-party-source-writer.mjs +228 -0
- package/edition/third-party-subjects.mjs +128 -0
- package/edition/trust-policy-arming-verb.mjs +60 -0
- package/edition/trust-policy-arming.mjs +144 -0
- package/edition/trust-policy-image-ownership.mjs +85 -0
- package/edition/trusted-executable.mjs +166 -0
- package/edition/verifier-binding.mjs +45 -0
- package/edition/verifier-digest-pin.mjs +17 -0
- package/edition/verifier-files.mjs +8 -0
- package/edition/verifier-kernel-schemas.mjs +22 -0
- package/edition/verifier-provenance.mjs +64 -0
- package/edition/verify-catalogue-evidence.mjs +247 -0
- package/edition/verify-image-evidence.mjs +493 -0
- package/edition/verify-image-platforms.mjs +523 -0
- package/edition/why.mjs +56 -0
- package/edition/workspace-sections.mjs +287 -0
- package/package.json +83 -4
- package/README.md +0 -3
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// develop-image-record — the body of `xema-edition develop image-record <id> --selection <file> --pins <file> --out <file>
|
|
2
|
+
// --channel <tag>`. (`--registry <root>` is still accepted for one release and unused.)
|
|
3
|
+
//
|
|
4
|
+
// Writes the edition's image build record (images-from-record.mjs, the file `resolve --images-from` reads) from the
|
|
5
|
+
// records `select-records --channel <tag>` selected. Its subjects are per-edition (`<kind>:<id>`), so they come from the
|
|
6
|
+
// checked-out snapshot, which select-records cannot know: they are enumerated by resolveImages over the admitted
|
|
7
|
+
// snapshot (resolveDevelopSnapshot) with the SELECTION standing in for every hash (`createSelectedImages`), so no
|
|
8
|
+
// content hash is computed for them:
|
|
9
|
+
//
|
|
10
|
+
// every first-party subject takes the selection's image named by its develop repository: digest AND contentHash from
|
|
11
|
+
// the selection, source commit the snapshot's (freeze-graph --member-pins pinned it at the selected commit). It goes
|
|
12
|
+
// in `satellites`. Several subjects may share one image. A composed subject's members keep the snapshot's rows
|
|
13
|
+
// (service, packageDir) and each takes as its key the contentHash of the member service's own selected develop image
|
|
14
|
+
// (`selectedMembers`): the contract's key, stated by the producer, never computed or compared. Each satellite is then checked against the
|
|
15
|
+
// producer record the selection pinned, read by its own record tag (develop-channel-satellites.mjs): it must sit at
|
|
16
|
+
// the snapshot's commit and list the image. The channel tag moving after selection does not affect the run.
|
|
17
|
+
// THE ONE DECLARED EXCEPTION (XEMA-2373): a subject of a member that keeps its current resolution
|
|
18
|
+
// (develop-current-resolution.mjs, today xema-docs) has no build record, so its content hash IS computed and its develop
|
|
19
|
+
// image probed by it (createDevelopImageProbe): the digest `<develop repository>:<contentHash>` resolves to goes in
|
|
20
|
+
// `images`, with the computed hash and the snapshot's source commit. It ends when that member leaves the edition.
|
|
21
|
+
// a third-party subject is ignored: it keeps its pinned seal at resolve.
|
|
22
|
+
//
|
|
23
|
+
// Each entry's platforms are read from the registry BY DIGEST (resolvePublishedPlatforms: the index's runnable
|
|
24
|
+
// children, attestations skipped, each proven by its image config; or the single manifest's config). Every problem is
|
|
25
|
+
// refused (ImageBuildRecordError), the subjects' at once; nothing is written then. The output passes parseImageBuildRecord.
|
|
26
|
+
|
|
27
|
+
import { writeOnce } from './customer-edition-verbs.mjs';
|
|
28
|
+
import { createChannelSatelliteVerifier } from './develop-channel-satellites.mjs';
|
|
29
|
+
import { keepsCurrentResolution } from './develop-current-resolution.mjs';
|
|
30
|
+
import { DEVELOP_EDITION_STEPS, resolveDevelopSnapshot } from './develop-edition-resolve.mjs';
|
|
31
|
+
import { developGraphMembers } from './develop-graph.mjs';
|
|
32
|
+
import { developImageRefFor } from './develop-image-coordinate.mjs';
|
|
33
|
+
import { createDevelopImageProbe } from './develop-image-probe.mjs';
|
|
34
|
+
import { parseSelectionPins } from './develop-selection-pins.mjs';
|
|
35
|
+
import { editionsRoot } from './edition-workspace.mjs';
|
|
36
|
+
import { firstPartySources } from './first-party-source-ids.mjs';
|
|
37
|
+
import { ImageBuildRecordError, parseImageBuildRecord, requireImageBuildRecordKernel, subjectKeyOf } from './images-from-record.mjs';
|
|
38
|
+
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
39
|
+
import { loadRegistryCredentialDeclarations, splitImageRef } from './registry-credentials.mjs';
|
|
40
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
41
|
+
import { compareStrings } from './string-order.mjs';
|
|
42
|
+
import { registryClientsFor, resolvePublishedPlatforms } from './verify-image-platforms.mjs';
|
|
43
|
+
|
|
44
|
+
const SELECTION_REQUIRED = Object.freeze(['channel', 'pins', 'snapshot', 'images']);
|
|
45
|
+
const IMAGE_TEXT_KEYS = Object.freeze(['name', 'digest', 'contentHash', 'member']);
|
|
46
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A selection `select-records --channel` wrote, checked strictly: its sections are present and shaped, each image names
|
|
50
|
+
* its owning `member`, and `pins` and `currentResolution` pass the one selection-pins validation (40-hex pins, and
|
|
51
|
+
* `currentResolution` exactly what the graph members declare).
|
|
52
|
+
*
|
|
53
|
+
* @param {unknown} document
|
|
54
|
+
* @param {readonly {repo: string}[]} members the develop graph
|
|
55
|
+
* @param {string} source the file, named in the refusal
|
|
56
|
+
*/
|
|
57
|
+
export function parseSelection(document, members, source) {
|
|
58
|
+
if (!isPlainObject(document)) {throw new ImageBuildRecordError(`selection ${source} must be a JSON object`);}
|
|
59
|
+
const problems = SELECTION_REQUIRED.filter((key) => document[key] === undefined).map((key) => `has no "${key}"`);
|
|
60
|
+
if (document.channel !== undefined && typeof document.channel !== 'string') {problems.push('"channel" must be a string');}
|
|
61
|
+
if (document.snapshot !== undefined && !isPlainObject(document.snapshot)) {problems.push('"snapshot" must be an object');}
|
|
62
|
+
if (document.images !== undefined && !Array.isArray(document.images)) {problems.push('"images" must be an array');}
|
|
63
|
+
(Array.isArray(document.images) ? document.images : []).forEach((image, index) => {
|
|
64
|
+
for (const key of IMAGE_TEXT_KEYS) {
|
|
65
|
+
if (!isPlainObject(image) || typeof image[key] !== 'string') {problems.push(`images[${index}].${key} must be a string`);}
|
|
66
|
+
}
|
|
67
|
+
if (isPlainObject(image) && image.members !== undefined && !Array.isArray(image.members)) {problems.push(`images[${index}].members must be an array`);}
|
|
68
|
+
});
|
|
69
|
+
if (problems.length > 0) {throw new ImageBuildRecordError(`selection ${source} is refused:\n ${problems.join('\n ')}`);}
|
|
70
|
+
parseSelectionPins(document, source, '--selection', members);
|
|
71
|
+
return document;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The selection as `resolveImages` consumes it (`recordedImages`): `take` gives each first-party subject the selected
|
|
76
|
+
* image's content hash (and a composed subject its members' keys, `selectedMembers`), never computed; `computesHash`
|
|
77
|
+
* names the declared XEMA-2373 exception, the subjects of a member that keeps its current resolution, which
|
|
78
|
+
* resolveImages hashes; `settle` refuses, all at once, every subject with no digest (no selected image of its develop
|
|
79
|
+
* repository) and every composed member with no selected image of its own. Which member's record listed the image is
|
|
80
|
+
* not checked.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} selection a parsed selection
|
|
83
|
+
* @param {{distributionId: string, declaredSources: object}} context `declaredSources` is `firstPartySources`
|
|
84
|
+
*/
|
|
85
|
+
export function createSelectedImages(selection, { distributionId, declaredSources }) {
|
|
86
|
+
const problems = [];
|
|
87
|
+
const images = new Map();
|
|
88
|
+
const unresolved = (subject) => ({ sourceCommit: subject.sourceCommit, contentHash: null });
|
|
89
|
+
return {
|
|
90
|
+
computesHash: (subject) => Object.hasOwn(declaredSources, subject.repo) && keepsCurrentResolution(declaredSources[subject.repo]),
|
|
91
|
+
take(subject) {
|
|
92
|
+
const key = subjectKeyOf(subject);
|
|
93
|
+
const repository = developImageRefFor(subject, declaredSources);
|
|
94
|
+
const image = selection.images.find(({ name }) => name === repository);
|
|
95
|
+
if (image === undefined) {
|
|
96
|
+
problems.push(`${key} (${subject.repo}) has no digest: the selection has no image ${repository}`);
|
|
97
|
+
return unresolved(subject);
|
|
98
|
+
}
|
|
99
|
+
const members = selectedMembers(key, subject, selection, problems, declaredSources);
|
|
100
|
+
images.set(key, { image, members });
|
|
101
|
+
return { sourceCommit: subject.sourceCommit, contentHash: image.contentHash, ...(members === undefined ? {} : { members }) };
|
|
102
|
+
},
|
|
103
|
+
settle() {
|
|
104
|
+
if (problems.length > 0) {
|
|
105
|
+
throw new ImageBuildRecordError(`the image build record of "${distributionId}" cannot be written (${problems.length} problem(s)):\n ${problems.join('\n ')}`);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
/** The selected image a subject took, or undefined for one it did not (a hashed or third-party subject). */
|
|
109
|
+
imageOf: (subject) => images.get(subjectKeyOf(subject))?.image,
|
|
110
|
+
/** The member rows a composed subject took (`{service, packageDir, key}`), or undefined. */
|
|
111
|
+
membersOf: (subject) => images.get(subjectKeyOf(subject))?.members,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A composed subject's members, each with its KEY: the contract's key is the member service's own image-input content
|
|
117
|
+
* hash, the value its own subject carries, and the producer's build record states it as the `contentHash` of the
|
|
118
|
+
* service's own develop image (the composed image's namespace plus the service name). It is taken from the selected
|
|
119
|
+
* image unchanged: never computed, never compared. A member with no selected image of its own is a problem naming the
|
|
120
|
+
* member and the composed subject. A subject that is not composed gives undefined.
|
|
121
|
+
*/
|
|
122
|
+
export function selectedMembers(key, subject, selection, problems, declaredSources) {
|
|
123
|
+
if (subject.members === undefined) {return undefined;}
|
|
124
|
+
if (!subject.imageRef.endsWith(subject.id)) {
|
|
125
|
+
problems.push(`${key}: its imageRef ${subject.imageRef} does not end in its id, so its members' develop images cannot be named`);
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const namespace = subject.imageRef.slice(0, subject.imageRef.length - subject.id.length);
|
|
129
|
+
const missing = [];
|
|
130
|
+
const members = subject.members.map((member) => {
|
|
131
|
+
const repository = developImageRefFor({ repo: subject.repo, imageRef: `${namespace}${member.service}` }, declaredSources);
|
|
132
|
+
const image = selection.images.find(({ name }) => name === repository);
|
|
133
|
+
if (image === undefined) {missing.push(`${member.service} (${repository})`);}
|
|
134
|
+
return { ...member, key: image?.contentHash };
|
|
135
|
+
});
|
|
136
|
+
if (missing.length > 0) {
|
|
137
|
+
problems.push(`${key}: the selection has no image of its member${missing.length > 1 ? 's' : ''} ${missing.join(', ')}, whose content hash is that member's key`);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return members;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const platformOf = ({ platform }) => ({ os: platform.os, architecture: platform.architecture, ...(platform.variant === undefined ? {} : { variant: platform.variant }) });
|
|
144
|
+
|
|
145
|
+
/** A cached reader of the platforms a develop image serves, read by digest through one registry client per authority. */
|
|
146
|
+
export function createPlatformReader(clientFor) {
|
|
147
|
+
const cache = new Map();
|
|
148
|
+
return (repository, digest) => {
|
|
149
|
+
const key = `${repository}@${digest}`;
|
|
150
|
+
if (!cache.has(key)) {
|
|
151
|
+
const { authority, repository: path } = splitImageRef(repository);
|
|
152
|
+
cache.set(key, resolvePublishedPlatforms({ client: clientFor(authority), repository: path, digest, coordinate: key })
|
|
153
|
+
.then(({ platforms }) => platforms.map(platformOf)));
|
|
154
|
+
}
|
|
155
|
+
return cache.get(key);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {object} request
|
|
161
|
+
* @param {string} request.id the edition
|
|
162
|
+
* @param {object} request.pins the snapshot pins
|
|
163
|
+
* @param {object} request.selection a parsed selection
|
|
164
|
+
* @param {object} request.steps resolve steps (tests replace them)
|
|
165
|
+
* @param {Function} [request.resolveSnapshot] ({id, pins, steps}) => {inputs, lock}; replaced by tests
|
|
166
|
+
* @param {Function} request.createProbe ({distributionId}) => the develop image probe (the XEMA-2373 exception only)
|
|
167
|
+
* @param {(repository: string, digest: string) => Promise<object[]>} request.platformsOf
|
|
168
|
+
* @param {object} request.declaredSources `firstPartySources`
|
|
169
|
+
* @param {Function} [request.loadKernel] the kernel loader (tests replace it)
|
|
170
|
+
* @param {(claims: {subject: object, entry: object}[]) => Promise<void>} request.verifySatellites the channel check of
|
|
171
|
+
* the satellite entries: each is listed by the producer record the selection pinned, at the pinned commit
|
|
172
|
+
* @returns {Promise<{schemaVersion: 1, images: object[], satellites: object[]}>}
|
|
173
|
+
*/
|
|
174
|
+
export async function buildImageRecord({ id, pins, selection, steps = DEVELOP_EDITION_STEPS, resolveSnapshot = resolveDevelopSnapshot, createProbe, platformsOf, declaredSources, loadKernel = loadKernelDistributionSchemas, verifySatellites }) {
|
|
175
|
+
const kernel = requireImageBuildRecordKernel(await loadKernel());
|
|
176
|
+
const { inputs, lock } = await resolveSnapshot({ id, pins, steps });
|
|
177
|
+
const selected = createSelectedImages(selection, { distributionId: id, declaredSources });
|
|
178
|
+
const { imageLock } = await steps.resolveImages(id, { inputs, lock, recordedImages: selected });
|
|
179
|
+
const firstParty = imageLock.subjects.filter((subject) => Object.hasOwn(declaredSources, subject.repo));
|
|
180
|
+
const current = firstParty.filter((subject) => keepsCurrentResolution(declaredSources[subject.repo]));
|
|
181
|
+
let probed = new Map();
|
|
182
|
+
if (current.length > 0) {
|
|
183
|
+
const probe = createProbe({ distributionId: id });
|
|
184
|
+
current.forEach((subject) => probe.observe(subject));
|
|
185
|
+
probed = await probe.settle();
|
|
186
|
+
}
|
|
187
|
+
const problems = [];
|
|
188
|
+
const built = current.map((subject) => ({ subject: subjectKeyOf(subject), repository: developImageRefFor(subject, declaredSources), contentHash: subject.contentHash, digest: probed.get(subjectKeyOf(subject)), sourceSha: subject.sourceCommit }));
|
|
189
|
+
const claimed = firstParty.filter((subject) => !keepsCurrentResolution(declaredSources[subject.repo])).map((subject) => {
|
|
190
|
+
const image = selected.imageOf(subject);
|
|
191
|
+
const members = selected.membersOf(subject)?.map(({ service, key }) => ({ service, key })).sort((a, b) => compareStrings(a.service, b.service));
|
|
192
|
+
const entry = { subject: subjectKeyOf(subject), repository: image.name, contentHash: image.contentHash, digest: image.digest, sourceSha: subject.sourceCommit, ...(members === undefined ? {} : { members }) };
|
|
193
|
+
return { subject, entry };
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
await verifySatellites(claimed);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (!(error instanceof ImageBuildRecordError)) {throw error;}
|
|
199
|
+
problems.push(error.message);
|
|
200
|
+
}
|
|
201
|
+
const withPlatforms = async (entries) => {
|
|
202
|
+
const out = [];
|
|
203
|
+
for (const entry of entries) {
|
|
204
|
+
try {
|
|
205
|
+
out.push({ ...entry, platforms: await platformsOf(entry.repository, entry.digest) });
|
|
206
|
+
} catch (error) {
|
|
207
|
+
problems.push(`${entry.subject}: the platforms of ${entry.repository}@${entry.digest} could not be read: ${error.message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out.sort((a, b) => compareStrings(a.subject, b.subject));
|
|
211
|
+
};
|
|
212
|
+
const record = { schemaVersion: kernel.IMAGE_BUILD_RECORD_SCHEMA_VERSION, images: await withPlatforms(built), satellites: await withPlatforms(claimed.map(({ entry }) => entry)) };
|
|
213
|
+
if (problems.length > 0) {
|
|
214
|
+
throw new ImageBuildRecordError(`the image build record of "${id}" cannot be written (${problems.length} problem(s)):\n ${problems.join('\n ')}`);
|
|
215
|
+
}
|
|
216
|
+
return parseImageBuildRecord(record, 'develop image-record', { loadKernel: async () => kernel });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The `image-record` verb. Writes `--out` once, only after every subject is covered. */
|
|
220
|
+
export async function runImageRecord({
|
|
221
|
+
id,
|
|
222
|
+
options,
|
|
223
|
+
env = process.env,
|
|
224
|
+
steps = DEVELOP_EDITION_STEPS,
|
|
225
|
+
members = developGraphMembers(),
|
|
226
|
+
declaredSources = firstPartySources('enumerating the first-party image subjects'),
|
|
227
|
+
createProbe = (request) => createDevelopImageProbe({ ...request, declaredSources, env }),
|
|
228
|
+
resolveSnapshot,
|
|
229
|
+
clientFor,
|
|
230
|
+
write = writeOnce,
|
|
231
|
+
...channelInjections
|
|
232
|
+
}) {
|
|
233
|
+
const selection = parseSelection(readStrictJsonFile(options['--selection']), members, options['--selection']);
|
|
234
|
+
const channel = options['--channel'];
|
|
235
|
+
if (channel === undefined) {
|
|
236
|
+
throw new Error('image-record requires --channel <tag>');
|
|
237
|
+
}
|
|
238
|
+
if (selection.channel !== channel) {
|
|
239
|
+
throw new ImageBuildRecordError(`selection ${options['--selection']} was selected through channel ${selection.channel}, not ${channel}`);
|
|
240
|
+
}
|
|
241
|
+
const declarations = clientFor === undefined ? loadRegistryCredentialDeclarations(editionsRoot()) : null;
|
|
242
|
+
const clients = new Map();
|
|
243
|
+
const registryClient = clientFor ?? ((authority) => {
|
|
244
|
+
if (!clients.has(authority)) {clients.set(authority, registryClientsFor([{ imageRef: `${authority}/x` }], { declarations, env }).get(authority));}
|
|
245
|
+
return clients.get(authority);
|
|
246
|
+
});
|
|
247
|
+
const record = await buildImageRecord({
|
|
248
|
+
id,
|
|
249
|
+
pins: readStrictJsonFile(options['--pins']),
|
|
250
|
+
selection,
|
|
251
|
+
steps,
|
|
252
|
+
...(resolveSnapshot === undefined ? {} : { resolveSnapshot }),
|
|
253
|
+
createProbe,
|
|
254
|
+
platformsOf: createPlatformReader(registryClient),
|
|
255
|
+
declaredSources,
|
|
256
|
+
verifySatellites: createChannelSatelliteVerifier({ channel, selection, env, ...channelInjections }),
|
|
257
|
+
});
|
|
258
|
+
write(options['--out'], record);
|
|
259
|
+
return record;
|
|
260
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { isRefusal } from './collected-refusals.mjs';
|
|
2
|
+
|
|
3
|
+
// develop-resolve-report — every refusal of one develop resolve, in one report.
|
|
4
|
+
//
|
|
5
|
+
// `xema-edition develop resolve` runs a set of checks. A check that needs the output of another is a DEPENDENT of
|
|
6
|
+
// it; every other check is independent. The collector runs each check, records a refusal instead of throwing it, and
|
|
7
|
+
// skips (records as blocked) every check whose dependency was refused or blocked. `settle()` then throws ONE error whose
|
|
8
|
+
// message is the table, so an operator sees what to fix, and who owns each fix, after a single run. Nothing is
|
|
9
|
+
// downgraded: every refusal stays a refusal, and a run with a row writes nothing.
|
|
10
|
+
|
|
11
|
+
/** The checks of a develop resolve, in the order the report lists them. */
|
|
12
|
+
export const DevelopResolveCheck = Object.freeze({
|
|
13
|
+
EditionProtocol: 'edition-protocol',
|
|
14
|
+
DistributionLock: 'distribution-lock',
|
|
15
|
+
BundleSigner: 'bundle-signer',
|
|
16
|
+
PlaneInvariants: 'plane-invariants',
|
|
17
|
+
OperationAdmission: 'operation-admission',
|
|
18
|
+
AdapterBundles: 'adapter-bundles',
|
|
19
|
+
AuthorizationBase: 'authorization-base',
|
|
20
|
+
DevelopImages: 'develop-images',
|
|
21
|
+
EngineImage: 'engine-image',
|
|
22
|
+
ChartSet: 'chart-set',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Who fixes a refused check. */
|
|
26
|
+
export const ResolveCheckOwner = Object.freeze({
|
|
27
|
+
Producer: 'producer (the repository the refusal names)',
|
|
28
|
+
XemaCli: 'xema-cli',
|
|
29
|
+
EditionOwner: 'edition-owner (the repository that declares the edition)',
|
|
30
|
+
DeployOwner: 'deploy-owner (the repository that builds the engine and the charts)',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const RESOLVE_CHECK_OWNERS = Object.freeze({
|
|
34
|
+
[DevelopResolveCheck.EditionProtocol]: ResolveCheckOwner.XemaCli,
|
|
35
|
+
[DevelopResolveCheck.DistributionLock]: ResolveCheckOwner.EditionOwner,
|
|
36
|
+
[DevelopResolveCheck.BundleSigner]: ResolveCheckOwner.EditionOwner,
|
|
37
|
+
[DevelopResolveCheck.PlaneInvariants]: ResolveCheckOwner.EditionOwner,
|
|
38
|
+
[DevelopResolveCheck.OperationAdmission]: ResolveCheckOwner.Producer,
|
|
39
|
+
[DevelopResolveCheck.AdapterBundles]: ResolveCheckOwner.Producer,
|
|
40
|
+
[DevelopResolveCheck.AuthorizationBase]: ResolveCheckOwner.EditionOwner,
|
|
41
|
+
[DevelopResolveCheck.DevelopImages]: ResolveCheckOwner.Producer,
|
|
42
|
+
[DevelopResolveCheck.EngineImage]: ResolveCheckOwner.DeployOwner,
|
|
43
|
+
[DevelopResolveCheck.ChartSet]: ResolveCheckOwner.DeployOwner,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const C = DevelopResolveCheck;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What each check needs verified before it runs: a check that consumes a predecessor's result is not run on the state a
|
|
50
|
+
* refused predecessor left behind, it is reported `blocked by` the refusal at the root.
|
|
51
|
+
* The checks that read the lock but consume no other check's verdict are independent of each other on purpose, so one run
|
|
52
|
+
* names all of them: the plane invariants (the graph), the operation admission (the lock's requirements) and the adapter
|
|
53
|
+
* bundles (the lock against the registry). The authorization base is composed from, and the images are hashed from, the
|
|
54
|
+
* lock AFTER those verified it. Chart set and engine image read only the pins.
|
|
55
|
+
*/
|
|
56
|
+
export const RESOLVE_CHECK_DEPENDENCIES = Object.freeze({
|
|
57
|
+
[C.EditionProtocol]: [],
|
|
58
|
+
[C.DistributionLock]: [C.EditionProtocol],
|
|
59
|
+
[C.BundleSigner]: [C.DistributionLock],
|
|
60
|
+
[C.PlaneInvariants]: [C.DistributionLock],
|
|
61
|
+
[C.OperationAdmission]: [C.DistributionLock],
|
|
62
|
+
[C.AdapterBundles]: [C.DistributionLock],
|
|
63
|
+
[C.AuthorizationBase]: [C.DistributionLock, C.BundleSigner, C.PlaneInvariants, C.OperationAdmission],
|
|
64
|
+
[C.DevelopImages]: [C.DistributionLock, C.BundleSigner, C.PlaneInvariants, C.OperationAdmission],
|
|
65
|
+
[C.EngineImage]: [],
|
|
66
|
+
[C.ChartSet]: [],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** How a recorded check ended. */
|
|
70
|
+
export const ResolveRowStatus = Object.freeze({ Refused: 'refused', Blocked: 'blocked' });
|
|
71
|
+
|
|
72
|
+
/** What `run` returns for a check that did not produce a value: refused, or blocked by a dependency. */
|
|
73
|
+
export const BLOCKED = Symbol('develop-resolve-blocked');
|
|
74
|
+
|
|
75
|
+
const CHECK_ORDER = Object.values(DevelopResolveCheck);
|
|
76
|
+
const COLUMNS = Object.freeze(['CHECK', 'OWNER', 'STATUS', 'DETAIL']);
|
|
77
|
+
const DETAIL_INDENT = ' ';
|
|
78
|
+
|
|
79
|
+
const statusOf = (row) => (row.status === ResolveRowStatus.Blocked ? `blocked by ${row.blockedBy}` : ResolveRowStatus.Refused);
|
|
80
|
+
|
|
81
|
+
const NOTICE_HEADING = 'NOTICE: ungranted optional operations';
|
|
82
|
+
|
|
83
|
+
/** The notices as one section: never a refusal, so it is separate from the table. Empty when there are none. */
|
|
84
|
+
export const renderResolveNotices = (notices) =>
|
|
85
|
+
notices.length === 0 ? '' : [`${NOTICE_HEADING} (${notices.length})`, ...notices.map((notice) => `${DETAIL_INDENT}${notice}`)].join('\n');
|
|
86
|
+
|
|
87
|
+
/** The report rows as one plain-text table, in the order of {@link DevelopResolveCheck}; a multi-line detail is indented under its row. */
|
|
88
|
+
export function renderResolveReport(rows) {
|
|
89
|
+
const sorted = [...rows].sort((a, b) => CHECK_ORDER.indexOf(a.check) - CHECK_ORDER.indexOf(b.check));
|
|
90
|
+
const cells = sorted.map((row) => {
|
|
91
|
+
const [firstLine = '', ...more] = (row.detail ?? '').split('\n');
|
|
92
|
+
return { columns: [row.check, row.owner, statusOf(row), firstLine], more };
|
|
93
|
+
});
|
|
94
|
+
const widths = COLUMNS.slice(0, -1).map((name, i) => Math.max(name.length, ...cells.map((c) => c.columns[i].length)));
|
|
95
|
+
const line = (values) => [...values.slice(0, -1).map((v, i) => v.padEnd(widths[i])), values.at(-1)].join(' ').trimEnd();
|
|
96
|
+
const body = cells.flatMap(({ columns, more }) => [line(columns), ...more.map((m) => `${DETAIL_INDENT}${m}`.trimEnd())]);
|
|
97
|
+
return [`develop resolve refused: ${rows.length} check(s) did not pass; nothing was sealed or written`, line(COLUMNS), ...body].join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The one error of a develop resolve that had a refusal; `.rows` are the recorded rows, the message is the table. */
|
|
101
|
+
export class DevelopResolveRefusedError extends Error {
|
|
102
|
+
constructor(rows, notices = []) {
|
|
103
|
+
super([renderResolveReport(rows), renderResolveNotices(notices)].filter((part) => part !== '').join('\n\n'));
|
|
104
|
+
this.name = 'DevelopResolveRefusedError';
|
|
105
|
+
this.rows = rows;
|
|
106
|
+
this.notices = notices;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createResolveReport() {
|
|
111
|
+
const rows = [];
|
|
112
|
+
const byCheck = new Map();
|
|
113
|
+
const notices = [];
|
|
114
|
+
const record = (row) => {
|
|
115
|
+
rows.push(row);
|
|
116
|
+
byCheck.set(row.check, row);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
rows,
|
|
121
|
+
notices,
|
|
122
|
+
/** Add one line to the NOTICE section; it never refuses and never changes the exit code. */
|
|
123
|
+
notice(line) {
|
|
124
|
+
notices.push(line);
|
|
125
|
+
},
|
|
126
|
+
/** The NOTICE section as text ('' when there is none). */
|
|
127
|
+
noticeText: () => renderResolveNotices(notices),
|
|
128
|
+
/**
|
|
129
|
+
* Run `fn` as `check`, unless a check it depends on was refused or blocked (it is then blocked by the root refusal).
|
|
130
|
+
* `classify(error)` may name another check the thrown error belongs to; that check is then the one refused,
|
|
131
|
+
* and `check` is blocked by it.
|
|
132
|
+
*/
|
|
133
|
+
async run(check, dependsOn, fn, { classify = () => check } = {}) {
|
|
134
|
+
const blocker = dependsOn.map((dependency) => byCheck.get(dependency)).find((row) => row !== undefined);
|
|
135
|
+
if (blocker !== undefined) {
|
|
136
|
+
// Always the refusal at the root: a check blocked by a blocked check names the check that was refused.
|
|
137
|
+
const blockedBy = blocker.status === ResolveRowStatus.Blocked ? blocker.blockedBy : blocker.check;
|
|
138
|
+
record({ check, owner: RESOLVE_CHECK_OWNERS[check], status: ResolveRowStatus.Blocked, blockedBy });
|
|
139
|
+
return BLOCKED;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return await fn();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// A defect (a TypeError, a failed system call, a registry answering 5xx) is not a refusal: it propagates as thrown.
|
|
145
|
+
if (!isRefusal(error)) {throw error;}
|
|
146
|
+
const filedUnder = classify(error);
|
|
147
|
+
record({ check: filedUnder, owner: RESOLVE_CHECK_OWNERS[filedUnder], status: ResolveRowStatus.Refused, detail: error.message, error });
|
|
148
|
+
if (filedUnder !== check) {
|
|
149
|
+
record({ check, owner: RESOLVE_CHECK_OWNERS[check], status: ResolveRowStatus.Blocked, blockedBy: filedUnder });
|
|
150
|
+
}
|
|
151
|
+
return BLOCKED;
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
/** Throw the one error when any check was refused or blocked. */
|
|
155
|
+
settle() {
|
|
156
|
+
if (rows.length > 0) {throw new DevelopResolveRefusedError(rows, notices);}
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// develop-select-records — the body of `xema-edition develop select-records --branch develop --channel <tag>
|
|
2
|
+
// --out <file>`. (`--registry <root>` is still accepted for one release and unused.)
|
|
3
|
+
//
|
|
4
|
+
// For every member of the develop graph that does not keep its current resolution, read the one manifest
|
|
5
|
+
// <root>/build-records/<name>:<tag> (a tag the producer's CI moves to its newest green record; tags are never listed,
|
|
6
|
+
// so <root> may be a pull-through proxy), parse its single build-record layer with the kernel's BuildRecordSchema and
|
|
7
|
+
// hold it to the member: its producer repository and branch (develop-channel-record.mjs). No cosign and no git
|
|
8
|
+
// ancestry: the records are unsigned, their trust is the registry write ACL, and the tag only moves forward.
|
|
9
|
+
//
|
|
10
|
+
// The selection is { channel, pins, snapshot, images, currentResolution }, written once (wx); images[] entries carry
|
|
11
|
+
// `member`, the owning repo handle, and the record's digest and contentHash. Each member prints one line, in repo order,
|
|
12
|
+
// after --out is written (develop-selection-line.mjs): `<short name>: channel <tag> -> record <commit12> (run <run>)`;
|
|
13
|
+
// short name = the repo's last path segment, refused when two members share one.
|
|
14
|
+
//
|
|
15
|
+
// A member that declares a currentResolution (develop-current-resolution.mjs) is skipped, its registry never read; the
|
|
16
|
+
// selection lists it as `currentResolution: { <repo>: <ticket> }` and the verb prints `<short name>: current resolution
|
|
17
|
+
// (leaves the edition, <ticket>)` in its place.
|
|
18
|
+
//
|
|
19
|
+
// Every failure is a hard refusal naming the repository, and nothing is written. Two classes: `RecordRefused`
|
|
20
|
+
// (the tag is missing, or the record itself says no) and a plain Error (the registry could not answer), which the
|
|
21
|
+
// operator retries rather than reads.
|
|
22
|
+
|
|
23
|
+
import { writeOnce } from './customer-edition-verbs.mjs';
|
|
24
|
+
import { withChannelReader } from './develop-build-record.mjs';
|
|
25
|
+
import { loadChannelRecord } from './develop-channel-record.mjs';
|
|
26
|
+
import { currentResolutionOf, keepsCurrentResolution } from './develop-current-resolution.mjs';
|
|
27
|
+
import { developGraphMembers } from './develop-graph.mjs';
|
|
28
|
+
import { assertShortNamesUnambiguous, formatSelectionLine } from './develop-selection-line.mjs';
|
|
29
|
+
import { compareStrings } from './lib.mjs';
|
|
30
|
+
import { RecordRefused } from './record-refused.mjs';
|
|
31
|
+
|
|
32
|
+
// The record name this verb's tests and callers read from here, owned by the shared core.
|
|
33
|
+
export { UnknownBuildRecordProducerError } from './develop-build-record.mjs';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The selection of a channel tag: every member that does not keep its current resolution takes the record the tag
|
|
37
|
+
* names. A missing tag or a record that does not hold refuses the whole run, naming the member.
|
|
38
|
+
*/
|
|
39
|
+
export async function selectChannelRecords({ branch, channel, members, kernel, clientFor }) {
|
|
40
|
+
assertShortNamesUnambiguous(members);
|
|
41
|
+
const selected = [];
|
|
42
|
+
for (const member of members) {
|
|
43
|
+
if (keepsCurrentResolution(member)) {continue;}
|
|
44
|
+
const loaded = await loadChannelRecord({ kernel, clientFor, member, branch, channel });
|
|
45
|
+
selected.push({ member, ...loaded });
|
|
46
|
+
}
|
|
47
|
+
return { ...composeSelection(selected, { channel }), currentResolution: currentResolutionOf(members) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The snapshot a channel selection writes. A name built by two records is refused: an image has one builder. */
|
|
51
|
+
export function composeSelection(selected, { channel }) {
|
|
52
|
+
const pins = {};
|
|
53
|
+
const snapshot = {};
|
|
54
|
+
const owners = new Map();
|
|
55
|
+
const images = [];
|
|
56
|
+
for (const { member, record, snapshot: run } of selected) {
|
|
57
|
+
pins[member.repo] = record.commit;
|
|
58
|
+
snapshot[member.repo] = run;
|
|
59
|
+
for (const image of record.images) {
|
|
60
|
+
if (owners.has(image.name)) {
|
|
61
|
+
throw new RecordRefused(member.repo, `image ${image.name} is also built by ${owners.get(image.name)}; an image has one builder`);
|
|
62
|
+
}
|
|
63
|
+
owners.set(image.name, member.repo);
|
|
64
|
+
images.push({ name: image.name, digest: image.digest, contentHash: image.contentHash, member: member.repo, ...(image.members === undefined ? {} : { members: image.members }) });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const sorted = (object) => Object.fromEntries(Object.entries(object).sort(([a], [b]) => compareStrings(a, b)));
|
|
68
|
+
return { channel, pins: sorted(pins), snapshot: sorted(snapshot), images: images.sort((a, b) => compareStrings(a.name, b.name)) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The `select-records` verb. Writes `--out` once, only after every member was selected. */
|
|
72
|
+
export async function runSelectRecords({
|
|
73
|
+
options,
|
|
74
|
+
env = process.env,
|
|
75
|
+
members = developGraphMembers(),
|
|
76
|
+
write = writeOnce,
|
|
77
|
+
print = (text) => process.stdout.write(text),
|
|
78
|
+
...readerInjections
|
|
79
|
+
}) {
|
|
80
|
+
const channel = options['--channel'];
|
|
81
|
+
if (channel === undefined) {throw new Error('select-records requires --channel <tag>: the records are read through the tag the producer\'s CI moves');}
|
|
82
|
+
return withChannelReader({ env, ...readerInjections }, async ({ kernel, clientFor }) => {
|
|
83
|
+
const selection = await selectChannelRecords({ branch: options['--branch'], channel, members, kernel, clientFor });
|
|
84
|
+
write(options['--out'], selection);
|
|
85
|
+
for (const member of members) {
|
|
86
|
+
print(`${formatSelectionLine(member, selection)}\n`);
|
|
87
|
+
}
|
|
88
|
+
return selection;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// develop-selection-line — the one stdout line `select-records` prints per member, and the check that lines are unambiguous.
|
|
2
|
+
//
|
|
3
|
+
// <short name>: channel <tag> -> record <12-hex commit> (run <run>)
|
|
4
|
+
// <short name>: current resolution (leaves the edition, <ticket>)
|
|
5
|
+
//
|
|
6
|
+
// The short name is the repo handle's last path segment.
|
|
7
|
+
|
|
8
|
+
import { keepsCurrentResolution } from './develop-current-resolution.mjs';
|
|
9
|
+
|
|
10
|
+
export const shortNameOf = (repo) => repo.split('/').at(-1);
|
|
11
|
+
|
|
12
|
+
/** One graph member's line (no newline): its current-resolution line, or the channel record it was selected at. */
|
|
13
|
+
export function formatSelectionLine(member, selection) {
|
|
14
|
+
const { repo } = member;
|
|
15
|
+
if (keepsCurrentResolution(member)) {
|
|
16
|
+
return `${shortNameOf(repo)}: current resolution (leaves the edition, ${member.currentResolution})`;
|
|
17
|
+
}
|
|
18
|
+
return `${shortNameOf(repo)}: channel ${selection.channel} -> record ${selection.pins[repo].slice(0, 12)} (run ${selection.snapshot[repo]})`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Two members with one short name would print indistinguishable lines: refused by name. */
|
|
22
|
+
export function assertShortNamesUnambiguous(members) {
|
|
23
|
+
const seen = new Map();
|
|
24
|
+
for (const { repo } of members) {
|
|
25
|
+
const name = shortNameOf(repo);
|
|
26
|
+
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`);}
|
|
27
|
+
seen.set(name, repo);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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 (--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
|
+
assertCommitPins(pins, `${flag} ${path}`);
|
|
21
|
+
if (members !== undefined) {
|
|
22
|
+
const problem = currentResolutionProblem(document.currentResolution, members);
|
|
23
|
+
if (problem !== null) {throw new Error(`${flag} ${path}: ${problem}`);}
|
|
24
|
+
}
|
|
25
|
+
return pins;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A pin map's every value is a 40-hex commit (a selection's pins, follow's --current-pins); returns it. `label` names the input. */
|
|
29
|
+
export function assertCommitPins(pins, label) {
|
|
30
|
+
for (const [key, commit] of Object.entries(pins)) {
|
|
31
|
+
if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) {
|
|
32
|
+
throw new Error(`${label}: pin ${JSON.stringify(key)} is not a 40-hex commit: ${JSON.stringify(commit)}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return pins;
|
|
36
|
+
}
|