mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): bind local compose revisions to release catalogs
This commit is contained in:
@@ -11,8 +11,8 @@ import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
normalizeLocalDeploymentComposePreflightCommand,
|
||||
normalizeLocalDeploymentPrepareCommand,
|
||||
type LocalDeploymentComposePreflightResult,
|
||||
type LocalDeploymentPrepareCommand,
|
||||
type LocalDeploymentProfile,
|
||||
} from '../foundation/contract';
|
||||
import { inspectActiveComposeImageSelection } from './composeRevision';
|
||||
@@ -357,7 +357,7 @@ export async function preflightLocalDeploymentCompose(
|
||||
paths.applicationConfig,
|
||||
identity.uid,
|
||||
);
|
||||
const syntheticPrepare = normalizeLocalDeploymentPrepareCommand({
|
||||
const syntheticPrepare: Readonly<LocalDeploymentPrepareCommand> = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.prepare',
|
||||
options: {
|
||||
@@ -369,7 +369,10 @@ export async function preflightLocalDeploymentCompose(
|
||||
: { busyTimeoutMs: application.busyTimeoutMs }),
|
||||
service: {
|
||||
kind: 'compose',
|
||||
image: selection.image,
|
||||
releaseSelection: {
|
||||
path: paths.composeSelection,
|
||||
expectedSelectionDigest: selection.selectionDigest,
|
||||
},
|
||||
allowRootService: command.options.allowRootService,
|
||||
},
|
||||
},
|
||||
@@ -380,7 +383,7 @@ export async function preflightLocalDeploymentCompose(
|
||||
registeredAtMs: 0,
|
||||
activatedAtMs: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
preflightPublishedFile(
|
||||
paths.applicationConfig,
|
||||
applicationConfiguration(syntheticPrepare, paths),
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
currentIdentity,
|
||||
LocalDeploymentConfigurationError,
|
||||
normalizeLocalDeploymentComposeRevisionCommand,
|
||||
type LocalDeploymentComposeRevisionCommand,
|
||||
type LocalDeploymentComposeRevisionResult,
|
||||
type LocalDeploymentPrepareCommand,
|
||||
type NormalizedLocalDeploymentComposeRevisionCommand,
|
||||
type NormalizedLocalDeploymentPrepareCommand,
|
||||
} from '../foundation/contract';
|
||||
import type { LocalComposeReleaseAuthority } from './releaseSelection';
|
||||
import {
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
@@ -20,19 +21,23 @@ import {
|
||||
} from '../foundation/files';
|
||||
import { deploymentPaths } from '../foundation/render';
|
||||
|
||||
const SELECTION_SCHEMA = 'qinglong/local-compose-image-selection@v1';
|
||||
const SELECTION_SCHEMA = 'qinglong/local-compose-image-selection@v2';
|
||||
const CATALOG_SCHEMA = 'qinglong/release-catalog-consumption-ceremony@v1';
|
||||
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
||||
const IMAGE_PATTERN =
|
||||
/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/;
|
||||
/^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-application@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_.-]+$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
export interface ComposeImageSelection {
|
||||
export interface ComposeImageSelection extends LocalComposeReleaseAuthority {
|
||||
readonly generation: number;
|
||||
readonly previousGeneration: number;
|
||||
readonly rollbackTargetGeneration: number;
|
||||
readonly mutationId: string;
|
||||
readonly changedAtMs: number;
|
||||
readonly image: string;
|
||||
}
|
||||
|
||||
function selectionContents(selection: Readonly<ComposeImageSelection>): string {
|
||||
@@ -44,12 +49,30 @@ function selectionContents(selection: Readonly<ComposeImageSelection>): string {
|
||||
` rollback_target_generation: ${selection.rollbackTargetGeneration}`,
|
||||
` mutation_id: ${selection.mutationId}`,
|
||||
` changed_at_ms: ${selection.changedAtMs}`,
|
||||
` release_selection_digest: ${selection.selectionDigest}`,
|
||||
` release_set_digest: ${selection.releaseSetDigest}`,
|
||||
` release_version: ${selection.releaseVersion}`,
|
||||
` release_source_revision: ${selection.releaseSourceRevision}`,
|
||||
` release_source_ref: ${selection.releaseSourceRef}`,
|
||||
` release_scope: ${selection.releaseScope}`,
|
||||
` catalog_schema: ${selection.catalogSchema}`,
|
||||
` catalog_source_repository: ${selection.catalogSourceRepository}`,
|
||||
` catalog_workflow_identity: ${selection.catalogWorkflowIdentity}`,
|
||||
` catalog_immutable_reference: ${selection.catalogImmutableReference}`,
|
||||
` catalog_manifest_digest: ${selection.catalogManifestDigest}`,
|
||||
` catalog_consumption_report_digest: ${selection.catalogConsumptionReportDigest}`,
|
||||
` catalog_discovery_tag_authority: ${selection.catalogDiscoveryTagAuthority}`,
|
||||
` allow_root_service: ${selection.allowRootService}`,
|
||||
'services:',
|
||||
' qinglong3:',
|
||||
` image: ${selection.image}`,
|
||||
' labels:',
|
||||
` io.qinglong.deployment.generation: "${selection.generation}"`,
|
||||
` io.qinglong.deployment.mutation: "${selection.mutationId}"`,
|
||||
` io.qinglong.release.selection: "${selection.selectionDigest}"`,
|
||||
` io.qinglong.release.set: "${selection.releaseSetDigest}"`,
|
||||
` io.qinglong.release.catalog-manifest: "${selection.catalogManifestDigest}"`,
|
||||
` io.qinglong.release.catalog-report: "${selection.catalogConsumptionReportDigest}"`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -70,7 +93,7 @@ function parseSelection(
|
||||
label: string,
|
||||
): Readonly<ComposeImageSelection> {
|
||||
const match =
|
||||
/^x-qinglong-image-selection:\n schema: qinglong\/local-compose-image-selection@v1\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]+)\nservices:\n qinglong3:\n image: ([^\n]+)\n labels:\n io\.qinglong\.deployment\.generation: "([0-9]+)"\n io\.qinglong\.deployment\.mutation: "([0-9a-f-]+)"\n$/.exec(
|
||||
/^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(
|
||||
contents,
|
||||
);
|
||||
if (!match) {
|
||||
@@ -87,9 +110,26 @@ function parseSelection(
|
||||
);
|
||||
const mutationId = match[4];
|
||||
const changedAtMs = Number(match[5]);
|
||||
const image = match[6];
|
||||
const labelGeneration = Number(match[7]);
|
||||
const labelMutationId = match[8];
|
||||
const selectionDigest = match[6]!;
|
||||
const releaseSetDigest = match[7]!;
|
||||
const releaseVersion = match[8]!;
|
||||
const releaseSourceRevision = match[9]!;
|
||||
const releaseSourceRef = match[10]!;
|
||||
const releaseScope = match[11] as 'local' | 'all';
|
||||
const catalogSourceRepository = match[12]!;
|
||||
const catalogWorkflowIdentity = match[13]!;
|
||||
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 imageMatch = IMAGE_PATTERN.exec(image);
|
||||
if (
|
||||
generation < 1 ||
|
||||
previousGeneration !== generation - 1 ||
|
||||
@@ -98,12 +138,25 @@ function parseSelection(
|
||||
changedAtMs < 0 ||
|
||||
!mutationId ||
|
||||
!UUID_V4_PATTERN.test(mutationId) ||
|
||||
!image ||
|
||||
!IMAGE_PATTERN.test(image) ||
|
||||
image.includes('..') ||
|
||||
image.includes('//') ||
|
||||
!DIGEST_PATTERN.test(selectionDigest) ||
|
||||
!DIGEST_PATTERN.test(releaseSetDigest) ||
|
||||
!VERSION_PATTERN.test(releaseVersion) ||
|
||||
!SOURCE_REVISION_PATTERN.test(releaseSourceRevision) ||
|
||||
releaseSourceRef !== `refs/tags/v${releaseVersion}` ||
|
||||
!SOURCE_REPOSITORY_PATTERN.test(catalogSourceRepository) ||
|
||||
catalogWorkflowIdentity !==
|
||||
`https://github.com/${catalogSourceRepository}/.github/workflows/ql3-image-release.yml@${releaseSourceRef}` ||
|
||||
!DIGEST_PATTERN.test(catalogManifestDigest) ||
|
||||
!DIGEST_PATTERN.test(catalogConsumptionReportDigest) ||
|
||||
!imageMatch ||
|
||||
catalogImmutableReference !==
|
||||
`ghcr.io/${imageMatch?.[1]}/qinglong3-release-catalog@${catalogManifestDigest}` ||
|
||||
labelGeneration !== generation ||
|
||||
labelMutationId !== mutationId
|
||||
labelMutationId !== mutationId ||
|
||||
labelSelectionDigest !== selectionDigest ||
|
||||
labelReleaseSetDigest !== releaseSetDigest ||
|
||||
labelCatalogManifestDigest !== catalogManifestDigest ||
|
||||
labelCatalogReportDigest !== catalogConsumptionReportDigest
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} value is invalid`);
|
||||
}
|
||||
@@ -114,6 +167,20 @@ function parseSelection(
|
||||
mutationId,
|
||||
changedAtMs,
|
||||
image,
|
||||
allowRootService,
|
||||
selectionDigest,
|
||||
releaseSetDigest,
|
||||
releaseVersion,
|
||||
releaseSourceRevision,
|
||||
releaseSourceRef,
|
||||
releaseScope,
|
||||
catalogSchema: CATALOG_SCHEMA,
|
||||
catalogSourceRepository,
|
||||
catalogWorkflowIdentity,
|
||||
catalogImmutableReference,
|
||||
catalogManifestDigest,
|
||||
catalogConsumptionReportDigest,
|
||||
catalogDiscoveryTagAuthority: 'none' as const,
|
||||
});
|
||||
if (selectionContents(selection) !== contents) {
|
||||
throw new LocalDeploymentConfigurationError(`${label} is not canonical`);
|
||||
@@ -157,11 +224,33 @@ function revisionPath(root: string, generation: number): string {
|
||||
}
|
||||
|
||||
function commandIntent(
|
||||
command: Readonly<LocalDeploymentComposeRevisionCommand>,
|
||||
command: Readonly<NormalizedLocalDeploymentComposeRevisionCommand>,
|
||||
): string {
|
||||
return `${JSON.stringify(command, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function releaseAuthorityFromSelection(
|
||||
selection: Readonly<ComposeImageSelection>,
|
||||
): Readonly<LocalComposeReleaseAuthority> {
|
||||
return Object.freeze({
|
||||
image: selection.image,
|
||||
allowRootService: selection.allowRootService,
|
||||
selectionDigest: selection.selectionDigest,
|
||||
releaseSetDigest: selection.releaseSetDigest,
|
||||
releaseVersion: selection.releaseVersion,
|
||||
releaseSourceRevision: selection.releaseSourceRevision,
|
||||
releaseSourceRef: selection.releaseSourceRef,
|
||||
releaseScope: selection.releaseScope,
|
||||
catalogSchema: selection.catalogSchema,
|
||||
catalogSourceRepository: selection.catalogSourceRepository,
|
||||
catalogWorkflowIdentity: selection.catalogWorkflowIdentity,
|
||||
catalogImmutableReference: selection.catalogImmutableReference,
|
||||
catalogManifestDigest: selection.catalogManifestDigest,
|
||||
catalogConsumptionReportDigest: selection.catalogConsumptionReportDigest,
|
||||
catalogDiscoveryTagAuthority: selection.catalogDiscoveryTagAuthority,
|
||||
});
|
||||
}
|
||||
|
||||
function releaseLock(lockPath: string, intent: string, uid: number): void {
|
||||
preflightPublishedFile(lockPath, intent, 0o600, uid, 'compose revision lock');
|
||||
fs.unlinkSync(lockPath);
|
||||
@@ -169,7 +258,7 @@ function releaseLock(lockPath: string, intent: string, uid: number): void {
|
||||
}
|
||||
|
||||
export function initialComposeImageSelection(
|
||||
command: Readonly<LocalDeploymentPrepareCommand>,
|
||||
command: Readonly<NormalizedLocalDeploymentPrepareCommand>,
|
||||
): string {
|
||||
if (command.options.service.kind !== 'compose') {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
@@ -182,7 +271,7 @@ export function initialComposeImageSelection(
|
||||
rollbackTargetGeneration: 0,
|
||||
mutationId: command.request.activateMutationId,
|
||||
changedAtMs: command.request.activatedAtMs,
|
||||
image: command.options.service.image,
|
||||
...command.options.service.releaseSelection.authority,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,10 +403,10 @@ export async function switchLocalDeploymentComposeRevision(
|
||||
'active compose selection',
|
||||
);
|
||||
const nextGeneration = command.request.expectedGeneration + 1;
|
||||
let image: string;
|
||||
let releaseAuthority: Readonly<LocalComposeReleaseAuthority>;
|
||||
let rollbackTargetGeneration = 0;
|
||||
if (command.operation === 'local.deployment.compose.upgrade') {
|
||||
image = command.request.image;
|
||||
releaseAuthority = command.request.releaseSelection.authority;
|
||||
} else {
|
||||
rollbackTargetGeneration = command.request.targetGeneration;
|
||||
const target = readSelectionFile(
|
||||
@@ -330,7 +419,7 @@ export async function switchLocalDeploymentComposeRevision(
|
||||
'rollback target generation drifted',
|
||||
);
|
||||
}
|
||||
image = target.selection.image;
|
||||
releaseAuthority = releaseAuthorityFromSelection(target.selection);
|
||||
}
|
||||
const nextContents = selectionContents({
|
||||
generation: nextGeneration,
|
||||
@@ -338,7 +427,7 @@ export async function switchLocalDeploymentComposeRevision(
|
||||
rollbackTargetGeneration,
|
||||
mutationId: command.request.mutationId,
|
||||
changedAtMs: command.request.changedAtMs,
|
||||
image,
|
||||
...releaseAuthority,
|
||||
});
|
||||
if (command.request.changedAtMs < observed.selection.changedAtMs) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import crypto from 'node:crypto';
|
||||
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 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.-]+)?$/;
|
||||
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})$/;
|
||||
|
||||
export interface LocalComposeReleaseSelectionInput {
|
||||
readonly path: string;
|
||||
readonly expectedSelectionDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalComposeReleaseAuthority {
|
||||
readonly image: string;
|
||||
readonly allowRootService: boolean;
|
||||
readonly selectionDigest: string;
|
||||
readonly releaseSetDigest: string;
|
||||
readonly releaseVersion: string;
|
||||
readonly releaseSourceRevision: string;
|
||||
readonly releaseSourceRef: string;
|
||||
readonly releaseScope: 'local' | 'all';
|
||||
readonly catalogSchema: typeof CATALOG_SCHEMA;
|
||||
readonly catalogSourceRepository: string;
|
||||
readonly catalogWorkflowIdentity: string;
|
||||
readonly catalogImmutableReference: string;
|
||||
readonly catalogManifestDigest: string;
|
||||
readonly catalogConsumptionReportDigest: string;
|
||||
readonly catalogDiscoveryTagAuthority: 'none';
|
||||
}
|
||||
|
||||
export interface ResolvedLocalComposeReleaseSelection
|
||||
extends LocalComposeReleaseSelectionInput {
|
||||
readonly authority: Readonly<LocalComposeReleaseAuthority>;
|
||||
}
|
||||
|
||||
export class LocalComposeReleaseSelectionError extends Error {
|
||||
public constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = 'LocalComposeReleaseSelectionError';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message: string, cause?: unknown): never {
|
||||
throw new LocalComposeReleaseSelectionError(message, { cause });
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
JSON.stringify(Object.keys(value).sort()) !==
|
||||
JSON.stringify([...keys].sort())
|
||||
) {
|
||||
fail(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function digest(value: string): string {
|
||||
return `sha256:${crypto
|
||||
.createHash('sha256')
|
||||
.update(value, 'utf8')
|
||||
.digest('hex')}`;
|
||||
}
|
||||
|
||||
function readSelectionFile(filePath: string, uid: number): string {
|
||||
let parentStat: fs.Stats;
|
||||
try {
|
||||
parentStat = fs.lstatSync(path.dirname(filePath));
|
||||
} catch (error) {
|
||||
fail('release selection is unavailable', error);
|
||||
}
|
||||
if (
|
||||
!parentStat.isDirectory() ||
|
||||
parentStat.isSymbolicLink() ||
|
||||
parentStat.uid !== uid ||
|
||||
(parentStat.mode & 0o777) !== 0o700 ||
|
||||
fs.realpathSync(path.dirname(filePath)) !== path.dirname(filePath)
|
||||
) {
|
||||
fail('release selection must be a private canonical current-UID file');
|
||||
}
|
||||
let descriptor: number | undefined;
|
||||
let before: fs.Stats;
|
||||
let after: fs.Stats;
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
before = fs.fstatSync(descriptor);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.uid !== uid ||
|
||||
(before.mode & 0o777) !== 0o600 ||
|
||||
before.nlink !== 1 ||
|
||||
before.size < 2 ||
|
||||
before.size > MAX_SELECTION_BYTES ||
|
||||
fs.realpathSync(filePath) !== filePath
|
||||
) {
|
||||
fail('release selection must be a private canonical current-UID file');
|
||||
}
|
||||
bytes = fs.readFileSync(descriptor);
|
||||
after = fs.fstatSync(descriptor);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalComposeReleaseSelectionError) throw error;
|
||||
fail('release selection cannot be read through a stable descriptor', error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
if (
|
||||
before.dev !== after.dev ||
|
||||
before.ino !== after.ino ||
|
||||
before.size !== after.size ||
|
||||
before.mtimeMs !== after.mtimeMs ||
|
||||
before.ctimeMs !== after.ctimeMs ||
|
||||
bytes.byteLength !== before.size
|
||||
) {
|
||||
fail('release selection changed while being read');
|
||||
}
|
||||
const contents = bytes.toString('utf8');
|
||||
if (!Buffer.from(contents, 'utf8').equals(bytes)) {
|
||||
fail('release selection must contain valid UTF-8');
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
export function resolveLocalComposeReleaseSelection(
|
||||
input: Readonly<LocalComposeReleaseSelectionInput>,
|
||||
uid: number,
|
||||
allowRootService: boolean,
|
||||
): Readonly<ResolvedLocalComposeReleaseSelection> {
|
||||
if (
|
||||
typeof input.path !== 'string' ||
|
||||
!path.isAbsolute(input.path) ||
|
||||
path.resolve(input.path) !== input.path ||
|
||||
typeof input.expectedSelectionDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(input.expectedSelectionDigest)
|
||||
) {
|
||||
fail('release selection input is invalid');
|
||||
}
|
||||
const contents = readSelectionFile(input.path, uid);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(contents);
|
||||
} catch (error) {
|
||||
fail('release selection must contain valid JSON', error);
|
||||
}
|
||||
if (`${JSON.stringify(value)}\n` !== contents) {
|
||||
fail('release selection must use canonical JSON encoding');
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'catalog',
|
||||
'deploymentFamily',
|
||||
'release',
|
||||
'releaseSetDigest',
|
||||
'schema',
|
||||
'schemaVersion',
|
||||
'selectionDigest',
|
||||
'service',
|
||||
'verification',
|
||||
],
|
||||
'release selection',
|
||||
);
|
||||
exactKeys(
|
||||
value.release,
|
||||
['scope', 'sourceRef', 'sourceRevision', 'version'],
|
||||
'release',
|
||||
);
|
||||
exactKeys(
|
||||
value.catalog,
|
||||
[
|
||||
'consumptionReportDigest',
|
||||
'discoveryTagAuthority',
|
||||
'immutableReference',
|
||||
'manifestDigest',
|
||||
'releaseSetDigest',
|
||||
'schema',
|
||||
'sourceRepository',
|
||||
'workflowIdentity',
|
||||
],
|
||||
'catalog',
|
||||
);
|
||||
exactKeys(value.service, ['allowRootService', 'image', 'kind'], 'service');
|
||||
exactKeys(
|
||||
value.verification,
|
||||
[
|
||||
'catalogConsumption',
|
||||
'deploymentMutation',
|
||||
'externalToolResultsReplayed',
|
||||
'networkAccess',
|
||||
'releaseSet',
|
||||
'sourceRecordsReplayed',
|
||||
],
|
||||
'verification',
|
||||
);
|
||||
const release = value.release;
|
||||
const catalog = value.catalog;
|
||||
const service = value.service;
|
||||
const verification = value.verification;
|
||||
const image = typeof service.image === 'string' ? service.image : '';
|
||||
const imageMatch = IMAGE_PATTERN.exec(image);
|
||||
const { selectionDigest, ...unsigned } = value;
|
||||
const calculatedDigest = digest(JSON.stringify(unsigned));
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
value.schema !== LOCAL_SELECTION_SCHEMA ||
|
||||
value.deploymentFamily !== 'local' ||
|
||||
typeof release.version !== 'string' ||
|
||||
!VERSION_PATTERN.test(release.version) ||
|
||||
typeof release.sourceRevision !== 'string' ||
|
||||
!SOURCE_REVISION_PATTERN.test(release.sourceRevision) ||
|
||||
release.sourceRef !== `refs/tags/v${release.version}` ||
|
||||
(release.scope !== 'local' && release.scope !== 'all') ||
|
||||
typeof value.releaseSetDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.releaseSetDigest) ||
|
||||
catalog.schema !== CATALOG_SCHEMA ||
|
||||
typeof catalog.sourceRepository !== 'string' ||
|
||||
!SOURCE_REPOSITORY_PATTERN.test(catalog.sourceRepository) ||
|
||||
catalog.workflowIdentity !==
|
||||
`https://github.com/${catalog.sourceRepository}/.github/workflows/ql3-image-release.yml@${release.sourceRef}` ||
|
||||
typeof catalog.manifestDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(catalog.manifestDigest) ||
|
||||
typeof catalog.consumptionReportDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(catalog.consumptionReportDigest) ||
|
||||
catalog.releaseSetDigest !== value.releaseSetDigest ||
|
||||
catalog.discoveryTagAuthority !== 'none' ||
|
||||
!imageMatch ||
|
||||
catalog.immutableReference !==
|
||||
`ghcr.io/${imageMatch[1]}/qinglong3-release-catalog@${catalog.manifestDigest}` ||
|
||||
service.kind !== 'compose' ||
|
||||
service.allowRootService !== allowRootService ||
|
||||
verification.releaseSet !==
|
||||
'standalone_structure_identity_and_self_digest' ||
|
||||
verification.sourceRecordsReplayed !== false ||
|
||||
verification.catalogConsumption !== 'offline_reconstructed' ||
|
||||
verification.externalToolResultsReplayed !== false ||
|
||||
verification.networkAccess !== false ||
|
||||
verification.deploymentMutation !== false ||
|
||||
typeof selectionDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(selectionDigest) ||
|
||||
selectionDigest !== calculatedDigest ||
|
||||
selectionDigest !== input.expectedSelectionDigest
|
||||
) {
|
||||
fail('release selection identity or digest binding is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
path: input.path,
|
||||
expectedSelectionDigest: input.expectedSelectionDigest,
|
||||
authority: Object.freeze({
|
||||
image,
|
||||
allowRootService,
|
||||
selectionDigest,
|
||||
releaseSetDigest: value.releaseSetDigest,
|
||||
releaseVersion: release.version,
|
||||
releaseSourceRevision: release.sourceRevision,
|
||||
releaseSourceRef: release.sourceRef,
|
||||
releaseScope: release.scope,
|
||||
catalogSchema: CATALOG_SCHEMA,
|
||||
catalogSourceRepository: catalog.sourceRepository,
|
||||
catalogWorkflowIdentity: catalog.workflowIdentity,
|
||||
catalogImmutableReference: catalog.immutableReference,
|
||||
catalogManifestDigest: catalog.manifestDigest,
|
||||
catalogConsumptionReportDigest: catalog.consumptionReportDigest,
|
||||
catalogDiscoveryTagAuthority: 'none' as const,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,12 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { LocalSetupResult } from '../../lifecycle/localSetup';
|
||||
import {
|
||||
LocalComposeReleaseSelectionError,
|
||||
resolveLocalComposeReleaseSelection,
|
||||
type LocalComposeReleaseSelectionInput,
|
||||
type ResolvedLocalComposeReleaseSelection,
|
||||
} from '../compose/releaseSelection';
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||
@@ -9,8 +15,6 @@ const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
||||
const KEY_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
|
||||
const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const IMAGE_DIGEST_PATTERN =
|
||||
/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/;
|
||||
|
||||
export type LocalDeploymentProfile = 'edge' | 'standalone';
|
||||
export type LocalDeploymentServiceKind = 'systemd' | 'openrc' | 'compose';
|
||||
@@ -24,10 +28,15 @@ export interface LocalDeploymentProcessService {
|
||||
|
||||
export interface LocalDeploymentComposeService {
|
||||
readonly kind: 'compose';
|
||||
readonly image: string;
|
||||
readonly releaseSelection: Readonly<LocalComposeReleaseSelectionInput>;
|
||||
readonly allowRootService: boolean;
|
||||
}
|
||||
|
||||
export interface NormalizedLocalDeploymentComposeService
|
||||
extends LocalDeploymentComposeService {
|
||||
readonly releaseSelection: Readonly<ResolvedLocalComposeReleaseSelection>;
|
||||
}
|
||||
|
||||
export type LocalDeploymentService =
|
||||
| LocalDeploymentProcessService
|
||||
| LocalDeploymentComposeService;
|
||||
@@ -51,6 +60,16 @@ export interface LocalDeploymentPrepareCommand {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface NormalizedLocalDeploymentPrepareCommand
|
||||
extends Omit<LocalDeploymentPrepareCommand, 'options'> {
|
||||
readonly options: Omit<LocalDeploymentPrepareCommand['options'], 'service'> &
|
||||
Readonly<{
|
||||
service:
|
||||
| Readonly<LocalDeploymentProcessService>
|
||||
| Readonly<NormalizedLocalDeploymentComposeService>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalDeploymentPrepareResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly status: 'prepared' | 'existing';
|
||||
@@ -121,12 +140,23 @@ export interface LocalDeploymentComposeUpgradeCommand {
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
expectedGeneration: number;
|
||||
image: string;
|
||||
releaseSelection: Readonly<LocalComposeReleaseSelectionInput>;
|
||||
mutationId: string;
|
||||
changedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface NormalizedLocalDeploymentComposeUpgradeCommand
|
||||
extends Omit<LocalDeploymentComposeUpgradeCommand, 'request'> {
|
||||
readonly request: Omit<
|
||||
LocalDeploymentComposeUpgradeCommand['request'],
|
||||
'releaseSelection'
|
||||
> &
|
||||
Readonly<{
|
||||
releaseSelection: Readonly<ResolvedLocalComposeReleaseSelection>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LocalDeploymentComposeRollbackCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'local.deployment.compose.rollback';
|
||||
@@ -146,6 +176,10 @@ export type LocalDeploymentComposeRevisionCommand =
|
||||
| LocalDeploymentComposeUpgradeCommand
|
||||
| LocalDeploymentComposeRollbackCommand;
|
||||
|
||||
export type NormalizedLocalDeploymentComposeRevisionCommand =
|
||||
| NormalizedLocalDeploymentComposeUpgradeCommand
|
||||
| LocalDeploymentComposeRollbackCommand;
|
||||
|
||||
export interface LocalDeploymentComposeRevisionResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation:
|
||||
@@ -474,7 +508,9 @@ function validateExecutable(
|
||||
function normalizeService(
|
||||
value: unknown,
|
||||
uid: number,
|
||||
): Readonly<LocalDeploymentService> {
|
||||
):
|
||||
| Readonly<LocalDeploymentProcessService>
|
||||
| Readonly<NormalizedLocalDeploymentComposeService> {
|
||||
const service = object(value, 'service');
|
||||
if (service.kind === 'systemd' || service.kind === 'openrc') {
|
||||
exact(
|
||||
@@ -503,25 +539,47 @@ function normalizeService(
|
||||
});
|
||||
}
|
||||
if (service.kind === 'compose') {
|
||||
exact(service, ['allowRootService', 'image', 'kind'], 'service');
|
||||
if (
|
||||
typeof service.image !== 'string' ||
|
||||
!IMAGE_DIGEST_PATTERN.test(service.image) ||
|
||||
service.image.includes('..') ||
|
||||
service.image.includes('//') ||
|
||||
service.image.split('@').length !== 2
|
||||
) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'compose image must be an immutable sha256 reference',
|
||||
exact(service, ['allowRootService', 'kind', 'releaseSelection'], 'service');
|
||||
const allowRootService = validateRootAcknowledgement(
|
||||
service.allowRootService,
|
||||
uid,
|
||||
);
|
||||
const releaseSelection = object(
|
||||
service.releaseSelection,
|
||||
'releaseSelection',
|
||||
);
|
||||
exact(
|
||||
releaseSelection,
|
||||
['expectedSelectionDigest', 'path'],
|
||||
'releaseSelection',
|
||||
);
|
||||
let resolved: Readonly<ResolvedLocalComposeReleaseSelection>;
|
||||
try {
|
||||
resolved = resolveLocalComposeReleaseSelection(
|
||||
{
|
||||
path: safeAbsolutePath(
|
||||
releaseSelection.path,
|
||||
'releaseSelection.path',
|
||||
),
|
||||
expectedSelectionDigest:
|
||||
releaseSelection.expectedSelectionDigest as string,
|
||||
},
|
||||
uid,
|
||||
allowRootService,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalComposeReleaseSelectionError) {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
'compose release selection is invalid',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: 'compose' as const,
|
||||
image: service.image,
|
||||
allowRootService: validateRootAcknowledgement(
|
||||
service.allowRootService,
|
||||
uid,
|
||||
),
|
||||
releaseSelection: resolved,
|
||||
allowRootService,
|
||||
});
|
||||
}
|
||||
throw new LocalDeploymentConfigurationError('service kind is invalid');
|
||||
@@ -529,7 +587,7 @@ function normalizeService(
|
||||
|
||||
export function normalizeLocalDeploymentPrepareCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentPrepareCommand> {
|
||||
): Readonly<NormalizedLocalDeploymentPrepareCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(
|
||||
command,
|
||||
@@ -659,7 +717,7 @@ export function normalizeLocalDeploymentStatusCommand(
|
||||
|
||||
export function normalizeLocalDeploymentComposeRevisionCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentComposeRevisionCommand> {
|
||||
): Readonly<NormalizedLocalDeploymentComposeRevisionCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(
|
||||
command,
|
||||
@@ -689,17 +747,17 @@ export function normalizeLocalDeploymentComposeRevisionCommand(
|
||||
if (command.operation === 'local.deployment.compose.upgrade') {
|
||||
exact(
|
||||
request,
|
||||
['changedAtMs', 'expectedGeneration', 'image', 'mutationId'],
|
||||
['changedAtMs', 'expectedGeneration', 'mutationId', 'releaseSelection'],
|
||||
'request',
|
||||
);
|
||||
const service = normalizeService(
|
||||
{
|
||||
kind: 'compose',
|
||||
image: request.image,
|
||||
releaseSelection: request.releaseSelection,
|
||||
allowRootService: normalizedOptions.allowRootService,
|
||||
},
|
||||
identity.uid,
|
||||
) as Readonly<LocalDeploymentComposeService>;
|
||||
) as Readonly<NormalizedLocalDeploymentComposeService>;
|
||||
if (
|
||||
typeof request.mutationId !== 'string' ||
|
||||
!UUID_V4_PATTERN.test(request.mutationId)
|
||||
@@ -719,7 +777,7 @@ export function normalizeLocalDeploymentComposeRevisionCommand(
|
||||
99_999,
|
||||
'expectedGeneration',
|
||||
),
|
||||
image: service.image,
|
||||
releaseSelection: service.releaseSelection,
|
||||
mutationId: request.mutationId,
|
||||
changedAtMs: boundedInteger(
|
||||
request.changedAtMs,
|
||||
|
||||
@@ -108,6 +108,10 @@ export {
|
||||
type LocalDeploymentStatusCommand,
|
||||
type LocalDeploymentStatusResult,
|
||||
} from './foundation/contract';
|
||||
export {
|
||||
type LocalComposeReleaseAuthority,
|
||||
type LocalComposeReleaseSelectionInput,
|
||||
} from './compose/releaseSelection';
|
||||
export {
|
||||
normalizeLocalDeploymentLegacyStopCommand,
|
||||
type LocalDeploymentLegacyStopCommand,
|
||||
|
||||
@@ -26,6 +26,67 @@ function rootAcknowledgement() {
|
||||
return typeof process.getuid === 'function' && process.getuid() === 0;
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function releaseSelectionForImage(managementRoot, image, allowRootService) {
|
||||
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',
|
||||
release: {
|
||||
version: '3.0.0-alpha.0',
|
||||
sourceRevision: '3'.repeat(40),
|
||||
sourceRef: 'refs/tags/v3.0.0-alpha.0',
|
||||
scope: 'local',
|
||||
},
|
||||
releaseSetDigest,
|
||||
catalog: {
|
||||
schema: 'qinglong/release-catalog-consumption-ceremony@v1',
|
||||
sourceRepository: 'example/qinglong',
|
||||
workflowIdentity:
|
||||
'https://github.com/example/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v3.0.0-alpha.0',
|
||||
immutableReference: `ghcr.io/example/qinglong3-release-catalog@${manifestDigest}`,
|
||||
manifestDigest,
|
||||
consumptionReportDigest,
|
||||
releaseSetDigest,
|
||||
discoveryTagAuthority: 'none',
|
||||
},
|
||||
deploymentFamily: 'local',
|
||||
service: {
|
||||
kind: 'compose',
|
||||
image,
|
||||
allowRootService,
|
||||
},
|
||||
verification: {
|
||||
releaseSet: 'standalone_structure_identity_and_self_digest',
|
||||
sourceRecordsReplayed: false,
|
||||
catalogConsumption: 'offline_reconstructed',
|
||||
externalToolResultsReplayed: false,
|
||||
networkAccess: false,
|
||||
deploymentMutation: false,
|
||||
},
|
||||
};
|
||||
const selectionDigest = sha256(JSON.stringify(unsigned));
|
||||
const contents = `${JSON.stringify({ ...unsigned, selectionDigest })}\n`;
|
||||
const filePath = path.join(
|
||||
managementRoot,
|
||||
`release-selection-${selectionDigest.slice(7)}.json`,
|
||||
);
|
||||
if (fs.existsSync(filePath)) {
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8'), contents);
|
||||
} else {
|
||||
fs.writeFileSync(filePath, contents, { mode: 0o600, flag: 'wx' });
|
||||
}
|
||||
return Object.freeze({
|
||||
path: filePath,
|
||||
expectedSelectionDigest: selectionDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function fixture(t, kind = 'systemd') {
|
||||
const managementRoot = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-deployment-')),
|
||||
@@ -36,11 +97,18 @@ function fixture(t, kind = 'systemd') {
|
||||
const applicationEntrypoint = fs.realpathSync(
|
||||
path.resolve(__dirname, '../../ql3-local-application/dist/cli.js'),
|
||||
);
|
||||
const composeImage = `ghcr.io/example/qinglong3-local-application@sha256:${'a'.repeat(
|
||||
64,
|
||||
)}`;
|
||||
const service =
|
||||
kind === 'compose'
|
||||
? {
|
||||
kind,
|
||||
image: `registry.example/qinglong3-local@sha256:${'a'.repeat(64)}`,
|
||||
releaseSelection: releaseSelectionForImage(
|
||||
managementRoot,
|
||||
composeImage,
|
||||
rootAcknowledgement(),
|
||||
),
|
||||
allowRootService: rootAcknowledgement(),
|
||||
}
|
||||
: {
|
||||
@@ -71,7 +139,13 @@ function fixture(t, kind = 'systemd') {
|
||||
fs.writeFileSync(commandFilePath, `${JSON.stringify(command)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
return { command, commandFilePath, deploymentRoot, managementRoot };
|
||||
return {
|
||||
command,
|
||||
commandFilePath,
|
||||
composeImage,
|
||||
deploymentRoot,
|
||||
managementRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function mode(filePath) {
|
||||
@@ -133,6 +207,18 @@ function legacyStopCommand(state, cutoverId = 'cutover-edge-1') {
|
||||
}
|
||||
|
||||
function composeRevisionCommand(state, operation, request) {
|
||||
let normalizedRequest = request;
|
||||
if (operation === 'local.deployment.compose.upgrade' && request.image) {
|
||||
const { image, ...rest } = request;
|
||||
normalizedRequest = {
|
||||
...rest,
|
||||
releaseSelection: releaseSelectionForImage(
|
||||
state.managementRoot,
|
||||
image,
|
||||
rootAcknowledgement(),
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
@@ -140,7 +226,7 @@ function composeRevisionCommand(state, operation, request) {
|
||||
deploymentRoot: state.deploymentRoot,
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
request,
|
||||
request: normalizedRequest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -250,7 +336,9 @@ async function createFailedRollbackRestoreState(state, suffix = '61') {
|
||||
await switchLocalDeploymentComposeRevision(
|
||||
composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
|
||||
expectedGeneration: 1,
|
||||
image: `registry.example/qinglong3-local@sha256:${'f'.repeat(64)}`,
|
||||
image: `ghcr.io/example/qinglong3-local-application@sha256:${'f'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
mutationId: `00000000-0000-4000-8000-000000000d${suffix}`,
|
||||
changedAtMs: 6_900,
|
||||
}),
|
||||
@@ -296,21 +384,41 @@ function composeSelection({
|
||||
mutationId,
|
||||
changedAtMs,
|
||||
image,
|
||||
releaseSelection,
|
||||
}) {
|
||||
const selected = JSON.parse(fs.readFileSync(releaseSelection.path, 'utf8'));
|
||||
return [
|
||||
'x-qinglong-image-selection:',
|
||||
' schema: qinglong/local-compose-image-selection@v1',
|
||||
' schema: qinglong/local-compose-image-selection@v2',
|
||||
` generation: ${generation}`,
|
||||
` previous_generation: ${previousGeneration}`,
|
||||
` rollback_target_generation: ${rollbackTargetGeneration}`,
|
||||
` mutation_id: ${mutationId}`,
|
||||
` changed_at_ms: ${changedAtMs}`,
|
||||
` release_selection_digest: ${selected.selectionDigest}`,
|
||||
` release_set_digest: ${selected.releaseSetDigest}`,
|
||||
` release_version: ${selected.release.version}`,
|
||||
` release_source_revision: ${selected.release.sourceRevision}`,
|
||||
` release_source_ref: ${selected.release.sourceRef}`,
|
||||
` release_scope: ${selected.release.scope}`,
|
||||
` catalog_schema: ${selected.catalog.schema}`,
|
||||
` catalog_source_repository: ${selected.catalog.sourceRepository}`,
|
||||
` catalog_workflow_identity: ${selected.catalog.workflowIdentity}`,
|
||||
` catalog_immutable_reference: ${selected.catalog.immutableReference}`,
|
||||
` catalog_manifest_digest: ${selected.catalog.manifestDigest}`,
|
||||
` catalog_consumption_report_digest: ${selected.catalog.consumptionReportDigest}`,
|
||||
` catalog_discovery_tag_authority: ${selected.catalog.discoveryTagAuthority}`,
|
||||
` allow_root_service: ${selected.service.allowRootService}`,
|
||||
'services:',
|
||||
' qinglong3:',
|
||||
` image: ${image}`,
|
||||
' labels:',
|
||||
` io.qinglong.deployment.generation: "${generation}"`,
|
||||
` io.qinglong.deployment.mutation: "${mutationId}"`,
|
||||
` io.qinglong.release.selection: "${selected.selectionDigest}"`,
|
||||
` io.qinglong.release.set: "${selected.releaseSetDigest}"`,
|
||||
` io.qinglong.release.catalog-manifest: "${selected.catalog.manifestDigest}"`,
|
||||
` io.qinglong.release.catalog-report: "${selected.catalog.consumptionReportDigest}"`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -784,7 +892,9 @@ test('observes Compose generation and recovery fences with low constant work', a
|
||||
await switchLocalDeploymentComposeRevision(
|
||||
composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
|
||||
expectedGeneration: 1,
|
||||
image: `registry.example/qinglong3-local@sha256:${'b'.repeat(64)}`,
|
||||
image: `ghcr.io/example/qinglong3-local-application@sha256:${'b'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
mutationId: '00000000-0000-4000-8000-000000000d31',
|
||||
changedAtMs: 6_000,
|
||||
}),
|
||||
@@ -919,6 +1029,18 @@ test('renders bounded OpenRC and immutable rootless Compose descriptors', async
|
||||
fs.readFileSync(selectionPath, 'utf8'),
|
||||
/^ image: .*@sha256:[a-f0-9]{64}$/m,
|
||||
);
|
||||
assert.match(
|
||||
fs.readFileSync(selectionPath, 'utf8'),
|
||||
/^ schema: qinglong\/local-compose-image-selection@v2$/m,
|
||||
);
|
||||
assert.match(
|
||||
fs.readFileSync(selectionPath, 'utf8'),
|
||||
/^ catalog_immutable_reference: ghcr\.io\/example\/qinglong3-release-catalog@sha256:[a-f0-9]{64}$/m,
|
||||
);
|
||||
assert.match(
|
||||
fs.readFileSync(selectionPath, 'utf8'),
|
||||
/^ release_selection_digest: sha256:[a-f0-9]{64}$/m,
|
||||
);
|
||||
const config = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(compose.deploymentRoot, 'local-application.json'),
|
||||
@@ -935,11 +1057,82 @@ test('renders bounded OpenRC and immutable rootless Compose descriptors', async
|
||||
);
|
||||
});
|
||||
|
||||
test('fails before deployment mutation on unbound or drifted release selections', async (t) => {
|
||||
const state = fixture(t, 'compose');
|
||||
const selectionPath = state.command.options.service.releaseSelection.path;
|
||||
await assert.rejects(
|
||||
prepareLocalDeployment({
|
||||
...state.command,
|
||||
options: {
|
||||
...state.command.options,
|
||||
service: {
|
||||
kind: 'compose',
|
||||
image: state.composeImage,
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
LocalDeploymentConfigurationError,
|
||||
);
|
||||
assert.equal(fs.existsSync(state.deploymentRoot), false);
|
||||
|
||||
await assert.rejects(
|
||||
prepareLocalDeployment({
|
||||
...state.command,
|
||||
options: {
|
||||
...state.command.options,
|
||||
service: {
|
||||
...state.command.options.service,
|
||||
releaseSelection: {
|
||||
path: selectionPath,
|
||||
expectedSelectionDigest: `sha256:${'f'.repeat(64)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
LocalDeploymentConfigurationError,
|
||||
);
|
||||
assert.equal(fs.existsSync(state.deploymentRoot), false);
|
||||
|
||||
fs.chmodSync(selectionPath, 0o644);
|
||||
await assert.rejects(
|
||||
prepareLocalDeployment(state.command),
|
||||
LocalDeploymentConfigurationError,
|
||||
);
|
||||
assert.equal(fs.existsSync(state.deploymentRoot), false);
|
||||
|
||||
fs.chmodSync(selectionPath, 0o600);
|
||||
const drifted = JSON.parse(fs.readFileSync(selectionPath, 'utf8'));
|
||||
drifted.catalog.releaseSetDigest = `sha256:${'e'.repeat(64)}`;
|
||||
delete drifted.selectionDigest;
|
||||
drifted.selectionDigest = sha256(JSON.stringify(drifted));
|
||||
fs.writeFileSync(selectionPath, `${JSON.stringify(drifted)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
await assert.rejects(
|
||||
prepareLocalDeployment({
|
||||
...state.command,
|
||||
options: {
|
||||
...state.command.options,
|
||||
service: {
|
||||
...state.command.options.service,
|
||||
releaseSelection: {
|
||||
path: selectionPath,
|
||||
expectedSelectionDigest: drifted.selectionDigest,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
LocalDeploymentConfigurationError,
|
||||
);
|
||||
assert.equal(fs.existsSync(state.deploymentRoot), false);
|
||||
});
|
||||
|
||||
test('preflights exact local image, Compose merge and SQLite capability', async (t) => {
|
||||
const state = fixture(t, 'compose');
|
||||
await prepareLocalDeployment(state.command);
|
||||
const dockerSocketPath = path.join(state.managementRoot, 'docker.sock');
|
||||
const image = state.command.options.service.image;
|
||||
const image = state.composeImage;
|
||||
const composeSource = fs.readFileSync(
|
||||
path.join(state.deploymentRoot, 'service', 'compose.yaml'),
|
||||
'utf8',
|
||||
@@ -1071,7 +1264,7 @@ test('Compose preflight rejects unproven image compatibility', async (t) => {
|
||||
const incompatibleImage = JSON.stringify([
|
||||
{
|
||||
Id: `sha256:${'1'.repeat(64)}`,
|
||||
RepoDigests: [state.command.options.service.image],
|
||||
RepoDigests: [state.composeImage],
|
||||
Architecture: 'amd64',
|
||||
Os: 'linux',
|
||||
Config: {
|
||||
@@ -1201,7 +1394,7 @@ test('explicitly collects the oldest Compose backup and preserves exact rollout
|
||||
await switchLocalDeploymentComposeRevision(
|
||||
composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
|
||||
expectedGeneration: generation - 1,
|
||||
image: `registry.example/qinglong3-local@sha256:${imageCharacter.repeat(
|
||||
image: `ghcr.io/example/qinglong3-local-application@sha256:${imageCharacter.repeat(
|
||||
64,
|
||||
)}`,
|
||||
mutationId: `00000000-0000-4000-8000-000000000d${generation}0`,
|
||||
@@ -1260,7 +1453,9 @@ test('explicitly collects the oldest Compose backup and preserves exact rollout
|
||||
switchLocalDeploymentComposeRevision(
|
||||
composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
|
||||
expectedGeneration: 4,
|
||||
image: `registry.example/qinglong3-local@sha256:${'e'.repeat(64)}`,
|
||||
image: `ghcr.io/example/qinglong3-local-application@sha256:${'e'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
mutationId: '00000000-0000-4000-8000-000000000d45',
|
||||
changedAtMs: 7_200,
|
||||
}),
|
||||
@@ -1377,7 +1572,9 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async
|
||||
await switchLocalDeploymentComposeRevision(
|
||||
composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
|
||||
expectedGeneration: 1,
|
||||
image: `registry.example/qinglong3-local@sha256:${'b'.repeat(64)}`,
|
||||
image: `ghcr.io/example/qinglong3-local-application@sha256:${'b'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
mutationId: '00000000-0000-4000-8000-000000000d52',
|
||||
changedAtMs: 6_900,
|
||||
}),
|
||||
@@ -1402,7 +1599,7 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async
|
||||
assert.match(selection, /^ rollback_target_generation: 1$/m);
|
||||
assert.match(
|
||||
selection,
|
||||
new RegExp(`^ image: ${state.command.options.service.image}$`, 'm'),
|
||||
new RegExp(`^ image: ${state.composeImage}$`, 'm'),
|
||||
);
|
||||
assert.equal(
|
||||
harness.calls.filter((args) => args[0] === 'compose' && args.includes('up'))
|
||||
@@ -1440,7 +1637,9 @@ test('records an unhealthy candidate observation failure as recovery unknown', a
|
||||
await switchLocalDeploymentComposeRevision(
|
||||
composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
|
||||
expectedGeneration: 1,
|
||||
image: `registry.example/qinglong3-local@sha256:${'b'.repeat(64)}`,
|
||||
image: `ghcr.io/example/qinglong3-local-application@sha256:${'b'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
mutationId: '00000000-0000-4000-8000-000000000d65',
|
||||
changedAtMs: 6_900,
|
||||
}),
|
||||
@@ -1482,7 +1681,9 @@ test('resumes a rollback generation after response loss without restarting the f
|
||||
await switchLocalDeploymentComposeRevision(
|
||||
composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
|
||||
expectedGeneration: 1,
|
||||
image: `registry.example/qinglong3-local@sha256:${'c'.repeat(64)}`,
|
||||
image: `ghcr.io/example/qinglong3-local-application@sha256:${'c'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
mutationId: '00000000-0000-4000-8000-000000000d55',
|
||||
changedAtMs: 6_950,
|
||||
}),
|
||||
@@ -1869,7 +2070,9 @@ test('fails closed before Compose up when the rollout backup cannot be created',
|
||||
await switchLocalDeploymentComposeRevision(
|
||||
composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
|
||||
expectedGeneration: 1,
|
||||
image: `registry.example/qinglong3-local@sha256:${'d'.repeat(64)}`,
|
||||
image: `ghcr.io/example/qinglong3-local-application@sha256:${'d'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
mutationId: '00000000-0000-4000-8000-000000000d57',
|
||||
changedAtMs: 6_975,
|
||||
}),
|
||||
@@ -1939,7 +2142,7 @@ test('upgrades and rolls back Compose image selections with generation CAS', asy
|
||||
);
|
||||
const revisions = path.join(state.deploymentRoot, 'service', 'revisions');
|
||||
const stableDescriptor = fs.readFileSync(composePath, 'utf8');
|
||||
const upgradedImage = `registry.example/qinglong3-local@sha256:${'b'.repeat(
|
||||
const upgradedImage = `ghcr.io/example/qinglong3-local-application@sha256:${'b'.repeat(
|
||||
64,
|
||||
)}`;
|
||||
const upgrade = composeRevisionCommand(
|
||||
@@ -1983,10 +2186,7 @@ test('upgrades and rolls back Compose image selections with generation CAS', asy
|
||||
const active = fs.readFileSync(selectionPath, 'utf8');
|
||||
assert.match(active, /^ generation: 3$/m);
|
||||
assert.match(active, /^ rollback_target_generation: 1$/m);
|
||||
assert.match(
|
||||
active,
|
||||
new RegExp(`^ image: ${state.command.options.service.image}$`, 'm'),
|
||||
);
|
||||
assert.match(active, new RegExp(`^ image: ${state.composeImage}$`, 'm'));
|
||||
assert.equal(
|
||||
fs
|
||||
.readFileSync(path.join(revisions, '2.yaml'), 'utf8')
|
||||
@@ -2028,7 +2228,9 @@ test('upgrades and rolls back Compose image selections with generation CAS', asy
|
||||
test('recovers Compose response-loss and deterministic stage windows', async (t) => {
|
||||
const state = fixture(t, 'compose');
|
||||
await prepareLocalDeployment(state.command);
|
||||
const image = `registry.example/qinglong3-local@sha256:${'c'.repeat(64)}`;
|
||||
const image = `ghcr.io/example/qinglong3-local-application@sha256:${'c'.repeat(
|
||||
64,
|
||||
)}`;
|
||||
const mutationId = '00000000-0000-4000-8000-000000000d21';
|
||||
const command = composeRevisionCommand(
|
||||
state,
|
||||
@@ -2053,6 +2255,7 @@ test('recovers Compose response-loss and deterministic stage windows', async (t)
|
||||
mutationId,
|
||||
changedAtMs: 4_000,
|
||||
image,
|
||||
releaseSelection: command.request.releaseSelection,
|
||||
});
|
||||
fs.writeFileSync(selectionStagePath, next, { mode: 0o600 });
|
||||
const recoveredStage = await switchLocalDeploymentComposeRevision(command);
|
||||
@@ -2109,7 +2312,9 @@ test('fails closed on Compose revision drift and an unrelated in-flight lock', a
|
||||
switchLocalDeploymentComposeRevision(
|
||||
composeRevisionCommand(clean, 'local.deployment.compose.upgrade', {
|
||||
expectedGeneration: 1,
|
||||
image: `registry.example/qinglong3-local@sha256:${'d'.repeat(64)}`,
|
||||
image: `ghcr.io/example/qinglong3-local-application@sha256:${'d'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
mutationId: '00000000-0000-4000-8000-000000000d32',
|
||||
changedAtMs: 5_001,
|
||||
}),
|
||||
@@ -2131,7 +2336,9 @@ test('fails closed on Compose revision drift and an unrelated in-flight lock', a
|
||||
'local.deployment.compose.upgrade',
|
||||
{
|
||||
expectedGeneration: 1,
|
||||
image: `registry.example/qinglong3-local@sha256:${'f'.repeat(64)}`,
|
||||
image: `ghcr.io/example/qinglong3-local-application@sha256:${'f'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
mutationId: '00000000-0000-4000-8000-000000000d57',
|
||||
changedAtMs: 5_002,
|
||||
},
|
||||
@@ -2182,7 +2389,9 @@ test('Compose revision CLI emits only a low-sensitive generation result', async
|
||||
'local.deployment.compose.upgrade',
|
||||
{
|
||||
expectedGeneration: 1,
|
||||
image: `registry.example/qinglong3-local@sha256:${'e'.repeat(64)}`,
|
||||
image: `ghcr.io/example/qinglong3-local-application@sha256:${'e'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
mutationId: '00000000-0000-4000-8000-000000000d41',
|
||||
changedAtMs: 6_000,
|
||||
},
|
||||
@@ -2233,7 +2442,11 @@ test('rejects drift, widening, mutable images and unacknowledged root', async (t
|
||||
...mutable.command.options,
|
||||
service: {
|
||||
...mutable.command.options.service,
|
||||
image: 'registry.example/qinglong3-local:latest',
|
||||
releaseSelection: releaseSelectionForImage(
|
||||
mutable.managementRoot,
|
||||
'ghcr.io/example/qinglong3-local-application:latest',
|
||||
rootAcknowledgement(),
|
||||
),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user