mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 20:15:19 +08:00
feat(ql3): prepare adopted deployment bundles
This commit is contained in:
@@ -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,
|
LocalDeploymentConfigurationError,
|
||||||
normalizeLocalDeploymentComposeRevisionCommand,
|
normalizeLocalDeploymentComposeRevisionCommand,
|
||||||
type LocalDeploymentComposeRevisionResult,
|
type LocalDeploymentComposeRevisionResult,
|
||||||
|
type NormalizedLocalDeploymentComposeService,
|
||||||
type NormalizedLocalDeploymentComposeRevisionCommand,
|
type NormalizedLocalDeploymentComposeRevisionCommand,
|
||||||
type NormalizedLocalDeploymentPrepareCommand,
|
type NormalizedLocalDeploymentPrepareCommand,
|
||||||
} from '../foundation/contract';
|
} from '../foundation/contract';
|
||||||
@@ -265,13 +266,25 @@ export function initialComposeImageSelection(
|
|||||||
'initial compose selection requires a compose service',
|
'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({
|
return selectionContents({
|
||||||
generation: 1,
|
generation: 1,
|
||||||
previousGeneration: 0,
|
previousGeneration: 0,
|
||||||
rollbackTargetGeneration: 0,
|
rollbackTargetGeneration: 0,
|
||||||
mutationId: command.request.activateMutationId,
|
mutationId: input.mutationId,
|
||||||
changedAtMs: command.request.activatedAtMs,
|
changedAtMs: input.changedAtMs,
|
||||||
...command.options.service.releaseSelection.authority,
|
...input.service.releaseSelection.authority,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,17 @@ export {
|
|||||||
type LocalDeploymentStatusCommand,
|
type LocalDeploymentStatusCommand,
|
||||||
type LocalDeploymentStatusResult,
|
type LocalDeploymentStatusResult,
|
||||||
} from './foundation/contract';
|
} from './foundation/contract';
|
||||||
|
export {
|
||||||
|
prepareLocalDeploymentAdoptedBundle,
|
||||||
|
runLocalDeploymentAdoptedBundleCommandFile,
|
||||||
|
verifyLocalDeploymentAdoptedBundle,
|
||||||
|
type LocalDeploymentAdoptedBundleResult,
|
||||||
|
} from './adopted-bundle/adoptedBundle';
|
||||||
|
export {
|
||||||
|
normalizeLocalDeploymentAdoptedBundleCommand,
|
||||||
|
type LocalDeploymentAdoptedBundleCommand,
|
||||||
|
type LocalDeploymentAdoptedBundleOperation,
|
||||||
|
} from './adopted-bundle/contract';
|
||||||
export {
|
export {
|
||||||
type LocalComposeReleaseAuthority,
|
type LocalComposeReleaseAuthority,
|
||||||
type LocalComposeReleaseSelectionInput,
|
type LocalComposeReleaseSelectionInput,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||||
restoreLocalDeploymentComposeCommitCommandFile,
|
restoreLocalDeploymentComposeCommitCommandFile,
|
||||||
restoreLocalDeploymentComposePrepareCommandFile,
|
restoreLocalDeploymentComposePrepareCommandFile,
|
||||||
|
runLocalDeploymentAdoptedBundleCommandFile,
|
||||||
runLocalDeploymentCutoverManualCommandFile,
|
runLocalDeploymentCutoverManualCommandFile,
|
||||||
runLocalDeploymentLegacyRollbackCommandFile,
|
runLocalDeploymentLegacyRollbackCommandFile,
|
||||||
runLocalDeploymentDockerTargetCommandFile,
|
runLocalDeploymentDockerTargetCommandFile,
|
||||||
@@ -26,7 +27,7 @@ import {
|
|||||||
} from './localDeployment';
|
} from './localDeployment';
|
||||||
|
|
||||||
const USAGE =
|
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> {
|
async function main(argv: readonly string[]): Promise<void> {
|
||||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||||
@@ -36,6 +37,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
if (
|
if (
|
||||||
argv.length !== 3 ||
|
argv.length !== 3 ||
|
||||||
(argv[0] !== 'prepare' &&
|
(argv[0] !== 'prepare' &&
|
||||||
|
argv[0] !== 'adopted-prepare' &&
|
||||||
|
argv[0] !== 'adopted-verify' &&
|
||||||
argv[0] !== 'status' &&
|
argv[0] !== 'status' &&
|
||||||
argv[0] !== 'service-intent-prepare' &&
|
argv[0] !== 'service-intent-prepare' &&
|
||||||
argv[0] !== 'service-outcome-consume' &&
|
argv[0] !== 'service-outcome-consume' &&
|
||||||
@@ -74,6 +77,13 @@ async function main(argv: readonly string[]): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const output = await (argv[0] === 'prepare'
|
const output = await (argv[0] === 'prepare'
|
||||||
? prepareLocalDeploymentCommandFile(argv[2]!)
|
? 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'
|
: argv[0] === 'status'
|
||||||
? inspectLocalDeploymentStatusCommandFile(argv[2]!)
|
? inspectLocalDeploymentStatusCommandFile(argv[2]!)
|
||||||
: argv[0] === 'service-intent-prepare'
|
: 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');
|
||||||
|
});
|
||||||
@@ -1786,6 +1786,7 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
'src/deployment/service-manager/serviceManagerIntent.ts',
|
'src/deployment/service-manager/serviceManagerIntent.ts',
|
||||||
'src/deployment/service-manager/serviceCutoverConsumer.ts',
|
'src/deployment/service-manager/serviceCutoverConsumer.ts',
|
||||||
'src/deployment/service-manager/legacy-rollback/preparation.ts',
|
'src/deployment/service-manager/legacy-rollback/preparation.ts',
|
||||||
|
'src/deployment/adopted-bundle/material.ts',
|
||||||
].includes(path.relative(packageDirectory, filePath)) &&
|
].includes(path.relative(packageDirectory, filePath)) &&
|
||||||
specifier ===
|
specifier ===
|
||||||
'@qinglong/local-sqlite/data-directory-application-commit'
|
'@qinglong/local-sqlite/data-directory-application-commit'
|
||||||
@@ -2634,6 +2635,14 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
) {
|
) {
|
||||||
continue;
|
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 (
|
if (
|
||||||
packagePath === 'packages/ql3-local-owner-cli' &&
|
packagePath === 'packages/ql3-local-owner-cli' &&
|
||||||
path.relative(packageDirectory, filePath) ===
|
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) => {
|
test('local AI application imports only the reviewed dynamic composition subpaths', (t) => {
|
||||||
const root = fs.mkdtempSync(
|
const root = fs.mkdtempSync(
|
||||||
path.join(os.tmpdir(), 'ql3-local-ai-application-boundary-'),
|
path.join(os.tmpdir(), 'ql3-local-ai-application-boundary-'),
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 131,
|
sourceFiles: 134,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 130,
|
nestedSourceFiles: 133,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user