mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 12:05:27 +08:00
feat(ql3): rehearse reviewed automation rollback
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const DOMAINS = Object.freeze([
|
||||
'schema_lineage',
|
||||
'automation',
|
||||
'secret_and_config',
|
||||
'run_history',
|
||||
'plugin_package',
|
||||
'ai_and_tool',
|
||||
'identity_policy_audit',
|
||||
'unknown',
|
||||
]);
|
||||
const DATABASES = Object.freeze(['legacy', 'target']);
|
||||
const FACT_KINDS = Object.freeze(['schema_object', 'table']);
|
||||
const MAX_JSON_BYTES = 4 * 1024 * 1024;
|
||||
const DIGEST = /^[0-9a-f]{64}$/u;
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
||||
const UUID_V7 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function canonicalFile(filePath, maximumBytes, label) {
|
||||
const resolved = path.resolve(filePath);
|
||||
const stat = fs.lstatSync(resolved);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > maximumBytes ||
|
||||
fs.realpathSync(resolved) !== resolved
|
||||
) {
|
||||
fail(`${label} must be one bounded canonical regular file`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function readJson(filePath, label) {
|
||||
const resolved = canonicalFile(filePath, MAX_JSON_BYTES, label);
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
||||
} catch {
|
||||
return fail(`${label} must contain valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeExclusive(filePath, records) {
|
||||
const resolved = path.resolve(filePath);
|
||||
const parent = path.dirname(resolved);
|
||||
if (
|
||||
fs.existsSync(resolved) ||
|
||||
!fs.lstatSync(parent).isDirectory() ||
|
||||
fs.realpathSync(parent) !== parent
|
||||
) {
|
||||
fail('fixture output must be a new file below one canonical directory');
|
||||
}
|
||||
const descriptor = fs.openSync(
|
||||
resolved,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
descriptor,
|
||||
`${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
|
||||
);
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function fixtureRoot(value) {
|
||||
const resolved = path.resolve(value || '');
|
||||
if (
|
||||
!path.isAbsolute(value || '') ||
|
||||
!fs.lstatSync(resolved).isDirectory() ||
|
||||
fs.realpathSync(resolved) !== resolved
|
||||
) {
|
||||
fail('reconciliation root must be one canonical directory');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function validateSummary(root, status) {
|
||||
const summary = readJson(
|
||||
path.join(root, 'summary.json'),
|
||||
'reconciliation summary',
|
||||
);
|
||||
if (
|
||||
summary?.schemaVersion !== 1 ||
|
||||
summary?.schema !==
|
||||
'qinglong/local-alpha-reconciliation-rehearsal-summary@v1' ||
|
||||
summary?.status !== status ||
|
||||
(summary?.profile !== 'edge' && summary?.profile !== 'standalone')
|
||||
) {
|
||||
fail(`reconciliation summary is not in ${status} state`);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function validateFact(fact, expected) {
|
||||
if (
|
||||
fact?.schemaVersion !== 1 ||
|
||||
fact?.schema !== 'qinglong3-local-reconciliation-diagnostic-fact' ||
|
||||
fact?.database !== expected.database ||
|
||||
fact?.domain !== expected.domain ||
|
||||
fact?.factKind !== expected.factKind ||
|
||||
!Number.isSafeInteger(fact.ordinal) ||
|
||||
fact.ordinal < 1 ||
|
||||
!DIGEST.test(fact.factDigest || '') ||
|
||||
!['informational', 'required', 'blocked'].includes(fact.decisionRequirement)
|
||||
) {
|
||||
fail('diagnostic fact is incompatible with the fixture contract');
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticFacts(root, summary) {
|
||||
const facts = [];
|
||||
let pageCount = 0;
|
||||
for (const database of DATABASES) {
|
||||
for (const domain of DOMAINS) {
|
||||
for (const factKind of FACT_KINDS) {
|
||||
let pageNumber = 0;
|
||||
let expectedOffset = 0;
|
||||
while (true) {
|
||||
const page = readJson(
|
||||
path.join(
|
||||
root,
|
||||
'diagnostics',
|
||||
`${database}-${domain}-${factKind}-${pageNumber}.json`,
|
||||
),
|
||||
'diagnostic page',
|
||||
);
|
||||
if (
|
||||
page?.schemaVersion !== 1 ||
|
||||
page?.schema !== 'qinglong3-local-reconciliation-diagnostic-page' ||
|
||||
page?.state !== 'reconciliation_review_prepared' ||
|
||||
page?.reviewId !== summary.review.reviewId ||
|
||||
page?.planDigest !== summary.plan.planDigest ||
|
||||
page?.preparationDigest !== summary.review.preparationDigest ||
|
||||
page?.database !== database ||
|
||||
page?.domain !== domain ||
|
||||
page?.factKind !== factKind ||
|
||||
page?.offset !== expectedOffset ||
|
||||
page?.limit !== 64 ||
|
||||
!Array.isArray(page.records) ||
|
||||
page.recordCount !== page.records.length ||
|
||||
!DIGEST.test(page.pageDigest || '')
|
||||
) {
|
||||
fail('diagnostic page is detached or malformed');
|
||||
}
|
||||
for (const fact of page.records) {
|
||||
validateFact(fact, { database, domain, factKind });
|
||||
facts.push(fact);
|
||||
}
|
||||
pageCount += 1;
|
||||
if (page.complete === true && page.nextOffset === null) break;
|
||||
if (
|
||||
page.complete !== false ||
|
||||
!Number.isSafeInteger(page.nextOffset) ||
|
||||
page.nextOffset <= expectedOffset
|
||||
) {
|
||||
fail('diagnostic pagination is invalid');
|
||||
}
|
||||
expectedOffset = page.nextOffset;
|
||||
pageNumber += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
pageCount !== summary.review.diagnosticPages ||
|
||||
facts.length !== summary.review.diagnosticRecords
|
||||
) {
|
||||
fail('diagnostic inventory differs from the reconciliation summary');
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
function reviewFixture(root, output) {
|
||||
const summary = validateSummary(root, 'operator_decision_required');
|
||||
if (
|
||||
!UUID_V4.test(summary.review?.reviewId || '') ||
|
||||
!DIGEST.test(summary.plan?.planDigest || '') ||
|
||||
!DIGEST.test(summary.review?.preparationDigest || '')
|
||||
) {
|
||||
fail('review fixture identity is invalid');
|
||||
}
|
||||
const facts = diagnosticFacts(root, summary);
|
||||
let adoptedAutomationTables = 0;
|
||||
const decisions = [];
|
||||
for (const fact of facts) {
|
||||
if (fact.decisionRequirement === 'informational') continue;
|
||||
const automationTable =
|
||||
fact.database === 'legacy' &&
|
||||
fact.domain === 'automation' &&
|
||||
fact.factKind === 'table' &&
|
||||
fact.tableName === 'Crontabs' &&
|
||||
fact.decisionRequirement === 'required';
|
||||
if (automationTable) adoptedAutomationTables += 1;
|
||||
const blocked = fact.decisionRequirement === 'blocked';
|
||||
const legacy = fact.database === 'legacy';
|
||||
const legacyRunHistory = legacy && fact.domain === 'run_history';
|
||||
decisions.push({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-reconciliation-review-decision',
|
||||
database: fact.database,
|
||||
domain: fact.domain,
|
||||
factKind: fact.factKind,
|
||||
ordinal: fact.ordinal,
|
||||
factDigest: fact.factDigest,
|
||||
disposition: automationTable
|
||||
? 'adopt_legacy'
|
||||
: blocked || legacyRunHistory
|
||||
? 'manual_external'
|
||||
: legacy
|
||||
? 'exclude_legacy'
|
||||
: 'retain_target',
|
||||
reason: automationTable
|
||||
? 'prefer_legacy'
|
||||
: blocked || legacyRunHistory
|
||||
? 'external_recovery_required'
|
||||
: legacy
|
||||
? 'legacy_excluded'
|
||||
: 'preserve_target',
|
||||
});
|
||||
}
|
||||
if (adoptedAutomationTables !== 1 || decisions.length < 1) {
|
||||
fail('fixture requires exactly one reviewable Legacy Crontabs table');
|
||||
}
|
||||
const records = [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-reconciliation-review-decision-header',
|
||||
diagnosticsContractVersion: 1,
|
||||
reviewId: summary.review.reviewId,
|
||||
profile: summary.profile,
|
||||
planDigest: summary.plan.planDigest,
|
||||
preparationDigest: summary.review.preparationDigest,
|
||||
},
|
||||
...decisions,
|
||||
];
|
||||
const filePath = writeExclusive(output, records);
|
||||
return Object.freeze({
|
||||
mode: 'review',
|
||||
filePath,
|
||||
decisionCount: decisions.length,
|
||||
adoptedAutomationTables,
|
||||
});
|
||||
}
|
||||
|
||||
function automationFixture(root, output) {
|
||||
const summary = validateSummary(root, 'automation_decision_required');
|
||||
const automation = summary.automation;
|
||||
const decision = summary.decision;
|
||||
if (
|
||||
!UUID_V4.test(automation?.automationId || '') ||
|
||||
!UUID_V7.test(decision?.decisionId || '') ||
|
||||
!DIGEST.test(automation?.automationPlanDigest || '') ||
|
||||
automation.rowCount !== 1 ||
|
||||
automation.eligibleCount !== 1 ||
|
||||
automation.conflictCount !== 0
|
||||
) {
|
||||
fail('fixture requires one conflict-free eligible Automation row');
|
||||
}
|
||||
const directory = path.join(root, 'automation', automation.automationId);
|
||||
const receipt = readJson(
|
||||
path.join(directory, 'receipt.json'),
|
||||
'Automation plan receipt',
|
||||
);
|
||||
if (
|
||||
receipt?.schemaVersion !== 1 ||
|
||||
receipt?.schema !==
|
||||
'qinglong3-local-reconciliation-automation-plan-receipt' ||
|
||||
receipt?.automationId !== automation.automationId ||
|
||||
receipt?.automationPlanDigest !== automation.automationPlanDigest ||
|
||||
receipt?.rowCount !== 1 ||
|
||||
receipt?.eligibleCount !== 1 ||
|
||||
receipt?.manualCount !== 0 ||
|
||||
receipt?.conflictCount !== 0 ||
|
||||
!DIGEST.test(receipt.legacyInventoryDigest || '')
|
||||
) {
|
||||
fail('Automation plan receipt is not fixture-eligible');
|
||||
}
|
||||
const planPath = canonicalFile(
|
||||
path.join(directory, 'plan.ndjson'),
|
||||
8 * 1024 * 1024,
|
||||
'Automation row plan',
|
||||
);
|
||||
let records;
|
||||
try {
|
||||
records = fs
|
||||
.readFileSync(planPath, 'utf8')
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line));
|
||||
} catch {
|
||||
return fail('Automation row plan must be canonical NDJSON');
|
||||
}
|
||||
const rows = records.filter(
|
||||
(record) =>
|
||||
record?.kind === 'qinglong3-local-reconciliation-automation-plan-row',
|
||||
);
|
||||
if (
|
||||
rows.length !== 1 ||
|
||||
rows[0].schemaVersion !== 1 ||
|
||||
rows[0].requirement !== 'review_adopt' ||
|
||||
rows[0].target?.state !== 'absent' ||
|
||||
!Number.isSafeInteger(rows[0].rowOrdinal) ||
|
||||
rows[0].rowOrdinal < 1 ||
|
||||
!DIGEST.test(rows[0].sourceDigest || '')
|
||||
) {
|
||||
fail('Automation row plan contains a non-lossless or conflicting row');
|
||||
}
|
||||
const outputRecords = [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-decision-review-file-header',
|
||||
decisionId: decision.decisionId,
|
||||
profile: summary.profile,
|
||||
planDigest: automation.automationPlanDigest,
|
||||
inventoryDigest: receipt.legacyInventoryDigest,
|
||||
},
|
||||
{
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-decision-review-file-row',
|
||||
decision: {
|
||||
rowOrdinal: rows[0].rowOrdinal,
|
||||
sourceDigest: rows[0].sourceDigest,
|
||||
disposition: 'adopt',
|
||||
reason: 'reviewed_lossless',
|
||||
},
|
||||
},
|
||||
];
|
||||
const filePath = writeExclusive(output, outputRecords);
|
||||
return Object.freeze({
|
||||
mode: 'automation',
|
||||
filePath,
|
||||
decisionCount: 1,
|
||||
automationPlanDigest: automation.automationPlanDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const values = {};
|
||||
for (const argument of argv) {
|
||||
const match = /^--([a-z-]+)=(.+)$/u.exec(argument);
|
||||
if (!match || Object.hasOwn(values, match[1]))
|
||||
fail('arguments are invalid');
|
||||
values[match[1]] = match[2];
|
||||
}
|
||||
if (
|
||||
!['review', 'automation'].includes(values.mode) ||
|
||||
JSON.stringify(Object.keys(values).sort()) !==
|
||||
JSON.stringify(['mode', 'output', 'reconciliation-root'])
|
||||
) {
|
||||
fail('fixture arguments are invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: values.mode,
|
||||
reconciliationRoot: fixtureRoot(values['reconciliation-root']),
|
||||
output: path.resolve(values.output),
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(argv) {
|
||||
const options = parseArguments(argv);
|
||||
const report =
|
||||
options.mode === 'review'
|
||||
? reviewFixture(options.reconciliationRoot, options.output)
|
||||
: automationFixture(options.reconciliationRoot, options.output);
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
return report;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
runCli(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : 'decision fixture failed'}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
automationFixture,
|
||||
diagnosticFacts,
|
||||
parseArguments,
|
||||
reviewFixture,
|
||||
runCli,
|
||||
});
|
||||
Reference in New Issue
Block a user