@xemahq/xema 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth.d.ts +9 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +66 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +1 -0
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +916 -860
- package/dist/edition/verify-image-evidence.bundle.provenance.json +495 -489
- 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/edition/README.md +54 -2
- package/edition/adapter-bundled-versions.mjs +2 -1
- package/edition/authorization-base.mjs +9 -8
- package/edition/biome-content.mjs +23 -29
- package/edition/candidate-edition.mjs +692 -0
- package/edition/checkout-indexed-source-repositories.mjs +6 -8
- package/edition/collected-refusals.mjs +59 -0
- package/edition/contract-authority.mjs +122 -0
- package/edition/cosign-certificate-constraints.mjs +18 -8
- package/edition/customer-edition-verbs.mjs +30 -8
- package/edition/develop-chart-set.mjs +7 -6
- package/edition/develop-edition-invariants.mjs +2 -1
- package/edition/develop-edition-resolve.mjs +109 -31
- package/edition/develop-edition.mjs +49 -8
- package/edition/develop-engine-image.mjs +95 -0
- package/edition/develop-image-probe.mjs +2 -1
- package/edition/develop-resolve-report.mjs +159 -0
- package/edition/develop-snapshot-inputs.mjs +4 -3
- package/edition/develop-verify-image-record.mjs +23 -4
- package/edition/edition-artifact.mjs +17 -7
- package/edition/edition-protocol.mjs +5 -2
- package/edition/images-from-record.mjs +362 -0
- package/edition/lib.mjs +1 -19
- package/edition/locked-bundle-signer.mjs +15 -3
- package/edition/operation-grant-admission.mjs +51 -9
- package/edition/resolve-distribution.mjs +95 -82
- package/edition/resolve-images.mjs +12 -5
- package/edition/shallow-checkout.mjs +18 -0
- package/edition/workspace-sections.mjs +23 -3
- package/package.json +7 -3
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
// develop-edition — the develop edition's command line.
|
|
3
3
|
//
|
|
4
4
|
// resolve <id> --pins <file> --out <dir> --registry <root>
|
|
5
|
-
// --engine-
|
|
5
|
+
// --engine-repository <repository> --engine-signer <certificate identity> --cosign <absolute-path> [--local]
|
|
6
|
+
// [--images-from <image build record>]
|
|
6
7
|
// resolve the edition from the snapshot, seal it against the develop image
|
|
7
8
|
// repositories, write the descriptor and the four locks to <dir>, print the target.
|
|
9
|
+
// The engine is found and proven here (see `engine`), and every refusal of the run is reported
|
|
10
|
+
// in one table (develop-resolve-report.mjs): independent checks all run, a dependent one is
|
|
11
|
+
// reported `blocked by <check>`, and when anything is refused nothing is sealed or written.
|
|
12
|
+
// --images-from takes the digests from the everyday job's image build record instead of the registry probe:
|
|
13
|
+
// no probe and no rehash; satellite entries are cosign-verified against their producer's develop build
|
|
14
|
+
// records, and a subject missing, duplicated or extra in the record refuses (images-from-record.mjs).
|
|
8
15
|
// The workflow run is recorded from GITHUB_REPOSITORY / GITHUB_RUN_ID;
|
|
9
16
|
// --local stamps `local` instead, for a resolve outside GitHub Actions.
|
|
10
17
|
// resolve --definition <file> --pins <file> --out <dir> --registry <root>
|
|
@@ -16,10 +23,17 @@
|
|
|
16
23
|
// declares (`freeze-sources --definition` writes it); the catalogue digest is frozen into the descriptor's
|
|
17
24
|
// provenance pins as `catalogue`. The id is the definition's. Takes --data-root, --editions-root,
|
|
18
25
|
// --sources-root, --cache-dir (no --deploy-root, no --kernel: the kernel is the catalogue's, from npm).
|
|
19
|
-
//
|
|
26
|
+
// engine <id> --pins <file> --engine-repository <repository> --engine-signer <certificate identity> --cosign <absolute-path>
|
|
27
|
+
// print <repository>@sha256:<digest>: the engine xema-deploy CI published (tag sha-<12 hex>) for the
|
|
28
|
+
// snapshot's xema-deploy commit, its signature verified offline against the pinned trust root as made by
|
|
29
|
+
// --engine-signer with the signed annotations commit=<that commit> and branch=develop
|
|
30
|
+
// (develop-engine-image.mjs). The image `target` and `check-existing` take as --engine-image.
|
|
31
|
+
// target <id> --pins <file> --registry <root> --engine-image <repository>@sha256:<digest> [--images-from <image build record>]
|
|
20
32
|
// print <root>/<develop segment>/xema-edition-<id>:<tag>, the tag being sha256 of the canonical
|
|
21
33
|
// { cli, engine, pins, sourceImages } (snapshotEditionTag): every input of the bytes the artifact records,
|
|
22
|
-
// so the same pins with another engine, source-image map or CLI version are another artifact.
|
|
34
|
+
// so the same pins with another engine, source-image map or CLI version are another artifact. With
|
|
35
|
+
// --images-from the record's digest (imageBuildRecordDigest) is one more input, named imageBuildRecord; resolve
|
|
36
|
+
// --images-from prints the same target. The root is always the caller's
|
|
23
37
|
// registry root (e.g. registry.example.com/acme); the develop namespace segment and the repository
|
|
24
38
|
// prefix come from edition-contract.json, and no root is implied.
|
|
25
39
|
// target --definition <file> --pins <file> --registry <root> --engine-image <repository>@sha256:<digest>
|
|
@@ -92,11 +106,12 @@
|
|
|
92
106
|
import { resolve } from 'node:path';
|
|
93
107
|
|
|
94
108
|
import { writeCatalogue } from './catalogue.mjs';
|
|
95
|
-
import { CUSTOMER_ROOTS, headResolver, parseEditionVerbArgs, runCustomerVerb, writeOnce } from './customer-edition-verbs.mjs';
|
|
109
|
+
import { CUSTOMER_ROOTS, IMAGES_FROM_FLAG, headResolver, parseEditionVerbArgs, runCustomerVerb, writeOnce } from './customer-edition-verbs.mjs';
|
|
96
110
|
import { DEFINITION_FLAG } from './customer-flags.mjs';
|
|
97
111
|
import { runDevelopCoverage } from './develop-coverage-run.mjs';
|
|
98
112
|
import { CoverageExit } from './develop-coverage.mjs';
|
|
99
113
|
import { resolveDevelopEdition } from './develop-edition-resolve.mjs';
|
|
114
|
+
import { resolveDevelopEngineImage } from './develop-engine-image.mjs';
|
|
100
115
|
import { assertNoToolingOverride, freezeGraph, freezeSources } from './develop-freeze.mjs';
|
|
101
116
|
import { runSelectRecords } from './develop-select-records.mjs';
|
|
102
117
|
import { runVerifyImageRecord } from './develop-verify-image-record.mjs';
|
|
@@ -104,6 +119,7 @@ import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, wo
|
|
|
104
119
|
import { EditionChannel, EditionKind } from './edition-constants.mjs';
|
|
105
120
|
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, kernelRepoDir } from './edition-workspace.mjs';
|
|
106
121
|
import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
|
|
122
|
+
import { createSatelliteVerifier, imageBuildRecordDigest, readImageBuildRecord } from './images-from-record.mjs';
|
|
107
123
|
import { assertKernelCommitSupported, chooseKernelCommit } from './kernel-protocol-choice.mjs';
|
|
108
124
|
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
109
125
|
import { snapshotPinSource } from './pin-source.mjs';
|
|
@@ -150,21 +166,36 @@ function parseOverrides(text) {
|
|
|
150
166
|
return parsed;
|
|
151
167
|
}
|
|
152
168
|
|
|
169
|
+
/** The target-tag input of `--images-from`: the record's digest, or nothing on the probe path. */
|
|
170
|
+
function imageBuildRecordTagInput(options) {
|
|
171
|
+
return options[IMAGES_FROM_FLAG] === undefined ? {} : { imageBuildRecordDigest: imageBuildRecordDigest(readImageBuildRecord(options[IMAGES_FROM_FLAG])) };
|
|
172
|
+
}
|
|
173
|
+
|
|
153
174
|
async function run(argv, env) {
|
|
154
175
|
const { command, id, options, customer } = parseDevelopEditionArgs(argv);
|
|
155
176
|
switch (command) {
|
|
156
177
|
case 'target': {
|
|
157
178
|
if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
|
|
158
|
-
console.log(developEditionTarget(id, readStrictJsonFile(options['--pins']), { registry: options['--registry'], engineImage: options['--engine-image'] }));
|
|
179
|
+
console.log(developEditionTarget(id, readStrictJsonFile(options['--pins']), { registry: options['--registry'], engineImage: options['--engine-image'], ...imageBuildRecordTagInput(options) }));
|
|
159
180
|
return;
|
|
160
181
|
}
|
|
161
182
|
case 'resolve': {
|
|
162
183
|
if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
|
|
163
184
|
const pins = readStrictJsonFile(options['--pins']);
|
|
164
185
|
const workflow = options['--local'] ? LOCAL_WORKFLOW : workflowFromEnv(env);
|
|
165
|
-
const target = developEditionTarget(id, pins, { registry: options['--registry'], engineImage: options['--engine-image'] });
|
|
166
186
|
const evidenceTrustPolicy = await descriptorEvidenceTrust({ kind: EditionKind.Resolved, channel: EditionChannel.Develop });
|
|
167
|
-
const { catalogue, ...resolved } = await resolveDevelopEdition({
|
|
187
|
+
const { catalogue, engineImage, ...resolved } = await resolveDevelopEdition({
|
|
188
|
+
id,
|
|
189
|
+
pins,
|
|
190
|
+
env,
|
|
191
|
+
engine: { repository: options['--engine-repository'], signer: options['--engine-signer'] },
|
|
192
|
+
cosignBinary: options['--cosign'],
|
|
193
|
+
...(options[IMAGES_FROM_FLAG] === undefined ? {} : {
|
|
194
|
+
imagesFrom: readImageBuildRecord(options[IMAGES_FROM_FLAG]),
|
|
195
|
+
verifySatellites: createSatelliteVerifier({ registry: options['--registry'], cosignBinary: options['--cosign'], env }),
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
const target = developEditionTarget(id, pins, { registry: options['--registry'], engineImage, ...imageBuildRecordTagInput(options) });
|
|
168
199
|
const outDir = resolve(options['--out']);
|
|
169
200
|
writeEditionArtifact({
|
|
170
201
|
outDir,
|
|
@@ -173,13 +204,23 @@ async function run(argv, env) {
|
|
|
173
204
|
pins,
|
|
174
205
|
workflow,
|
|
175
206
|
evidenceTrustPolicy,
|
|
176
|
-
engineImage
|
|
207
|
+
engineImage,
|
|
177
208
|
...resolved,
|
|
178
209
|
});
|
|
179
210
|
writeCatalogue(outDir, catalogue);
|
|
180
211
|
console.log(target);
|
|
181
212
|
return;
|
|
182
213
|
}
|
|
214
|
+
case 'engine':
|
|
215
|
+
console.log(
|
|
216
|
+
await resolveDevelopEngineImage({
|
|
217
|
+
pins: readStrictJsonFile(options['--pins']),
|
|
218
|
+
engine: { repository: options['--engine-repository'], signer: options['--engine-signer'] },
|
|
219
|
+
cosignBinary: options['--cosign'],
|
|
220
|
+
env,
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
return;
|
|
183
224
|
case 'coverage':
|
|
184
225
|
return runDevelopCoverage({
|
|
185
226
|
id,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// develop-engine-image — the engine image a develop edition is deployed with, found and proven.
|
|
2
|
+
//
|
|
3
|
+
// The edition's engine is the one the deploy repository's CI published for EXACTLY the deploy commit the snapshot pins
|
|
4
|
+
// (tag `sha-<12 hex>`, keyless signature by a `workflow_run` follower of a green push CI). The tag only finds the digest.
|
|
5
|
+
// The signature is checked offline against the pinned trust root through the CLI's one cosign path (pinned-cosign.mjs)
|
|
6
|
+
// with the publisher's EXACT identity, issuer, workflow repository and ref (cosign-certificate-constraints.mjs); never a
|
|
7
|
+
// pattern. A follower runs the default branch's copy of the workflow, so the certificate's own workflow commit is not the
|
|
8
|
+
// built one: the built commit is bound by the signed annotations commit=<40 hex> and branch=develop.
|
|
9
|
+
// The digest is what the edition records.
|
|
10
|
+
|
|
11
|
+
import { RefusalError } from './collected-refusals.mjs';
|
|
12
|
+
import { certificateIdentityArgs } from './cosign-certificate-constraints.mjs';
|
|
13
|
+
import { assertEngineImage } from './edition-artifact.mjs';
|
|
14
|
+
import { assertPublisher, publisherIdentity } from './edition-publisher.mjs';
|
|
15
|
+
import { deployPinKey } from './pin-source.mjs';
|
|
16
|
+
import { cosignEnvironment, withPinnedCosign } from './pinned-cosign.mjs';
|
|
17
|
+
import { splitImageRef } from './registry-credentials.mjs';
|
|
18
|
+
import { isNotPublishedYet } from './seal-image-digests.mjs';
|
|
19
|
+
import { GITHUB_ACTIONS_OIDC_ISSUER } from './third-party-source-evidence.mjs';
|
|
20
|
+
|
|
21
|
+
const COMMIT_PATTERN = /^[a-f0-9]{40}$/;
|
|
22
|
+
const TAG_PREFIX = 'sha-';
|
|
23
|
+
const TAG_COMMIT_LENGTH = 12;
|
|
24
|
+
const DEVELOP_BRANCH = 'develop';
|
|
25
|
+
const STDERR_TAIL_BYTES = 800;
|
|
26
|
+
|
|
27
|
+
/** The tag xema-deploy CI gives the engine it publishes for `deploySha`. */
|
|
28
|
+
export const engineTagOf = (deploySha) => `${TAG_PREFIX}${deploySha.slice(0, TAG_COMMIT_LENGTH)}`;
|
|
29
|
+
|
|
30
|
+
const IDENTITY_PATTERN = /^https:\/\/github\.com\/([^/@]+\/[^/@]+)\/(\.github\/workflows\/[^/@]+)@(.+)$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The signer an exact certificate identity `https://github.com/<owner>/<name>/.github/workflows/<file>@<ref>` names, held to
|
|
34
|
+
* the publisher grammar of edition-contract.json (so no pattern, no wildcard).
|
|
35
|
+
*/
|
|
36
|
+
export function engineSignerOf(identity) {
|
|
37
|
+
const match = typeof identity === 'string' ? IDENTITY_PATTERN.exec(identity) : null;
|
|
38
|
+
if (match === null) {
|
|
39
|
+
throw new RefusalError(`--engine-signer must be the exact certificate identity https://github.com/<owner>/<name>/.github/workflows/<file>@<ref>; got ${JSON.stringify(identity)}`);
|
|
40
|
+
}
|
|
41
|
+
const publisher = assertPublisher({ repository: match[1], workflow: match[2], ref: match[3] }, '--engine-signer');
|
|
42
|
+
return { certificateIdentity: publisherIdentity(publisher), oidcIssuer: GITHUB_ACTIONS_OIDC_ISSUER, githubWorkflow: { repository: publisher.repository, ref: publisher.ref } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tail = (text) => String(text).trim().slice(-STDERR_TAIL_BYTES);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} request
|
|
49
|
+
* @param {Record<string, string>} request.pins the snapshot's pins; the xema-deploy commit is read from them
|
|
50
|
+
* @param {{repository: string, signer: string}} request.engine the engine repository and the signer's exact certificate identity
|
|
51
|
+
* @param {string} request.cosignBinary absolute path of cosign
|
|
52
|
+
* @param {object} request.env the environment the registry credentials are named in
|
|
53
|
+
* @param {object} [request.pinned] withPinnedCosign's injections (tests)
|
|
54
|
+
* @returns {Promise<string>} `<repository>@sha256:<digest>`
|
|
55
|
+
*/
|
|
56
|
+
export async function resolveDevelopEngineImage({ pins, engine, cosignBinary, env, pinned = {} }) {
|
|
57
|
+
const deploySha = pins[deployPinKey()];
|
|
58
|
+
if (typeof deploySha !== 'string' || !COMMIT_PATTERN.test(deploySha)) {
|
|
59
|
+
throw new RefusalError(`the pin ${deployPinKey()} is ${JSON.stringify(deploySha)}, not a 40-hex commit`);
|
|
60
|
+
}
|
|
61
|
+
const { repository } = engine;
|
|
62
|
+
const signer = engineSignerOf(engine.signer);
|
|
63
|
+
const tag = engineTagOf(deploySha);
|
|
64
|
+
const { authority, repository: path } = splitImageRef(repository);
|
|
65
|
+
return withPinnedCosign({ cosignBinary, env, ...pinned }, async ({ runCosign, cosignEnvFor, clientFor, trustedRootPath }) => {
|
|
66
|
+
let digest;
|
|
67
|
+
try {
|
|
68
|
+
digest = (await clientFor(authority).getManifestByTag(path, tag)).digest;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!isNotPublishedYet(error)) {throw error;}
|
|
71
|
+
throw new RefusalError(
|
|
72
|
+
`no engine ${repository}:${tag} for the ${deployPinKey()} commit ${deploySha}. The deploy repository's CI publishes one for a green push to develop or main, ` +
|
|
73
|
+
'or a CI dispatch at that branch; a commit pushed with GITHUB_TOKEN has none until CI is dispatched. ' +
|
|
74
|
+
`Dispatch that CI at develop, or pin ${deployPinKey()} to a commit that has an engine.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const image = `${repository}@${digest}`;
|
|
78
|
+
const result = await runCosign(
|
|
79
|
+
[
|
|
80
|
+
'verify', '--offline', '--trusted-root', trustedRootPath,
|
|
81
|
+
...certificateIdentityArgs(signer),
|
|
82
|
+
'-a', `commit=${deploySha}`, '-a', `branch=${DEVELOP_BRANCH}`,
|
|
83
|
+
image,
|
|
84
|
+
],
|
|
85
|
+
cosignEnvironment(cosignEnvFor, authority),
|
|
86
|
+
);
|
|
87
|
+
if (result.code !== 0) {
|
|
88
|
+
throw new RefusalError(
|
|
89
|
+
`${image} (${tag}) is not signed by the engine signer ${signer.certificateIdentity} with commit=${deploySha} branch=${DEVELOP_BRANCH}; ` +
|
|
90
|
+
`refusing it as this edition's engine (cosign exit ${result.code}): ${tail(result.stderr)}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return assertEngineImage(image);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// every missing coordinate. It verifies existence only: the platform check, the
|
|
11
11
|
// digest decision and the size bounds stay in the seal, which still runs.
|
|
12
12
|
|
|
13
|
+
import { RefusalError } from './collected-refusals.mjs';
|
|
13
14
|
import { developImageRef } from './develop-image-coordinate.mjs';
|
|
14
15
|
import { editionsRoot } from './edition-workspace.mjs';
|
|
15
16
|
import { loadRegistryCredentialDeclarations, splitImageRef } from './registry-credentials.mjs';
|
|
@@ -28,7 +29,7 @@ export function notBuiltYetMessage(subject, channel = 'develop') {
|
|
|
28
29
|
|
|
29
30
|
/** The ONE refusal naming every subject of `channel` (develop unless named) whose coordinate is absent. */
|
|
30
31
|
export function notBuiltYetError(distributionId, missingSubjects, channel = 'develop') {
|
|
31
|
-
return new
|
|
32
|
+
return new RefusalError(
|
|
32
33
|
`${missingSubjects.length} ${channel} image(s) of "${distributionId}" are not built yet ` +
|
|
33
34
|
"(the producer's CI is not green or still running):\n " +
|
|
34
35
|
missingSubjects.map((subject) => notBuiltYetMessage(subject, channel)).join('\n '),
|
|
@@ -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
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { execFileSync } from 'node:child_process';
|
|
19
19
|
|
|
20
|
+
import { RefusalError } from './collected-refusals.mjs';
|
|
20
21
|
import { dataRoot, deployRoot, editionsRoot, kernelRepoDir, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
|
|
21
22
|
import { buildIndexesAtPins } from './indexes-at-pins.mjs';
|
|
22
23
|
import {
|
|
@@ -45,14 +46,14 @@ function git(dir, args) {
|
|
|
45
46
|
*/
|
|
46
47
|
export function assertFrozenCheckout({ dir, pins, key }) {
|
|
47
48
|
const pinned = pins[key];
|
|
48
|
-
if (pinned === undefined) {throw new
|
|
49
|
+
if (pinned === undefined) {throw new RefusalError(`the snapshot has no pin "${key}" for ${dir}`);}
|
|
49
50
|
const head = git(dir, ['rev-parse', 'HEAD']);
|
|
50
51
|
if (head !== pinned) {
|
|
51
|
-
throw new
|
|
52
|
+
throw new RefusalError(`${dir} is at ${head}, but the snapshot pins "${key}" at ${pinned}`);
|
|
52
53
|
}
|
|
53
54
|
const dirty = git(dir, ['status', '--porcelain']);
|
|
54
55
|
if (dirty.length > 0) {
|
|
55
|
-
throw new
|
|
56
|
+
throw new RefusalError(`${dir} is not clean, so it is not the frozen commit ${pinned} ("${key}"):\n${dirty}`);
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -58,8 +58,28 @@ function producerMember(producer) {
|
|
|
58
58
|
* @param {string} request.registry the registry root that holds `build-records/<repo>`
|
|
59
59
|
* @returns {Promise<{image: string, digest: string, producer: string, commit: string}>}
|
|
60
60
|
*/
|
|
61
|
-
export async function verifyImageRecord({ image, producer,
|
|
61
|
+
export async function verifyImageRecord({ image, producer, ...verifier }) {
|
|
62
62
|
const { name, digest } = parseImageReference(image);
|
|
63
|
+
const record = await findVerifiedProducerImageRecord({
|
|
64
|
+
producer,
|
|
65
|
+
...verifier,
|
|
66
|
+
wanted: `lists ${name}@${digest}`,
|
|
67
|
+
matches: (candidate) => candidate.images.some((listed) => listed.name === name && listed.digest === digest),
|
|
68
|
+
});
|
|
69
|
+
return { image: name, digest, producer, commit: record.commit };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The newest verified develop ci.yaml record of `producer` (newest run first) for which `matches(record)` holds.
|
|
74
|
+
* Every record read on the way is cosign-verified, so a newer bad record refuses before an older one is read.
|
|
75
|
+
* Refused, naming `wanted`, when no verified ci.yaml record matches.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} request
|
|
78
|
+
* @param {string} request.producer `<owner>/<repo>`
|
|
79
|
+
* @param {(record: object) => boolean} request.matches
|
|
80
|
+
* @param {string} request.wanted what a match is, for the refusal ("lists <name>@<digest>")
|
|
81
|
+
*/
|
|
82
|
+
export async function findVerifiedProducerImageRecord({ producer, matches, wanted, registry, dataRootRepositoryUrl, kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath }) {
|
|
63
83
|
const member = producerMember(producer);
|
|
64
84
|
const branch = VERIFY_IMAGE_RECORD_BRANCH;
|
|
65
85
|
const { repository, authority, client, runs } = await listBuildRecordRuns({ kernel, clientFor, registry, member, branch });
|
|
@@ -69,10 +89,9 @@ export async function verifyImageRecord({ image, producer, registry, dataRootRep
|
|
|
69
89
|
repository, member, branch, dataRootRepositoryUrl, tag,
|
|
70
90
|
});
|
|
71
91
|
if (record.producer.workflow !== PRODUCER_IMAGE_WORKFLOW) {continue;}
|
|
72
|
-
if (
|
|
73
|
-
return { image: name, digest, producer, commit: record.commit };
|
|
92
|
+
if (matches(record)) {return record;}
|
|
74
93
|
}
|
|
75
|
-
throw new RecordRefused(producer, `no verified ${PRODUCER_IMAGE_WORKFLOW} build record of ${repository} for branch ${branch}
|
|
94
|
+
throw new RecordRefused(producer, `no verified ${PRODUCER_IMAGE_WORKFLOW} build record of ${repository} for branch ${branch} ${wanted}`);
|
|
76
95
|
}
|
|
77
96
|
|
|
78
97
|
/** The `verify-image-record` verb. */
|
|
@@ -290,7 +290,7 @@ export function canonicalSourceImages(sourceImages = new Map()) {
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
/**
|
|
293
|
-
* The tag of an edition resolved from a snapshot (a develop edition, a customer release edition): sha256 of the canonical `{ cli, engine, pins, sourceImages }
|
|
293
|
+
* The tag of an edition resolved from a snapshot (a develop edition, a customer release edition): sha256 of the canonical `{ cli, engine, pins, sourceImages }`, plus `imageBuildRecord` (the record's digest) for a develop resolve --images-from. Every input of
|
|
294
294
|
* the bytes the artifact records is inside it. The pins fix the locks and the descriptor's pins: a vendor edition's
|
|
295
295
|
* commits fix its definitions; a customer edition's pins carry its sources' commits, the catalogue digest and the
|
|
296
296
|
* digest of its canonical definition (`definition`: vendor selection, image registry mirror, Store publisher anchors); the
|
|
@@ -300,25 +300,35 @@ export function canonicalSourceImages(sourceImages = new Map()) {
|
|
|
300
300
|
* only refuse the existing one (`check-existing`) or reuse a stale one. `provenance.workflow` (the run that
|
|
301
301
|
* wrote the bytes) is the one recorded field outside it: a fact about the artifact, not an input to it.
|
|
302
302
|
*/
|
|
303
|
-
export function snapshotEditionTag({ pins, engineImage, sourceImages = new Map(), version = cliVersion() }) {
|
|
303
|
+
export function snapshotEditionTag({ pins, engineImage, sourceImages = new Map(), imageBuildRecordDigest, version = cliVersion() }) {
|
|
304
304
|
return sha256Hex(
|
|
305
305
|
serializeJson({
|
|
306
306
|
cli: version,
|
|
307
307
|
engine: assertEngineImage(engineImage),
|
|
308
308
|
pins: sortedSnapshotPins(pins),
|
|
309
309
|
sourceImages: canonicalSourceImages(sourceImages),
|
|
310
|
+
// A develop resolve from an image build record (--images-from): the record fixes the image rows, and a satellite
|
|
311
|
+
// digest changes independently of the pins. Absent on the probe path, whose tag inputs are unchanged.
|
|
312
|
+
...(imageBuildRecordDigest === undefined ? {} : { imageBuildRecord: assertImageBuildRecordDigest(imageBuildRecordDigest) }),
|
|
310
313
|
}),
|
|
311
314
|
);
|
|
312
315
|
}
|
|
313
316
|
|
|
317
|
+
function assertImageBuildRecordDigest(digest) {
|
|
318
|
+
if (typeof digest !== 'string' || !/^[0-9a-f]{64}$/.test(digest)) {
|
|
319
|
+
throw new Error(`an image build record digest must be 64 lowercase hex; got ${JSON.stringify(digest)}`);
|
|
320
|
+
}
|
|
321
|
+
return digest;
|
|
322
|
+
}
|
|
323
|
+
|
|
314
324
|
/** The target of an edition resolved from a snapshot of pins, on either channel: the tag is `snapshotEditionTag`. */
|
|
315
|
-
export function snapshotEditionTarget(channel, distributionId, pins, { registry, engineImage, sourceImages }) {
|
|
316
|
-
return editionTarget(distributionId, registry, channel, snapshotEditionTag({ pins, engineImage, sourceImages }));
|
|
325
|
+
export function snapshotEditionTarget(channel, distributionId, pins, { registry, engineImage, sourceImages, imageBuildRecordDigest }) {
|
|
326
|
+
return editionTarget(distributionId, registry, channel, snapshotEditionTag({ pins, engineImage, sourceImages, imageBuildRecordDigest }));
|
|
317
327
|
}
|
|
318
328
|
|
|
319
|
-
/** A develop edition's target: the tag is `snapshotEditionTag` of the pins, the engine
|
|
320
|
-
export function developEditionTarget(distributionId, pins, { registry, engineImage, sourceImages }) {
|
|
321
|
-
return snapshotEditionTarget(EditionChannel.Develop, distributionId, pins, { registry, engineImage, sourceImages });
|
|
329
|
+
/** A develop edition's target: the tag is `snapshotEditionTag` of the pins, the engine, the source images and any image build record. */
|
|
330
|
+
export function developEditionTarget(distributionId, pins, { registry, engineImage, sourceImages, imageBuildRecordDigest }) {
|
|
331
|
+
return snapshotEditionTarget(EditionChannel.Develop, distributionId, pins, { registry, engineImage, sourceImages, imageBuildRecordDigest });
|
|
322
332
|
}
|
|
323
333
|
|
|
324
334
|
function realpathOfPlanned(path) {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { realpathSync } from 'node:fs';
|
|
28
28
|
|
|
29
|
+
import { RefusalError } from './collected-refusals.mjs';
|
|
29
30
|
import { readBoundedNoFollowFile } from './no-follow-file.mjs';
|
|
30
31
|
|
|
31
32
|
/** The edition protocols this library interprets: version -> content digest. A protocol is listed only
|
|
@@ -35,7 +36,7 @@ import { readBoundedNoFollowFile } from './no-follow-file.mjs';
|
|
|
35
36
|
* bundle-signer protocol (`bundleSigner` on every OCI bundle row); v34 is the kernel's output-mapping, JSON Pointer and
|
|
36
37
|
* handoff-repair-turns closure additions (XEMA-2122); v35 is the kernel's independent releases on a resolved deployment (XEMA-2244);
|
|
37
38
|
* v36 is the kernel's JobFailureReason workflow closure addition (XEMA-2282);
|
|
38
|
-
* v37 is the kernel's DistributionLock.catalogueContentDigest and effective-timeout wording (XEMA-2254, XEMA-2282); v38 is the kernel's optionalPeers service-manifest field (XEMA-2315); v33 to
|
|
39
|
+
* v37 is the kernel's DistributionLock.catalogueContentDigest and effective-timeout wording (XEMA-2254, XEMA-2282); v38 is the kernel's optionalPeers service-manifest field (XEMA-2315); v39 admits an optional operation on an undeclared domain as ungranted (`owner-undeclared`, TEAM-1 EDR-2); v40 is the kernel's resource `sharing` field (owner or shared; one owner row per resource); v33 to v39 are not yet published: their identities and contracts are captured from the
|
|
39
40
|
* linked kernel), so v29 is not listed either. */
|
|
40
41
|
export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
|
|
41
42
|
Object.freeze({ version: 28, contentDigest: 'f7795478b1bfe9a41bcbea9efe496011fcd209a56c90294029a08801e84cf92c' }),
|
|
@@ -48,6 +49,8 @@ export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
|
|
|
48
49
|
Object.freeze({ version: 36, contentDigest: 'b6b0c8ec3fc4182c6c3b623685af6293cfed1321f8d7c1891a83652d864c3df0' }),
|
|
49
50
|
Object.freeze({ version: 37, contentDigest: 'f10adca1ca5b6f51f3971756261032eeddd60e372ebfcff06a2a9d8a796d0fef' }),
|
|
50
51
|
Object.freeze({ version: 38, contentDigest: 'f670cb922c989fd40ac03703a612e7a6df2937233a1a20a394a859ec835d47d4' }),
|
|
52
|
+
Object.freeze({ version: 39, contentDigest: 'b2159ede1755526cb63b6c533bc00caaead8151784864caf1a4d682281f47808' }),
|
|
53
|
+
Object.freeze({ version: 40, contentDigest: 'e26613bfccc87e2ccc0d94ac2c412cc35d1f48f48559bb8d0606f14d499db528' }),
|
|
51
54
|
]);
|
|
52
55
|
|
|
53
56
|
/** Where a kernel package publishes its identity and its contract, package-relative. */
|
|
@@ -86,7 +89,7 @@ export const EditionProtocolMismatchReason = Object.freeze({
|
|
|
86
89
|
NotOnDevelop: 'not-on-develop',
|
|
87
90
|
});
|
|
88
91
|
|
|
89
|
-
export class EditionProtocolMismatchError extends
|
|
92
|
+
export class EditionProtocolMismatchError extends RefusalError {
|
|
90
93
|
constructor(reason, message) {
|
|
91
94
|
super(message);
|
|
92
95
|
this.name = 'EditionProtocolMismatchError';
|