feat(ql3): prepare adopted deployment bundles

This commit is contained in:
whyour
2026-08-21 14:09:59 +08:00
parent 07cb5ea0a8
commit 190117a662
10 changed files with 2231 additions and 6 deletions
@@ -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<LocalDeploymentAdoptedBundleResult> {
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<LocalDeploymentAdoptedBundleResult> {
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<LocalDeploymentAdoptedBundleResult> {
const input = readPrivateLocalCommandFile(filePath);
return operation === 'local.deployment.adopted.prepare'
? prepareLocalDeploymentAdoptedBundle(input)
: verifyLocalDeploymentAdoptedBundle(input);
}
export type { LocalDeploymentAdoptedBundleCommand };
@@ -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<LocalComposeReleaseSelectionInput>;
readonly allowRootService: boolean;
}
export interface NormalizedLocalDeploymentAdoptedComposeService
extends Omit<LocalDeploymentAdoptedComposeService, 'releaseSelection'> {
readonly releaseSelection: Readonly<ResolvedLocalComposeReleaseSelection>;
}
export type LocalDeploymentAdoptedService =
| LocalDeploymentProcessService
| LocalDeploymentAdoptedComposeService;
export type NormalizedLocalDeploymentAdoptedService =
| Readonly<LocalDeploymentProcessService>
| Readonly<NormalizedLocalDeploymentAdoptedComposeService>;
export interface LocalDeploymentAdoptedBundleCommand {
readonly schemaVersion: 1;
readonly operation: LocalDeploymentAdoptedBundleOperation;
readonly options: Readonly<{
deploymentRoot: string;
profile: LocalDeploymentProfile;
instanceId: string;
busyTimeoutMs?: number;
service: Readonly<LocalDeploymentAdoptedService>;
}>;
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<LocalDeploymentAdoptedBundleCommand, 'options'> {
readonly options: Omit<
LocalDeploymentAdoptedBundleCommand['options'],
'service'
> &
Readonly<{ service: NormalizedLocalDeploymentAdoptedService }>;
}
function object(value: unknown, label: string): Record<string, unknown> {
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<string, unknown>;
}
function exact(
value: Record<string, unknown>,
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<NormalizedLocalDeploymentAdoptedBundleCommand> {
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',
),
}),
}),
});
}
@@ -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<LocalDeploymentAdoptedBundlePaths>;
readonly applicationConfig: string;
readonly descriptor: Readonly<{
fileName: string;
contents: string;
mode: number;
}>;
readonly composeSelection: string | null;
readonly receipt: Readonly<LocalDeploymentAdoptedBundleReceipt>;
readonly receiptContents: string;
}
function configurationError(message: string, cause?: unknown): never {
throw new LocalDeploymentConfigurationError(message, { cause });
}
function object(value: unknown, label: string): Record<string, unknown> {
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<string, unknown>;
}
function exact(
value: Record<string, unknown>,
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<NormalizedLocalDeploymentAdoptedBundleCommand>,
): Readonly<LocalDeploymentAdoptedBundlePaths> {
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<NormalizedLocalDeploymentAdoptedBundleCommand>,
uid: number,
gid: number,
): Readonly<LocalDeploymentAdoptedEvidence> {
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<NormalizedLocalDeploymentAdoptedBundleCommand>,
paths: Readonly<LocalDeploymentAdoptedBundlePaths>,
): 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<NormalizedLocalDeploymentAdoptedBundleCommand>,
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<NormalizedLocalDeploymentAdoptedBundleCommand>,
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<NormalizedLocalDeploymentAdoptedBundleCommand>,
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<NormalizedLocalDeploymentAdoptedBundleCommand>,
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<NormalizedLocalDeploymentAdoptedBundleCommand>,
evidence: Readonly<LocalDeploymentAdoptedEvidence>,
uid: number,
gid: number,
): Readonly<LocalDeploymentAdoptedBundleMaterial> {
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`,
});
}
@@ -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<NormalizedLocalDeploymentComposeService>;
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,
});
}
@@ -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,
@@ -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 <prepare|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
async function main(argv: readonly string[]): Promise<void> {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
@@ -36,6 +37,8 @@ async function main(argv: readonly string[]): Promise<void> {
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<void> {
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'
@@ -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');
});
+9
View File
@@ -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) ===
@@ -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-'),
+2 -2
View File
@@ -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' },
},
);