@xemahq/xema 0.28.0 → 0.29.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/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +8 -7
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +88 -11
- package/dist/edition/verify-image-evidence.bundle.provenance.json +48 -42
- package/dist/runtime/edition/verb-map.d.ts +2 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +2 -0
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/dist/runtime/runner/chart-set-lock.d.ts +4 -1
- package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
- package/dist/runtime/runner/chart-set-lock.js +39 -4
- package/dist/runtime/runner/chart-set-lock.js.map +1 -1
- package/edition/README.md +69 -32
- package/edition/build-biome-index.mjs +1 -1
- package/edition/build-platform-service-index.mjs +1 -1
- package/edition/build-runtime-image-source-index.mjs +64 -20
- package/edition/catalogue-fetch.mjs +36 -9
- package/edition/catalogue-pin.mjs +71 -0
- package/edition/catalogue.mjs +11 -0
- package/edition/chart-set-source-commit.mjs +17 -7
- package/edition/checkout-indexed-source-repositories.mjs +17 -17
- package/edition/customer-flags.mjs +0 -1
- package/edition/develop-customer-edition.mjs +23 -5
- package/edition/develop-edition.mjs +147 -48
- package/edition/develop-freeze.mjs +69 -25
- package/edition/develop-seal.mjs +39 -25
- package/edition/dist-check.mjs +1 -1
- package/edition/edition-artifact.mjs +65 -18
- package/edition/edition-biome-manifests.mjs +2 -3
- package/edition/edition-chart-set.mjs +59 -6
- package/edition/edition-config.mjs +28 -2
- package/edition/edition-constants.mjs +10 -4
- package/edition/edition-contract-parse.mjs +52 -0
- package/edition/edition-contract.json +10 -1
- package/edition/edition-workspace.mjs +12 -9
- package/edition/evidence-trust-policy.mjs +26 -0
- package/edition/external-source-walk.mjs +48 -61
- package/edition/git-source-auth.mjs +104 -25
- package/edition/image-evidence.mjs +20 -10
- package/edition/kernel-contract-authority.mjs +23 -1
- package/edition/lib.mjs +13 -24
- package/edition/oras.mjs +32 -10
- package/edition/pin-source.mjs +6 -1
- package/edition/registry-credentials.mjs +11 -1
- package/edition/release-edition-artifact.mjs +3 -3
- package/edition/resolve-chart-set.mjs +50 -30
- package/edition/resolve-customer-edition.mjs +63 -7
- package/edition/resolve-distribution.mjs +1 -1
- package/edition/resolve-edition.mjs +12 -11
- package/edition/resolve-images.mjs +36 -15
- package/edition/seal-image-digests.mjs +31 -7
- package/edition/source-closure.mjs +1 -2
- package/edition/trust-policy-arming-verb.mjs +68 -0
- package/package.json +3 -3
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
// freezeGraph the graph repositories, the data root, the kernel, the deploy checkout, the editions root
|
|
10
10
|
// freezeSources one pin per `sources[]` entry the chain declares
|
|
11
11
|
//
|
|
12
|
-
// `resolveHead(url)` is the one network seam (default: `git ls-remote`
|
|
13
|
-
//
|
|
12
|
+
// `resolveHead({ name, url })` is the one network seam (default: `git ls-remote` through
|
|
13
|
+
// git-source-auth: the data root's token only for a repository under its
|
|
14
|
+
// `sourceRepositoryUrlPrefix`, no credential for any other, never argv).
|
|
14
15
|
|
|
15
16
|
import { execFileSync } from 'node:child_process';
|
|
16
17
|
|
|
17
|
-
import {
|
|
18
|
+
import { assertCanonicalSourceUrl, assertSourceDeclaresNoCredential, isUnderSourcePrefix, withSourceGitEnv } from './git-source-auth.mjs';
|
|
18
19
|
import { canonicalPublicHttpsRepositoryUrl } from './lib.mjs';
|
|
19
20
|
import {
|
|
20
21
|
dataPinKey,
|
|
@@ -31,26 +32,32 @@ import {
|
|
|
31
32
|
const DEVELOP_REF = 'refs/heads/develop';
|
|
32
33
|
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
33
34
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
35
|
+
/**
|
|
36
|
+
* `git ls-remote <url> refs/heads/develop` for one source `{ name, url }`; throws naming the repository when
|
|
37
|
+
* absent. The source is held and its git environment built by `withSourceGitEnv`: the workspace credential
|
|
38
|
+
* reaches only a repository under `sourceRepositoryUrlPrefix`.
|
|
39
|
+
*/
|
|
40
|
+
export function lsRemoteDevelopHead(source, { workspace, env = process.env, runGit = execFileSync }) {
|
|
41
|
+
const output = withSourceGitEnv(source, { workspace, env }, (gitEnv) =>
|
|
42
|
+
runGit('git', ['ls-remote', source.url, DEVELOP_REF], {
|
|
43
|
+
env: gitEnv,
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
+
}),
|
|
47
|
+
).trim();
|
|
41
48
|
const [sha, ref] = output.split(/\s+/);
|
|
42
49
|
if (!SHA_PATTERN.test(sha ?? '') || ref !== DEVELOP_REF) {
|
|
43
|
-
throw new Error(`${url} has no ${DEVELOP_REF}; a develop edition cannot be frozen without it`);
|
|
50
|
+
throw new Error(`${source.url} has no ${DEVELOP_REF}; a develop edition cannot be frozen without it`);
|
|
44
51
|
}
|
|
45
52
|
return sha;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
/** A resolver that asks `resolveHead` at most once per distinct URL
|
|
55
|
+
/** A resolver that asks `resolveHead` at most once per distinct URL; it takes the source `{ name, url }`. */
|
|
49
56
|
function memoizedHeads(resolveHead) {
|
|
50
57
|
const heads = new Map();
|
|
51
|
-
return (
|
|
52
|
-
if (!heads.has(url)) {heads.set(url, resolveHead(
|
|
53
|
-
return heads.get(url);
|
|
58
|
+
return (source) => {
|
|
59
|
+
if (!heads.has(source.url)) {heads.set(source.url, resolveHead(source));}
|
|
60
|
+
return heads.get(source.url);
|
|
54
61
|
};
|
|
55
62
|
}
|
|
56
63
|
|
|
@@ -135,11 +142,11 @@ export function freezeGraph({
|
|
|
135
142
|
}
|
|
136
143
|
const pins = Object.fromEntries(checkedOut.map(({ key, sha }) => [key, sha]));
|
|
137
144
|
for (const pin of runtimeSourceIndex.repositoryPins) {
|
|
138
|
-
pins[pin.repo] = overrides[pin.repo] ?? head(pin.repositoryUrl);
|
|
145
|
+
pins[pin.repo] = overrides[pin.repo] ?? head({ name: pin.repo, url: pin.repositoryUrl });
|
|
139
146
|
}
|
|
140
147
|
if (editionsRootUrl !== undefined) {
|
|
141
148
|
const url = canonicalPublicHttpsRepositoryUrl(editionsRootUrl, '--editions-root-url');
|
|
142
|
-
pins[EDITIONS_ROOT_PIN_KEY] = overrides[EDITIONS_ROOT_PIN_KEY] ?? head(url);
|
|
149
|
+
pins[EDITIONS_ROOT_PIN_KEY] = overrides[EDITIONS_ROOT_PIN_KEY] ?? head({ name: EDITIONS_ROOT_PIN_KEY, url });
|
|
143
150
|
}
|
|
144
151
|
return sortedSnapshotPins(pins);
|
|
145
152
|
}
|
|
@@ -152,22 +159,59 @@ export function freezeGraph({
|
|
|
152
159
|
export function freezeSources({ pins, declaredSources, overrides = {}, resolveHead }) {
|
|
153
160
|
assertOverrides(overrides, SOURCE_PHASE);
|
|
154
161
|
const head = memoizedHeads(resolveHead);
|
|
155
|
-
const
|
|
162
|
+
const sourceByName = new Map();
|
|
156
163
|
for (const source of declaredSources) {
|
|
157
|
-
const known =
|
|
158
|
-
if (known !== undefined && known !== source.url) {
|
|
159
|
-
throw new Error(`source "${source.name}" is declared at two URLs (${known}, ${source.url})`);
|
|
164
|
+
const known = sourceByName.get(source.name);
|
|
165
|
+
if (known !== undefined && known.url !== source.url) {
|
|
166
|
+
throw new Error(`source "${source.name}" is declared at two URLs (${known.url}, ${source.url})`);
|
|
160
167
|
}
|
|
161
|
-
|
|
168
|
+
sourceByName.set(source.name, source);
|
|
162
169
|
}
|
|
163
170
|
for (const key of Object.keys(overrides)) {
|
|
164
|
-
if (snapshotPinNamespace(key) === SnapshotPinNamespace.Source && !
|
|
171
|
+
if (snapshotPinNamespace(key) === SnapshotPinNamespace.Source && !sourceByName.has(sourceNameOfPinKey(key))) {
|
|
165
172
|
throw new Error(`override names "${key}", but the chain declares no such source`);
|
|
166
173
|
}
|
|
167
174
|
}
|
|
168
175
|
const frozen = { ...pins };
|
|
169
|
-
for (const [name,
|
|
170
|
-
frozen[sourcePinKey(name)] = overrides[sourcePinKey(name)] ?? head(
|
|
176
|
+
for (const [name, source] of sourceByName) {
|
|
177
|
+
frozen[sourcePinKey(name)] = overrides[sourcePinKey(name)] ?? head(source);
|
|
171
178
|
}
|
|
172
179
|
return sortedSnapshotPins(frozen);
|
|
173
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The declared source URLs a customer freeze may ask, held BEFORE any network call: the token of the
|
|
184
|
+
* run (EVIDENCE_REPOSITORIES_TOKEN) is sent to the origin of each URL, so a URL is refused unless it is
|
|
185
|
+
* a canonical public HTTPS repository URL written in its canonical form and lies under the workspace's
|
|
186
|
+
* `sourceRepositoryUrlPrefix`. A definition that merely names another origin never receives the token.
|
|
187
|
+
*/
|
|
188
|
+
export function assertCustomerSourceUrls(sources, sourceRepositoryUrlPrefix) {
|
|
189
|
+
if (typeof sourceRepositoryUrlPrefix !== 'string' || sourceRepositoryUrlPrefix.length === 0) {
|
|
190
|
+
throw new Error('a customer freeze takes the workspace\'s sourceRepositoryUrlPrefix');
|
|
191
|
+
}
|
|
192
|
+
for (const source of sources) {
|
|
193
|
+
assertSourceDeclaresNoCredential(source);
|
|
194
|
+
const canonical = assertCanonicalSourceUrl(source);
|
|
195
|
+
const context = `source "${source.name}" url`;
|
|
196
|
+
if (!isUnderSourcePrefix(canonical, sourceRepositoryUrlPrefix)) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`${context} ${JSON.stringify(source.url)} is not under the workspace's sourceRepositoryUrlPrefix ${sourceRepositoryUrlPrefix}: ` +
|
|
199
|
+
'the source token is sent only to repositories under that prefix',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The pins of a CUSTOMER edition: exactly one `sources/<name>` commit per source its definition declares,
|
|
207
|
+
* each the head of refs/heads/develop of that source's repository, resolved once. Every URL is held by
|
|
208
|
+
* `assertCustomerSourceUrls` before the first head is asked.
|
|
209
|
+
*/
|
|
210
|
+
export function freezeCustomerSources({ definition, resolveHead, sourceRepositoryUrlPrefix }) {
|
|
211
|
+
const sources = definition?.sources ?? [];
|
|
212
|
+
if (!Array.isArray(sources) || sources.some((source) => typeof source?.name !== 'string' || typeof source?.url !== 'string')) {
|
|
213
|
+
throw new Error('the definition\'s sources must be an array of {name, url}');
|
|
214
|
+
}
|
|
215
|
+
assertCustomerSourceUrls(sources, sourceRepositoryUrlPrefix);
|
|
216
|
+
return freezeSources({ pins: {}, declaredSources: sources, resolveHead });
|
|
217
|
+
}
|
package/edition/develop-seal.mjs
CHANGED
|
@@ -25,26 +25,30 @@ function notBuiltYetMessage(subject) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
+
* The row-level seal: every subject re-homed to its develop repository, verified
|
|
29
|
+
* there for its required platforms, and given the digest the registry served.
|
|
30
|
+
* It takes SUBJECTS, not a lock, so a caller that owns only some rows of a lock
|
|
31
|
+
* (a customer's own image rows) seals exactly those.
|
|
32
|
+
*
|
|
28
33
|
* @param {object} request
|
|
29
|
-
* @param {
|
|
30
|
-
* @
|
|
34
|
+
* @param {string} request.distributionId the edition the subjects belong to (named in refusals)
|
|
35
|
+
* @param {object[]} request.subjects image subjects (release refs, unsealed)
|
|
36
|
+
* @returns the subjects with develop refs and verified digests
|
|
31
37
|
*/
|
|
32
|
-
export async function
|
|
33
|
-
|
|
38
|
+
export async function sealDevelopImageSubjects({
|
|
39
|
+
distributionId,
|
|
40
|
+
subjects,
|
|
34
41
|
distributionsRoot = editionsRoot(),
|
|
35
42
|
env = process.env,
|
|
36
43
|
fetchImpl = globalThis.fetch,
|
|
37
44
|
run = null,
|
|
38
45
|
}) {
|
|
39
|
-
const
|
|
40
|
-
...
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
imageRef: developImageRef(subject.imageRef),
|
|
44
|
-
})),
|
|
45
|
-
};
|
|
46
|
+
const developSubjects = subjects.map((subject) => ({
|
|
47
|
+
...subject,
|
|
48
|
+
imageRef: developImageRef(subject.imageRef),
|
|
49
|
+
}));
|
|
46
50
|
const report = await verifyDistributionPlatforms({
|
|
47
|
-
imageLock:
|
|
51
|
+
imageLock: { distributionId, subjects: developSubjects },
|
|
48
52
|
distributionsRoot,
|
|
49
53
|
env,
|
|
50
54
|
fetchImpl,
|
|
@@ -55,23 +59,33 @@ export async function sealDevelopImages({
|
|
|
55
59
|
if (aborting) {throw aborting.error;}
|
|
56
60
|
if (report.failures.length > 0) {
|
|
57
61
|
throw new Error(
|
|
58
|
-
`${report.failures.length} develop image(s) of "${
|
|
62
|
+
`${report.failures.length} develop image(s) of "${distributionId}" are not built yet ` +
|
|
59
63
|
'(the producer\'s CI is not green or still running):\n ' +
|
|
60
64
|
report.failures.map(({ subject }) => notBuiltYetMessage(subject)).join('\n '),
|
|
61
65
|
);
|
|
62
66
|
}
|
|
63
|
-
const { writes, unchanged } = planDigestSeal({ subjects:
|
|
67
|
+
const { writes, unchanged } = planDigestSeal({ subjects: developSubjects, verified: report.subjects });
|
|
64
68
|
const digests = new Map([...writes, ...unchanged].map(({ key, digest }) => [key, digest]));
|
|
69
|
+
return developSubjects.map((subject) => ({
|
|
70
|
+
...subject,
|
|
71
|
+
imageDigest: digests.get(`${subject.kind}:${subject.id}`),
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The seal of a WHOLE images lock (the vendor snapshot): every subject sealed,
|
|
77
|
+
* and the result parsed as the kernel's image lock.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} request
|
|
80
|
+
* @param {object} request.imageLock the snapshot images lock (release refs, unsealed)
|
|
81
|
+
* @returns the images lock with develop refs and verified digests, kernel-validated
|
|
82
|
+
*/
|
|
83
|
+
export async function sealDevelopImages({ imageLock, ...sealOptions }) {
|
|
84
|
+
const subjects = await sealDevelopImageSubjects({
|
|
85
|
+
distributionId: imageLock.distributionId,
|
|
86
|
+
subjects: imageLock.subjects,
|
|
87
|
+
...sealOptions,
|
|
88
|
+
});
|
|
65
89
|
const schemas = await loadKernelDistributionSchemas();
|
|
66
|
-
|
|
67
|
-
return validateImmutableImageLock(
|
|
68
|
-
parseImageLock({
|
|
69
|
-
...developLock,
|
|
70
|
-
subjects: developLock.subjects.map((subject) => ({
|
|
71
|
-
...subject,
|
|
72
|
-
imageDigest: digests.get(`${subject.kind}:${subject.id}`),
|
|
73
|
-
})),
|
|
74
|
-
}),
|
|
75
|
-
schemas,
|
|
76
|
-
);
|
|
90
|
+
return validateImmutableImageLock(schemas.parseImageLock({ ...imageLock, subjects }), schemas);
|
|
77
91
|
}
|
package/edition/dist-check.mjs
CHANGED
|
@@ -74,7 +74,7 @@ import { withoutSealedDigests } from './seal-image-digests.mjs';
|
|
|
74
74
|
import { committedRepositoryPins, createPinnedSourceAuthority } from './source-authority.mjs';
|
|
75
75
|
|
|
76
76
|
if (isEntryModule(import.meta.url)) {
|
|
77
|
-
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Deploy, WorkspaceRoot.Cache
|
|
77
|
+
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Deploy, WorkspaceRoot.Cache]);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// edition-artifact — the files of an edition and where they may go.
|
|
2
2
|
//
|
|
3
3
|
// ONE layout for develop and release: an OCI artifact at
|
|
4
|
-
// `<registry>/xema-edition-<id
|
|
4
|
+
// `<registry root>/<channel segment>/xema-edition-<id>` (release: `editions`, develop: `develop-editions`,
|
|
5
|
+
// from edition-contract.json), addressed by digest, whose titled layers are
|
|
5
6
|
// `xema-edition.json` (the descriptor) plus the lock files. It is written to a
|
|
6
7
|
// scratch directory; the workflow pushes it with oras and signs the digest.
|
|
7
8
|
// Tags are for humans only (develop: the snapshot hash; release: the
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
EDITION_DESCRIPTOR_SCHEMA,
|
|
23
24
|
EDITION_LOCKS,
|
|
24
25
|
EDITION_REPOSITORY_PREFIX,
|
|
26
|
+
EDITION_REPOSITORY_SEGMENTS,
|
|
25
27
|
EditionChannel,
|
|
26
28
|
EditionLockFile,
|
|
27
29
|
EvidenceTrustState,
|
|
@@ -144,42 +146,80 @@ export function parseEditionDescriptor(value) {
|
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
const REGISTRY_PATTERN = /^[a-z0-9.-]+(:[0-9]+)?(\/[a-z0-9._-]+)+$/;
|
|
149
|
+
/** `.` and `..` match the segment grammar and name no namespace: a registry resolves them away. */
|
|
150
|
+
const isDotSegment = (segment) => segment === '.' || segment === '..';
|
|
147
151
|
const TAG_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
|
|
148
152
|
|
|
149
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
/**
|
|
154
|
+
* The registry ROOT is the caller's `<host>[:port]/<namespace>[/...]`; the CLI appends the channel's
|
|
155
|
+
* namespace segment AND `xema-edition-<id>` itself, so a root already ending in either is refused.
|
|
156
|
+
*/
|
|
157
|
+
export function assertRegistry(registryRoot) {
|
|
158
|
+
if (typeof registryRoot !== 'string' || !REGISTRY_PATTERN.test(registryRoot) || registryRoot.split('/').slice(1).some(isDotSegment)) {
|
|
152
159
|
throw new Error(
|
|
153
|
-
`--registry ${JSON.stringify(
|
|
160
|
+
`--registry ${JSON.stringify(registryRoot)} is not <registry-host>[:port]/<namespace>[/...] (lowercase, e.g. registry.example.com/acme)`,
|
|
154
161
|
);
|
|
155
162
|
}
|
|
156
|
-
const last =
|
|
157
|
-
if (last.startsWith(EDITION_REPOSITORY_PREFIX)) {
|
|
163
|
+
const last = registryRoot.split('/').pop();
|
|
164
|
+
if (Object.values(EDITION_REPOSITORY_SEGMENTS).includes(last) || last.startsWith(EDITION_REPOSITORY_PREFIX)) {
|
|
158
165
|
throw new Error(
|
|
159
|
-
`--registry ${
|
|
160
|
-
`the
|
|
166
|
+
`--registry ${registryRoot} ends in "${last}", which the CLI appends itself: pass the registry root; ` +
|
|
167
|
+
`the CLI appends <${Object.values(EDITION_REPOSITORY_SEGMENTS).join('|')}>/${EDITION_REPOSITORY_PREFIX}<id>`,
|
|
161
168
|
);
|
|
162
169
|
}
|
|
163
170
|
}
|
|
164
171
|
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
function assertChannel(channel) {
|
|
173
|
+
if (!Object.values(EditionChannel).includes(channel)) {
|
|
174
|
+
throw new Error(`edition channel ${JSON.stringify(channel)} must be one of ${Object.values(EditionChannel).join(' | ')}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** `<registry root>/<channel segment>/xema-edition-<id>`: the repository of an edition produced for `channel`. */
|
|
179
|
+
export function editionRepository(distributionId, registryRoot, channel) {
|
|
167
180
|
assertCanonicalDistributionId(distributionId);
|
|
168
|
-
assertRegistry(
|
|
169
|
-
|
|
181
|
+
assertRegistry(registryRoot);
|
|
182
|
+
assertChannel(channel);
|
|
183
|
+
return `${registryRoot}/${EDITION_REPOSITORY_SEGMENTS[channel]}/${EDITION_REPOSITORY_PREFIX}${distributionId}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The one parser of an edition repository `<root>/<segment>/xema-edition-<id>`, returning
|
|
188
|
+
* `{ registryRoot, channel, distributionId }`. The penultimate segment must be one of the contract's
|
|
189
|
+
* channel segments and the last must carry the repository prefix.
|
|
190
|
+
*/
|
|
191
|
+
export function parseEditionRepository(repository) {
|
|
192
|
+
const segments = typeof repository === 'string' ? repository.split('/') : [];
|
|
193
|
+
const last = segments.at(-1) ?? '';
|
|
194
|
+
const segment = segments.at(-2);
|
|
195
|
+
const channel = Object.values(EditionChannel).find((candidate) => EDITION_REPOSITORY_SEGMENTS[candidate] === segment);
|
|
196
|
+
if (channel === undefined) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`edition repository ${JSON.stringify(repository)}: its namespace segment ${JSON.stringify(segment)} is not one of ` +
|
|
199
|
+
Object.values(EDITION_REPOSITORY_SEGMENTS).map((name) => `"${name}"`).join(', '),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (!last.startsWith(EDITION_REPOSITORY_PREFIX)) {
|
|
203
|
+
throw new Error(`edition repository ${JSON.stringify(repository)}: its name ${JSON.stringify(last)} lacks the "${EDITION_REPOSITORY_PREFIX}" prefix`);
|
|
204
|
+
}
|
|
205
|
+
const distributionId = last.slice(EDITION_REPOSITORY_PREFIX.length);
|
|
206
|
+
assertCanonicalDistributionId(distributionId);
|
|
207
|
+
const registryRoot = segments.slice(0, -2).join('/');
|
|
208
|
+
assertRegistry(registryRoot);
|
|
209
|
+
return { registryRoot, channel, distributionId };
|
|
170
210
|
}
|
|
171
211
|
|
|
172
212
|
/** `<repository>:<tag>`; the tag is for humans, the artifact is addressed by its digest. */
|
|
173
|
-
export function editionTarget(distributionId,
|
|
213
|
+
export function editionTarget(distributionId, registryRoot, channel, tag) {
|
|
174
214
|
if (typeof tag !== 'string' || !TAG_PATTERN.test(tag)) {
|
|
175
215
|
throw new Error(`tag ${JSON.stringify(tag)} is not an OCI tag`);
|
|
176
216
|
}
|
|
177
|
-
return `${editionRepository(distributionId,
|
|
217
|
+
return `${editionRepository(distributionId, registryRoot, channel)}:${tag}`;
|
|
178
218
|
}
|
|
179
219
|
|
|
180
220
|
/** A develop edition's target: the tag is its snapshot hash. */
|
|
181
221
|
export function developEditionTarget(distributionId, pins, { registry }) {
|
|
182
|
-
return editionTarget(distributionId, registry, snapshotHash(pins));
|
|
222
|
+
return editionTarget(distributionId, registry, EditionChannel.Develop, snapshotHash(pins));
|
|
183
223
|
}
|
|
184
224
|
|
|
185
225
|
function realpathOfPlanned(path) {
|
|
@@ -249,9 +289,10 @@ export function writeEditionArtifact({
|
|
|
249
289
|
* An existing snapshot-hash tag is reused only if it IS this snapshot: the tag
|
|
250
290
|
* is a hash of the pins, but a tag is mutable registry state, so the pulled
|
|
251
291
|
* descriptor must say the same edition, the same channel, exactly the same pins,
|
|
252
|
-
* the same engine image, the same evidence trust state and the same lock set.
|
|
292
|
+
* the same engine image, the same evidence trust state and the same lock set. A customer artifact (`catalogueDigest` given) must also record that digest
|
|
293
|
+
* as its catalogue provenance pin. Throws naming the first difference.
|
|
253
294
|
*/
|
|
254
|
-
export function assertExistingArtifactMatches({ artifactDir, distributionId, channel, pins, engineImage, evidenceTrustPolicy }) {
|
|
295
|
+
export function assertExistingArtifactMatches({ artifactDir, distributionId, channel, pins, engineImage, evidenceTrustPolicy, catalogueDigest }) {
|
|
255
296
|
const descriptorPath = join(artifactDir, EDITION_DESCRIPTOR_FILE);
|
|
256
297
|
if (!existsSync(descriptorPath)) {
|
|
257
298
|
throw new Error(`the existing artifact holds no ${EDITION_DESCRIPTOR_FILE}; refusing to reuse a tag that is not an edition`);
|
|
@@ -267,6 +308,12 @@ export function assertExistingArtifactMatches({ artifactDir, distributionId, cha
|
|
|
267
308
|
if (existing.evidenceTrustPolicy !== evidenceTrustPolicy) {
|
|
268
309
|
throw new Error(`the existing artifact records evidenceTrustPolicy ${JSON.stringify(existing.evidenceTrustPolicy)}, this data root's is ${JSON.stringify(evidenceTrustPolicy)}; refusing to reuse it`);
|
|
269
310
|
}
|
|
311
|
+
if (catalogueDigest !== undefined && existing.provenance.pins[CATALOGUE_PIN_KEY] !== catalogueDigest) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
`the existing artifact's catalogue provenance pin is ${JSON.stringify(existing.provenance.pins[CATALOGUE_PIN_KEY])}, ` +
|
|
314
|
+
`the pinned catalogue is ${catalogueDigest}; refusing to reuse it`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
270
317
|
if (serializeJson(existing.provenance.pins) !== serializeJson(sortedSnapshotPins(pins))) {
|
|
271
318
|
throw new Error('the existing artifact was resolved from different pins than this snapshot (tag and content disagree); refusing to reuse it');
|
|
272
319
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// biome of one edition, as JSON.
|
|
4
4
|
//
|
|
5
5
|
// usage: xema edition biome-manifests <id> --json --data-root <dir> --editions-root <dir>
|
|
6
|
-
// --sources-root <dir> --
|
|
6
|
+
// --sources-root <dir> --kernel <dir>
|
|
7
7
|
//
|
|
8
8
|
// For every biome of the edition's resolved lock this reads the manifest AT the
|
|
9
9
|
// commit the lock pins (the way the seal and `xema edition check` read it) and
|
|
@@ -31,7 +31,7 @@ import { committedRepositoryPins, createPinnedSourceAuthority } from './source-a
|
|
|
31
31
|
export const EDITION_BIOME_MANIFESTS_SCHEMA = 'xema.edition-biome-manifests/v1';
|
|
32
32
|
|
|
33
33
|
const USAGE =
|
|
34
|
-
'usage: xema edition biome-manifests <id> --json --data-root <dir> --editions-root <dir> --sources-root <dir> --
|
|
34
|
+
'usage: xema edition biome-manifests <id> --json --data-root <dir> --editions-root <dir> --sources-root <dir> --kernel <dir>';
|
|
35
35
|
|
|
36
36
|
function readLock(distributionId, file) {
|
|
37
37
|
try {
|
|
@@ -64,7 +64,6 @@ async function main() {
|
|
|
64
64
|
WorkspaceRoot.Data,
|
|
65
65
|
WorkspaceRoot.Editions,
|
|
66
66
|
WorkspaceRoot.Sources,
|
|
67
|
-
WorkspaceRoot.SourceHash,
|
|
68
67
|
WorkspaceRoot.Kernel,
|
|
69
68
|
]);
|
|
70
69
|
const operands = rest.filter((arg) => arg !== '--json');
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// So each edition directory holds `<id>/chart-set.lock.json`: a byte-identical
|
|
13
13
|
// copy of the fleet pin AT THE MOMENT IT IS WRITTEN, with each publisher's
|
|
14
|
-
// ROSTER (chart name → {version, digest}) read from the index, so a
|
|
14
|
+
// ROSTER (chart name → {version, digest}) read from the index, and the index chart's own manifest digest (`indexDigest`), so a
|
|
15
15
|
// consumer selects every chart without asking the registry. It is part of the
|
|
16
16
|
// edition's file set (compute-edition-coordinates.sh), hashed into its
|
|
17
17
|
// coordinate and published with it, and xema-deploy vendors it beside the other
|
|
@@ -51,7 +51,38 @@ const CHART_VERSION_COORDINATE = Object.freeze({
|
|
|
51
51
|
[EditionChannel.Develop]: /-dev\.[0-9a-f]{12}$/,
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/** The chart-set lock schema every reader accepts; a lock of any other schema is re-resolved, never migrated. */
|
|
55
|
+
export const CHART_SET_LOCK_SCHEMA_VERSION = 4;
|
|
56
|
+
|
|
57
|
+
/** The version prefix of a develop snapshot chart set (`0.0.0-dev.<sha12>`): xema-deploy's frozen-SHA charts, which have no index chart. */
|
|
58
|
+
export const DEVELOP_CHART_VERSION_PREFIX = '0.0.0-dev.';
|
|
59
|
+
|
|
60
|
+
/** The length of the lowercase-hex xema-deploy SHA a develop snapshot version carries. */
|
|
61
|
+
export const DEVELOP_CHART_SHA_LENGTH = 12;
|
|
62
|
+
|
|
63
|
+
/** The full develop snapshot version grammar: `0.0.0-dev.<12 lowercase hex>`, anchored. */
|
|
64
|
+
export const DEVELOP_CHART_VERSION_PATTERN = new RegExp(
|
|
65
|
+
`^${DEVELOP_CHART_VERSION_PREFIX.replaceAll('.', '\\.')}[0-9a-f]{${DEVELOP_CHART_SHA_LENGTH}}$`,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/** Does `version` start with the develop prefix, whether or not it is the full develop grammar? */
|
|
69
|
+
export const hasDevelopChartVersionPrefix = (version) => typeof version === 'string' && version.startsWith(DEVELOP_CHART_VERSION_PREFIX);
|
|
70
|
+
|
|
71
|
+
/** Is `version` a develop snapshot version? The one predicate for "this publisher has no index chart". */
|
|
72
|
+
export const isDevelopChartVersion = (version) => typeof version === 'string' && DEVELOP_CHART_VERSION_PATTERN.test(version);
|
|
73
|
+
|
|
74
|
+
/** An OCI manifest digest: `sha256:` and 64 lowercase hex. */
|
|
75
|
+
export const OCI_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
76
|
+
|
|
77
|
+
/** The instruction every refusal of an outdated lock carries. */
|
|
78
|
+
export const RESOLVE_CHART_SET_INSTRUCTION = 're-resolve the chart set with `xema edition chart-set resolve`';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A chart-set lock (schemaVersion 4) that pins every member by version and OCI
|
|
82
|
+
* manifest digest, with `channel`'s version coordinate, and pins each publisher's
|
|
83
|
+
* index chart by `indexDigest` (null exactly for a develop snapshot version,
|
|
84
|
+
* which has no index chart; the release channel refuses null).
|
|
85
|
+
*/
|
|
55
86
|
export function assertRosteredChartSetLock(lock, label, channel) {
|
|
56
87
|
const coordinate = CHART_VERSION_COORDINATE[channel];
|
|
57
88
|
if (coordinate === undefined) {
|
|
@@ -60,24 +91,46 @@ export function assertRosteredChartSetLock(lock, label, channel) {
|
|
|
60
91
|
const fail = (why) => {
|
|
61
92
|
throw new Error(`${label} is not a rostered chart-set lock: ${why}`);
|
|
62
93
|
};
|
|
63
|
-
if (lock?.schemaVersion !==
|
|
94
|
+
if (lock?.schemaVersion !== CHART_SET_LOCK_SCHEMA_VERSION) {
|
|
95
|
+
fail(`schemaVersion must be ${CHART_SET_LOCK_SCHEMA_VERSION} (got ${JSON.stringify(lock?.schemaVersion)}); ${RESOLVE_CHART_SET_INSTRUCTION}`);
|
|
96
|
+
}
|
|
64
97
|
if (!Array.isArray(lock.publishers) || lock.publishers.length === 0) {fail('publishers must be a non-empty array');}
|
|
65
|
-
for (const pin of lock.publishers) {
|
|
98
|
+
for (const [position, pin] of lock.publishers.entries()) {
|
|
66
99
|
if (!pin?.charts || typeof pin.charts !== 'object' || Array.isArray(pin.charts) || Object.keys(pin.charts).length === 0) {
|
|
67
100
|
fail(`${pin?.index ?? '?'} carries no roster (charts: name -> member version) — run resolve-chart-set.mjs`);
|
|
68
101
|
}
|
|
69
102
|
for (const [name, member] of Object.entries(pin.charts)) {
|
|
70
103
|
if (!member || typeof member !== 'object' || Array.isArray(member)) {fail(`${pin.index}: ${name} must be {version, digest}`);}
|
|
71
104
|
if (!coordinate.test(String(member.version))) {fail(`${pin.index}: ${name} has no ${channel} content coordinate (${member.version}); a ${channel} chart set carries ${coordinate}`);}
|
|
72
|
-
if (
|
|
105
|
+
if (!OCI_DIGEST_PATTERN.test(String(member.digest))) {fail(`${pin.index}: ${name} has no sha256 manifest digest`);}
|
|
73
106
|
}
|
|
107
|
+
assertIndexDigest(pin, position, channel, fail);
|
|
74
108
|
}
|
|
75
109
|
return lock;
|
|
76
110
|
}
|
|
77
111
|
|
|
112
|
+
/** The `indexDigest` rule: present, a sha256 digest unless the version is a develop snapshot (then null), and never null in a release lock. */
|
|
113
|
+
function assertIndexDigest(pin, position, channel, fail) {
|
|
114
|
+
const where = `publisher ${position} (${pin?.index ?? '?'})`;
|
|
115
|
+
if (!pin || typeof pin !== 'object' || !Object.hasOwn(pin, 'indexDigest')) {
|
|
116
|
+
fail(`${where} carries no indexDigest key (required: "sha256:<64 hex>" for a release index, null for a develop snapshot version)`);
|
|
117
|
+
}
|
|
118
|
+
if (hasDevelopChartVersionPrefix(pin.version) && !isDevelopChartVersion(pin.version)) {
|
|
119
|
+
fail(`${where} has malformed develop chart version ${JSON.stringify(pin.version)}; a develop version is exactly ${DEVELOP_CHART_VERSION_PREFIX}<${DEVELOP_CHART_SHA_LENGTH} lowercase hex>`);
|
|
120
|
+
}
|
|
121
|
+
const develop = isDevelopChartVersion(pin.version);
|
|
122
|
+
if (pin.indexDigest === null) {
|
|
123
|
+
if (!develop) {fail(`${where} has indexDigest null with version ${JSON.stringify(pin.version)}; null is only for a develop snapshot version (${DEVELOP_CHART_VERSION_PREFIX}<sha12>)`);}
|
|
124
|
+
if (channel === EditionChannel.Release) {fail(`${where} has indexDigest null, which a release chart set refuses; a release index chart is pinned by digest`);}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (develop) {fail(`${where} has an indexDigest with develop snapshot version ${pin.version}; a develop snapshot has no index chart, so indexDigest must be null`);}
|
|
128
|
+
if (!OCI_DIGEST_PATTERN.test(String(pin.indexDigest))) {fail(`${where} has a malformed indexDigest (${JSON.stringify(pin.indexDigest)}); expected "sha256:<64 lowercase hex>"`);}
|
|
129
|
+
}
|
|
130
|
+
|
|
78
131
|
/**
|
|
79
132
|
* Write the RELEASE edition's copy of the fleet pin — byte-identical to
|
|
80
|
-
* `pinText`. Only a rostered pin (schemaVersion
|
|
133
|
+
* `pinText`. Only a rostered pin (schemaVersion 4) with release chart
|
|
81
134
|
* coordinates is written.
|
|
82
135
|
*/
|
|
83
136
|
export function writeEditionChartSet(editionDir, pinText) {
|
|
@@ -78,6 +78,22 @@ function requireSourceAuthUsername(config) {
|
|
|
78
78
|
return value;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** `chartRegistry` and `chartPublishers` are a vendor-only pair: both declared or both absent. */
|
|
82
|
+
function optionalChartSet(document) {
|
|
83
|
+
const registry = document.chartRegistry !== undefined;
|
|
84
|
+
const publishers = document.chartPublishers !== undefined;
|
|
85
|
+
if (registry !== publishers) {
|
|
86
|
+
throw new EditionWorkspaceConfigError(
|
|
87
|
+
`${EDITION_WORKSPACE_CONFIG_FILE}: "chartRegistry" and "chartPublishers" are a vendor-only pair, both declared or both absent; only "${registry ? 'chartRegistry' : 'chartPublishers'}" is`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (!registry) {return {};}
|
|
91
|
+
return {
|
|
92
|
+
chartRegistry: requireString(document, 'chartRegistry', CHART_REGISTRY, 'oci://<host>/<path>'),
|
|
93
|
+
chartPublishers: requirePublishers(document),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
81
97
|
function requirePublishers(config) {
|
|
82
98
|
const value = config.chartPublishers;
|
|
83
99
|
if (!Array.isArray(value) || value.length === 0) {
|
|
@@ -166,8 +182,7 @@ export function parseEditionWorkspaceConfig(document) {
|
|
|
166
182
|
return Object.freeze({
|
|
167
183
|
...sections,
|
|
168
184
|
imageRegistry: requireString(document, 'imageRegistry', IMAGE_REGISTRY, '<host>/<namespace> with no scheme or trailing slash'),
|
|
169
|
-
|
|
170
|
-
chartPublishers: requirePublishers(document),
|
|
185
|
+
...optionalChartSet(document),
|
|
171
186
|
sourceRepositoryUrlPrefix: requireString(document, 'sourceRepositoryUrlPrefix', SOURCE_URL_PREFIX, 'https://<host>/<organisation>/ with a trailing slash'),
|
|
172
187
|
sourceAuthUsername: requireSourceAuthUsername(document),
|
|
173
188
|
evidenceTrustPolicy: requireEvidenceTrustPolicy(document),
|
|
@@ -190,6 +205,17 @@ export function resetEditionWorkspaceConfig() {
|
|
|
190
205
|
cached = undefined;
|
|
191
206
|
}
|
|
192
207
|
|
|
208
|
+
/** The vendor chart set's coordinates `{chartRegistry, chartPublishers}`; a data root that declares neither (a customer's) is refused, naming the file and both keys. */
|
|
209
|
+
export function vendorChartSetConfig() {
|
|
210
|
+
const { chartRegistry, chartPublishers } = editionWorkspaceConfig();
|
|
211
|
+
if (chartRegistry === undefined) {
|
|
212
|
+
throw new EditionWorkspaceConfigError(
|
|
213
|
+
`${EDITION_WORKSPACE_CONFIG_FILE} in ${dataRoot()} declares no "chartRegistry" and "chartPublishers", which vendor chart-set code needs`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return { chartRegistry, chartPublishers };
|
|
217
|
+
}
|
|
218
|
+
|
|
193
219
|
/**
|
|
194
220
|
* One product-data section, refused by name when the data root does not carry
|
|
195
221
|
* it. `needed` says which operation wanted it, so the refusal is actionable.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// edition-constants — the closed vocabulary of the edition OCI artifact, in one
|
|
2
2
|
// place so the writer, the guards and the workflow cannot disagree about what an
|
|
3
3
|
// edition IS. ONE layout serves both channels: an artifact at
|
|
4
|
-
// `<registry>/xema-edition-<id>`, addressed by digest,
|
|
5
|
-
// descriptor layer plus the lock layers.
|
|
4
|
+
// `<registry root>/<channel segment>/xema-edition-<id>`, addressed by digest,
|
|
5
|
+
// carrying a titled descriptor layer plus the lock layers.
|
|
6
6
|
|
|
7
7
|
import { EditionChannel } from './edition-channel.mjs';
|
|
8
8
|
import { EDITION_CHART_SET_FILE } from './edition-chart-set.mjs';
|
|
9
|
+
import { parseEditionContract } from './edition-contract-parse.mjs';
|
|
9
10
|
import editionContract from './edition-contract.json' with { type: 'json' };
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -15,16 +16,21 @@ import editionContract from './edition-contract.json' with { type: 'json' };
|
|
|
15
16
|
* It is a JSON import, not a read at run time, so a bundle (the evidence
|
|
16
17
|
* verifier) carries it.
|
|
17
18
|
*/
|
|
18
|
-
export const EDITION_CONTRACT = Object.freeze(structuredClone(editionContract));
|
|
19
|
+
export const EDITION_CONTRACT = Object.freeze(structuredClone(parseEditionContract(editionContract)));
|
|
19
20
|
|
|
20
21
|
/** The descriptor layer's file name; its `org.opencontainers.image.title`. */
|
|
21
22
|
export const EDITION_DESCRIPTOR_FILE = EDITION_CONTRACT.descriptorFile;
|
|
22
23
|
export const EDITION_DESCRIPTOR_SCHEMA = EDITION_CONTRACT.descriptorSchema;
|
|
23
24
|
/** The ONE artifactType of every edition artifact, develop or release. */
|
|
24
25
|
export const EDITION_ARTIFACT_TYPE = EDITION_CONTRACT.artifactType;
|
|
25
|
-
/** The repository name prefix: `<registry>/<prefix><id>`. */
|
|
26
|
+
/** The repository name prefix: `<registry root>/<channel segment>/<prefix><id>`. */
|
|
26
27
|
export const EDITION_REPOSITORY_PREFIX = EDITION_CONTRACT.repositoryPrefix;
|
|
27
28
|
|
|
29
|
+
/** The namespace segment of each channel's edition repositories, keyed by channel: `<registry root>/<segment>/<prefix><id>`. */
|
|
30
|
+
export const EDITION_REPOSITORY_SEGMENTS = Object.freeze(
|
|
31
|
+
Object.fromEntries(Object.values(EditionChannel).map((channel) => [channel, EDITION_CONTRACT.repositories[channel].namespaceSegment])),
|
|
32
|
+
);
|
|
33
|
+
|
|
28
34
|
export { EditionChannel };
|
|
29
35
|
|
|
30
36
|
/** Whether the data root's editions are evidence-gated, as the descriptor records it. */
|