feat(ql3): close public local release pair

This commit is contained in:
whyour
2026-08-27 14:51:33 +08:00
parent 238df17fdf
commit 78c261b556
31 changed files with 558 additions and 91 deletions
@@ -22,11 +22,13 @@ import {
} from '../foundation/files';
import { deploymentPaths } from '../foundation/render';
const SELECTION_SCHEMA = 'qinglong/local-compose-image-selection@v2';
const SELECTION_SCHEMA = 'qinglong/local-compose-image-selection@v3';
const CATALOG_SCHEMA = 'qinglong/release-catalog-consumption-ceremony@v1';
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
const IMAGE_PATTERN =
/^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-application@sha256:[0-9a-f]{64}$/;
const OPERATOR_IMAGE_PATTERN =
/^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-operator@sha256:[0-9a-f]{64}$/;
const VERSION_PATTERN = /^3\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/;
const SOURCE_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
@@ -63,6 +65,7 @@ function selectionContents(selection: Readonly<ComposeImageSelection>): string {
` catalog_manifest_digest: ${selection.catalogManifestDigest}`,
` catalog_consumption_report_digest: ${selection.catalogConsumptionReportDigest}`,
` catalog_discovery_tag_authority: ${selection.catalogDiscoveryTagAuthority}`,
` operator_image: ${selection.operatorImage}`,
` allow_root_service: ${selection.allowRootService}`,
'services:',
' qinglong3:',
@@ -94,7 +97,7 @@ function parseSelection(
label: string,
): Readonly<ComposeImageSelection> {
const match =
/^x-qinglong-image-selection:\n schema: qinglong\/local-compose-image-selection@v2\n generation: (0|[1-9][0-9]{0,5})\n previous_generation: (0|[1-9][0-9]{0,5})\n rollback_target_generation: (0|[1-9][0-9]{0,5})\n mutation_id: ([0-9a-f-]+)\n changed_at_ms: ([0-9]+)\n release_selection_digest: (sha256:[a-f0-9]{64})\n release_set_digest: (sha256:[a-f0-9]{64})\n release_version: ([^\n]+)\n release_source_revision: ([a-f0-9]{40})\n release_source_ref: ([^\n]+)\n release_scope: (local|all)\n catalog_schema: qinglong\/release-catalog-consumption-ceremony@v1\n catalog_source_repository: ([^\n]+)\n catalog_workflow_identity: ([^\n]+)\n catalog_immutable_reference: ([^\n]+)\n catalog_manifest_digest: (sha256:[a-f0-9]{64})\n catalog_consumption_report_digest: (sha256:[a-f0-9]{64})\n catalog_discovery_tag_authority: none\n allow_root_service: (true|false)\nservices:\n qinglong3:\n image: ([^\n]+)\n labels:\n io\.qinglong\.deployment\.generation: "([0-9]+)"\n io\.qinglong\.deployment\.mutation: "([0-9a-f-]+)"\n io\.qinglong\.release\.selection: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.set: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.catalog-manifest: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.catalog-report: "(sha256:[a-f0-9]{64})"\n$/.exec(
/^x-qinglong-image-selection:\n schema: qinglong\/local-compose-image-selection@v3\n generation: (0|[1-9][0-9]{0,5})\n previous_generation: (0|[1-9][0-9]{0,5})\n rollback_target_generation: (0|[1-9][0-9]{0,5})\n mutation_id: ([0-9a-f-]+)\n changed_at_ms: ([0-9]+)\n release_selection_digest: (sha256:[a-f0-9]{64})\n release_set_digest: (sha256:[a-f0-9]{64})\n release_version: ([^\n]+)\n release_source_revision: ([a-f0-9]{40})\n release_source_ref: ([^\n]+)\n release_scope: (local|all)\n catalog_schema: qinglong\/release-catalog-consumption-ceremony@v1\n catalog_source_repository: ([^\n]+)\n catalog_workflow_identity: ([^\n]+)\n catalog_immutable_reference: ([^\n]+)\n catalog_manifest_digest: (sha256:[a-f0-9]{64})\n catalog_consumption_report_digest: (sha256:[a-f0-9]{64})\n catalog_discovery_tag_authority: none\n operator_image: ([^\n]+)\n allow_root_service: (true|false)\nservices:\n qinglong3:\n image: ([^\n]+)\n labels:\n io\.qinglong\.deployment\.generation: "([0-9]+)"\n io\.qinglong\.deployment\.mutation: "([0-9a-f-]+)"\n io\.qinglong\.release\.selection: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.set: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.catalog-manifest: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.catalog-report: "(sha256:[a-f0-9]{64})"\n$/.exec(
contents,
);
if (!match) {
@@ -122,15 +125,17 @@ function parseSelection(
const catalogImmutableReference = match[14]!;
const catalogManifestDigest = match[15]!;
const catalogConsumptionReportDigest = match[16]!;
const allowRootService = match[17] === 'true';
const image = match[18]!;
const labelGeneration = Number(match[19]);
const labelMutationId = match[20];
const labelSelectionDigest = match[21];
const labelReleaseSetDigest = match[22];
const labelCatalogManifestDigest = match[23];
const labelCatalogReportDigest = match[24];
const operatorImage = match[17]!;
const allowRootService = match[18] === 'true';
const image = match[19]!;
const labelGeneration = Number(match[20]);
const labelMutationId = match[21];
const labelSelectionDigest = match[22];
const labelReleaseSetDigest = match[23];
const labelCatalogManifestDigest = match[24];
const labelCatalogReportDigest = match[25];
const imageMatch = IMAGE_PATTERN.exec(image);
const operatorImageMatch = OPERATOR_IMAGE_PATTERN.exec(operatorImage);
if (
generation < 1 ||
previousGeneration !== generation - 1 ||
@@ -150,6 +155,8 @@ function parseSelection(
!DIGEST_PATTERN.test(catalogManifestDigest) ||
!DIGEST_PATTERN.test(catalogConsumptionReportDigest) ||
!imageMatch ||
!operatorImageMatch ||
operatorImageMatch[1] !== imageMatch[1] ||
catalogImmutableReference !==
`ghcr.io/${imageMatch?.[1]}/qinglong3-release-catalog@${catalogManifestDigest}` ||
labelGeneration !== generation ||
@@ -168,6 +175,7 @@ function parseSelection(
mutationId,
changedAtMs,
image,
operatorImage,
allowRootService,
selectionDigest,
releaseSetDigest,
@@ -235,6 +243,7 @@ function releaseAuthorityFromSelection(
): Readonly<LocalComposeReleaseAuthority> {
return Object.freeze({
image: selection.image,
operatorImage: selection.operatorImage,
allowRootService: selection.allowRootService,
selectionDigest: selection.selectionDigest,
releaseSetDigest: selection.releaseSetDigest,
@@ -3,7 +3,7 @@ import fs from 'node:fs';
import path from 'node:path';
const MAX_SELECTION_BYTES = 64 * 1024;
const LOCAL_SELECTION_SCHEMA = 'qinglong/local-compose-release-image@v2';
const LOCAL_SELECTION_SCHEMA = 'qinglong/local-compose-release-image@v3';
const CATALOG_SCHEMA = 'qinglong/release-catalog-consumption-ceremony@v1';
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
const VERSION_PATTERN = /^3\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
@@ -11,6 +11,8 @@ const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/;
const SOURCE_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const IMAGE_PATTERN =
/^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-application@(sha256:[a-f0-9]{64})$/;
const OPERATOR_IMAGE_PATTERN =
/^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-operator@(sha256:[a-f0-9]{64})$/;
export interface LocalComposeReleaseSelectionInput {
readonly path: string;
@@ -19,6 +21,7 @@ export interface LocalComposeReleaseSelectionInput {
export interface LocalComposeReleaseAuthority {
readonly image: string;
readonly operatorImage: string;
readonly allowRootService: boolean;
readonly selectionDigest: string;
readonly releaseSetDigest: string;
@@ -171,6 +174,7 @@ export function resolveLocalComposeReleaseSelection(
'schemaVersion',
'selectionDigest',
'service',
'operator',
'verification',
],
'release selection',
@@ -195,6 +199,7 @@ export function resolveLocalComposeReleaseSelection(
'catalog',
);
exactKeys(value.service, ['allowRootService', 'image', 'kind'], 'service');
exactKeys(value.operator, ['image', 'kind', 'network'], 'operator');
exactKeys(
value.verification,
[
@@ -210,9 +215,13 @@ export function resolveLocalComposeReleaseSelection(
const release = value.release;
const catalog = value.catalog;
const service = value.service;
const operator = value.operator;
const verification = value.verification;
const image = typeof service.image === 'string' ? service.image : '';
const imageMatch = IMAGE_PATTERN.exec(image);
const operatorImage =
typeof operator.image === 'string' ? operator.image : '';
const operatorImageMatch = OPERATOR_IMAGE_PATTERN.exec(operatorImage);
const { selectionDigest, ...unsigned } = value;
const calculatedDigest = digest(JSON.stringify(unsigned));
if (
@@ -239,10 +248,14 @@ export function resolveLocalComposeReleaseSelection(
catalog.releaseSetDigest !== value.releaseSetDigest ||
catalog.discoveryTagAuthority !== 'none' ||
!imageMatch ||
!operatorImageMatch ||
operatorImageMatch[1] !== imageMatch[1] ||
catalog.immutableReference !==
`ghcr.io/${imageMatch[1]}/qinglong3-release-catalog@${catalog.manifestDigest}` ||
service.kind !== 'compose' ||
service.allowRootService !== allowRootService ||
operator.kind !== 'short-lived' ||
operator.network !== 'none-by-default' ||
verification.releaseSet !==
'standalone_structure_identity_and_self_digest' ||
verification.sourceRecordsReplayed !== false ||
@@ -262,6 +275,7 @@ export function resolveLocalComposeReleaseSelection(
expectedSelectionDigest: input.expectedSelectionDigest,
authority: Object.freeze({
image,
operatorImage,
allowRootService,
selectionDigest,
releaseSetDigest: value.releaseSetDigest,
@@ -56,12 +56,16 @@ function releaseSelection(managementRoot, marker = 'a') {
const image = `ghcr.io/example/qinglong3-local-application@sha256:${marker.repeat(
64,
)}`;
const operatorImage = image.replace(
'qinglong3-local-application',
'qinglong3-local-operator',
);
const releaseSetDigest = prefixedDigest(`release-set:${image}`);
const manifestDigest = prefixedDigest(`catalog-manifest:${image}`);
const consumptionReportDigest = prefixedDigest(`catalog-report:${image}`);
const unsigned = {
schemaVersion: 1,
schema: 'qinglong/local-compose-release-image@v2',
schema: 'qinglong/local-compose-release-image@v3',
release: {
version: '3.0.0-alpha.0',
sourceRevision: '3'.repeat(40),
@@ -86,6 +90,11 @@ function releaseSelection(managementRoot, marker = 'a') {
image,
allowRootService: rootAcknowledgement(),
},
operator: {
kind: 'short-lived',
image: operatorImage,
network: 'none-by-default',
},
verification: {
releaseSet: 'standalone_structure_identity_and_self_digest',
sourceRecordsReplayed: false,
@@ -31,12 +31,16 @@ function sha256(value) {
}
function releaseSelectionForImage(managementRoot, image, allowRootService) {
const operatorImage = image.replace(
'qinglong3-local-application',
'qinglong3-local-operator',
);
const releaseSetDigest = sha256(`release-set:${image}`);
const manifestDigest = sha256(`catalog-manifest:${image}`);
const consumptionReportDigest = sha256(`catalog-report:${image}`);
const unsigned = {
schemaVersion: 1,
schema: 'qinglong/local-compose-release-image@v2',
schema: 'qinglong/local-compose-release-image@v3',
release: {
version: '3.0.0-alpha.0',
sourceRevision: '3'.repeat(40),
@@ -61,6 +65,11 @@ function releaseSelectionForImage(managementRoot, image, allowRootService) {
image,
allowRootService,
},
operator: {
kind: 'short-lived',
image: operatorImage,
network: 'none-by-default',
},
verification: {
releaseSet: 'standalone_structure_identity_and_self_digest',
sourceRecordsReplayed: false,
@@ -393,7 +402,7 @@ function composeSelection({
const selected = JSON.parse(fs.readFileSync(releaseSelection.path, 'utf8'));
return [
'x-qinglong-image-selection:',
' schema: qinglong/local-compose-image-selection@v2',
' schema: qinglong/local-compose-image-selection@v3',
` generation: ${generation}`,
` previous_generation: ${previousGeneration}`,
` rollback_target_generation: ${rollbackTargetGeneration}`,
@@ -412,6 +421,7 @@ function composeSelection({
` catalog_manifest_digest: ${selected.catalog.manifestDigest}`,
` catalog_consumption_report_digest: ${selected.catalog.consumptionReportDigest}`,
` catalog_discovery_tag_authority: ${selected.catalog.discoveryTagAuthority}`,
` operator_image: ${selected.operator.image}`,
` allow_root_service: ${selected.service.allowRootService}`,
'services:',
' qinglong3:',
@@ -1056,7 +1066,7 @@ test('renders bounded OpenRC and immutable rootless Compose descriptors', async
);
assert.match(
fs.readFileSync(selectionPath, 'utf8'),
/^ schema: qinglong\/local-compose-image-selection@v2$/m,
/^ schema: qinglong\/local-compose-image-selection@v3$/m,
);
assert.match(
fs.readFileSync(selectionPath, 'utf8'),
@@ -2200,6 +2210,16 @@ test('upgrades and rolls back Compose image selections with generation CAS', asy
fs.readFileSync(selectionPath, 'utf8'),
new RegExp(`^ image: ${upgradedImage}$`, 'm'),
);
assert.match(
fs.readFileSync(selectionPath, 'utf8'),
new RegExp(
`^ operator_image: ${upgradedImage.replace(
'/qinglong3-local-application@',
'/qinglong3-local-operator@',
)}$`,
'm',
),
);
assert.equal(mode(path.join(revisions, '2.yaml')), 0o600);
assert.equal(fs.readFileSync(composePath, 'utf8'), stableDescriptor);
@@ -2224,6 +2244,16 @@ test('upgrades and rolls back Compose image selections with generation CAS', asy
assert.match(active, /^ generation: 3$/m);
assert.match(active, /^ rollback_target_generation: 1$/m);
assert.match(active, new RegExp(`^ image: ${state.composeImage}$`, 'm'));
assert.match(
active,
new RegExp(
`^ operator_image: ${state.composeImage.replace(
'/qinglong3-local-application@',
'/qinglong3-local-operator@',
)}$`,
'm',
),
);
assert.equal(
fs
.readFileSync(path.join(revisions, '2.yaml'), 'utf8')