From 190117a66298cffc5817bf29331a21e83581a638 Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 21 Aug 2026 14:09:59 +0800 Subject: [PATCH] feat(ql3): prepare adopted deployment bundles --- .../adopted-bundle/adoptedBundle.ts | 438 ++++++++++ .../src/deployment/adopted-bundle/contract.ts | 443 ++++++++++ .../src/deployment/adopted-bundle/material.ts | 821 ++++++++++++++++++ .../src/deployment/compose/composeRevision.ts | 19 +- .../src/deployment/localDeployment.ts | 11 + .../src/deployment/localDeploymentCli.ts | 12 +- .../test/adoptedDeploymentBundle.test.cjs | 439 ++++++++++ scripts/ql3-cluster-dependency-audit.cjs | 9 + test/back/ql3ClusterDependencyAudit.test.cjs | 41 + test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- 10 files changed, 2231 insertions(+), 6 deletions(-) create mode 100644 packages/ql3-local-owner-cli/src/deployment/adopted-bundle/adoptedBundle.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/adopted-bundle/contract.ts create mode 100644 packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts create mode 100644 packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs diff --git a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/adoptedBundle.ts b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/adoptedBundle.ts new file mode 100644 index 00000000..503f6b5e --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/adoptedBundle.ts @@ -0,0 +1,438 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readPrivateLocalCommandFile } from '@qinglong/local-command-file'; + +import { currentIdentity } from '../foundation/contract'; +import { LocalDeploymentConfigurationError } from '../foundation/error'; +import { + ensurePrivateDirectory, + preflightPublishedFile, + publishExactFile, + validatePrivateDirectory, +} from '../foundation/files'; +import { + normalizeLocalDeploymentAdoptedBundleCommand, + type LocalDeploymentAdoptedBundleCommand, + type LocalDeploymentAdoptedBundleOperation, +} from './contract'; +import { + renderLocalDeploymentAdoptedBundleMaterial, + verifyLocalDeploymentAdoptedEvidence, +} from './material'; + +export interface LocalDeploymentAdoptedBundleResult { + readonly schemaVersion: 1; + readonly operation: LocalDeploymentAdoptedBundleOperation; + readonly status: 'prepared' | 'existing' | 'verified'; + readonly bundleId: string; + readonly bundleDigest: string; + readonly profile: 'edge' | 'standalone'; + readonly service: Readonly<{ + kind: 'systemd' | 'openrc' | 'compose'; + status: 'prepared' | 'existing' | 'verified'; + }>; + readonly applicationConfiguration: Readonly<{ + schema: 'qinglong/local-application-process@v4'; + status: 'prepared' | 'existing' | 'verified'; + }>; + readonly directories: Readonly<{ + created: number; + existing: number; + }>; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError(message, { cause }); +} + +function validatePrivateFile( + filePath: string, + uid: number, + gid: number, + label: string, +): void { + try { + const stat = fs.lstatSync(filePath); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.uid !== uid || + stat.gid !== gid || + (stat.mode & 0o777) !== 0o600 || + stat.nlink !== 1 || + stat.size < 2 || + fs.realpathSync(filePath) !== filePath + ) { + configurationError(`${label} identity is invalid`); + } + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + configurationError(`${label} is unavailable`, error); + } +} + +function stagePath(targetPath: string): string { + return path.join( + path.dirname(targetPath), + `.${path.basename(targetPath)}.ql3-deploy-stage`, + ); +} + +function verifyPublishedFile( + filePath: string, + expected: string, + mode: number, + uid: number, + gid: number, + label: string, +): void { + try { + const stat = fs.lstatSync(filePath); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.uid !== uid || + stat.gid !== gid || + (stat.mode & 0o777) !== mode || + stat.nlink !== 1 || + stat.size !== Buffer.byteLength(expected, 'utf8') || + fs.realpathSync(filePath) !== filePath || + fs.existsSync(stagePath(filePath)) || + fs.readFileSync(filePath, 'utf8') !== expected + ) { + configurationError(`${label} terminal material drifted`); + } + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + configurationError(`${label} cannot be verified`, error); + } +} + +function rejectAlternateDescriptors( + selectedPath: string, + serviceRoot: string, +): void { + for (const fileName of [ + 'qinglong3.service', + 'qinglong3.openrc', + 'compose.yaml', + ]) { + const candidate = path.join(serviceRoot, fileName); + if (candidate !== selectedPath && fs.existsSync(candidate)) { + configurationError('an alternate service descriptor already exists'); + } + } +} + +function validateExistingPrerequisites( + root: string, + serviceRoot: string, + ownerPepperKeyring: string, + ownerPepperBackup: string, + secretKeyring: string, + commitmentRoot: string, + uid: number, + gid: number, +): void { + validatePrivateDirectory(root, uid, 'deploymentRoot'); + validatePrivateDirectory( + ownerPepperKeyring, + uid, + 'ownerPepperKeyringDirectory', + ); + validatePrivateDirectory( + ownerPepperBackup, + uid, + 'ownerPepperBackupDirectory', + ); + validatePrivateDirectory(serviceRoot, uid, 'serviceDescriptorRoot'); + validatePrivateDirectory( + path.join(serviceRoot, 'cutovers'), + uid, + 'cutoverRoot', + ); + validatePrivateDirectory(commitmentRoot, uid, 'cutoverJournalRoot'); + validatePrivateFile(secretKeyring, uid, gid, 'localSecretKeyring'); +} + +export function prepareLocalDeploymentAdoptedBundle( + input: unknown, +): Readonly { + const command = normalizeLocalDeploymentAdoptedBundleCommand( + input, + 'local.deployment.adopted.prepare', + ); + const identity = currentIdentity(); + const evidence = verifyLocalDeploymentAdoptedEvidence( + command, + identity.uid, + identity.gid, + ); + const material = renderLocalDeploymentAdoptedBundleMaterial( + command, + evidence, + identity.uid, + identity.gid, + ); + validateExistingPrerequisites( + command.options.deploymentRoot, + material.paths.service, + material.paths.ownerPepperKeyring, + material.paths.ownerPepperBackup, + material.paths.secretKeyring, + path.dirname(command.request.cutover.commitmentPath), + identity.uid, + identity.gid, + ); + rejectAlternateDescriptors(material.paths.descriptor, material.paths.service); + if ( + command.options.service.kind !== 'compose' && + (fs.existsSync(material.paths.composeSelection) || + fs.existsSync(material.paths.composeRevisions)) + ) { + configurationError('process service cannot inherit Compose material'); + } + preflightPublishedFile( + material.paths.applicationConfig, + material.applicationConfig, + 0o600, + identity.uid, + 'adopted application configuration', + ); + preflightPublishedFile( + material.paths.descriptor, + material.descriptor.contents, + material.descriptor.mode, + identity.uid, + 'adopted service descriptor', + ); + preflightPublishedFile( + material.paths.bundleReceipt, + material.receiptContents, + 0o600, + identity.uid, + 'adopted bundle receipt', + ); + if (material.composeSelection !== null) { + preflightPublishedFile( + material.paths.composeRevision, + material.composeSelection, + 0o600, + identity.uid, + 'initial adopted compose revision', + ); + preflightPublishedFile( + material.paths.composeSelection, + material.composeSelection, + 0o600, + identity.uid, + 'active adopted compose selection', + ); + } + const directoryPaths = [ + [material.paths.receipts, 'receiptRoot'], + [material.paths.artifacts, 'artifactRoot'], + [material.paths.pluginStaging, 'pluginStagingRoot'], + [material.paths.pluginActivation, 'pluginActivationRoot'], + ...(material.composeSelection === null + ? [] + : ([[material.paths.composeRevisions, 'composeRevisionRoot']] as const)), + ] as const; + const directoryStatuses = directoryPaths.map(([directory, label]) => + ensurePrivateDirectory(directory, identity.uid, label), + ); + const applicationStatus = publishExactFile( + material.paths.applicationConfig, + material.applicationConfig, + 0o600, + identity.uid, + 'adopted application configuration', + ); + const serviceStatus = publishExactFile( + material.paths.descriptor, + material.descriptor.contents, + material.descriptor.mode, + identity.uid, + 'adopted service descriptor', + ); + const composeRevisionStatus = + material.composeSelection === null + ? 'existing' + : publishExactFile( + material.paths.composeRevision, + material.composeSelection, + 0o600, + identity.uid, + 'initial adopted compose revision', + ); + const composeSelectionStatus = + material.composeSelection === null + ? 'existing' + : publishExactFile( + material.paths.composeSelection, + material.composeSelection, + 0o600, + identity.uid, + 'active adopted compose selection', + ); + const receiptStatus = publishExactFile( + material.paths.bundleReceipt, + material.receiptContents, + 0o600, + identity.uid, + 'adopted bundle receipt', + ); + const createdDirectories = directoryStatuses.filter( + (status) => status === 'prepared', + ).length; + const prepared = + createdDirectories > 0 || + applicationStatus === 'prepared' || + serviceStatus === 'prepared' || + composeRevisionStatus === 'prepared' || + composeSelectionStatus === 'prepared' || + receiptStatus === 'prepared'; + return Object.freeze({ + schemaVersion: 1 as const, + operation: 'local.deployment.adopted.prepare' as const, + status: prepared ? ('prepared' as const) : ('existing' as const), + bundleId: command.request.bundleId, + bundleDigest: material.receipt.bundleDigest, + profile: command.options.profile, + service: Object.freeze({ + kind: command.options.service.kind, + status: serviceStatus, + }), + applicationConfiguration: Object.freeze({ + schema: 'qinglong/local-application-process@v4' as const, + status: applicationStatus, + }), + directories: Object.freeze({ + created: createdDirectories, + existing: directoryStatuses.length - createdDirectories, + }), + }); +} + +export function verifyLocalDeploymentAdoptedBundle( + input: unknown, +): Readonly { + const command = normalizeLocalDeploymentAdoptedBundleCommand( + input, + 'local.deployment.adopted.verify', + ); + const identity = currentIdentity(); + const evidence = verifyLocalDeploymentAdoptedEvidence( + command, + identity.uid, + identity.gid, + ); + const material = renderLocalDeploymentAdoptedBundleMaterial( + command, + evidence, + identity.uid, + identity.gid, + ); + validateExistingPrerequisites( + command.options.deploymentRoot, + material.paths.service, + material.paths.ownerPepperKeyring, + material.paths.ownerPepperBackup, + material.paths.secretKeyring, + path.dirname(command.request.cutover.commitmentPath), + identity.uid, + identity.gid, + ); + rejectAlternateDescriptors(material.paths.descriptor, material.paths.service); + for (const [directory, label] of [ + [material.paths.receipts, 'receiptRoot'], + [material.paths.artifacts, 'artifactRoot'], + [material.paths.pluginStaging, 'pluginStagingRoot'], + [material.paths.pluginActivation, 'pluginActivationRoot'], + ...(material.composeSelection === null + ? [] + : ([[material.paths.composeRevisions, 'composeRevisionRoot']] as const)), + ] as const) { + validatePrivateDirectory(directory, identity.uid, label); + } + verifyPublishedFile( + material.paths.applicationConfig, + material.applicationConfig, + 0o600, + identity.uid, + identity.gid, + 'adopted application configuration', + ); + verifyPublishedFile( + material.paths.descriptor, + material.descriptor.contents, + material.descriptor.mode, + identity.uid, + identity.gid, + 'adopted service descriptor', + ); + verifyPublishedFile( + material.paths.bundleReceipt, + material.receiptContents, + 0o600, + identity.uid, + identity.gid, + 'adopted bundle receipt', + ); + if (material.composeSelection !== null) { + verifyPublishedFile( + material.paths.composeRevision, + material.composeSelection, + 0o600, + identity.uid, + identity.gid, + 'initial adopted compose revision', + ); + verifyPublishedFile( + material.paths.composeSelection, + material.composeSelection, + 0o600, + identity.uid, + identity.gid, + 'active adopted compose selection', + ); + } else if ( + fs.existsSync(material.paths.composeSelection) || + fs.existsSync(material.paths.composeRevisions) + ) { + configurationError('process service cannot inherit Compose material'); + } + return Object.freeze({ + schemaVersion: 1 as const, + operation: 'local.deployment.adopted.verify' as const, + status: 'verified' as const, + bundleId: command.request.bundleId, + bundleDigest: material.receipt.bundleDigest, + profile: command.options.profile, + service: Object.freeze({ + kind: command.options.service.kind, + status: 'verified' as const, + }), + applicationConfiguration: Object.freeze({ + schema: 'qinglong/local-application-process@v4' as const, + status: 'verified' as const, + }), + directories: Object.freeze({ + created: 0, + existing: material.composeSelection === null ? 4 : 5, + }), + }); +} + +export function runLocalDeploymentAdoptedBundleCommandFile( + filePath: string, + operation: LocalDeploymentAdoptedBundleOperation, +): Readonly { + const input = readPrivateLocalCommandFile(filePath); + return operation === 'local.deployment.adopted.prepare' + ? prepareLocalDeploymentAdoptedBundle(input) + : verifyLocalDeploymentAdoptedBundle(input); +} + +export type { LocalDeploymentAdoptedBundleCommand }; diff --git a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/contract.ts b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/contract.ts new file mode 100644 index 00000000..dce531af --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/contract.ts @@ -0,0 +1,443 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + LocalComposeReleaseSelectionError, + resolveLocalComposeReleaseSelection, + type LocalComposeReleaseSelectionInput, + type ResolvedLocalComposeReleaseSelection, +} from '../compose/releaseSelection'; +import { + currentIdentity, + type LocalDeploymentProcessService, + type LocalDeploymentProfile, +} from '../foundation/contract'; +import { LocalDeploymentConfigurationError } from '../foundation/error'; + +const MAX_PATH_BYTES = 4_096; +const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/; +const INSTANCE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +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 type LocalDeploymentAdoptedBundleOperation = + | 'local.deployment.adopted.prepare' + | 'local.deployment.adopted.verify'; + +export interface LocalDeploymentAdoptedComposeService { + readonly kind: 'compose'; + readonly releaseSelection: Readonly; + readonly allowRootService: boolean; +} + +export interface NormalizedLocalDeploymentAdoptedComposeService + extends Omit { + readonly releaseSelection: Readonly; +} + +export type LocalDeploymentAdoptedService = + | LocalDeploymentProcessService + | LocalDeploymentAdoptedComposeService; + +export type NormalizedLocalDeploymentAdoptedService = + | Readonly + | Readonly; + +export interface LocalDeploymentAdoptedBundleCommand { + readonly schemaVersion: 1; + readonly operation: LocalDeploymentAdoptedBundleOperation; + readonly options: Readonly<{ + deploymentRoot: string; + profile: LocalDeploymentProfile; + instanceId: string; + busyTimeoutMs?: number; + service: Readonly; + }>; + readonly request: Readonly<{ + bundleId: string; + preparedAtMs: number; + cutoverId: string; + storage: Readonly<{ + sourcePath: string; + targetPath: string; + recoveryPath: string; + manifestPath: string; + activationPath: string; + expectedActivationDigest: string; + }>; + cutover: Readonly<{ + commitmentPath: string; + expectedCommitmentDigest: string; + }>; + legacyDataApplication: Readonly<{ + commitPath: string; + expectedCommitDigest: string; + expectedReceiptDigest: string; + }>; + }>; +} + +export interface NormalizedLocalDeploymentAdoptedBundleCommand + extends Omit { + readonly options: Omit< + LocalDeploymentAdoptedBundleCommand['options'], + 'service' + > & + Readonly<{ service: NormalizedLocalDeploymentAdoptedService }>; +} + +function object(value: unknown, label: string): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + throw new LocalDeploymentConfigurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + throw new LocalDeploymentConfigurationError(`${label} shape is invalid`); + } +} + +function safeAbsolutePath(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + !path.isAbsolute(value) || + path.normalize(value) !== value || + path.parse(value).root === value || + value.includes('\0') || + value.includes('//') || + !SAFE_PATH_PATTERN.test(value) || + Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES + ) { + throw new LocalDeploymentConfigurationError( + `${label} must be a supervisor-safe absolute path`, + ); + } + return value; +} + +function strictDescendant(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative.length > 0 && + !relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative) + ); +} + +function validateRootAcknowledgement(value: unknown, uid: number): boolean { + if (typeof value !== 'boolean' || (uid === 0) !== value) { + throw new LocalDeploymentConfigurationError( + uid === 0 + ? 'root execution requires explicit allowRootService=true' + : 'allowRootService must be false for a non-root service identity', + ); + } + return value; +} + +function trustedFile( + value: unknown, + label: string, + executable: boolean, + uid: number, +): string { + const filePath = safeAbsolutePath(value, label); + let stat: fs.Stats; + try { + stat = fs.lstatSync(filePath); + } catch (error) { + throw new LocalDeploymentConfigurationError(`${label} is unavailable`, { + cause: error, + }); + } + if ( + !stat.isFile() || + stat.isSymbolicLink() || + fs.realpathSync(filePath) !== filePath || + (stat.uid !== 0 && stat.uid !== uid) || + (stat.mode & 0o022) !== 0 || + (executable && (stat.mode & 0o111) === 0) + ) { + throw new LocalDeploymentConfigurationError( + `${label} must be a canonical trusted regular file`, + ); + } + return filePath; +} + +function normalizeService( + value: unknown, + uid: number, +): NormalizedLocalDeploymentAdoptedService { + const service = object(value, 'service'); + if (service.kind === 'systemd' || service.kind === 'openrc') { + exact( + service, + ['allowRootService', 'applicationEntrypoint', 'kind', 'nodeExecutable'], + 'service', + ); + return Object.freeze({ + kind: service.kind, + nodeExecutable: trustedFile( + service.nodeExecutable, + 'nodeExecutable', + true, + uid, + ), + applicationEntrypoint: trustedFile( + service.applicationEntrypoint, + 'applicationEntrypoint', + false, + uid, + ), + allowRootService: validateRootAcknowledgement( + service.allowRootService, + uid, + ), + }); + } + if (service.kind !== 'compose') { + throw new LocalDeploymentConfigurationError('service kind is invalid'); + } + exact(service, ['allowRootService', 'kind', 'releaseSelection'], 'service'); + const allowRootService = validateRootAcknowledgement( + service.allowRootService, + uid, + ); + const releaseSelection = object(service.releaseSelection, 'releaseSelection'); + exact( + releaseSelection, + ['expectedSelectionDigest', 'path'], + 'releaseSelection', + ); + try { + return Object.freeze({ + kind: 'compose' as const, + allowRootService, + releaseSelection: 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; + } +} + +function digest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new LocalDeploymentConfigurationError(`${label} is invalid`); + } + return value; +} + +export function normalizeLocalDeploymentAdoptedBundleCommand( + value: unknown, + expectedOperation?: LocalDeploymentAdoptedBundleOperation, +): Readonly { + const command = object(value, 'adopted deployment bundle command'); + exact( + command, + ['operation', 'options', 'request', 'schemaVersion'], + 'command', + ); + if ( + command.schemaVersion !== 1 || + (command.operation !== 'local.deployment.adopted.prepare' && + command.operation !== 'local.deployment.adopted.verify') || + (expectedOperation !== undefined && command.operation !== expectedOperation) + ) { + throw new LocalDeploymentConfigurationError( + 'schemaVersion or operation is invalid', + ); + } + const options = object(command.options, 'options'); + const optionalKeys = Object.hasOwn(options, 'busyTimeoutMs') + ? ['busyTimeoutMs'] + : []; + exact( + options, + ['deploymentRoot', 'instanceId', 'profile', 'service', ...optionalKeys], + 'options', + ); + if ( + (options.profile !== 'edge' && options.profile !== 'standalone') || + typeof options.instanceId !== 'string' || + !INSTANCE_ID_PATTERN.test(options.instanceId) + ) { + throw new LocalDeploymentConfigurationError( + 'deployment identity is invalid', + ); + } + const deploymentRoot = safeAbsolutePath( + options.deploymentRoot, + 'deploymentRoot', + ); + const busyTimeoutMs = options.busyTimeoutMs; + if ( + busyTimeoutMs !== undefined && + (!Number.isSafeInteger(busyTimeoutMs) || + (busyTimeoutMs as number) < 100 || + (busyTimeoutMs as number) > 30_000) + ) { + throw new LocalDeploymentConfigurationError('busyTimeoutMs is invalid'); + } + const normalizedBusyTimeoutMs = + busyTimeoutMs === undefined ? undefined : (busyTimeoutMs as number); + const request = object(command.request, 'request'); + exact( + request, + [ + 'bundleId', + 'cutover', + 'cutoverId', + 'legacyDataApplication', + 'preparedAtMs', + 'storage', + ], + 'request', + ); + if ( + typeof request.bundleId !== 'string' || + !UUID_V4_PATTERN.test(request.bundleId) || + !Number.isSafeInteger(request.preparedAtMs) || + (request.preparedAtMs as number) < 0 || + typeof request.cutoverId !== 'string' || + !INSTANCE_ID_PATTERN.test(request.cutoverId) + ) { + throw new LocalDeploymentConfigurationError('request identity is invalid'); + } + const storage = object(request.storage, 'storage'); + exact( + storage, + [ + 'activationPath', + 'expectedActivationDigest', + 'manifestPath', + 'recoveryPath', + 'sourcePath', + 'targetPath', + ], + 'storage', + ); + const cutover = object(request.cutover, 'cutover'); + exact(cutover, ['commitmentPath', 'expectedCommitmentDigest'], 'cutover'); + const dataApplication = object( + request.legacyDataApplication, + 'legacyDataApplication', + ); + exact( + dataApplication, + ['commitPath', 'expectedCommitDigest', 'expectedReceiptDigest'], + 'legacyDataApplication', + ); + const sourcePath = safeAbsolutePath(storage.sourcePath, 'sourcePath'); + const authorityPaths = { + targetPath: safeAbsolutePath(storage.targetPath, 'targetPath'), + recoveryPath: safeAbsolutePath(storage.recoveryPath, 'recoveryPath'), + manifestPath: safeAbsolutePath(storage.manifestPath, 'manifestPath'), + activationPath: safeAbsolutePath(storage.activationPath, 'activationPath'), + commitmentPath: safeAbsolutePath(cutover.commitmentPath, 'commitmentPath'), + commitPath: safeAbsolutePath(dataApplication.commitPath, 'commitPath'), + }; + if ( + strictDescendant(deploymentRoot, sourcePath) || + Object.values(authorityPaths).some( + (candidate) => !strictDescendant(deploymentRoot, candidate), + ) || + new Set([sourcePath, ...Object.values(authorityPaths)]).size !== 7 || + authorityPaths.commitmentPath !== + path.join( + deploymentRoot, + 'service', + 'cutovers', + request.cutoverId, + '0002-legacy-stopped.json', + ) + ) { + throw new LocalDeploymentConfigurationError( + 'adopted authority path boundary is invalid', + ); + } + const identity = currentIdentity(); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + options: Object.freeze({ + deploymentRoot, + profile: options.profile, + instanceId: options.instanceId, + ...(normalizedBusyTimeoutMs === undefined + ? {} + : { busyTimeoutMs: normalizedBusyTimeoutMs }), + service: normalizeService(options.service, identity.uid), + }), + request: Object.freeze({ + bundleId: request.bundleId, + preparedAtMs: request.preparedAtMs as number, + cutoverId: request.cutoverId, + storage: Object.freeze({ + sourcePath, + targetPath: authorityPaths.targetPath, + recoveryPath: authorityPaths.recoveryPath, + manifestPath: authorityPaths.manifestPath, + activationPath: authorityPaths.activationPath, + expectedActivationDigest: digest( + storage.expectedActivationDigest, + 'expectedActivationDigest', + ), + }), + cutover: Object.freeze({ + commitmentPath: authorityPaths.commitmentPath, + expectedCommitmentDigest: digest( + cutover.expectedCommitmentDigest, + 'expectedCommitmentDigest', + ), + }), + legacyDataApplication: Object.freeze({ + commitPath: authorityPaths.commitPath, + expectedCommitDigest: digest( + dataApplication.expectedCommitDigest, + 'expectedCommitDigest', + ), + expectedReceiptDigest: digest( + dataApplication.expectedReceiptDigest, + 'expectedReceiptDigest', + ), + }), + }), + }); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts new file mode 100644 index 00000000..0e7b4a84 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts @@ -0,0 +1,821 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + LocalDataDirectoryApplicationCommitError, + normalizeLocalDataDirectoryApplicationCommit, +} from '@qinglong/local-sqlite/data-directory-application-commit'; + +import { initialComposeImageSelectionFromAuthority } from '../compose/composeRevision'; +import { cutoverDigest } from '../cutover/targetEvidence'; +import { LocalDeploymentConfigurationError } from '../foundation/error'; +import { composeProjectName } from '../foundation/render'; +import type { + NormalizedLocalDeploymentAdoptedBundleCommand, + NormalizedLocalDeploymentAdoptedComposeService, +} from './contract'; + +const MAX_JSON_BYTES = 1024 * 1024; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; + +export interface LocalDeploymentAdoptedBundlePaths { + readonly ownerPepperKeyring: string; + readonly ownerPepperBackup: string; + readonly secretKeyring: string; + readonly receipts: string; + readonly artifacts: string; + readonly pluginStaging: string; + readonly pluginActivation: string; + readonly service: string; + readonly applicationConfig: string; + readonly descriptor: string; + readonly bundleReceipt: string; + readonly composeSelection: string; + readonly composeRevisions: string; + readonly composeRevision: string; +} + +export interface LocalDeploymentAdoptedEvidence { + readonly activationDigest: string; + readonly commitmentDigest: string; + readonly commitDigest: string; + readonly receiptDigest: string; + readonly manifestDigest: string; + readonly sourceSha256: string; + readonly recoverySha256: string; + readonly targetDevice: string; + readonly targetInode: string; + readonly targetIdentityDigest: string; +} + +export interface LocalDeploymentAdoptedBundleReceipt { + readonly schemaVersion: 1; + readonly kind: 'qinglong3-local-adopted-deployment-bundle'; + readonly state: 'prepared'; + readonly bundleId: string; + readonly preparedAtMs: number; + readonly profile: 'edge' | 'standalone'; + readonly instanceId: string; + readonly cutoverId: string; + readonly serviceKind: 'systemd' | 'openrc' | 'compose'; + readonly deploymentRootDigest: string; + readonly sourcePathDigest: string; + readonly applicationConfigDigest: string; + readonly serviceDescriptorDigest: string; + readonly composeSelectionDigest: string | null; + readonly activationDigest: string; + readonly commitmentDigest: string; + readonly legacyDataApplicationCommitDigest: string; + readonly legacyDataApplicationReceiptDigest: string; + readonly manifestDigest: string; + readonly sourceSha256: string; + readonly recoverySha256: string; + readonly targetIdentityDigest: string; + readonly bundleDigest: string; +} + +export interface LocalDeploymentAdoptedBundleMaterial { + readonly paths: Readonly; + readonly applicationConfig: string; + readonly descriptor: Readonly<{ + fileName: string; + contents: string; + mode: number; + }>; + readonly composeSelection: string | null; + readonly receipt: Readonly; + readonly receiptContents: string; +} + +function configurationError(message: string, cause?: unknown): never { + throw new LocalDeploymentConfigurationError(message, { cause }); +} + +function object(value: unknown, label: string): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + configurationError(`${label} must be an object`); + } + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + configurationError(`${label} shape is invalid`); + } +} + +function sha256(value: string | Buffer): string { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function readPrivateJson( + filePath: string, + uid: number, + gid: number, + label: string, +): unknown { + let descriptor: number | undefined; + let bytes: Buffer | undefined; + try { + const before = fs.lstatSync(filePath, { bigint: true }); + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if ( + !opened.isFile() || + opened.isSymbolicLink() || + opened.dev !== before.dev || + opened.ino !== before.ino || + Number(opened.uid) !== uid || + Number(opened.gid) !== gid || + (Number(opened.mode) & 0o777) !== 0o600 || + opened.nlink !== 1n || + opened.size < 2n || + opened.size > BigInt(MAX_JSON_BYTES) || + fs.realpathSync(filePath) !== filePath + ) { + configurationError(`${label} identity is invalid`); + } + bytes = Buffer.alloc(Number(opened.size)); + let offset = 0; + while (offset < bytes.byteLength) { + const count = fs.readSync( + descriptor, + bytes, + offset, + bytes.byteLength - offset, + offset, + ); + if (count === 0) break; + offset += count; + } + const after = fs.fstatSync(descriptor, { bigint: true }); + if ( + offset !== bytes.byteLength || + after.dev !== opened.dev || + after.ino !== opened.ino || + after.size !== opened.size || + after.mtimeNs !== opened.mtimeNs || + after.ctimeNs !== opened.ctimeNs || + after.uid !== opened.uid || + after.gid !== opened.gid || + after.mode !== opened.mode || + after.nlink !== opened.nlink + ) { + configurationError(`${label} changed while reading`); + } + return JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes), + ) as unknown; + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError(`${label} cannot be read`, error); + } finally { + bytes?.fill(0); + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function privateFileIdentity( + filePath: string, + uid: number, + gid: number, + label: string, +): Readonly<{ device: string; inode: string; digest: string }> { + try { + const stat = fs.lstatSync(filePath, { bigint: true }); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + Number(stat.uid) !== uid || + Number(stat.gid) !== gid || + (Number(stat.mode) & 0o777) !== 0o600 || + stat.nlink !== 1n || + fs.realpathSync(filePath) !== filePath + ) { + configurationError(`${label} identity is invalid`); + } + const device = stat.dev.toString(); + const inode = stat.ino.toString(); + return Object.freeze({ + device, + inode, + digest: cutoverDigest({ + pathDigest: sha256(filePath), + device, + inode, + uid, + gid, + mode: 0o600, + }), + }); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError(`${label} cannot be inspected`, error); + } +} + +function stableFileSha256( + filePath: string, + uid: number, + gid: number, + label: string, +): string { + let descriptor: number | undefined; + const buffer = Buffer.allocUnsafe(64 * 1024); + try { + const before = fs.lstatSync(filePath, { bigint: true }); + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if ( + !opened.isFile() || + opened.dev !== before.dev || + opened.ino !== before.ino || + Number(opened.uid) !== uid || + Number(opened.gid) !== gid || + (Number(opened.mode) & 0o777) !== 0o600 || + opened.nlink !== 1n || + fs.realpathSync(filePath) !== filePath + ) { + configurationError(`${label} identity is invalid`); + } + const hash = crypto.createHash('sha256'); + for (;;) { + const count = fs.readSync(descriptor, buffer, 0, buffer.byteLength, null); + if (count === 0) break; + hash.update(buffer.subarray(0, count)); + } + const after = fs.fstatSync(descriptor, { bigint: true }); + if ( + after.dev !== opened.dev || + after.ino !== opened.ino || + after.size !== opened.size || + after.mtimeNs !== opened.mtimeNs || + after.ctimeNs !== opened.ctimeNs || + after.uid !== opened.uid || + after.gid !== opened.gid || + after.mode !== opened.mode || + after.nlink !== opened.nlink + ) { + configurationError(`${label} changed while hashing`); + } + return hash.digest('hex'); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError(`${label} cannot be hashed`, error); + } finally { + buffer.fill(0); + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +export function adoptedBundlePaths( + command: Readonly, +): Readonly { + const root = command.options.deploymentRoot; + const service = path.join(root, 'service'); + const descriptorName = + command.options.service.kind === 'systemd' + ? 'qinglong3.service' + : command.options.service.kind === 'openrc' + ? 'qinglong3.openrc' + : 'compose.yaml'; + return Object.freeze({ + ownerPepperKeyring: path.join(root, 'owner-peppers'), + ownerPepperBackup: path.join(root, 'owner-pepper-backup'), + secretKeyring: path.join(root, 'local-secret-keyring.json'), + receipts: path.join(root, 'receipts'), + artifacts: path.join(root, 'artifacts'), + pluginStaging: path.join(root, 'plugin-staging'), + pluginActivation: path.join(root, 'plugin-activation'), + service, + applicationConfig: path.join(root, 'local-application.json'), + descriptor: path.join(service, descriptorName), + bundleReceipt: path.join(service, 'adopted-bundle.json'), + composeSelection: path.join(service, 'compose.image.yaml'), + composeRevisions: path.join(service, 'revisions'), + composeRevision: path.join(service, 'revisions', '1.yaml'), + }); +} + +export function verifyLocalDeploymentAdoptedEvidence( + command: Readonly, + uid: number, + gid: number, +): Readonly { + const activation = object( + readPrivateJson( + command.request.storage.activationPath, + uid, + gid, + 'activation', + ), + 'activation', + ); + exact( + activation, + [ + 'activationDigest', + 'adoptionManifestDigest', + 'createdAtMs', + 'kind', + 'planDigest', + 'profile', + 'recoverySha256', + 'schemaVersion', + 'sourcePathDigest', + 'sourceSha256', + 'state', + 'targetDevice', + 'targetInode', + 'targetPathDigest', + 'targetSha256', + ], + 'activation', + ); + const { activationDigest, ...activationPayload } = activation; + const manifest = object( + readPrivateJson( + command.request.storage.manifestPath, + uid, + gid, + 'adoption manifest', + ), + 'adoption manifest', + ); + const { manifestDigest, ...manifestPayload } = manifest; + const commitment = object( + readPrivateJson( + command.request.cutover.commitmentPath, + uid, + gid, + 'legacy silence commitment', + ), + 'legacy silence commitment', + ); + exact( + commitment, + [ + 'activationDigest', + 'commitmentDigest', + 'controller', + 'cutoverId', + 'instanceId', + 'kind', + 'observedAtMs', + 'previousRecordDigest', + 'profile', + 'requestedAtMs', + 'schemaVersion', + 'state', + ], + 'legacy silence commitment', + ); + const controller = object(commitment.controller, 'commitment controller'); + exact( + controller, + [ + 'endpointDigest', + 'kind', + 'legacyContainerId', + 'legacyContainerIdentityDigest', + 'legacySourceBindingDigest', + ], + 'commitment controller', + ); + const { commitmentDigest, ...commitmentPayload } = commitment; + if ( + activation.schemaVersion !== 1 || + activation.kind !== 'qinglong3-local-sqlite-activation' || + activation.state !== 'prepared' || + activation.profile !== command.options.profile || + activation.sourcePathDigest !== + sha256(command.request.storage.sourcePath) || + activation.targetPathDigest !== + sha256(command.request.storage.targetPath) || + activationDigest !== command.request.storage.expectedActivationDigest || + typeof activationDigest !== 'string' || + !DIGEST_PATTERN.test(activationDigest) || + typeof activation.sourceSha256 !== 'string' || + !DIGEST_PATTERN.test(activation.sourceSha256) || + typeof activation.recoverySha256 !== 'string' || + !DIGEST_PATTERN.test(activation.recoverySha256) || + typeof activation.targetSha256 !== 'string' || + !DIGEST_PATTERN.test(activation.targetSha256) || + typeof activation.adoptionManifestDigest !== 'string' || + !DIGEST_PATTERN.test(activation.adoptionManifestDigest) || + cutoverDigest(activationPayload) !== activationDigest || + typeof manifestDigest !== 'string' || + !DIGEST_PATTERN.test(manifestDigest) || + cutoverDigest(manifestPayload) !== manifestDigest || + activation.adoptionManifestDigest !== manifestDigest || + commitment.schemaVersion !== 1 || + commitment.kind !== 'qinglong3-local-legacy-silence-commitment' || + commitment.state !== 'legacy_stopped' || + commitment.cutoverId !== command.request.cutoverId || + commitment.profile !== command.options.profile || + commitment.instanceId !== command.options.instanceId || + commitment.activationDigest !== activationDigest || + commitmentDigest !== command.request.cutover.expectedCommitmentDigest || + typeof commitmentDigest !== 'string' || + !DIGEST_PATTERN.test(commitmentDigest) || + controller.kind !== 'docker' || + typeof controller.endpointDigest !== 'string' || + !DIGEST_PATTERN.test(controller.endpointDigest) || + typeof controller.legacyContainerId !== 'string' || + !DIGEST_PATTERN.test(controller.legacyContainerId) || + typeof controller.legacyContainerIdentityDigest !== 'string' || + !DIGEST_PATTERN.test(controller.legacyContainerIdentityDigest) || + typeof controller.legacySourceBindingDigest !== 'string' || + !DIGEST_PATTERN.test(controller.legacySourceBindingDigest) || + cutoverDigest(commitmentPayload) !== commitmentDigest + ) { + configurationError('adopted activation or commitment drifted'); + } + let dataCommit; + try { + dataCommit = normalizeLocalDataDirectoryApplicationCommit( + readPrivateJson( + command.request.legacyDataApplication.commitPath, + uid, + gid, + 'legacy data application commit', + ), + ); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + if (error instanceof LocalDataDirectoryApplicationCommitError) { + return configurationError( + 'legacy data application commit is invalid', + error, + ); + } + throw error; + } + if ( + dataCommit.profile !== command.options.profile || + dataCommit.commitDigest !== + command.request.legacyDataApplication.expectedCommitDigest || + dataCommit.receiptDigest !== + command.request.legacyDataApplication.expectedReceiptDigest + ) { + configurationError('legacy data application commit drifted'); + } + const source = privateFileIdentity( + command.request.storage.sourcePath, + uid, + gid, + 'legacy source', + ); + const target = privateFileIdentity( + command.request.storage.targetPath, + uid, + gid, + 'target database', + ); + const recovery = privateFileIdentity( + command.request.storage.recoveryPath, + uid, + gid, + 'recovery database', + ); + const sourceSha256 = stableFileSha256( + command.request.storage.sourcePath, + uid, + gid, + 'legacy source', + ); + const recoverySha256 = stableFileSha256( + command.request.storage.recoveryPath, + uid, + gid, + 'recovery database', + ); + if ( + activation.targetDevice !== target.device || + activation.targetInode !== target.inode || + activation.sourceSha256 !== sourceSha256 || + activation.recoverySha256 !== recoverySha256 + ) { + configurationError('adopted data identity drifted'); + } + return Object.freeze({ + activationDigest, + commitmentDigest, + commitDigest: dataCommit.commitDigest, + receiptDigest: dataCommit.receiptDigest, + manifestDigest, + sourceSha256, + recoverySha256, + targetDevice: target.device, + targetInode: target.inode, + targetIdentityDigest: cutoverDigest({ + source: source.digest, + target: target.digest, + recovery: recovery.digest, + manifestDigest, + }), + }); +} + +function applicationConfig( + command: Readonly, + paths: Readonly, +): string { + const pageSize = command.options.profile === 'edge' ? 4 : 16; + return `${JSON.stringify( + { + schema: 'qinglong/local-application-process@v4', + instanceId: command.options.instanceId, + profile: command.options.profile, + storage: { + mode: 'adopted', + sourcePath: command.request.storage.sourcePath, + targetPath: command.request.storage.targetPath, + recoveryPath: command.request.storage.recoveryPath, + manifestPath: command.request.storage.manifestPath, + activationPath: command.request.storage.activationPath, + expectedActivationDigest: + command.request.storage.expectedActivationDigest, + ...(command.options.busyTimeoutMs === undefined + ? {} + : { busyTimeoutMs: command.options.busyTimeoutMs }), + }, + runtime: { + receiptRoot: paths.receipts, + artifactRoot: paths.artifacts, + secretKeyringPath: paths.secretKeyring, + }, + pluginPackages: { + stagingRoot: paths.pluginStaging, + activationRoot: paths.pluginActivation, + recoverySource: { mode: 'disabled' }, + pageSize, + maxPages: pageSize, + taskPublicationPageSize: pageSize, + taskPublicationMaxPages: pageSize, + }, + ai: { deployment: 'excluded' }, + cutover: { + cutoverId: command.request.cutoverId, + commitmentPath: command.request.cutover.commitmentPath, + expectedCommitmentDigest: + command.request.cutover.expectedCommitmentDigest, + }, + legacyDataApplication: { + commitPath: command.request.legacyDataApplication.commitPath, + expectedCommitDigest: + command.request.legacyDataApplication.expectedCommitDigest, + expectedReceiptDigest: + command.request.legacyDataApplication.expectedReceiptDigest, + }, + }, + null, + 2, + )}\n`; +} + +function systemdDescriptor( + command: Readonly, + configPath: string, + uid: number, + gid: number, +): string { + if (command.options.service.kind === 'compose') { + configurationError('systemd descriptor requires a process service'); + } + const edge = command.options.profile === 'edge'; + return [ + '[Unit]', + 'Description=QingLong 3.0 adopted local automation runtime', + 'After=local-fs.target', + '', + '[Service]', + 'Type=simple', + `User=${uid}`, + `Group=${gid}`, + `WorkingDirectory=${command.options.deploymentRoot}`, + `ExecStart=${command.options.service.nodeExecutable} ${command.options.service.applicationEntrypoint} --config ${configPath}`, + 'Environment=NODE_ENV=production', + 'UMask=0077', + 'KillSignal=SIGTERM', + 'TimeoutStopSec=30s', + 'Restart=on-failure', + 'RestartSec=5s', + 'RestartPreventExitStatus=64', + 'NoNewPrivileges=yes', + 'PrivateTmp=yes', + 'ProtectSystem=strict', + `ReadWritePaths=${command.options.deploymentRoot}`, + `ReadOnlyPaths=${command.request.storage.sourcePath}`, + 'ProtectKernelTunables=yes', + 'ProtectKernelModules=yes', + 'ProtectControlGroups=yes', + 'RestrictSUIDSGID=yes', + 'LockPersonality=yes', + `LimitNOFILE=${edge ? 1024 : 4096}`, + `TasksMax=${edge ? 64 : 256}`, + `MemoryMax=${edge ? '128M' : '256M'}`, + '', + '[Install]', + 'WantedBy=multi-user.target', + '', + ].join('\n'); +} + +function openrcDescriptor( + command: Readonly, + configPath: string, + uid: number, + gid: number, +): string { + if (command.options.service.kind === 'compose') { + configurationError('OpenRC descriptor requires a process service'); + } + const edge = command.options.profile === 'edge'; + return [ + '#!/sbin/openrc-run', + '', + 'name="qinglong3"', + 'description="QingLong 3.0 adopted local automation runtime"', + `command="${command.options.service.nodeExecutable}"`, + `command_args="${command.options.service.applicationEntrypoint} --config ${configPath}"`, + `command_user="${uid}:${gid}"`, + `directory="${command.options.deploymentRoot}"`, + 'supervisor="supervise-daemon"', + 'respawn_delay=5', + 'respawn_max=5', + 'respawn_period=60', + 'retry="TERM/30/KILL/5"', + 'umask=0077', + `rc_ulimit="-n ${edge ? 1024 : 4096}"`, + '', + 'depend() {', + ' need localmount', + ' after bootmisc', + '}', + '', + ].join('\n'); +} + +function composeDescriptor( + command: Readonly, + configDigest: string, + uid: number, + gid: number, +): string { + const edge = command.options.profile === 'edge'; + return [ + `name: ${composeProjectName(command.options.instanceId)}`, + '', + 'services:', + ' qinglong3:', + ` user: "${uid}:${gid}"`, + ' read_only: true', + ' init: true', + ' network_mode: none', + ' command:', + ' - --config', + ` - ${command.options.deploymentRoot}/local-application.json`, + ' volumes:', + ' - type: bind', + ` source: ${command.options.deploymentRoot}`, + ` target: ${command.options.deploymentRoot}`, + ' - type: bind', + ` source: ${command.request.storage.sourcePath}`, + ` target: ${command.request.storage.sourcePath}`, + ' read_only: true', + ' tmpfs:', + ' - /tmp:rw,noexec,nosuid,nodev,size=16m', + ' cap_drop:', + ' - ALL', + ' security_opt:', + ' - no-new-privileges:true', + ' restart: "no"', + ' stop_grace_period: 30s', + ` mem_limit: ${edge ? '128m' : '256m'}`, + ` pids_limit: ${edge ? 64 : 256}`, + ' labels:', + ' io.qinglong.deployment.mode: adopted', + ` io.qinglong.deployment.profile: ${command.options.profile}`, + ` io.qinglong.deployment.instance: ${command.options.instanceId}`, + ` io.qinglong.deployment.bundle: "${command.request.bundleId}"`, + ` io.qinglong.application.config: "${configDigest}"`, + ` io.qinglong.data.commit: "${command.request.legacyDataApplication.expectedCommitDigest}"`, + ` io.qinglong.data.receipt: "${command.request.legacyDataApplication.expectedReceiptDigest}"`, + '', + ].join('\n'); +} + +function serviceDescriptor( + command: Readonly, + configPath: string, + configDigest: string, + uid: number, + gid: number, +): Readonly<{ fileName: string; contents: string; mode: number }> { + if (command.options.service.kind === 'systemd') { + return Object.freeze({ + fileName: 'qinglong3.service', + contents: systemdDescriptor(command, configPath, uid, gid), + mode: 0o600, + }); + } + if (command.options.service.kind === 'openrc') { + return Object.freeze({ + fileName: 'qinglong3.openrc', + contents: openrcDescriptor(command, configPath, uid, gid), + mode: 0o700, + }); + } + return Object.freeze({ + fileName: 'compose.yaml', + contents: composeDescriptor(command, configDigest, uid, gid), + mode: 0o600, + }); +} + +export function renderLocalDeploymentAdoptedBundleMaterial( + command: Readonly, + evidence: Readonly, + uid: number, + gid: number, +): Readonly { + const paths = adoptedBundlePaths(command); + const config = applicationConfig(command, paths); + const configDigest = sha256(config); + const descriptor = serviceDescriptor( + command, + paths.applicationConfig, + configDigest, + uid, + gid, + ); + const composeSelection = + command.options.service.kind === 'compose' + ? initialComposeImageSelectionFromAuthority({ + service: command.options + .service as NormalizedLocalDeploymentAdoptedComposeService, + mutationId: command.request.bundleId, + changedAtMs: command.request.preparedAtMs, + }) + : null; + const receiptPayload = { + schemaVersion: 1 as const, + kind: 'qinglong3-local-adopted-deployment-bundle' as const, + state: 'prepared' as const, + bundleId: command.request.bundleId, + preparedAtMs: command.request.preparedAtMs, + profile: command.options.profile, + instanceId: command.options.instanceId, + cutoverId: command.request.cutoverId, + serviceKind: command.options.service.kind, + deploymentRootDigest: sha256(command.options.deploymentRoot), + sourcePathDigest: sha256(command.request.storage.sourcePath), + applicationConfigDigest: configDigest, + serviceDescriptorDigest: sha256(descriptor.contents), + composeSelectionDigest: + composeSelection === null ? null : sha256(composeSelection), + activationDigest: evidence.activationDigest, + commitmentDigest: evidence.commitmentDigest, + legacyDataApplicationCommitDigest: evidence.commitDigest, + legacyDataApplicationReceiptDigest: evidence.receiptDigest, + manifestDigest: evidence.manifestDigest, + sourceSha256: evidence.sourceSha256, + recoverySha256: evidence.recoverySha256, + targetIdentityDigest: evidence.targetIdentityDigest, + }; + const receipt = Object.freeze({ + ...receiptPayload, + bundleDigest: cutoverDigest(receiptPayload), + }); + return Object.freeze({ + paths, + applicationConfig: config, + descriptor, + composeSelection, + receipt, + receiptContents: `${JSON.stringify(receipt, null, 2)}\n`, + }); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/compose/composeRevision.ts b/packages/ql3-local-owner-cli/src/deployment/compose/composeRevision.ts index ac0ef9a4..15cfb2ce 100644 --- a/packages/ql3-local-owner-cli/src/deployment/compose/composeRevision.ts +++ b/packages/ql3-local-owner-cli/src/deployment/compose/composeRevision.ts @@ -8,6 +8,7 @@ import { LocalDeploymentConfigurationError, normalizeLocalDeploymentComposeRevisionCommand, type LocalDeploymentComposeRevisionResult, + type NormalizedLocalDeploymentComposeService, type NormalizedLocalDeploymentComposeRevisionCommand, type NormalizedLocalDeploymentPrepareCommand, } from '../foundation/contract'; @@ -265,13 +266,25 @@ export function initialComposeImageSelection( 'initial compose selection requires a compose service', ); } + return initialComposeImageSelectionFromAuthority({ + service: command.options.service, + mutationId: command.request.activateMutationId, + changedAtMs: command.request.activatedAtMs, + }); +} + +export function initialComposeImageSelectionFromAuthority(input: { + readonly service: Readonly; + readonly mutationId: string; + readonly changedAtMs: number; +}): string { return selectionContents({ generation: 1, previousGeneration: 0, rollbackTargetGeneration: 0, - mutationId: command.request.activateMutationId, - changedAtMs: command.request.activatedAtMs, - ...command.options.service.releaseSelection.authority, + mutationId: input.mutationId, + changedAtMs: input.changedAtMs, + ...input.service.releaseSelection.authority, }); } diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts index 3f3ad3e4..29664a1c 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts @@ -122,6 +122,17 @@ export { type LocalDeploymentStatusCommand, type LocalDeploymentStatusResult, } from './foundation/contract'; +export { + prepareLocalDeploymentAdoptedBundle, + runLocalDeploymentAdoptedBundleCommandFile, + verifyLocalDeploymentAdoptedBundle, + type LocalDeploymentAdoptedBundleResult, +} from './adopted-bundle/adoptedBundle'; +export { + normalizeLocalDeploymentAdoptedBundleCommand, + type LocalDeploymentAdoptedBundleCommand, + type LocalDeploymentAdoptedBundleOperation, +} from './adopted-bundle/contract'; export { type LocalComposeReleaseAuthority, type LocalComposeReleaseSelectionInput, diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts index 8f68a9df..2ee56672 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeploymentCli.ts @@ -17,6 +17,7 @@ import { proveLocalDeploymentLegacyReadinessCommandFile, restoreLocalDeploymentComposeCommitCommandFile, restoreLocalDeploymentComposePrepareCommandFile, + runLocalDeploymentAdoptedBundleCommandFile, runLocalDeploymentCutoverManualCommandFile, runLocalDeploymentLegacyRollbackCommandFile, runLocalDeploymentDockerTargetCommandFile, @@ -26,7 +27,7 @@ import { } from './localDeployment'; const USAGE = - 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; + 'Usage: ql3-local-deploy --command-file /absolute/private-command.json'; async function main(argv: readonly string[]): Promise { if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) { @@ -36,6 +37,8 @@ async function main(argv: readonly string[]): Promise { if ( argv.length !== 3 || (argv[0] !== 'prepare' && + argv[0] !== 'adopted-prepare' && + argv[0] !== 'adopted-verify' && argv[0] !== 'status' && argv[0] !== 'service-intent-prepare' && argv[0] !== 'service-outcome-consume' && @@ -74,6 +77,13 @@ async function main(argv: readonly string[]): Promise { try { const output = await (argv[0] === 'prepare' ? prepareLocalDeploymentCommandFile(argv[2]!) + : argv[0] === 'adopted-prepare' || argv[0] === 'adopted-verify' + ? runLocalDeploymentAdoptedBundleCommandFile( + argv[2]!, + argv[0] === 'adopted-prepare' + ? 'local.deployment.adopted.prepare' + : 'local.deployment.adopted.verify', + ) : argv[0] === 'status' ? inspectLocalDeploymentStatusCommandFile(argv[2]!) : argv[0] === 'service-intent-prepare' diff --git a/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs b/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs new file mode 100644 index 00000000..205baf0f --- /dev/null +++ b/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs @@ -0,0 +1,439 @@ +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { + LocalDeploymentConfigurationError, + prepareLocalDeploymentAdoptedBundle, + verifyLocalDeploymentAdoptedBundle, +} = require('../dist/deployment/localDeployment.js'); +const { + createLocalDataDirectoryApplicationCommit, +} = require('@qinglong/local-sqlite/data-directory-application-commit'); +const { + normalizeLocalApplicationProcessConfig, +} = require('../../ql3-local-application/dist/production-process/processConfig.js'); + +function rootAcknowledgement() { + return process.getuid() === 0; +} + +function hexDigest(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function prefixedDigest(value) { + return `sha256:${hexDigest(value)}`; +} + +function canonicalDigest(value) { + return hexDigest(JSON.stringify(value)); +} + +function writePrivate(filePath, value) { + fs.writeFileSync( + filePath, + typeof value === 'string' ? value : `${JSON.stringify(value)}\n`, + { mode: 0o600 }, + ); +} + +function releaseSelection(managementRoot) { + const image = `ghcr.io/example/qinglong3-local-application@sha256:${'a'.repeat( + 64, + )}`; + 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', + 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: rootAcknowledgement(), + }, + verification: { + releaseSet: 'standalone_structure_identity_and_self_digest', + sourceRecordsReplayed: false, + catalogConsumption: 'offline_reconstructed', + externalToolResultsReplayed: false, + networkAccess: false, + deploymentMutation: false, + }, + }; + const selectionDigest = prefixedDigest(JSON.stringify(unsigned)); + const filePath = path.join(managementRoot, 'release-selection.json'); + writePrivate(filePath, { ...unsigned, selectionDigest }); + return { + path: filePath, + expectedSelectionDigest: selectionDigest, + }; +} + +function fixture(t, kind) { + const managementRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-adopted-bundle-')), + ); + fs.chmodSync(managementRoot, 0o700); + t.after(() => fs.rmSync(managementRoot, { recursive: true, force: true })); + const root = path.join(managementRoot, 'runtime'); + const serviceRoot = path.join(root, 'service'); + const cutoverId = 'cutover-edge-router-1'; + const cutoverRoot = path.join(serviceRoot, 'cutovers', cutoverId); + const transformationRoot = path.join(root, 'transformation'); + const adoptionRoot = path.join(root, 'adoption'); + for (const directory of [ + root, + path.join(root, 'owner-peppers'), + path.join(root, 'owner-pepper-backup'), + serviceRoot, + path.join(serviceRoot, 'cutovers'), + cutoverRoot, + transformationRoot, + adoptionRoot, + ]) { + fs.mkdirSync(directory, { mode: 0o700 }); + fs.chmodSync(directory, 0o700); + } + writePrivate(path.join(root, 'local-secret-keyring.json'), '{}\n'); + const sourcePath = path.join(managementRoot, 'legacy.sqlite'); + const targetPath = path.join(adoptionRoot, 'target.sqlite'); + const recoveryPath = path.join(adoptionRoot, 'recovery.sqlite'); + const manifestPath = path.join(adoptionRoot, 'manifest.json'); + const activationPath = path.join(adoptionRoot, 'activation.json'); + writePrivate(sourcePath, 'legacy source\n'); + writePrivate(targetPath, 'adopted target\n'); + writePrivate(recoveryPath, 'legacy source\n'); + const manifestPayload = { + schemaVersion: 1, + kind: 'qinglong3-local-sqlite-adoption-manifest-fixture', + }; + const manifestDigest = canonicalDigest(manifestPayload); + writePrivate(manifestPath, { ...manifestPayload, manifestDigest }); + const target = fs.statSync(targetPath, { bigint: true }); + const activationPayload = { + schemaVersion: 1, + kind: 'qinglong3-local-sqlite-activation', + state: 'prepared', + profile: 'edge', + createdAtMs: 1786416000000, + adoptionManifestDigest: manifestDigest, + planDigest: '2'.repeat(64), + sourcePathDigest: hexDigest(sourcePath), + sourceSha256: hexDigest(fs.readFileSync(sourcePath)), + recoverySha256: hexDigest(fs.readFileSync(recoveryPath)), + targetSha256: hexDigest(fs.readFileSync(targetPath)), + targetPathDigest: hexDigest(targetPath), + targetDevice: target.dev.toString(), + targetInode: target.ino.toString(), + }; + const activationDigest = canonicalDigest(activationPayload); + writePrivate(activationPath, { ...activationPayload, activationDigest }); + const commitmentPayload = { + schemaVersion: 1, + kind: 'qinglong3-local-legacy-silence-commitment', + state: 'legacy_stopped', + cutoverId, + profile: 'edge', + instanceId: 'edge-router-1', + activationDigest, + requestedAtMs: 1786416000010, + observedAtMs: 1786416000020, + previousRecordDigest: '3'.repeat(64), + controller: { + kind: 'docker', + endpointDigest: '4'.repeat(64), + legacyContainerId: '5'.repeat(64), + legacyContainerIdentityDigest: '6'.repeat(64), + legacySourceBindingDigest: '7'.repeat(64), + }, + }; + const commitmentDigest = canonicalDigest(commitmentPayload); + const commitmentPath = path.join(cutoverRoot, '0002-legacy-stopped.json'); + writePrivate(commitmentPath, { + ...commitmentPayload, + commitmentDigest, + }); + const commit = createLocalDataDirectoryApplicationCommit({ + mutationId: '00000000-0000-4000-8000-000000000001', + projectId: 'project-edge-router-1', + profile: 'edge', + sourceStageManifestDigest: '8'.repeat(64), + transformationDigest: '9'.repeat(64), + modelDigest: 'a'.repeat(64), + publicationDigest: 'b'.repeat(64), + receiptDigest: 'c'.repeat(64), + committedAtMs: 1786416000025, + receipt: { + secretCount: 2, + environmentSecretCount: 1, + sshSecretCount: 1, + }, + }); + const commitPath = path.join(transformationRoot, 'commit.json'); + writePrivate(commitPath, commit); + const applicationEntrypoint = fs.realpathSync( + path.resolve(__dirname, '../../ql3-local-application/dist/cli.js'), + ); + const service = + kind === 'compose' + ? { + kind, + releaseSelection: releaseSelection(managementRoot), + allowRootService: rootAcknowledgement(), + } + : { + kind, + nodeExecutable: fs.realpathSync(process.execPath), + applicationEntrypoint, + allowRootService: rootAcknowledgement(), + }; + const command = { + schemaVersion: 1, + operation: 'local.deployment.adopted.prepare', + options: { + deploymentRoot: root, + profile: 'edge', + instanceId: 'edge-router-1', + busyTimeoutMs: 100, + service, + }, + request: { + bundleId: '00000000-0000-4000-8000-000000000d88', + preparedAtMs: 1786416000030, + cutoverId, + storage: { + sourcePath, + targetPath, + recoveryPath, + manifestPath, + activationPath, + expectedActivationDigest: activationDigest, + }, + cutover: { + commitmentPath, + expectedCommitmentDigest: commitmentDigest, + }, + legacyDataApplication: { + commitPath, + expectedCommitDigest: commit.commitDigest, + expectedReceiptDigest: commit.receiptDigest, + }, + }, + }; + return { + root, + command, + commitPath, + commitmentPath, + sourcePath, + }; +} + +for (const kind of ['systemd', 'openrc', 'compose']) { + test(`prepares and verifies an exact adopted ${kind} bundle without activation`, (t) => { + const state = fixture(t, kind); + const prepared = prepareLocalDeploymentAdoptedBundle(state.command); + assert.equal(prepared.status, 'prepared'); + assert.equal(prepared.service.kind, kind); + assert.equal( + prepareLocalDeploymentAdoptedBundle(state.command).status, + 'existing', + ); + const verified = verifyLocalDeploymentAdoptedBundle({ + ...state.command, + operation: 'local.deployment.adopted.verify', + }); + assert.equal(verified.status, 'verified'); + assert.equal(verified.bundleDigest, prepared.bundleDigest); + const application = JSON.parse( + fs.readFileSync(path.join(state.root, 'local-application.json'), 'utf8'), + ); + assert.equal( + normalizeLocalApplicationProcessConfig(application).schema, + 'qinglong/local-application-process@v4', + ); + assert.equal(application.schema, 'qinglong/local-application-process@v4'); + assert.equal(application.storage.sourcePath, state.sourcePath); + assert.equal( + application.legacyDataApplication.expectedCommitDigest, + state.command.request.legacyDataApplication.expectedCommitDigest, + ); + const receipt = JSON.parse( + fs.readFileSync(path.join(state.root, 'service/adopted-bundle.json')), + ); + assert.equal( + receipt.legacyDataApplicationReceiptDigest, + state.command.request.legacyDataApplication.expectedReceiptDigest, + ); + assert.equal(receipt.serviceKind, kind); + assert.equal( + fs.existsSync(path.join(state.root, 'service/intents')), + false, + ); + assert.equal( + fs.existsSync(path.join(state.root, 'setup-receipt.json')), + false, + ); + if (kind === 'compose') { + const descriptor = fs.readFileSync( + path.join(state.root, 'service/compose.yaml'), + 'utf8', + ); + assert.match( + descriptor, + new RegExp( + `source: ${state.root.replaceAll( + '/', + '\\/', + )}\\n target: ${state.root.replaceAll('/', '\\/')}`, + ), + ); + assert.match( + descriptor, + new RegExp( + `source: ${state.sourcePath.replaceAll( + '/', + '\\/', + )}\\n target: ${state.sourcePath.replaceAll( + '/', + '\\/', + )}\\n read_only: true`, + ), + ); + assert.match(descriptor, /restart: "no"/); + assert.doesNotMatch(descriptor, /\/var\/lib\/qinglong3/); + assert.equal( + fs.readFileSync( + path.join(state.root, 'service/compose.image.yaml'), + 'utf8', + ), + fs.readFileSync( + path.join(state.root, 'service/revisions/1.yaml'), + 'utf8', + ), + ); + } else { + assert.equal( + fs.existsSync(path.join(state.root, 'service/compose.image.yaml')), + false, + ); + } + }); +} + +test('rejects commit drift before publishing any bundle material', (t) => { + const state = fixture(t, 'systemd'); + const commit = JSON.parse(fs.readFileSync(state.commitPath, 'utf8')); + writePrivate(state.commitPath, { ...commit, receiptDigest: 'd'.repeat(64) }); + assert.throws( + () => prepareLocalDeploymentAdoptedBundle(state.command), + LocalDeploymentConfigurationError, + ); + assert.equal( + fs.existsSync(path.join(state.root, 'local-application.json')), + false, + ); + assert.equal( + fs.existsSync(path.join(state.root, 'service/qinglong3.service')), + false, + ); + assert.equal( + fs.existsSync(path.join(state.root, 'service/adopted-bundle.json')), + false, + ); +}); + +test('verify fails closed when a committed source fact drifts', (t) => { + const state = fixture(t, 'openrc'); + prepareLocalDeploymentAdoptedBundle(state.command); + const commitment = JSON.parse(fs.readFileSync(state.commitmentPath, 'utf8')); + writePrivate(state.commitmentPath, { + ...commitment, + observedAtMs: commitment.observedAtMs + 1, + }); + assert.throws( + () => + verifyLocalDeploymentAdoptedBundle({ + ...state.command, + operation: 'local.deployment.adopted.verify', + }), + LocalDeploymentConfigurationError, + ); +}); + +test('requires the legacy source to be the only authority outside deployment root', (t) => { + const state = fixture(t, 'systemd'); + const sourceInside = path.join(state.root, 'legacy.sqlite'); + writePrivate(sourceInside, 'legacy source\n'); + assert.throws( + () => + prepareLocalDeploymentAdoptedBundle({ + ...state.command, + request: { + ...state.command.request, + storage: { + ...state.command.request.storage, + sourcePath: sourceInside, + }, + }, + }), + LocalDeploymentConfigurationError, + ); +}); + +test('exposes separate exact prepare and verify CLI operations', (t) => { + const state = fixture(t, 'systemd'); + const cli = path.resolve( + __dirname, + '../dist/deployment/localDeploymentCli.js', + ); + const preparePath = path.join( + path.dirname(state.root), + 'adopted-prepare.json', + ); + writePrivate(preparePath, state.command); + const prepared = spawnSync( + process.execPath, + [cli, 'adopted-prepare', '--command-file', preparePath], + { encoding: 'utf8' }, + ); + assert.equal(prepared.status, 0, prepared.stderr); + assert.equal(JSON.parse(prepared.stdout).status, 'prepared'); + const verifyPath = path.join(path.dirname(state.root), 'adopted-verify.json'); + writePrivate(verifyPath, { + ...state.command, + operation: 'local.deployment.adopted.verify', + }); + const verified = spawnSync( + process.execPath, + [cli, 'adopted-verify', '--command-file', verifyPath], + { encoding: 'utf8' }, + ); + assert.equal(verified.status, 0, verified.stderr); + assert.equal(JSON.parse(verified.stdout).status, 'verified'); +}); diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index 39622eef..77130da4 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -1786,6 +1786,7 @@ function auditSourceImports(root, packagePath, findings) { 'src/deployment/service-manager/serviceManagerIntent.ts', 'src/deployment/service-manager/serviceCutoverConsumer.ts', 'src/deployment/service-manager/legacy-rollback/preparation.ts', + 'src/deployment/adopted-bundle/material.ts', ].includes(path.relative(packageDirectory, filePath)) && specifier === '@qinglong/local-sqlite/data-directory-application-commit' @@ -2634,6 +2635,14 @@ function auditSourceImports(root, packagePath, findings) { ) { continue; } + if ( + packagePath === 'packages/ql3-local-owner-cli' && + path.relative(packageDirectory, filePath) === + 'src/deployment/adopted-bundle/material.ts' && + specifier === '@qinglong/local-sqlite/data-directory-application-commit' + ) { + continue; + } if ( packagePath === 'packages/ql3-local-owner-cli' && path.relative(packageDirectory, filePath) === diff --git a/test/back/ql3ClusterDependencyAudit.test.cjs b/test/back/ql3ClusterDependencyAudit.test.cjs index d5b69743..80d8c90e 100644 --- a/test/back/ql3ClusterDependencyAudit.test.cjs +++ b/test/back/ql3ClusterDependencyAudit.test.cjs @@ -2567,6 +2567,47 @@ test('service-manager Owner consumers receive only the pure data commit codec', ); }); +test('adopted deployment material receives only the pure data commit codec', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-adopted-bundle-codec-boundary-'), + ); + const bundleDirectory = path.join( + root, + 'packages/ql3-local-owner-cli/src/deployment/adopted-bundle', + ); + fs.mkdirSync(bundleDirectory, { recursive: true }); + fs.writeFileSync( + path.join(bundleDirectory, 'material.ts'), + [ + "import { normalize } from '@qinglong/local-sqlite/data-directory-application-commit';", + "import { mutate } from '@qinglong/local-sqlite/data-directory-adoption';", + ].join('\n'), + ); + fs.writeFileSync( + path.join(bundleDirectory, 'neighbor.ts'), + "import { normalize } from '@qinglong/local-sqlite/data-directory-application-commit';", + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const findings = []; + auditSourceImports(root, 'packages/ql3-local-owner-cli', findings); + assert.deepEqual( + findings.map(({ code, file, specifier }) => ({ code, file, specifier })), + [ + { + code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT', + file: 'packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts', + specifier: '@qinglong/local-sqlite/data-directory-adoption', + }, + { + code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT', + file: 'packages/ql3-local-owner-cli/src/deployment/adopted-bundle/neighbor.ts', + specifier: '@qinglong/local-sqlite/data-directory-application-commit', + }, + ], + ); +}); + test('local AI application imports only the reviewed dynamic composition subpaths', (t) => { const root = fs.mkdtempSync( path.join(os.tmpdir(), 'ql3-local-ai-application-boundary-'), diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 6d059c6c..e7c5ccbc 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localOwnerCli.rootSourceFileRoles, }, { - sourceFiles: 131, + sourceFiles: 134, rootSourceFiles: 1, rootSourceLines: 50, - nestedSourceFiles: 130, + nestedSourceFiles: 133, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, );