feat(ql3): rehearse legacy sqlite upgrade

This commit is contained in:
whyour
2026-08-21 00:52:58 +08:00
parent 4ea156f189
commit c9e41812cb
23 changed files with 1533 additions and 23 deletions
@@ -107,6 +107,7 @@ function verifyActivation(
'recoverySha256',
'schemaVersion',
'sourcePathDigest',
'sourceSha256',
'state',
'targetDevice',
'targetInode',
@@ -129,6 +130,8 @@ function verifyActivation(
if (
typeof activationDigest !== 'string' ||
!DIGEST_PATTERN.test(activationDigest) ||
typeof activation.sourceSha256 !== 'string' ||
!DIGEST_PATTERN.test(activation.sourceSha256) ||
digest(payload) !== activationDigest
) {
configurationError('activation digest does not match');
@@ -14,7 +14,7 @@ const HASH_BUFFER_BYTES = 64 * 1024;
export interface TargetDataReconciliationEvidence {
readonly disposition: LocalDeploymentTargetReconciliationDisposition;
readonly targetMatchesActivation: boolean | null;
readonly sourceMatchesRecovery: boolean | null;
readonly sourceMatchesActivation: boolean | null;
readonly targetSidecarsClear: boolean | null;
readonly sourceSidecarsClear: boolean | null;
readonly targetFileIdentityDigest: string;
@@ -194,6 +194,8 @@ export function readTargetDataReconciliationEvidenceForPaths(
!DIGEST_PATTERN.test(activation.targetSha256) ||
typeof activation.recoverySha256 !== 'string' ||
!DIGEST_PATTERN.test(activation.recoverySha256) ||
typeof activation.sourceSha256 !== 'string' ||
!DIGEST_PATTERN.test(activation.sourceSha256) ||
typeof activation.targetDevice !== 'string' ||
typeof activation.targetInode !== 'string' ||
cutoverDigest(payload) !== activationDigest
@@ -218,17 +220,17 @@ export function readTargetDataReconciliationEvidenceForPaths(
throw new Error('target database stable identity drifted');
}
const targetMatchesActivation = target.sha256 === activation.targetSha256;
const sourceMatchesRecovery = source.sha256 === activation.recoverySha256;
const sourceMatchesActivation = source.sha256 === activation.sourceSha256;
const disposition =
!targetMatchesActivation || !target.sidecarsClear
? ('reconciliation_required' as const)
: sourceMatchesRecovery && source.sidecarsClear
: sourceMatchesActivation && source.sidecarsClear
? ('rollback_candidate' as const)
: ('manual_review' as const);
return evidence({
disposition,
targetMatchesActivation,
sourceMatchesRecovery,
sourceMatchesActivation,
targetSidecarsClear: target.sidecarsClear,
sourceSidecarsClear: source.sidecarsClear,
targetFileIdentityDigest: target.identityDigest,
@@ -238,7 +240,7 @@ export function readTargetDataReconciliationEvidenceForPaths(
return evidence({
disposition: 'manual_review',
targetMatchesActivation: null,
sourceMatchesRecovery: null,
sourceMatchesActivation: null,
targetSidecarsClear: null,
sourceSidecarsClear: null,
targetFileIdentityDigest: UNKNOWN_DIGEST,
@@ -256,7 +258,7 @@ export function verifyTargetDataReconciliationEvidence(
'disposition',
'evidenceDigest',
'sourceFileIdentityDigest',
'sourceMatchesRecovery',
'sourceMatchesActivation',
'sourceSidecarsClear',
'targetFileIdentityDigest',
'targetMatchesActivation',
@@ -270,8 +272,8 @@ export function verifyTargetDataReconciliationEvidence(
candidate.disposition !== 'manual_review') ||
(candidate.targetMatchesActivation !== null &&
typeof candidate.targetMatchesActivation !== 'boolean') ||
(candidate.sourceMatchesRecovery !== null &&
typeof candidate.sourceMatchesRecovery !== 'boolean') ||
(candidate.sourceMatchesActivation !== null &&
typeof candidate.sourceMatchesActivation !== 'boolean') ||
(candidate.targetSidecarsClear !== null &&
typeof candidate.targetSidecarsClear !== 'boolean') ||
(candidate.sourceSidecarsClear !== null &&
@@ -147,6 +147,7 @@ export function verifyTargetRunActivation(
'recoverySha256',
'schemaVersion',
'sourcePathDigest',
'sourceSha256',
'state',
'targetDevice',
'targetInode',
@@ -166,6 +167,8 @@ export function verifyTargetRunActivation(
activationDigest !== command.request.expectedActivationDigest ||
typeof activationDigest !== 'string' ||
!DIGEST_PATTERN.test(activationDigest) ||
typeof activation.sourceSha256 !== 'string' ||
!DIGEST_PATTERN.test(activation.sourceSha256) ||
cutoverDigest(payload) !== activationDigest
) {
configurationError('activation does not match the target run request');
@@ -404,6 +404,8 @@ function verifyAdoptedEvidence(
activation.profile !== intent.profile ||
activation.sourcePathDigest !== textDigest(binding.sourcePath) ||
activation.targetPathDigest !== textDigest(binding.targetPath) ||
typeof activation.sourceSha256 !== 'string' ||
!DIGEST_PATTERN.test(activation.sourceSha256) ||
typeof activation.recoverySha256 !== 'string' ||
!DIGEST_PATTERN.test(activation.recoverySha256) ||
typeof activation.adoptionManifestDigest !== 'string' ||
@@ -451,7 +453,7 @@ function verifyAdoptedEvidence(
if (
activation.targetDevice !== target.device ||
activation.targetInode !== target.inode ||
sourceSha256 !== activation.recoverySha256 ||
sourceSha256 !== activation.sourceSha256 ||
recoverySha256 !== activation.recoverySha256
) {
configurationError('adopted data evidence drifted');
@@ -1,7 +1,7 @@
#!/usr/bin/env node
// Keep the one-shot adoption binary beside its lifecycle command.
import { runLegacyCrontabAdoptionCommandFile } from './adoption';
import { runLocalAdoptionProductCommandFile } from './adoptionCommand';
const USAGE =
'Usage: ql3-adoption run --command-file /absolute/private-command.json';
@@ -40,7 +40,7 @@ async function main(argv: readonly string[]): Promise<void> {
return;
}
try {
const result = await runLegacyCrontabAdoptionCommandFile(argv[2]!);
const result = await runLocalAdoptionProductCommandFile(argv[2]!);
process.stdout.write(`${JSON.stringify(result)}\n`);
} catch (error) {
const candidate = error as {
@@ -0,0 +1,42 @@
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { runLegacyCrontabAdoptionCommandFile } from './adoption';
import {
isLocalSqliteAdoptionProductOperation,
type LocalSqliteAdoptionProductOperation,
} from './sqlite-adoption/contract';
import type { LocalSqliteAdoptionProductCommandResult } from './sqlite-adoption/command';
export type LocalAdoptionProductCommandResult =
| Awaited<ReturnType<typeof runLegacyCrontabAdoptionCommandFile>>
| LocalSqliteAdoptionProductCommandResult;
function operation(value: unknown): unknown {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as { readonly operation?: unknown }).operation
: undefined;
}
export async function runLocalAdoptionProductCommandFile(
commandFilePath: string,
): Promise<Readonly<LocalAdoptionProductCommandResult>> {
let candidate: unknown;
try {
candidate = readPrivateLocalCommandFile(commandFilePath);
} catch {
// Preserve the established legacy error mapping for unreadable files.
return runLegacyCrontabAdoptionCommandFile(commandFilePath);
}
const selected = operation(candidate);
if (!isLocalSqliteAdoptionProductOperation(selected)) {
return runLegacyCrontabAdoptionCommandFile(commandFilePath);
}
const { runLocalSqliteAdoptionProductCommand } = await import(
'./sqlite-adoption/command.js'
);
return runLocalSqliteAdoptionProductCommand(
candidate as {
readonly operation: LocalSqliteAdoptionProductOperation;
},
);
}
@@ -0,0 +1,431 @@
import fs from 'node:fs';
import path from 'node:path';
import {
inspectLegacySqlitePath,
prepareLocalSqliteActivation,
stageLocalSqliteAdoption,
verifyLocalSqliteAdoption,
type LegacySqliteAdoptionPlan,
type LocalSqliteActivation,
type LocalSqliteAdoptionManifest,
} from '@qinglong/local-admin';
import {
LocalSqliteAdoptionCliConfigurationError,
normalizeLocalSqliteAdoptionProductCommand,
type LocalSqliteAdoptionProductCommand,
} from './contract';
interface StableFileIdentity {
readonly path: string;
readonly device: bigint;
readonly inode: bigint;
readonly size: bigint;
readonly modifiedAtNs: bigint;
readonly changedAtNs: bigint;
readonly mode: number;
readonly uid: number;
}
interface StableDirectoryIdentity {
readonly path: string;
readonly device: bigint;
readonly inode: bigint;
readonly mode: number;
readonly uid: number;
}
export type LocalSqliteAdoptionProductCommandResult = Readonly<{
schemaVersion: 1;
operation: LocalSqliteAdoptionProductCommand['operation'];
status: 'inspected' | 'staged' | 'verified' | 'prepared';
evidence: Readonly<Record<string, unknown>>;
}>;
function currentUid(): number {
if (
typeof process.getuid !== 'function' ||
typeof process.geteuid !== 'function' ||
process.getuid() !== process.geteuid()
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'real and effective POSIX users must match',
);
}
return process.getuid();
}
function inside(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return (
relative !== '' &&
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
function directoryIdentity(
directoryPath: string,
uid: number,
label: string,
): StableDirectoryIdentity {
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(directoryPath, { bigint: true });
} catch (error) {
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} is unavailable`,
error,
);
}
const mode = Number(stat.mode) & 0o777;
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== uid ||
mode !== 0o700 ||
fs.realpathSync(directoryPath) !== directoryPath
) {
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} must be an owner-controlled 0700 canonical directory`,
);
}
return Object.freeze({
path: directoryPath,
device: stat.dev,
inode: stat.ino,
mode,
uid,
});
}
function fileIdentity(
filePath: string,
uid: number,
label: string,
requirePrivateMode: boolean,
): StableFileIdentity {
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(filePath, { bigint: true });
} catch (error) {
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} is unavailable`,
error,
);
}
const mode = Number(stat.mode) & 0o777;
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.nlink !== 1n ||
Number(stat.uid) !== uid ||
(requirePrivateMode ? mode !== 0o600 : (mode & 0o022) !== 0) ||
fs.realpathSync(filePath) !== filePath ||
stat.size < 1n
) {
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} identity or mode is invalid`,
);
}
return Object.freeze({
path: filePath,
device: stat.dev,
inode: stat.ino,
size: stat.size,
modifiedAtNs: stat.mtimeNs,
changedAtNs: stat.ctimeNs,
mode,
uid,
});
}
function sameDirectory(expected: StableDirectoryIdentity): void {
const actual = directoryIdentity(
expected.path,
expected.uid,
'authority directory',
);
if (
actual.device !== expected.device ||
actual.inode !== expected.inode ||
actual.mode !== expected.mode
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'authority directory changed during command execution',
);
}
}
function sameFile(expected: StableFileIdentity): void {
const actual = fileIdentity(
expected.path,
expected.uid,
'authority file',
expected.mode === 0o600,
);
if (
actual.device !== expected.device ||
actual.inode !== expected.inode ||
actual.size !== expected.size ||
actual.modifiedAtNs !== expected.modifiedAtNs ||
actual.changedAtNs !== expected.changedAtNs ||
actual.mode !== expected.mode
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'authority file changed during command execution',
);
}
}
function assertMissing(filePath: string, label: string): void {
try {
fs.lstatSync(filePath);
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return;
}
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} cannot be inspected`,
error,
);
}
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} must not already exist`,
);
}
function authorityProof(command: Readonly<LocalSqliteAdoptionProductCommand>): {
readonly uid: number;
verify(): void;
verifyCreated(paths: readonly string[]): void;
} {
const uid = currentUid();
const options = command.options;
const root = directoryIdentity(options.deploymentRoot, uid, 'deploymentRoot');
const sourcePaths =
'sourcePath' in options ? [options.sourcePath] : ([] as string[]);
const immutablePaths = [
...sourcePaths,
...(command.operation === 'local-sqlite.adoption.verify' ||
command.operation === 'local-sqlite.activation.prepare'
? [
command.options.targetPath,
command.options.recoveryPath,
command.options.manifestPath,
]
: []),
];
const files = immutablePaths.map((candidate) =>
fileIdentity(
candidate,
uid,
candidate === ('sourcePath' in options ? options.sourcePath : undefined)
? 'legacy source'
: 'adoption evidence',
candidate !== ('sourcePath' in options ? options.sourcePath : undefined),
),
);
const outputPaths =
command.operation === 'local-sqlite.adoption.stage'
? [
command.options.targetPath,
command.options.recoveryPath,
command.options.manifestPath,
]
: command.operation === 'local-sqlite.activation.prepare'
? [command.options.activationPath]
: [];
const outputDirectories = [...new Set(outputPaths.map(path.dirname))].map(
(directory) => {
if (
!inside(options.deploymentRoot, directory) &&
directory !== options.deploymentRoot
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'adoption outputs must remain inside deploymentRoot',
);
}
return directoryIdentity(directory, uid, 'output directory');
},
);
for (const outputPath of outputPaths) {
if (!inside(options.deploymentRoot, outputPath)) {
throw new LocalSqliteAdoptionCliConfigurationError(
'adoption outputs must remain inside deploymentRoot',
);
}
assertMissing(outputPath, 'adoption output');
}
const uniqueFiles = new Set(
files.map((entry) => `${entry.device}:${entry.inode}`),
);
if (uniqueFiles.size !== files.length) {
throw new LocalSqliteAdoptionCliConfigurationError(
'adoption authority files must not share an inode',
);
}
return Object.freeze({
uid,
verify() {
if (currentUid() !== uid) {
throw new LocalSqliteAdoptionCliConfigurationError(
'POSIX user changed during command execution',
);
}
sameDirectory(root);
for (const directory of outputDirectories) sameDirectory(directory);
for (const file of files) sameFile(file);
},
verifyCreated(paths: readonly string[]) {
for (const filePath of paths) {
fileIdentity(filePath, uid, 'created adoption output', true);
}
},
});
}
function planEvidence(plan: Readonly<LegacySqliteAdoptionPlan>) {
return Object.freeze({
profile: plan.profile,
planDigest: plan.planDigest,
source: Object.freeze({
fileName: plan.source.fileName,
pathDigest: plan.source.pathDigest,
bytes: plan.source.bytes,
}),
catalog: Object.freeze({
digest: plan.catalog.digest,
objectCount: plan.catalog.objectCount,
tableCount: plan.catalog.tableNames.length,
tableNames: plan.catalog.tableNames,
}),
tasks: plan.tasks,
});
}
function adoptionEvidence(manifest: Readonly<LocalSqliteAdoptionManifest>) {
return Object.freeze({
profile: manifest.profile,
planDigest: manifest.planDigest,
manifestDigest: manifest.manifestDigest,
createdAtMs: manifest.createdAtMs,
source: Object.freeze({
fileName: manifest.source.fileName,
pathDigest: manifest.source.pathDigest,
bytes: manifest.source.bytes,
}),
catalog: Object.freeze({
digest: manifest.catalog.digest,
objectCount: manifest.catalog.objectCount,
tableCount: manifest.catalog.tableNames.length,
}),
tasks: manifest.tasks,
recovery: manifest.recovery,
target: manifest.target,
readiness: manifest.readiness,
});
}
function activationEvidence(activation: Readonly<LocalSqliteActivation>) {
return Object.freeze({ ...activation });
}
export async function runLocalSqliteAdoptionProductCommand(
value: unknown,
): Promise<LocalSqliteAdoptionProductCommandResult> {
const command = normalizeLocalSqliteAdoptionProductCommand(value);
const proof = authorityProof(command);
if (command.operation === 'local-sqlite.adoption.inspect') {
const options = command.options;
const plan = inspectLegacySqlitePath({
sourcePath: options.sourcePath,
profile: options.profile,
...(options.legacyTimezone === undefined
? {}
: { legacyTimezone: options.legacyTimezone }),
});
proof.verify();
return Object.freeze({
schemaVersion: 1,
operation: command.operation,
status: 'inspected',
evidence: planEvidence(plan),
});
}
if (command.operation === 'local-sqlite.adoption.stage') {
const options = command.options;
const manifest = await stageLocalSqliteAdoption({
sourcePath: options.sourcePath,
targetPath: options.targetPath,
recoveryPath: options.recoveryPath,
manifestPath: options.manifestPath,
profile: options.profile,
expectedPlanDigest: options.expectedPlanDigest,
...(options.legacyTimezone === undefined
? {}
: { legacyTimezone: options.legacyTimezone }),
});
proof.verify();
proof.verifyCreated([
options.targetPath,
options.recoveryPath,
options.manifestPath,
]);
return Object.freeze({
schemaVersion: 1,
operation: command.operation,
status: 'staged',
evidence: adoptionEvidence(manifest),
});
}
if (command.operation === 'local-sqlite.adoption.verify') {
const options = command.options;
const manifest = await verifyLocalSqliteAdoption({
targetPath: options.targetPath,
recoveryPath: options.recoveryPath,
manifestPath: options.manifestPath,
});
if (manifest.profile !== options.profile) {
throw new LocalSqliteAdoptionCliConfigurationError(
'verified adoption profile drifted',
);
}
proof.verify();
return Object.freeze({
schemaVersion: 1,
operation: command.operation,
status: 'verified',
evidence: adoptionEvidence(manifest),
});
}
const options = command.options;
const activation = await prepareLocalSqliteActivation({
sourcePath: options.sourcePath,
targetPath: options.targetPath,
recoveryPath: options.recoveryPath,
manifestPath: options.manifestPath,
activationPath: options.activationPath,
expectedManifestDigest: options.expectedManifestDigest,
});
if (activation.profile !== options.profile) {
throw new LocalSqliteAdoptionCliConfigurationError(
'prepared activation profile drifted',
);
}
proof.verify();
proof.verifyCreated([options.activationPath]);
return Object.freeze({
schemaVersion: 1,
operation: command.operation,
status: 'prepared',
evidence: activationEvidence(activation),
});
}
@@ -0,0 +1,244 @@
import path from 'node:path';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const MAX_PATH_BYTES = 4_096;
export type LocalSqliteAdoptionProductOperation =
| 'local-sqlite.adoption.inspect'
| 'local-sqlite.adoption.stage'
| 'local-sqlite.adoption.verify'
| 'local-sqlite.activation.prepare';
interface LocalSqliteAdoptionCommandOptionsBase {
readonly deploymentRoot: string;
readonly profile: 'edge' | 'standalone';
}
export interface InspectLocalSqliteAdoptionCommand {
readonly schemaVersion: 1;
readonly operation: 'local-sqlite.adoption.inspect';
readonly options: LocalSqliteAdoptionCommandOptionsBase & {
readonly sourcePath: string;
readonly legacyTimezone?: string;
};
}
export interface StageLocalSqliteAdoptionCommand {
readonly schemaVersion: 1;
readonly operation: 'local-sqlite.adoption.stage';
readonly options: LocalSqliteAdoptionCommandOptionsBase & {
readonly sourcePath: string;
readonly targetPath: string;
readonly recoveryPath: string;
readonly manifestPath: string;
readonly expectedPlanDigest: string;
readonly legacyTimezone?: string;
};
}
export interface VerifyLocalSqliteAdoptionCommand {
readonly schemaVersion: 1;
readonly operation: 'local-sqlite.adoption.verify';
readonly options: LocalSqliteAdoptionCommandOptionsBase & {
readonly targetPath: string;
readonly recoveryPath: string;
readonly manifestPath: string;
};
}
export interface PrepareLocalSqliteActivationCommand {
readonly schemaVersion: 1;
readonly operation: 'local-sqlite.activation.prepare';
readonly options: LocalSqliteAdoptionCommandOptionsBase & {
readonly sourcePath: string;
readonly targetPath: string;
readonly recoveryPath: string;
readonly manifestPath: string;
readonly activationPath: string;
readonly expectedManifestDigest: string;
};
}
export type LocalSqliteAdoptionProductCommand =
| InspectLocalSqliteAdoptionCommand
| StageLocalSqliteAdoptionCommand
| VerifyLocalSqliteAdoptionCommand
| PrepareLocalSqliteActivationCommand;
export class LocalSqliteAdoptionCliConfigurationError extends TypeError {
readonly code = 'LOCAL_SQLITE_ADOPTION_CLI_CONFIGURATION_INVALID';
constructor(message: string, readonly cause?: unknown) {
super(`Local SQLite adoption CLI configuration is invalid: ${message}`);
this.name = 'LocalSqliteAdoptionCliConfigurationError';
}
}
function exactKeys(value: object, expected: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
return (
actual.length === canonical.length &&
actual.every((key, index) => key === canonical[index])
);
}
function boundedPath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.parse(value).root === value ||
path.normalize(value) !== value ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} must be a normalized bounded absolute non-root path`,
);
}
return value;
}
function optionalTimezone(options: Record<string, unknown>): string[] {
return options.legacyTimezone === undefined ? [] : ['legacyTimezone'];
}
function assertTimezone(value: unknown): void {
if (
value !== undefined &&
(typeof value !== 'string' ||
value.length < 1 ||
value.length > 128 ||
/[\0\r\n]/.test(value))
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'legacyTimezone is invalid',
);
}
}
function assertDistinct(paths: readonly string[]): void {
const resolved = paths.map((value) => path.resolve(value));
if (new Set(resolved).size !== resolved.length) {
throw new LocalSqliteAdoptionCliConfigurationError(
'SQLite adoption paths must be distinct',
);
}
}
export function isLocalSqliteAdoptionProductOperation(
value: unknown,
): value is LocalSqliteAdoptionProductOperation {
return (
value === 'local-sqlite.adoption.inspect' ||
value === 'local-sqlite.adoption.stage' ||
value === 'local-sqlite.adoption.verify' ||
value === 'local-sqlite.activation.prepare'
);
}
export function normalizeLocalSqliteAdoptionProductCommand(
value: unknown,
): Readonly<LocalSqliteAdoptionProductCommand> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['schemaVersion', 'operation', 'options'])
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'command shape is invalid',
);
}
const candidate = value as Record<string, unknown>;
if (
candidate.schemaVersion !== 1 ||
!isLocalSqliteAdoptionProductOperation(candidate.operation) ||
!candidate.options ||
typeof candidate.options !== 'object' ||
Array.isArray(candidate.options)
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'command value is invalid',
);
}
const options = candidate.options as Record<string, unknown>;
const expected =
candidate.operation === 'local-sqlite.adoption.inspect'
? [
'deploymentRoot',
...optionalTimezone(options),
'profile',
'sourcePath',
]
: candidate.operation === 'local-sqlite.adoption.stage'
? [
'deploymentRoot',
'expectedPlanDigest',
...optionalTimezone(options),
'manifestPath',
'profile',
'recoveryPath',
'sourcePath',
'targetPath',
]
: candidate.operation === 'local-sqlite.adoption.verify'
? [
'deploymentRoot',
'manifestPath',
'profile',
'recoveryPath',
'targetPath',
]
: [
'activationPath',
'deploymentRoot',
'expectedManifestDigest',
'manifestPath',
'profile',
'recoveryPath',
'sourcePath',
'targetPath',
];
if (
!exactKeys(options, expected) ||
(options.profile !== 'edge' && options.profile !== 'standalone')
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'command options are invalid',
);
}
assertTimezone(options.legacyTimezone);
boundedPath(options.deploymentRoot, 'deploymentRoot');
for (const key of expected.filter((name) => name.endsWith('Path'))) {
boundedPath(options[key], key);
}
if (
candidate.operation === 'local-sqlite.adoption.stage' &&
(typeof options.expectedPlanDigest !== 'string' ||
!DIGEST_PATTERN.test(options.expectedPlanDigest))
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'expectedPlanDigest is invalid',
);
}
if (
candidate.operation === 'local-sqlite.activation.prepare' &&
(typeof options.expectedManifestDigest !== 'string' ||
!DIGEST_PATTERN.test(options.expectedManifestDigest))
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'expectedManifestDigest is invalid',
);
}
assertDistinct(
[
options.sourcePath,
options.targetPath,
options.recoveryPath,
options.manifestPath,
options.activationPath,
].filter((entry): entry is string => typeof entry === 'string'),
);
return Object.freeze(value as LocalSqliteAdoptionProductCommand);
}
@@ -154,6 +154,10 @@ function fixture(t) {
.createHash('sha256')
.update(legacySourcePath, 'utf8')
.digest('hex'),
sourceSha256: crypto
.createHash('sha256')
.update(fs.readFileSync(legacySourcePath))
.digest('hex'),
recoverySha256: crypto
.createHash('sha256')
.update(fs.readFileSync(recoveryPath))
@@ -917,7 +921,7 @@ test('stops an active target and proves an unchanged rollback candidate', async
assert.equal(request.state, 'target_stop_requested');
assert.equal(outcome.state, 'target_stopped');
assert.equal(outcome.evidence.reconciliation.targetMatchesActivation, true);
assert.equal(outcome.evidence.reconciliation.sourceMatchesRecovery, true);
assert.equal(outcome.evidence.reconciliation.sourceMatchesActivation, true);
const replay = stopLocalDeploymentDockerTarget(stopCommand(state), {
validateSocket() {
@@ -1304,7 +1308,7 @@ test('treats target SQLite sidecars as reconciliation-required', async (t) => {
assert.equal(outcome.evidence.reconciliation.targetSidecarsClear, false);
});
test('requires manual review when legacy source no longer matches recovery', async (t) => {
test('requires manual review when legacy source no longer matches activation', async (t) => {
const state = fixture(t);
await runLocalDeploymentDockerTarget(command(state), harness(state));
fs.appendFileSync(state.legacySourcePath, 'offline-legacy-drift\n');
@@ -1320,7 +1324,7 @@ test('requires manual review when legacy source no longer matches recovery', asy
),
);
assert.equal(outcome.evidence.reconciliation.targetMatchesActivation, true);
assert.equal(outcome.evidence.reconciliation.sourceMatchesRecovery, false);
assert.equal(outcome.evidence.reconciliation.sourceMatchesActivation, false);
});
test('recovers a crash after the stop barrier by converging stop-and-inspect', async (t) => {
@@ -168,6 +168,10 @@ function legacyStopCommand(state, cutoverId = 'cutover-edge-1') {
.createHash('sha256')
.update(path.resolve(legacySourcePath), 'utf8')
.digest('hex'),
sourceSha256: crypto
.createHash('sha256')
.update(fs.readFileSync(legacySourcePath))
.digest('hex'),
recoverySha256: '4'.repeat(64),
targetSha256: '5'.repeat(64),
targetPathDigest: '6'.repeat(64),
@@ -90,6 +90,7 @@ function fixture(t) {
adoptionManifestDigest: manifestDigest,
planDigest: '2'.repeat(64),
sourcePathDigest: sha256(sourcePath),
sourceSha256: sha256(fs.readFileSync(sourcePath)),
recoverySha256: sha256(fs.readFileSync(recoveryPath)),
targetSha256: sha256(fs.readFileSync(targetPath)),
targetPathDigest: sha256(targetPath),
@@ -694,7 +695,7 @@ test('prepares and exactly replays lossless service-manager legacy rollback evid
);
assert.equal(record.stoppedRecordDigest, stoppedResult.recordDigest);
assert.equal(record.reconciliation.targetMatchesActivation, true);
assert.equal(record.reconciliation.sourceMatchesRecovery, true);
assert.equal(record.reconciliation.sourceMatchesActivation, true);
assert.equal(record.reconciliation.targetSidecarsClear, true);
assert.equal(record.reconciliation.sourceSidecarsClear, true);
const replay = prepareLocalServiceManagerLegacyRollback(command);
@@ -0,0 +1,428 @@
const assert = require('node:assert/strict');
const { createHash } = require('node:crypto');
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const { test } = require('node:test');
const {
bootstrapLocalAdoptedProfileStorage,
} = require('@qinglong/local-admin/adopted-profile');
const {
readTargetDataReconciliationEvidenceForPaths,
} = require('../dist/deployment/cutover/targetDataEvidence');
const BINARY = path.join(__dirname, '../dist/lifecycle/adoptionCli.js');
function sha256(filePath) {
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function createLegacyDatabase(sourcePath) {
const source = new DatabaseSync(sourcePath);
source.exec(`
CREATE TABLE "Crontabs" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255),
command VARCHAR(255), schedule VARCHAR(255), timestamp VARCHAR(255),
saved TINYINT(1), status DECIMAL, isSystem DECIMAL, pid DECIMAL,
isDisabled DECIMAL, isPinned DECIMAL, log_path VARCHAR(255), labels JSON,
last_running_time DECIMAL, last_execution_time DECIMAL, sub_id DECIMAL,
extra_schedules JSON, task_before VARCHAR(255), task_after VARCHAR(255),
log_name VARCHAR(255), allow_multiple_instances DECIMAL,
work_dir VARCHAR(255), createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Dependences" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), type DECIMAL,
timestamp VARCHAR(255), status DECIMAL, log JSON, remark VARCHAR(255),
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Apps" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), scopes JSON,
client_id VARCHAR(255), client_secret VARCHAR(255), tokens JSON,
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Auths" (
id INTEGER PRIMARY KEY AUTOINCREMENT, ip VARCHAR(255), type VARCHAR(255),
info JSON, createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Envs" (
id INTEGER PRIMARY KEY AUTOINCREMENT, value VARCHAR(255),
timestamp VARCHAR(255), status DECIMAL, position DECIMAL,
name VARCHAR(255), remarks VARCHAR(255), isPinned DECIMAL, labels JSON,
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "Subscriptions" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), url VARCHAR(255),
schedule VARCHAR(255), interval_schedule JSON, type VARCHAR(255),
whitelist VARCHAR(255), blacklist VARCHAR(255), status DECIMAL,
dependences VARCHAR(255), extensions VARCHAR(255), sub_before VARCHAR(255),
sub_after VARCHAR(255), branch VARCHAR(255), pull_type VARCHAR(255),
pull_option JSON, pid DECIMAL, is_disabled DECIMAL, log_path VARCHAR(255),
schedule_type VARCHAR(255), alias VARCHAR(255), proxy VARCHAR(255),
autoAddCron DECIMAL, autoDelCron DECIMAL,
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "CrontabViews" (
id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), position DECIMAL,
isDisabled DECIMAL, filters JSON, sorts JSON, filterRelation VARCHAR(255),
type DECIMAL, createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "CrontabStats" (
id INTEGER PRIMARY KEY AUTOINCREMENT, ref_id DECIMAL NOT NULL,
date VARCHAR(255) NOT NULL, run_count DECIMAL, success_count DECIMAL,
fail_count DECIMAL, total_time DECIMAL, max_time DECIMAL,
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "RunningInstances" (
id INTEGER PRIMARY KEY AUTOINCREMENT, cron_id DECIMAL NOT NULL,
run_id VARCHAR(36), attempt_id VARCHAR(36), pid DECIMAL,
log_path VARCHAR(255), started_at DECIMAL NOT NULL, finished_at DECIMAL,
status DECIMAL NOT NULL, exit_code DECIMAL,
createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL
);
CREATE TABLE "PluginOwnedState" (
id INTEGER PRIMARY KEY, payload TEXT NOT NULL
);
INSERT INTO "Crontabs" (
id, name, command, schedule, status, isDisabled, isPinned,
createdAt, updatedAt
) VALUES (
1, 'Production-shaped legacy task', 'task /scripts/legacy.sh',
'0 0 * * *', 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);
INSERT INTO "Envs" (
id, name, value, status, position, createdAt, updatedAt
) VALUES (
1, 'LEGACY_VALUE', 'preserved', 0, 100,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);
INSERT INTO "Auths" (
id, type, info, createdAt, updatedAt
) VALUES (
1, 'systemConfig', '{"timezone":"UTC"}',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);
INSERT INTO "Apps" (
id, name, scopes, client_id, client_secret, createdAt, updatedAt
) VALUES (
1, 'legacy-app', '["crons"]', 'legacy-client', 'legacy-secret',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);
INSERT INTO "Subscriptions" (
id, name, alias, url, schedule, createdAt, updatedAt
) VALUES (
1, 'legacy-subscription', 'legacy-subscription',
'https://example.invalid/repo.git', '0 1 * * *',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);
INSERT INTO "PluginOwnedState" (id, payload)
VALUES (1, '{"preserved":true}');
`);
source.close();
fs.chmodSync(sourcePath, 0o600);
}
function fixture(t) {
const deploymentRoot = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-sqlite-upgrade-')),
);
fs.chmodSync(deploymentRoot, 0o700);
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
const commandsDirectory = path.join(deploymentRoot, 'commands');
const artifactsDirectory = path.join(deploymentRoot, 'artifacts');
fs.mkdirSync(commandsDirectory, { mode: 0o700 });
fs.mkdirSync(artifactsDirectory, { mode: 0o700 });
const value = {
deploymentRoot,
commandsDirectory,
sourcePath: path.join(deploymentRoot, 'database.sqlite'),
targetPath: path.join(artifactsDirectory, 'qinglong3.sqlite'),
recoveryPath: path.join(artifactsDirectory, 'database.pre-ql3.sqlite'),
manifestPath: path.join(artifactsDirectory, 'qinglong3-adoption.json'),
activationPath: path.join(artifactsDirectory, 'qinglong3-activation.json'),
};
createLegacyDatabase(value.sourcePath);
return value;
}
function runCommand(value, name, operation, options) {
const commandPath = path.join(value.commandsDirectory, `${name}.json`);
fs.writeFileSync(
commandPath,
`${JSON.stringify({ schemaVersion: 1, operation, options })}\n`,
{ mode: 0o600 },
);
const child = spawnSync(
process.execPath,
[BINARY, 'run', '--command-file', commandPath],
{ encoding: 'utf8' },
);
assert.equal(child.status, 0, child.stderr);
assert.equal(child.stderr, '');
return JSON.parse(child.stdout);
}
function baseOptions(value) {
return {
deploymentRoot: value.deploymentRoot,
profile: 'edge',
};
}
function prepareAdoption(t) {
const value = fixture(t);
const sourceBefore = sha256(value.sourcePath);
const inspected = runCommand(
value,
'inspect',
'local-sqlite.adoption.inspect',
{
...baseOptions(value),
sourcePath: value.sourcePath,
legacyTimezone: 'UTC',
},
);
const staged = runCommand(value, 'stage', 'local-sqlite.adoption.stage', {
...baseOptions(value),
sourcePath: value.sourcePath,
targetPath: value.targetPath,
recoveryPath: value.recoveryPath,
manifestPath: value.manifestPath,
expectedPlanDigest: inspected.evidence.planDigest,
legacyTimezone: 'UTC',
});
const verified = runCommand(value, 'verify', 'local-sqlite.adoption.verify', {
...baseOptions(value),
targetPath: value.targetPath,
recoveryPath: value.recoveryPath,
manifestPath: value.manifestPath,
});
const prepared = runCommand(
value,
'prepare',
'local-sqlite.activation.prepare',
{
...baseOptions(value),
sourcePath: value.sourcePath,
targetPath: value.targetPath,
recoveryPath: value.recoveryPath,
manifestPath: value.manifestPath,
activationPath: value.activationPath,
expectedManifestDigest: verified.evidence.manifestDigest,
},
);
assert.equal(inspected.status, 'inspected');
assert.deepEqual(inspected.evidence.catalog.tableNames, [
'Apps',
'Auths',
'CrontabStats',
'CrontabViews',
'Crontabs',
'Dependences',
'Envs',
'PluginOwnedState',
'RunningInstances',
'Subscriptions',
'sqlite_sequence',
]);
assert.equal(staged.status, 'staged');
assert.equal(verified.status, 'verified');
assert.equal(prepared.status, 'prepared');
assert.equal(sha256(value.sourcePath), sourceBefore);
for (const outputPath of [
value.targetPath,
value.recoveryPath,
value.manifestPath,
value.activationPath,
]) {
assert.equal(fs.statSync(outputPath).mode & 0o777, 0o600);
}
return {
...value,
sourceBefore,
activationDigest: prepared.evidence.activationDigest,
};
}
async function startAdoptedStorage(value) {
return bootstrapLocalAdoptedProfileStorage({
enabled: true,
profile: 'edge',
sourcePath: value.sourcePath,
targetPath: value.targetPath,
recoveryPath: value.recoveryPath,
manifestPath: value.manifestPath,
activationPath: value.activationPath,
expectedActivationDigest: value.activationDigest,
busyTimeoutMs: 100,
audit() {},
adoptionAudit() {},
});
}
function reconciliation(value) {
return readTargetDataReconciliationEvidenceForPaths(
{
profile: 'edge',
activationPath: value.activationPath,
legacySourcePath: value.sourcePath,
targetDatabasePath: value.targetPath,
expectedActivationDigest: value.activationDigest,
},
process.getuid(),
);
}
test('upgrades a production-shaped 2.x SQLite database and admits clean rollback', async (t) => {
const value = prepareAdoption(t);
const recovery = new DatabaseSync(value.recoveryPath, { readOnly: true });
assert.equal(
recovery
.prepare(
`SELECT COUNT(*) AS count FROM sqlite_master
WHERE name LIKE 'QingLong3%'`,
)
.get().count,
0,
);
assert.equal(
recovery.prepare('SELECT payload FROM "PluginOwnedState"').get().payload,
'{"preserved":true}',
);
recovery.close();
const target = new DatabaseSync(value.targetPath, { readOnly: true });
assert.equal(
target.prepare('SELECT COUNT(*) AS count FROM "Crontabs"').get().count,
1,
);
assert.equal(
target.prepare('SELECT value FROM "Envs" WHERE id = 1').get().value,
'preserved',
);
assert.ok(
target
.prepare(
`SELECT COUNT(*) AS count FROM sqlite_master
WHERE type = 'table' AND name LIKE 'QingLong3%'`,
)
.get().count > 0,
);
target.close();
const storage = await startAdoptedStorage(value);
assert.equal(storage.status, 'adopted_storage_ready');
const legacyWriter = new DatabaseSync(value.sourcePath, { timeout: 100 });
assert.throws(
() =>
legacyWriter
.prepare(
`INSERT INTO "Envs" (
name, value, createdAt, updatedAt
) VALUES ('BLOCKED', 'blocked', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
)
.run(),
(error) => error && error.errstr === 'database is locked',
);
await storage.stop();
const cleanEvidence = reconciliation(value);
assert.equal(
cleanEvidence.disposition,
'rollback_candidate',
JSON.stringify(cleanEvidence),
);
legacyWriter
.prepare(
`INSERT INTO "Envs" (
name, value, createdAt, updatedAt
) VALUES ('AFTER_ROLLBACK', 'released', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
)
.run();
legacyWriter.close();
const rolledBack = new DatabaseSync(value.sourcePath, { readOnly: true });
assert.equal(
rolledBack
.prepare(`SELECT value FROM "Envs" WHERE name = 'AFTER_ROLLBACK'`)
.get().value,
'released',
);
rolledBack.close();
});
test('requires reconciliation after the adopted target accepts a Run', async (t) => {
const value = prepareAdoption(t);
const storage = await startAdoptedStorage(value);
assert.equal(storage.status, 'adopted_storage_ready');
await storage.runs.transaction((transaction) =>
transaction.insertRun({
id: '019f9a00-0000-4000-a000-000000000383',
projectId: 'default',
taskId: 'legacy-cron:1',
taskRevision: 'revision-1',
taskName: 'D383 reconciliation proof',
legacyCronId: 1,
triggerType: 'manual',
executionOrigin: 'manual',
executionOwner: 'runtime',
triggeredBy: 'user:1',
status: 'created',
version: 0,
eventSequence: 0,
priority: 0,
createdAtMs: 1_760_000_000_383,
}),
);
await storage.stop();
const evidence = reconciliation(value);
assert.equal(evidence.disposition, 'reconciliation_required');
assert.equal(evidence.targetMatchesActivation, false);
assert.equal(
evidence.sourceMatchesActivation,
true,
JSON.stringify(evidence),
);
assert.equal(sha256(value.sourcePath), value.sourceBefore);
const source = new DatabaseSync(value.sourcePath, { readOnly: true });
assert.equal(
source
.prepare(
`SELECT COUNT(*) AS count FROM sqlite_master
WHERE name = 'QingLong3Runs'`,
)
.get().count,
0,
);
source.close();
});
test('rejects widened SQLite adoption command intent before inspection', (t) => {
const value = fixture(t);
const commandPath = path.join(value.commandsDirectory, 'widened.json');
fs.writeFileSync(
commandPath,
`${JSON.stringify({
schemaVersion: 1,
operation: 'local-sqlite.adoption.inspect',
options: {
...baseOptions(value),
sourcePath: value.sourcePath,
extraAuthority: true,
},
})}\n`,
{ mode: 0o600 },
);
const child = spawnSync(
process.execPath,
[BINARY, 'run', '--command-file', commandPath],
{ encoding: 'utf8' },
);
assert.equal(child.status, 1);
assert.equal(child.stdout, '');
assert.equal(
JSON.parse(child.stderr).code,
'LOCAL_SQLITE_ADOPTION_CLI_CONFIGURATION_INVALID',
);
});