feat(ql3): bind local compose revisions to release catalogs

This commit is contained in:
whyour
2026-08-16 16:02:53 +08:00
parent 2ccdd71851
commit dce72c800f
17 changed files with 973 additions and 130 deletions
@@ -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,