mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +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,
|
||||
normalizeLocalDeploymentComposeRevisionCommand,
|
||||
type LocalDeploymentComposeRevisionResult,
|
||||
type NormalizedLocalDeploymentComposeService,
|
||||
type NormalizedLocalDeploymentComposeRevisionCommand,
|
||||
type NormalizedLocalDeploymentPrepareCommand,
|
||||
} from '../foundation/contract';
|
||||
@@ -265,13 +266,25 @@ export function initialComposeImageSelection(
|
||||
'initial compose selection requires a compose service',
|
||||
);
|
||||
}
|
||||
return initialComposeImageSelectionFromAuthority({
|
||||
service: command.options.service,
|
||||
mutationId: command.request.activateMutationId,
|
||||
changedAtMs: command.request.activatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function initialComposeImageSelectionFromAuthority(input: {
|
||||
readonly service: Readonly<NormalizedLocalDeploymentComposeService>;
|
||||
readonly mutationId: string;
|
||||
readonly changedAtMs: number;
|
||||
}): string {
|
||||
return selectionContents({
|
||||
generation: 1,
|
||||
previousGeneration: 0,
|
||||
rollbackTargetGeneration: 0,
|
||||
mutationId: command.request.activateMutationId,
|
||||
changedAtMs: command.request.activatedAtMs,
|
||||
...command.options.service.releaseSelection.authority,
|
||||
mutationId: input.mutationId,
|
||||
changedAtMs: input.changedAtMs,
|
||||
...input.service.releaseSelection.authority,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,17 @@ export {
|
||||
type LocalDeploymentStatusCommand,
|
||||
type LocalDeploymentStatusResult,
|
||||
} from './foundation/contract';
|
||||
export {
|
||||
prepareLocalDeploymentAdoptedBundle,
|
||||
runLocalDeploymentAdoptedBundleCommandFile,
|
||||
verifyLocalDeploymentAdoptedBundle,
|
||||
type LocalDeploymentAdoptedBundleResult,
|
||||
} from './adopted-bundle/adoptedBundle';
|
||||
export {
|
||||
normalizeLocalDeploymentAdoptedBundleCommand,
|
||||
type LocalDeploymentAdoptedBundleCommand,
|
||||
type LocalDeploymentAdoptedBundleOperation,
|
||||
} from './adopted-bundle/contract';
|
||||
export {
|
||||
type LocalComposeReleaseAuthority,
|
||||
type LocalComposeReleaseSelectionInput,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
proveLocalDeploymentLegacyReadinessCommandFile,
|
||||
restoreLocalDeploymentComposeCommitCommandFile,
|
||||
restoreLocalDeploymentComposePrepareCommandFile,
|
||||
runLocalDeploymentAdoptedBundleCommandFile,
|
||||
runLocalDeploymentCutoverManualCommandFile,
|
||||
runLocalDeploymentLegacyRollbackCommandFile,
|
||||
runLocalDeploymentDockerTargetCommandFile,
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
} from './localDeployment';
|
||||
|
||||
const USAGE =
|
||||
'Usage: ql3-local-deploy <prepare|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
'Usage: ql3-local-deploy <prepare|adopted-prepare|adopted-verify|status|service-intent-prepare|service-outcome-consume|service-cutover-consume|service-legacy-rollback-prepare|service-legacy-rollback-authorize|service-legacy-rollback-consume|cutover-legacy-stop|cutover-target-start|cutover-target-restart|cutover-target-stop|cutover-legacy-rollback-prepare|cutover-legacy-rollback-commit|cutover-legacy-readiness-probe|cutover-manual-diagnose|cutover-manual-resolution-prepare|cutover-manual-resolution-commit|compose-revision|compose-preflight|compose-apply|compose-restore-prepare|compose-restore-commit|compose-evidence-collect-prepare|compose-evidence-collect-commit> --command-file /absolute/private-command.json';
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
@@ -36,6 +37,8 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
if (
|
||||
argv.length !== 3 ||
|
||||
(argv[0] !== 'prepare' &&
|
||||
argv[0] !== 'adopted-prepare' &&
|
||||
argv[0] !== 'adopted-verify' &&
|
||||
argv[0] !== 'status' &&
|
||||
argv[0] !== 'service-intent-prepare' &&
|
||||
argv[0] !== 'service-outcome-consume' &&
|
||||
@@ -74,6 +77,13 @@ async function main(argv: readonly string[]): Promise<void> {
|
||||
try {
|
||||
const output = await (argv[0] === 'prepare'
|
||||
? prepareLocalDeploymentCommandFile(argv[2]!)
|
||||
: argv[0] === 'adopted-prepare' || argv[0] === 'adopted-verify'
|
||||
? runLocalDeploymentAdoptedBundleCommandFile(
|
||||
argv[2]!,
|
||||
argv[0] === 'adopted-prepare'
|
||||
? 'local.deployment.adopted.prepare'
|
||||
: 'local.deployment.adopted.verify',
|
||||
)
|
||||
: argv[0] === 'status'
|
||||
? inspectLocalDeploymentStatusCommandFile(argv[2]!)
|
||||
: argv[0] === 'service-intent-prepare'
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalDeploymentConfigurationError,
|
||||
prepareLocalDeploymentAdoptedBundle,
|
||||
verifyLocalDeploymentAdoptedBundle,
|
||||
} = require('../dist/deployment/localDeployment.js');
|
||||
const {
|
||||
createLocalDataDirectoryApplicationCommit,
|
||||
} = require('@qinglong/local-sqlite/data-directory-application-commit');
|
||||
const {
|
||||
normalizeLocalApplicationProcessConfig,
|
||||
} = require('../../ql3-local-application/dist/production-process/processConfig.js');
|
||||
|
||||
function rootAcknowledgement() {
|
||||
return process.getuid() === 0;
|
||||
}
|
||||
|
||||
function hexDigest(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function prefixedDigest(value) {
|
||||
return `sha256:${hexDigest(value)}`;
|
||||
}
|
||||
|
||||
function canonicalDigest(value) {
|
||||
return hexDigest(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function writePrivate(filePath, value) {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
typeof value === 'string' ? value : `${JSON.stringify(value)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
|
||||
function releaseSelection(managementRoot) {
|
||||
const image = `ghcr.io/example/qinglong3-local-application@sha256:${'a'.repeat(
|
||||
64,
|
||||
)}`;
|
||||
const releaseSetDigest = prefixedDigest(`release-set:${image}`);
|
||||
const manifestDigest = prefixedDigest(`catalog-manifest:${image}`);
|
||||
const consumptionReportDigest = prefixedDigest(`catalog-report:${image}`);
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/local-compose-release-image@v2',
|
||||
release: {
|
||||
version: '3.0.0-alpha.0',
|
||||
sourceRevision: '3'.repeat(40),
|
||||
sourceRef: 'refs/tags/v3.0.0-alpha.0',
|
||||
scope: 'local',
|
||||
},
|
||||
releaseSetDigest,
|
||||
catalog: {
|
||||
schema: 'qinglong/release-catalog-consumption-ceremony@v1',
|
||||
sourceRepository: 'example/qinglong',
|
||||
workflowIdentity:
|
||||
'https://github.com/example/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v3.0.0-alpha.0',
|
||||
immutableReference: `ghcr.io/example/qinglong3-release-catalog@${manifestDigest}`,
|
||||
manifestDigest,
|
||||
consumptionReportDigest,
|
||||
releaseSetDigest,
|
||||
discoveryTagAuthority: 'none',
|
||||
},
|
||||
deploymentFamily: 'local',
|
||||
service: {
|
||||
kind: 'compose',
|
||||
image,
|
||||
allowRootService: rootAcknowledgement(),
|
||||
},
|
||||
verification: {
|
||||
releaseSet: 'standalone_structure_identity_and_self_digest',
|
||||
sourceRecordsReplayed: false,
|
||||
catalogConsumption: 'offline_reconstructed',
|
||||
externalToolResultsReplayed: false,
|
||||
networkAccess: false,
|
||||
deploymentMutation: false,
|
||||
},
|
||||
};
|
||||
const selectionDigest = prefixedDigest(JSON.stringify(unsigned));
|
||||
const filePath = path.join(managementRoot, 'release-selection.json');
|
||||
writePrivate(filePath, { ...unsigned, selectionDigest });
|
||||
return {
|
||||
path: filePath,
|
||||
expectedSelectionDigest: selectionDigest,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t, kind) {
|
||||
const managementRoot = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-adopted-bundle-')),
|
||||
);
|
||||
fs.chmodSync(managementRoot, 0o700);
|
||||
t.after(() => fs.rmSync(managementRoot, { recursive: true, force: true }));
|
||||
const root = path.join(managementRoot, 'runtime');
|
||||
const serviceRoot = path.join(root, 'service');
|
||||
const cutoverId = 'cutover-edge-router-1';
|
||||
const cutoverRoot = path.join(serviceRoot, 'cutovers', cutoverId);
|
||||
const transformationRoot = path.join(root, 'transformation');
|
||||
const adoptionRoot = path.join(root, 'adoption');
|
||||
for (const directory of [
|
||||
root,
|
||||
path.join(root, 'owner-peppers'),
|
||||
path.join(root, 'owner-pepper-backup'),
|
||||
serviceRoot,
|
||||
path.join(serviceRoot, 'cutovers'),
|
||||
cutoverRoot,
|
||||
transformationRoot,
|
||||
adoptionRoot,
|
||||
]) {
|
||||
fs.mkdirSync(directory, { mode: 0o700 });
|
||||
fs.chmodSync(directory, 0o700);
|
||||
}
|
||||
writePrivate(path.join(root, 'local-secret-keyring.json'), '{}\n');
|
||||
const sourcePath = path.join(managementRoot, 'legacy.sqlite');
|
||||
const targetPath = path.join(adoptionRoot, 'target.sqlite');
|
||||
const recoveryPath = path.join(adoptionRoot, 'recovery.sqlite');
|
||||
const manifestPath = path.join(adoptionRoot, 'manifest.json');
|
||||
const activationPath = path.join(adoptionRoot, 'activation.json');
|
||||
writePrivate(sourcePath, 'legacy source\n');
|
||||
writePrivate(targetPath, 'adopted target\n');
|
||||
writePrivate(recoveryPath, 'legacy source\n');
|
||||
const manifestPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-sqlite-adoption-manifest-fixture',
|
||||
};
|
||||
const manifestDigest = canonicalDigest(manifestPayload);
|
||||
writePrivate(manifestPath, { ...manifestPayload, manifestDigest });
|
||||
const target = fs.statSync(targetPath, { bigint: true });
|
||||
const activationPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-sqlite-activation',
|
||||
state: 'prepared',
|
||||
profile: 'edge',
|
||||
createdAtMs: 1786416000000,
|
||||
adoptionManifestDigest: manifestDigest,
|
||||
planDigest: '2'.repeat(64),
|
||||
sourcePathDigest: hexDigest(sourcePath),
|
||||
sourceSha256: hexDigest(fs.readFileSync(sourcePath)),
|
||||
recoverySha256: hexDigest(fs.readFileSync(recoveryPath)),
|
||||
targetSha256: hexDigest(fs.readFileSync(targetPath)),
|
||||
targetPathDigest: hexDigest(targetPath),
|
||||
targetDevice: target.dev.toString(),
|
||||
targetInode: target.ino.toString(),
|
||||
};
|
||||
const activationDigest = canonicalDigest(activationPayload);
|
||||
writePrivate(activationPath, { ...activationPayload, activationDigest });
|
||||
const commitmentPayload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-legacy-silence-commitment',
|
||||
state: 'legacy_stopped',
|
||||
cutoverId,
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
activationDigest,
|
||||
requestedAtMs: 1786416000010,
|
||||
observedAtMs: 1786416000020,
|
||||
previousRecordDigest: '3'.repeat(64),
|
||||
controller: {
|
||||
kind: 'docker',
|
||||
endpointDigest: '4'.repeat(64),
|
||||
legacyContainerId: '5'.repeat(64),
|
||||
legacyContainerIdentityDigest: '6'.repeat(64),
|
||||
legacySourceBindingDigest: '7'.repeat(64),
|
||||
},
|
||||
};
|
||||
const commitmentDigest = canonicalDigest(commitmentPayload);
|
||||
const commitmentPath = path.join(cutoverRoot, '0002-legacy-stopped.json');
|
||||
writePrivate(commitmentPath, {
|
||||
...commitmentPayload,
|
||||
commitmentDigest,
|
||||
});
|
||||
const commit = createLocalDataDirectoryApplicationCommit({
|
||||
mutationId: '00000000-0000-4000-8000-000000000001',
|
||||
projectId: 'project-edge-router-1',
|
||||
profile: 'edge',
|
||||
sourceStageManifestDigest: '8'.repeat(64),
|
||||
transformationDigest: '9'.repeat(64),
|
||||
modelDigest: 'a'.repeat(64),
|
||||
publicationDigest: 'b'.repeat(64),
|
||||
receiptDigest: 'c'.repeat(64),
|
||||
committedAtMs: 1786416000025,
|
||||
receipt: {
|
||||
secretCount: 2,
|
||||
environmentSecretCount: 1,
|
||||
sshSecretCount: 1,
|
||||
},
|
||||
});
|
||||
const commitPath = path.join(transformationRoot, 'commit.json');
|
||||
writePrivate(commitPath, commit);
|
||||
const applicationEntrypoint = fs.realpathSync(
|
||||
path.resolve(__dirname, '../../ql3-local-application/dist/cli.js'),
|
||||
);
|
||||
const service =
|
||||
kind === 'compose'
|
||||
? {
|
||||
kind,
|
||||
releaseSelection: releaseSelection(managementRoot),
|
||||
allowRootService: rootAcknowledgement(),
|
||||
}
|
||||
: {
|
||||
kind,
|
||||
nodeExecutable: fs.realpathSync(process.execPath),
|
||||
applicationEntrypoint,
|
||||
allowRootService: rootAcknowledgement(),
|
||||
};
|
||||
const command = {
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.adopted.prepare',
|
||||
options: {
|
||||
deploymentRoot: root,
|
||||
profile: 'edge',
|
||||
instanceId: 'edge-router-1',
|
||||
busyTimeoutMs: 100,
|
||||
service,
|
||||
},
|
||||
request: {
|
||||
bundleId: '00000000-0000-4000-8000-000000000d88',
|
||||
preparedAtMs: 1786416000030,
|
||||
cutoverId,
|
||||
storage: {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
recoveryPath,
|
||||
manifestPath,
|
||||
activationPath,
|
||||
expectedActivationDigest: activationDigest,
|
||||
},
|
||||
cutover: {
|
||||
commitmentPath,
|
||||
expectedCommitmentDigest: commitmentDigest,
|
||||
},
|
||||
legacyDataApplication: {
|
||||
commitPath,
|
||||
expectedCommitDigest: commit.commitDigest,
|
||||
expectedReceiptDigest: commit.receiptDigest,
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
root,
|
||||
command,
|
||||
commitPath,
|
||||
commitmentPath,
|
||||
sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
for (const kind of ['systemd', 'openrc', 'compose']) {
|
||||
test(`prepares and verifies an exact adopted ${kind} bundle without activation`, (t) => {
|
||||
const state = fixture(t, kind);
|
||||
const prepared = prepareLocalDeploymentAdoptedBundle(state.command);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(prepared.service.kind, kind);
|
||||
assert.equal(
|
||||
prepareLocalDeploymentAdoptedBundle(state.command).status,
|
||||
'existing',
|
||||
);
|
||||
const verified = verifyLocalDeploymentAdoptedBundle({
|
||||
...state.command,
|
||||
operation: 'local.deployment.adopted.verify',
|
||||
});
|
||||
assert.equal(verified.status, 'verified');
|
||||
assert.equal(verified.bundleDigest, prepared.bundleDigest);
|
||||
const application = JSON.parse(
|
||||
fs.readFileSync(path.join(state.root, 'local-application.json'), 'utf8'),
|
||||
);
|
||||
assert.equal(
|
||||
normalizeLocalApplicationProcessConfig(application).schema,
|
||||
'qinglong/local-application-process@v4',
|
||||
);
|
||||
assert.equal(application.schema, 'qinglong/local-application-process@v4');
|
||||
assert.equal(application.storage.sourcePath, state.sourcePath);
|
||||
assert.equal(
|
||||
application.legacyDataApplication.expectedCommitDigest,
|
||||
state.command.request.legacyDataApplication.expectedCommitDigest,
|
||||
);
|
||||
const receipt = JSON.parse(
|
||||
fs.readFileSync(path.join(state.root, 'service/adopted-bundle.json')),
|
||||
);
|
||||
assert.equal(
|
||||
receipt.legacyDataApplicationReceiptDigest,
|
||||
state.command.request.legacyDataApplication.expectedReceiptDigest,
|
||||
);
|
||||
assert.equal(receipt.serviceKind, kind);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(state.root, 'service/intents')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(state.root, 'setup-receipt.json')),
|
||||
false,
|
||||
);
|
||||
if (kind === 'compose') {
|
||||
const descriptor = fs.readFileSync(
|
||||
path.join(state.root, 'service/compose.yaml'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
descriptor,
|
||||
new RegExp(
|
||||
`source: ${state.root.replaceAll(
|
||||
'/',
|
||||
'\\/',
|
||||
)}\\n target: ${state.root.replaceAll('/', '\\/')}`,
|
||||
),
|
||||
);
|
||||
assert.match(
|
||||
descriptor,
|
||||
new RegExp(
|
||||
`source: ${state.sourcePath.replaceAll(
|
||||
'/',
|
||||
'\\/',
|
||||
)}\\n target: ${state.sourcePath.replaceAll(
|
||||
'/',
|
||||
'\\/',
|
||||
)}\\n read_only: true`,
|
||||
),
|
||||
);
|
||||
assert.match(descriptor, /restart: "no"/);
|
||||
assert.doesNotMatch(descriptor, /\/var\/lib\/qinglong3/);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(state.root, 'service/compose.image.yaml'),
|
||||
'utf8',
|
||||
),
|
||||
fs.readFileSync(
|
||||
path.join(state.root, 'service/revisions/1.yaml'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(state.root, 'service/compose.image.yaml')),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects commit drift before publishing any bundle material', (t) => {
|
||||
const state = fixture(t, 'systemd');
|
||||
const commit = JSON.parse(fs.readFileSync(state.commitPath, 'utf8'));
|
||||
writePrivate(state.commitPath, { ...commit, receiptDigest: 'd'.repeat(64) });
|
||||
assert.throws(
|
||||
() => prepareLocalDeploymentAdoptedBundle(state.command),
|
||||
LocalDeploymentConfigurationError,
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(state.root, 'local-application.json')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(state.root, 'service/qinglong3.service')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(state.root, 'service/adopted-bundle.json')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('verify fails closed when a committed source fact drifts', (t) => {
|
||||
const state = fixture(t, 'openrc');
|
||||
prepareLocalDeploymentAdoptedBundle(state.command);
|
||||
const commitment = JSON.parse(fs.readFileSync(state.commitmentPath, 'utf8'));
|
||||
writePrivate(state.commitmentPath, {
|
||||
...commitment,
|
||||
observedAtMs: commitment.observedAtMs + 1,
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyLocalDeploymentAdoptedBundle({
|
||||
...state.command,
|
||||
operation: 'local.deployment.adopted.verify',
|
||||
}),
|
||||
LocalDeploymentConfigurationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('requires the legacy source to be the only authority outside deployment root', (t) => {
|
||||
const state = fixture(t, 'systemd');
|
||||
const sourceInside = path.join(state.root, 'legacy.sqlite');
|
||||
writePrivate(sourceInside, 'legacy source\n');
|
||||
assert.throws(
|
||||
() =>
|
||||
prepareLocalDeploymentAdoptedBundle({
|
||||
...state.command,
|
||||
request: {
|
||||
...state.command.request,
|
||||
storage: {
|
||||
...state.command.request.storage,
|
||||
sourcePath: sourceInside,
|
||||
},
|
||||
},
|
||||
}),
|
||||
LocalDeploymentConfigurationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('exposes separate exact prepare and verify CLI operations', (t) => {
|
||||
const state = fixture(t, 'systemd');
|
||||
const cli = path.resolve(
|
||||
__dirname,
|
||||
'../dist/deployment/localDeploymentCli.js',
|
||||
);
|
||||
const preparePath = path.join(
|
||||
path.dirname(state.root),
|
||||
'adopted-prepare.json',
|
||||
);
|
||||
writePrivate(preparePath, state.command);
|
||||
const prepared = spawnSync(
|
||||
process.execPath,
|
||||
[cli, 'adopted-prepare', '--command-file', preparePath],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(prepared.status, 0, prepared.stderr);
|
||||
assert.equal(JSON.parse(prepared.stdout).status, 'prepared');
|
||||
const verifyPath = path.join(path.dirname(state.root), 'adopted-verify.json');
|
||||
writePrivate(verifyPath, {
|
||||
...state.command,
|
||||
operation: 'local.deployment.adopted.verify',
|
||||
});
|
||||
const verified = spawnSync(
|
||||
process.execPath,
|
||||
[cli, 'adopted-verify', '--command-file', verifyPath],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(verified.status, 0, verified.stderr);
|
||||
assert.equal(JSON.parse(verified.stdout).status, 'verified');
|
||||
});
|
||||
@@ -1786,6 +1786,7 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
'src/deployment/service-manager/serviceManagerIntent.ts',
|
||||
'src/deployment/service-manager/serviceCutoverConsumer.ts',
|
||||
'src/deployment/service-manager/legacy-rollback/preparation.ts',
|
||||
'src/deployment/adopted-bundle/material.ts',
|
||||
].includes(path.relative(packageDirectory, filePath)) &&
|
||||
specifier ===
|
||||
'@qinglong/local-sqlite/data-directory-application-commit'
|
||||
@@ -2634,6 +2635,14 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
packagePath === 'packages/ql3-local-owner-cli' &&
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/deployment/adopted-bundle/material.ts' &&
|
||||
specifier === '@qinglong/local-sqlite/data-directory-application-commit'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
packagePath === 'packages/ql3-local-owner-cli' &&
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
|
||||
@@ -2567,6 +2567,47 @@ test('service-manager Owner consumers receive only the pure data commit codec',
|
||||
);
|
||||
});
|
||||
|
||||
test('adopted deployment material receives only the pure data commit codec', (t) => {
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-adopted-bundle-codec-boundary-'),
|
||||
);
|
||||
const bundleDirectory = path.join(
|
||||
root,
|
||||
'packages/ql3-local-owner-cli/src/deployment/adopted-bundle',
|
||||
);
|
||||
fs.mkdirSync(bundleDirectory, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(bundleDirectory, 'material.ts'),
|
||||
[
|
||||
"import { normalize } from '@qinglong/local-sqlite/data-directory-application-commit';",
|
||||
"import { mutate } from '@qinglong/local-sqlite/data-directory-adoption';",
|
||||
].join('\n'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(bundleDirectory, 'neighbor.ts'),
|
||||
"import { normalize } from '@qinglong/local-sqlite/data-directory-application-commit';",
|
||||
);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const findings = [];
|
||||
auditSourceImports(root, 'packages/ql3-local-owner-cli', findings);
|
||||
assert.deepEqual(
|
||||
findings.map(({ code, file, specifier }) => ({ code, file, specifier })),
|
||||
[
|
||||
{
|
||||
code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts',
|
||||
specifier: '@qinglong/local-sqlite/data-directory-adoption',
|
||||
},
|
||||
{
|
||||
code: 'FORBIDDEN_LOCAL_ADOPTION_CLI_AUTHORITY_IMPORT',
|
||||
file: 'packages/ql3-local-owner-cli/src/deployment/adopted-bundle/neighbor.ts',
|
||||
specifier: '@qinglong/local-sqlite/data-directory-application-commit',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('local AI application imports only the reviewed dynamic composition subpaths', (t) => {
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-local-ai-application-boundary-'),
|
||||
|
||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 131,
|
||||
sourceFiles: 134,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 50,
|
||||
nestedSourceFiles: 130,
|
||||
nestedSourceFiles: 133,
|
||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user