mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
feat(ql3): rehearse reviewed automation rollback
This commit is contained in:
@@ -240,7 +240,7 @@ function validateMilestoneRecord(record, product, sourceRevision, variant) {
|
||||
: 'cluster_integration_candidate_not_public_release';
|
||||
const expectedSchema =
|
||||
product === 'local'
|
||||
? 'qinglong/alpha-local-milestone@v5'
|
||||
? 'qinglong/alpha-local-milestone@v6'
|
||||
: 'qinglong/alpha-cluster-milestone@v1';
|
||||
if (
|
||||
!exactKeys(record, ['artifactName', 'schema', 'maturity', 'manifest']) ||
|
||||
|
||||
@@ -12,7 +12,7 @@ const {
|
||||
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const SCHEMA = 'qinglong/alpha-local-milestone@v5';
|
||||
const SCHEMA = 'qinglong/alpha-local-milestone@v6';
|
||||
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
|
||||
const FILES = Object.freeze({
|
||||
readme: 'README.md',
|
||||
@@ -222,6 +222,8 @@ function bundleRecord(options, architecture) {
|
||||
upgradeReadinessSha256: report.upgradeReadinessSha256,
|
||||
upgradeRehearsalSha256: report.upgradeRehearsalSha256,
|
||||
upgradeCutoverRehearsalSha256: report.upgradeCutoverRehearsalSha256,
|
||||
upgradeReconciliationRehearsalSha256:
|
||||
report.upgradeReconciliationRehearsalSha256,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -238,6 +240,7 @@ function validateArtifactRecord(record, architecture, manifest) {
|
||||
'upgradeReadinessSha256',
|
||||
'upgradeRehearsalSha256',
|
||||
'upgradeCutoverRehearsalSha256',
|
||||
'upgradeReconciliationRehearsalSha256',
|
||||
]) ||
|
||||
record.artifactName !==
|
||||
artifactName(manifest.sourceRevision, architecture, manifest.variant) ||
|
||||
@@ -254,6 +257,7 @@ function validateArtifactRecord(record, architecture, manifest) {
|
||||
!SHA256_PATTERN.test(record.upgradeReadinessSha256 || '') ||
|
||||
!SHA256_PATTERN.test(record.upgradeRehearsalSha256 || '') ||
|
||||
!SHA256_PATTERN.test(record.upgradeCutoverRehearsalSha256 || '') ||
|
||||
!SHA256_PATTERN.test(record.upgradeReconciliationRehearsalSha256 || '') ||
|
||||
record.applicationImageId === record.operatorImageId
|
||||
) {
|
||||
fail(`${architecture} milestone artifact record is incompatible`);
|
||||
@@ -322,7 +326,7 @@ function auditLocalAlphaMilestone(options) {
|
||||
'artifacts',
|
||||
'readme',
|
||||
]) ||
|
||||
manifest.schemaVersion !== 5 ||
|
||||
manifest.schemaVersion !== 6 ||
|
||||
manifest.schema !== SCHEMA ||
|
||||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
|
||||
manifest.product !== 'local' ||
|
||||
@@ -371,7 +375,10 @@ function auditLocalAlphaMilestone(options) {
|
||||
new Set(records.map((record) => record.upgradeRehearsalSha256)).size !==
|
||||
ARCHITECTURES.length ||
|
||||
new Set(records.map((record) => record.upgradeCutoverRehearsalSha256))
|
||||
.size !== ARCHITECTURES.length
|
||||
.size !== ARCHITECTURES.length ||
|
||||
new Set(
|
||||
records.map((record) => record.upgradeReconciliationRehearsalSha256),
|
||||
).size !== ARCHITECTURES.length
|
||||
) {
|
||||
fail('milestone architecture subjects are not distinct');
|
||||
}
|
||||
@@ -398,7 +405,7 @@ function auditLocalAlphaMilestone(options) {
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/alpha-local-milestone-audit@v5',
|
||||
schema: 'qinglong/alpha-local-milestone-audit@v6',
|
||||
sourceRevision: manifest.sourceRevision,
|
||||
version: manifest.version,
|
||||
variant: manifest.variant,
|
||||
@@ -441,7 +448,9 @@ function finalizeLocalAlphaMilestone(options) {
|
||||
artifacts.amd64.upgradeRehearsalSha256 ===
|
||||
artifacts.arm64.upgradeRehearsalSha256 ||
|
||||
artifacts.amd64.upgradeCutoverRehearsalSha256 ===
|
||||
artifacts.arm64.upgradeCutoverRehearsalSha256
|
||||
artifacts.arm64.upgradeCutoverRehearsalSha256 ||
|
||||
artifacts.amd64.upgradeReconciliationRehearsalSha256 ===
|
||||
artifacts.arm64.upgradeReconciliationRehearsalSha256
|
||||
) {
|
||||
fail('milestone architecture subjects must be distinct');
|
||||
}
|
||||
@@ -454,7 +463,7 @@ function finalizeLocalAlphaMilestone(options) {
|
||||
path.join(normalized.outputRoot, FILES.readme),
|
||||
);
|
||||
const manifest = {
|
||||
schemaVersion: 5,
|
||||
schemaVersion: 6,
|
||||
schema: SCHEMA,
|
||||
maturity: 'alpha_candidate_not_public_release',
|
||||
product: 'local',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -10,8 +10,8 @@ const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs');
|
||||
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const SCHEMA = 'qinglong/alpha-local-trial-kit@v9';
|
||||
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v7';
|
||||
const SCHEMA = 'qinglong/alpha-local-trial-kit@v10';
|
||||
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v8';
|
||||
const QUICKSTART_TEMPLATE = path.join(
|
||||
DEFAULT_ROOT,
|
||||
'scripts/templates/ql3-local-alpha-quickstart.sh',
|
||||
@@ -28,6 +28,10 @@ const UPGRADE_CUTOVER_REHEARSAL_TEMPLATE = path.join(
|
||||
DEFAULT_ROOT,
|
||||
'scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh',
|
||||
);
|
||||
const UPGRADE_RECONCILIATION_REHEARSAL_TEMPLATE = path.join(
|
||||
DEFAULT_ROOT,
|
||||
'scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh',
|
||||
);
|
||||
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
|
||||
const VARIANTS = Object.freeze(['headless', 'console']);
|
||||
const ARCHIVE_MIN_BYTES = 1024;
|
||||
@@ -37,6 +41,7 @@ const MAX_QUICKSTART_BYTES = 256 * 1024;
|
||||
const MAX_UPGRADE_READINESS_BYTES = 256 * 1024;
|
||||
const MAX_UPGRADE_REHEARSAL_BYTES = 256 * 1024;
|
||||
const MAX_UPGRADE_CUTOVER_REHEARSAL_BYTES = 512 * 1024;
|
||||
const MAX_UPGRADE_RECONCILIATION_REHEARSAL_BYTES = 512 * 1024;
|
||||
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
||||
const REVISION_PATTERN = /^[0-9a-f]{40}$/u;
|
||||
const FILES = Object.freeze({
|
||||
@@ -47,6 +52,7 @@ const FILES = Object.freeze({
|
||||
upgradeReadiness: 'upgrade-readiness.sh',
|
||||
upgradeRehearsal: 'upgrade-rehearsal.sh',
|
||||
upgradeCutoverRehearsal: 'upgrade-cutover-rehearsal.sh',
|
||||
upgradeReconciliationRehearsal: 'reconciliation-rehearsal.sh',
|
||||
readme: 'README.md',
|
||||
manifest: 'manifest.json',
|
||||
checksums: 'SHA256SUMS',
|
||||
@@ -66,6 +72,7 @@ const VERIFICATION = Object.freeze({
|
||||
legacyUpgradeStage: 'passed',
|
||||
legacyUpgradeCutover: 'passed',
|
||||
legacyUpgradeReconciliationCapture: 'passed',
|
||||
legacyUpgradeReconciliationAutomationRollback: 'passed',
|
||||
});
|
||||
|
||||
function verificationGates(variant) {
|
||||
@@ -550,6 +557,35 @@ function renderUpgradeCutoverRehearsal(identity) {
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function renderUpgradeReconciliationRehearsal(identity) {
|
||||
const template = fs.readFileSync(
|
||||
assertCanonicalFile(
|
||||
UPGRADE_RECONCILIATION_REHEARSAL_TEMPLATE,
|
||||
MAX_UPGRADE_RECONCILIATION_REHEARSAL_BYTES,
|
||||
'upgrade reconciliation rehearsal template',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const replacements = Object.freeze({
|
||||
'@@OPERATOR_IMAGE@@': identity.images.operator.reference,
|
||||
'@@OPERATOR_ID@@': identity.images.operator.id,
|
||||
'@@ARCHITECTURE@@': identity.architecture,
|
||||
'@@SOURCE_REVISION@@': identity.sourceRevision,
|
||||
'@@ARCHIVE@@': identity.archive.file,
|
||||
'@@VARIANT@@': identity.variant,
|
||||
});
|
||||
let rendered = template;
|
||||
for (const [token, value] of Object.entries(replacements)) {
|
||||
rendered = rendered.replaceAll(token, value);
|
||||
}
|
||||
if (/@@[A-Z_]+@@/u.test(rendered)) {
|
||||
fail(
|
||||
'upgrade reconciliation rehearsal template contains an unresolved token',
|
||||
);
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function fileRecord(bundleRoot, name) {
|
||||
const filePath = path.join(bundleRoot, name);
|
||||
const stat = fs.lstatSync(filePath);
|
||||
@@ -740,8 +776,13 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
|
||||
renderUpgradeCutoverRehearsal(manifestIdentity),
|
||||
0o700,
|
||||
);
|
||||
writeExclusive(
|
||||
path.join(normalized.outputRoot, FILES.upgradeReconciliationRehearsal),
|
||||
renderUpgradeReconciliationRehearsal(manifestIdentity),
|
||||
0o700,
|
||||
);
|
||||
const manifest = {
|
||||
schemaVersion: 10,
|
||||
schemaVersion: 11,
|
||||
schema: SCHEMA,
|
||||
maturity: 'alpha_candidate_not_public_release',
|
||||
product: 'local',
|
||||
@@ -768,6 +809,10 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
|
||||
normalized.outputRoot,
|
||||
FILES.upgradeCutoverRehearsal,
|
||||
),
|
||||
upgradeReconciliationRehearsal: fileRecord(
|
||||
normalized.outputRoot,
|
||||
FILES.upgradeReconciliationRehearsal,
|
||||
),
|
||||
readme: fileRecord(normalized.outputRoot, FILES.readme),
|
||||
verification: fileRecord(
|
||||
normalized.outputRoot,
|
||||
@@ -787,6 +832,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
|
||||
FILES.upgradeReadiness,
|
||||
FILES.upgradeRehearsal,
|
||||
FILES.upgradeCutoverRehearsal,
|
||||
FILES.upgradeReconciliationRehearsal,
|
||||
FILES.readme,
|
||||
FILES.manifest,
|
||||
];
|
||||
@@ -859,10 +905,11 @@ function auditLocalAlphaTrialKit(options) {
|
||||
'upgradeReadiness',
|
||||
'upgradeRehearsal',
|
||||
'upgradeCutoverRehearsal',
|
||||
'upgradeReconciliationRehearsal',
|
||||
'readme',
|
||||
'verification',
|
||||
]) ||
|
||||
manifest.schemaVersion !== 10 ||
|
||||
manifest.schemaVersion !== 11 ||
|
||||
manifest.schema !== SCHEMA ||
|
||||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
|
||||
manifest.product !== 'local' ||
|
||||
@@ -963,6 +1010,29 @@ function auditLocalAlphaTrialKit(options) {
|
||||
'upgrade cutover rehearsal differs from the canonical cutover journey',
|
||||
);
|
||||
}
|
||||
validateFileRecord(
|
||||
manifest.upgradeReconciliationRehearsal,
|
||||
FILES.upgradeReconciliationRehearsal,
|
||||
bundleRoot,
|
||||
);
|
||||
const expectedUpgradeReconciliationRehearsal =
|
||||
renderUpgradeReconciliationRehearsal(manifest);
|
||||
const actualUpgradeReconciliationRehearsal = fs.readFileSync(
|
||||
assertCanonicalFile(
|
||||
path.join(bundleRoot, FILES.upgradeReconciliationRehearsal),
|
||||
MAX_UPGRADE_RECONCILIATION_REHEARSAL_BYTES,
|
||||
'upgrade reconciliation rehearsal',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
if (
|
||||
actualUpgradeReconciliationRehearsal !==
|
||||
expectedUpgradeReconciliationRehearsal
|
||||
) {
|
||||
fail(
|
||||
'upgrade reconciliation rehearsal differs from the canonical reviewed application and rollback journey',
|
||||
);
|
||||
}
|
||||
validateFileRecord(manifest.readme, FILES.readme, bundleRoot);
|
||||
validateOfflineSbom(
|
||||
readBoundedJson(
|
||||
@@ -1002,6 +1072,7 @@ function auditLocalAlphaTrialKit(options) {
|
||||
FILES.upgradeReadiness,
|
||||
FILES.upgradeRehearsal,
|
||||
FILES.upgradeCutoverRehearsal,
|
||||
FILES.upgradeReconciliationRehearsal,
|
||||
expectedArchive,
|
||||
].sort();
|
||||
const actualFiles = fs
|
||||
@@ -1025,6 +1096,7 @@ function auditLocalAlphaTrialKit(options) {
|
||||
FILES.upgradeReadiness,
|
||||
FILES.upgradeRehearsal,
|
||||
FILES.upgradeCutoverRehearsal,
|
||||
FILES.upgradeReconciliationRehearsal,
|
||||
FILES.readme,
|
||||
FILES.manifest,
|
||||
];
|
||||
@@ -1038,7 +1110,7 @@ function auditLocalAlphaTrialKit(options) {
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/alpha-local-trial-kit-audit@v6',
|
||||
schema: 'qinglong/alpha-local-trial-kit-audit@v7',
|
||||
sourceRevision: manifest.sourceRevision,
|
||||
version: manifest.version,
|
||||
architecture: manifest.architecture,
|
||||
@@ -1050,6 +1122,8 @@ function auditLocalAlphaTrialKit(options) {
|
||||
upgradeReadinessSha256: manifest.upgradeReadiness.sha256,
|
||||
upgradeRehearsalSha256: manifest.upgradeRehearsal.sha256,
|
||||
upgradeCutoverRehearsalSha256: manifest.upgradeCutoverRehearsal.sha256,
|
||||
upgradeReconciliationRehearsalSha256:
|
||||
manifest.upgradeReconciliationRehearsal.sha256,
|
||||
verificationSha256: manifest.verification.sha256,
|
||||
workflowRunId: verificationEvidence.workflow.runId,
|
||||
workflowRunAttempt: verificationEvidence.workflow.runAttempt,
|
||||
|
||||
@@ -210,6 +210,23 @@ function auditWorkflow(contents, findings) {
|
||||
'"status":"reconciliation_captured"',
|
||||
'"rollback":"not_authorized"',
|
||||
'reconciliation-capture-verify.result.json',
|
||||
'scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs',
|
||||
'sh "${BUNDLE_ROOT}/reconciliation-rehearsal.sh"',
|
||||
'RECONCILIATION_REVIEW_DECISION_ROOT=',
|
||||
'RECONCILIATION_AUTOMATION_DECISION_ROOT=',
|
||||
'"status":"operator_decision_required"',
|
||||
'"automaticDecision":"not_authorized"',
|
||||
'--mode=review',
|
||||
'"status":"automation_decision_required"',
|
||||
'"decisionAuthority":"authenticated_user"',
|
||||
'"automaticRowDecision":"not_authorized"',
|
||||
'--mode=automation',
|
||||
'apply-rollback edge',
|
||||
'"${LEGACY_READINESS_ROOT}"',
|
||||
'"status":"reconciliation_automation_rolled_back"',
|
||||
'automation-apply-verify.result.json',
|
||||
'automation-rollback-verify.result.json',
|
||||
'"completion":"not_attempted"',
|
||||
'docker rm "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}"',
|
||||
'--application-sbom="${APPLICATION_SBOM}"',
|
||||
'--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"',
|
||||
@@ -239,6 +256,10 @@ function auditWorkflow(contents, findings) {
|
||||
'/quickstart.sh"',
|
||||
'/upgrade-cutover-rehearsal.sh"',
|
||||
'--capture-after-write',
|
||||
'/reconciliation-rehearsal.sh"',
|
||||
'--mode=review',
|
||||
'--mode=automation',
|
||||
'apply-rollback edge',
|
||||
'name: Upload the tested native Local Alpha trial kit',
|
||||
]) {
|
||||
const index = contents.indexOf(value, cursor + 1);
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
OPERATOR_IMAGE='@@OPERATOR_IMAGE@@'
|
||||
OPERATOR_ID='@@OPERATOR_ID@@'
|
||||
ARCHITECTURE='@@ARCHITECTURE@@'
|
||||
SOURCE_REVISION='@@SOURCE_REVISION@@'
|
||||
ARCHIVE='@@ARCHIVE@@'
|
||||
VARIANT='@@VARIANT@@'
|
||||
|
||||
PLAN_ID='019f8680-143d-4000-8000-000000000201'
|
||||
REVIEW_ID='019f8680-143d-4000-8000-000000000301'
|
||||
APPLICATION_ID='019f8680-143d-4000-8000-000000000401'
|
||||
AUTOMATION_ID='019f8680-143d-4000-8000-000000000461'
|
||||
AUTOMATION_DECISION_ID='019f8680-143d-7000-8000-000000000471'
|
||||
AUTOMATION_MUTATION_ID='019f8680-143d-4000-8000-000000000481'
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "QingLong Local Alpha reconciliation rehearsal failed: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
phase() {
|
||||
printf '%s\n' "QingLong Local Alpha reconciliation phase: $1" >&2
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
'usage: sh reconciliation-rehearsal.sh prepare edge|standalone /absolute/rehearsal-root /absolute/capture-root /absolute/new/reconciliation-root <legacy-timezone|none>' \
|
||||
' sh reconciliation-rehearsal.sh review edge|standalone /absolute/rehearsal-root /absolute/capture-root /absolute/reconciliation-root /absolute/review-decisions.ndjson' \
|
||||
' sh reconciliation-rehearsal.sh apply-rollback edge|standalone /absolute/rehearsal-root /absolute/capture-root /absolute/reconciliation-root /absolute/automation-decisions.ndjson /absolute/legacy-root' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
safe_absolute_path() {
|
||||
case "$1" in
|
||||
/|*[!A-Za-z0-9_./@-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/)
|
||||
return 1
|
||||
;;
|
||||
/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
valid_digest() {
|
||||
[ "${#1}" -eq 64 ] || return 1
|
||||
case "$1" in *[!0-9a-f]*) return 1 ;; *) return 0 ;; esac
|
||||
}
|
||||
|
||||
extract_digest() {
|
||||
result_file=$1
|
||||
field=$2
|
||||
value=$(sed -n "s/^.*\"$field\":\"\([0-9a-f][0-9a-f]*\)\".*$/\1/p" "$result_file")
|
||||
valid_digest "$value" || fail "$field is missing or invalid in $result_file"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
extract_unsigned() {
|
||||
result_file=$1
|
||||
field=$2
|
||||
value=$(sed -n "s/^.*\"$field\":\([0-9][0-9]*\).*$/\1/p" "$result_file")
|
||||
case "$value" in ''|*[!0-9]*) fail "$field is missing or invalid in $result_file" ;; esac
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
extract_capture_id() {
|
||||
result_file=$1
|
||||
value=$(sed -n 's/^.*"captureId":"\([0-9a-f-][0-9a-f-]*\)".*$/\1/p' "$result_file")
|
||||
case "$value" in
|
||||
????????-????-4???-[89ab]???-????????????) printf '%s' "$value" ;;
|
||||
*) fail "captureId is missing or invalid in $result_file" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
non_overlapping() {
|
||||
left=$1
|
||||
right=$2
|
||||
[ "$left" != "$right" ] || return 1
|
||||
case "$left/" in "$right"/*) return 1 ;; esac
|
||||
case "$right/" in "$left"/*) return 1 ;; esac
|
||||
return 0
|
||||
}
|
||||
|
||||
canonical_directory() {
|
||||
selected=$1
|
||||
label=$2
|
||||
safe_absolute_path "$selected" || fail "$label is not a safe canonical absolute path"
|
||||
[ -d "$selected" ] || fail "$label does not exist"
|
||||
[ ! -L "$selected" ] || fail "$label must not be a symbolic link"
|
||||
[ "$(realpath "$selected")" = "$selected" ] || fail "$label is not canonical"
|
||||
}
|
||||
|
||||
private_decision_file() {
|
||||
decision_file=$1
|
||||
decision_label=$2
|
||||
safe_absolute_path "$decision_file" || fail "$decision_label is not a safe canonical absolute path"
|
||||
[ -f "$decision_file" ] || fail "$decision_label does not exist"
|
||||
[ ! -L "$decision_file" ] || fail "$decision_label must not be a symbolic link"
|
||||
[ "$(realpath "$decision_file")" = "$decision_file" ] || fail "$decision_label is not canonical"
|
||||
size=$(stat -c %s "$decision_file")
|
||||
[ "$size" -ge 2 ] && [ "$size" -le 4194304 ] || fail "$decision_label is empty or too large"
|
||||
file_mode=$(stat -c %a "$decision_file")
|
||||
[ "$file_mode" = 400 ] || [ "$file_mode" = 600 ] || fail "$decision_label must have mode 0400 or 0600"
|
||||
decision_parent=${decision_file%/*}
|
||||
[ -n "$decision_parent" ] || decision_parent=/
|
||||
canonical_directory "$decision_parent" "$decision_label parent"
|
||||
[ "$(stat -c %a "$decision_parent")" = 700 ] || fail "$decision_label parent must have mode 0700"
|
||||
extra_entry=$(find "$decision_parent" -mindepth 1 -maxdepth 1 ! -path "$decision_file" -print -quit)
|
||||
[ -z "$extra_entry" ] || fail "$decision_label parent must contain only the selected decision file"
|
||||
for authority_root in "$rehearsal_root" "$capture_root" "$reconciliation_root"; do
|
||||
non_overlapping "$decision_file" "$authority_root" || fail "$decision_label must be outside all authority roots"
|
||||
non_overlapping "$decision_parent" "$authority_root" || fail "$decision_label parent must be outside all authority roots"
|
||||
done
|
||||
}
|
||||
|
||||
[ "$#" -ge 1 ] || usage
|
||||
mode=$1
|
||||
[ "$mode" = apply-rollback ] && [ "$#" -eq 7 ] || {
|
||||
[ "$mode" != apply-rollback ] && [ "$#" -eq 6 ] || usage
|
||||
}
|
||||
profile=$2
|
||||
rehearsal_root=$3
|
||||
capture_root=$4
|
||||
reconciliation_root=$5
|
||||
phase_input=$6
|
||||
legacy_root=${7:-}
|
||||
|
||||
case "$mode" in prepare|review|apply-rollback) ;; *) usage ;; esac
|
||||
case "$profile" in edge|standalone) ;; *) usage ;; esac
|
||||
case "$VARIANT" in headless|console) ;; *) fail 'embedded Trial Kit variant is invalid' ;; esac
|
||||
[ "$(uname -s)" = Linux ] || fail 'reconciliation rehearsal requires a Linux Docker host'
|
||||
for tool in docker sha256sum grep sed stat date realpath find mv rm; do
|
||||
command -v "$tool" >/dev/null 2>&1 || fail "$tool is required"
|
||||
done
|
||||
canonical_directory "$rehearsal_root" 'rehearsal root'
|
||||
canonical_directory "$capture_root" 'capture root'
|
||||
safe_absolute_path "$reconciliation_root" || fail 'reconciliation root is not a safe canonical absolute path'
|
||||
non_overlapping "$rehearsal_root" "$capture_root" || fail 'rehearsal and capture roots overlap'
|
||||
non_overlapping "$rehearsal_root" "$reconciliation_root" || fail 'rehearsal and reconciliation roots overlap'
|
||||
non_overlapping "$capture_root" "$reconciliation_root" || fail 'capture and reconciliation roots overlap'
|
||||
if [ -n "$legacy_root" ]; then
|
||||
canonical_directory "$legacy_root" 'legacy root'
|
||||
for authority_root in "$rehearsal_root" "$capture_root" "$reconciliation_root"; do
|
||||
non_overlapping "$legacy_root" "$authority_root" || fail 'legacy root overlaps an authority root'
|
||||
done
|
||||
fi
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
|
||||
(CDPATH= cd -- "$script_dir" && sha256sum --check SHA256SUMS)
|
||||
docker info >/dev/null 2>&1 || fail 'docker daemon is unavailable'
|
||||
docker load --input "$script_dir/$ARCHIVE" >/dev/null
|
||||
operator_identity=$(docker image inspect --format '{{.Id}}|{{.Architecture}}|{{.Config.User}}|{{index .Config.Labels "org.opencontainers.image.revision"}}|{{index .Config.Labels "io.qinglong.lifecycle"}}|{{index .Config.Labels "io.qinglong.network"}}' "$OPERATOR_IMAGE")
|
||||
[ "$operator_identity" = "$OPERATOR_ID|$ARCHITECTURE|65532:65532|$SOURCE_REVISION|short-lived|none-by-default" ] || fail 'operator image identity is incompatible'
|
||||
|
||||
uid=$(id -u)
|
||||
gid=$(id -g)
|
||||
[ "$uid" -eq 0 ] && allow_root_service=true || allow_root_service=false
|
||||
plan_root="$reconciliation_root/plan"
|
||||
review_root="$reconciliation_root/review"
|
||||
diagnostic_root="$reconciliation_root/diagnostics"
|
||||
application_root="$reconciliation_root/application"
|
||||
automation_root="$reconciliation_root/automation"
|
||||
automation_decision_root="$reconciliation_root/automation-decision"
|
||||
automation_apply_root="$reconciliation_root/automation-apply"
|
||||
command_root="$reconciliation_root/commands"
|
||||
result_root="$reconciliation_root/results"
|
||||
target_database="$rehearsal_root/sqlite/qinglong3.sqlite"
|
||||
issuer_keyring="$rehearsal_root/reconciliation-review-issuer.keyring"
|
||||
owner_peppers="$rehearsal_root/owner-peppers"
|
||||
owner_credential="$rehearsal_root/owner-credential.json"
|
||||
|
||||
run_deploy() {
|
||||
subcommand=$1
|
||||
command_file=$2
|
||||
result_file=$3
|
||||
input_file=${4:-}
|
||||
set -- docker run --rm --read-only --user "$uid:$gid" --network none \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
--memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 32 \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \
|
||||
--mount "type=bind,src=$rehearsal_root,dst=$rehearsal_root" \
|
||||
--mount "type=bind,src=$capture_root,dst=$capture_root,readonly" \
|
||||
--mount "type=bind,src=$reconciliation_root,dst=$reconciliation_root"
|
||||
[ -z "$legacy_root" ] || set -- "$@" \
|
||||
--mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly"
|
||||
[ -z "$input_file" ] || set -- "$@" \
|
||||
--mount "type=bind,src=$decision_parent,dst=$decision_parent,readonly"
|
||||
set -- "$@" "$OPERATOR_IMAGE" deploy "$subcommand" \
|
||||
--command-file "$command_root/$command_file"
|
||||
result_stage="$result_root/.$result_file.$$"
|
||||
[ ! -e "$result_stage" ] || fail 'result staging path already exists'
|
||||
if "$@" >"$result_stage"; then
|
||||
chmod 0600 "$result_stage"
|
||||
mv -f "$result_stage" "$result_root/$result_file"
|
||||
else
|
||||
status=$?
|
||||
rm -f "$result_stage"
|
||||
return "$status"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$mode" = prepare ]; then
|
||||
[ ! -e "$reconciliation_root" ] || fail 'new reconciliation root already exists'
|
||||
reconciliation_parent=${reconciliation_root%/*}
|
||||
[ -n "$reconciliation_parent" ] || reconciliation_parent=/
|
||||
canonical_directory "$reconciliation_parent" 'reconciliation root parent'
|
||||
case "$phase_input" in
|
||||
none) legacy_timezone_json=null; legacy_timezone_summary=none ;;
|
||||
''|*[!A-Za-z0-9._+/-]*) fail 'legacy timezone is invalid' ;;
|
||||
*) legacy_timezone_json="\"$phase_input\""; legacy_timezone_summary=$phase_input ;;
|
||||
esac
|
||||
old_umask=$(umask)
|
||||
umask 077
|
||||
mkdir -m 0700 "$reconciliation_root"
|
||||
for directory in plan review diagnostics application automation automation-decision automation-apply commands results; do
|
||||
mkdir -m 0700 "$reconciliation_root/$directory"
|
||||
done
|
||||
umask "$old_umask"
|
||||
capture_result="$rehearsal_root/results/reconciliation-capture-verify.result.json"
|
||||
capture_commit_result="$rehearsal_root/results/reconciliation-capture-commit.result.json"
|
||||
grep -q '"status":"verified"' "$capture_result" || fail 'verified reconciliation capture is missing'
|
||||
capture_id=$(extract_capture_id "$capture_result")
|
||||
capture_bundle_digest=$(extract_digest "$capture_commit_result" bundleDigest)
|
||||
capture_head_digest=$(extract_digest "$capture_result" instanceHeadDigest)
|
||||
prepared_ms=$(($(date +%s) * 1000))
|
||||
cat >"$command_root/plan-prepare.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.plan.prepare","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","planRoot":"$plan_root","allowRootService":$allow_root_service},"request":{"planId":"$PLAN_ID","captureId":"$capture_id","expectedBundleDigest":"$capture_bundle_digest","expectedHeadDigest":"$capture_head_digest","legacyTimezone":$legacy_timezone_json,"preparedAtMs":$prepared_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/plan-prepare.json"
|
||||
phase 'prepare bounded reconciliation plan'
|
||||
run_deploy reconciliation-plan-prepare plan-prepare.json plan-prepare.result.json
|
||||
grep -q '"state":"reconciliation_plan_prepared"' "$result_root/plan-prepare.result.json" || fail 'reconciliation plan was not prepared'
|
||||
plan_preparation_digest=$(extract_digest "$result_root/plan-prepare.result.json" preparationDigest)
|
||||
committed_ms=$((prepared_ms + 1))
|
||||
cat >"$command_root/plan-commit.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.plan.commit","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","planRoot":"$plan_root","allowRootService":$allow_root_service},"request":{"planId":"$PLAN_ID","expectedPreparationDigest":"$plan_preparation_digest","committedAtMs":$committed_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/plan-commit.json"
|
||||
phase 'commit bounded reconciliation plan'
|
||||
run_deploy reconciliation-plan-commit plan-commit.json plan-commit.result.json
|
||||
grep -q '"state":"reconciliation_planned"' "$result_root/plan-commit.result.json" || fail 'reconciliation plan did not commit'
|
||||
plan_digest=$(extract_digest "$result_root/plan-commit.result.json" planDigest)
|
||||
cat >"$command_root/plan-verify.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.plan.verify","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","planRoot":"$plan_root","allowRootService":$allow_root_service},"request":{"planId":"$PLAN_ID","expectedPlanDigest":"$plan_digest"}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/plan-verify.json"
|
||||
run_deploy reconciliation-plan-verify plan-verify.json plan-verify.result.json
|
||||
grep -q '"status":"verified"' "$result_root/plan-verify.result.json" || fail 'reconciliation plan verification failed'
|
||||
plan_head_digest=$(extract_digest "$result_root/plan-verify.result.json" instanceHeadDigest)
|
||||
|
||||
review_prepared_ms=$((committed_ms + 1))
|
||||
cat >"$command_root/review-prepare.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.review.prepare","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","planRoot":"$plan_root","reviewRoot":"$review_root","allowRootService":$allow_root_service},"request":{"reviewId":"$REVIEW_ID","planId":"$PLAN_ID","expectedPlanDigest":"$plan_digest","expectedHeadDigest":"$plan_head_digest","preparedAtMs":$review_prepared_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/review-prepare.json"
|
||||
phase 'prepare strong-authentication reconciliation review'
|
||||
run_deploy reconciliation-review-prepare review-prepare.json review-prepare.result.json
|
||||
grep -q '"state":"reconciliation_review_prepared"' "$result_root/review-prepare.result.json" || fail 'reconciliation review was not prepared'
|
||||
review_preparation_digest=$(extract_digest "$result_root/review-prepare.result.json" preparationDigest)
|
||||
|
||||
page_count=0
|
||||
record_count=0
|
||||
for database in legacy target; do
|
||||
for domain in schema_lineage automation secret_and_config run_history plugin_package ai_and_tool identity_policy_audit unknown; do
|
||||
for fact_kind in schema_object table; do
|
||||
offset=0
|
||||
page=0
|
||||
while :; do
|
||||
stem="$database-$domain-$fact_kind-$page"
|
||||
cat >"$command_root/diagnostic-$stem.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.review.diagnostics","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","planRoot":"$plan_root","reviewRoot":"$review_root","allowRootService":$allow_root_service},"request":{"reviewId":"$REVIEW_ID","expectedPreparationDigest":"$review_preparation_digest","database":"$database","domain":"$domain","factKind":"$fact_kind","offset":$offset,"limit":64,"outputPath":"$diagnostic_root/$stem.json"}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/diagnostic-$stem.json"
|
||||
run_deploy reconciliation-review-diagnostics "diagnostic-$stem.json" "diagnostic-$stem.result.json"
|
||||
grep -q '"state":"reconciliation_review_prepared"' "$result_root/diagnostic-$stem.result.json" || fail "diagnostic page failed: $stem"
|
||||
records=$(extract_unsigned "$result_root/diagnostic-$stem.result.json" recordCount)
|
||||
record_count=$((record_count + records))
|
||||
page_count=$((page_count + 1))
|
||||
if grep -q '"complete":true' "$result_root/diagnostic-$stem.result.json"; then
|
||||
break
|
||||
fi
|
||||
offset=$(extract_unsigned "$result_root/diagnostic-$stem.result.json" nextOffset)
|
||||
page=$((page + 1))
|
||||
done
|
||||
done
|
||||
done
|
||||
done
|
||||
cat >"$reconciliation_root/summary.json" <<EOF
|
||||
{"schemaVersion":1,"schema":"qinglong/local-alpha-reconciliation-rehearsal-summary@v1","status":"operator_decision_required","profile":"$profile","variant":"$VARIANT","sourceRevision":"$SOURCE_REVISION","architecture":"$ARCHITECTURE","capture":{"captureId":"$capture_id","bundleDigest":"$capture_bundle_digest"},"plan":{"planId":"$PLAN_ID","planDigest":"$plan_digest"},"review":{"reviewId":"$REVIEW_ID","preparationDigest":"$review_preparation_digest","diagnosticPages":$page_count,"diagnosticRecords":$record_count},"legacyTimezone":"$legacy_timezone_summary","automaticDecision":"not_authorized","next":"supply_external_review_decisions"}
|
||||
EOF
|
||||
chmod 0600 "$reconciliation_root/summary.json"
|
||||
printf '%s\n' \
|
||||
'Bounded reconciliation plan and diagnostic pages are ready.' \
|
||||
"Summary: $reconciliation_root/summary.json" \
|
||||
"Diagnostics: $diagnostic_root" \
|
||||
'No review decision was generated or committed. Supply an external owner-private NDJSON decision file to the review phase.'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
canonical_directory "$reconciliation_root" 'reconciliation root'
|
||||
for directory in "$plan_root" "$review_root" "$diagnostic_root" "$application_root" "$automation_root" "$automation_decision_root" "$automation_apply_root" "$command_root" "$result_root"; do
|
||||
canonical_directory "$directory" 'reconciliation authority directory'
|
||||
done
|
||||
private_decision_file "$phase_input" 'external decision file'
|
||||
if [ -n "$legacy_root" ]; then
|
||||
non_overlapping "$decision_parent" "$legacy_root" || fail 'external decision file parent must be outside legacy root'
|
||||
fi
|
||||
|
||||
if [ "$mode" = review ]; then
|
||||
grep -q '"status":"operator_decision_required"' "$reconciliation_root/summary.json" || fail 'review phase is detached from operator-decision state'
|
||||
review_preparation_digest=$(extract_digest "$result_root/review-prepare.result.json" preparationDigest)
|
||||
review_head_digest=$(extract_digest "$result_root/review-prepare.result.json" instanceHeadDigest)
|
||||
if [ -e "$command_root/review-commit.json" ]; then
|
||||
committed_ms=$(extract_unsigned "$command_root/review-commit.json" committedAtMs)
|
||||
else
|
||||
committed_ms=$(($(date +%s) * 1000))
|
||||
cat >"$command_root/review-commit.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.review.commit","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","planRoot":"$plan_root","reviewRoot":"$review_root","targetDatabasePath":"$target_database","ownerPepperKeyringDirectory":"$owner_peppers","credentialFilePath":"$owner_credential","issuerKeyringPath":"$issuer_keyring","busyTimeoutMs":100,"allowRootService":$allow_root_service},"request":{"reviewId":"$REVIEW_ID","expectedPreparationDigest":"$review_preparation_digest","expectedHeadDigest":"$review_head_digest","decisionFilePath":"$phase_input","committedAtMs":$committed_ms,"authorizationLifetimeMs":60000}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/review-commit.json"
|
||||
fi
|
||||
phase 'commit explicit review decisions with real Owner authentication'
|
||||
run_deploy reconciliation-review-commit review-commit.json review-commit.result.json "$phase_input"
|
||||
grep -q '"state":"reconciliation_reviewed"' "$result_root/review-commit.result.json" || fail 'reconciliation review did not commit'
|
||||
review_digest=$(extract_digest "$result_root/review-commit.result.json" reviewDigest)
|
||||
cat >"$command_root/review-verify.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.review.verify","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","planRoot":"$plan_root","reviewRoot":"$review_root","issuerKeyringPath":"$issuer_keyring","allowRootService":$allow_root_service},"request":{"reviewId":"$REVIEW_ID","expectedReviewDigest":"$review_digest"}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/review-verify.json"
|
||||
run_deploy reconciliation-review-verify review-verify.json review-verify.result.json
|
||||
grep -q '"status":"verified"' "$result_root/review-verify.result.json" || fail 'reconciliation review verification failed'
|
||||
review_head_digest=$(extract_digest "$result_root/review-verify.result.json" instanceHeadDigest)
|
||||
|
||||
application_prepared_ms=$((committed_ms + 1))
|
||||
cat >"$command_root/application-prepare.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.application.prepare","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","planRoot":"$plan_root","reviewRoot":"$review_root","applicationRoot":"$application_root","issuerKeyringPath":"$issuer_keyring","allowRootService":$allow_root_service},"request":{"applicationId":"$APPLICATION_ID","reviewId":"$REVIEW_ID","expectedReviewDigest":"$review_digest","expectedHeadDigest":"$review_head_digest","preparedAtMs":$application_prepared_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/application-prepare.json"
|
||||
phase 'prepare reviewed cross-domain application plan'
|
||||
run_deploy reconciliation-application-prepare application-prepare.json application-prepare.result.json
|
||||
grep -q '"state":"reconciliation_application_prepared"' "$result_root/application-prepare.result.json" || fail 'application plan was not prepared'
|
||||
application_preparation_digest=$(extract_digest "$result_root/application-prepare.result.json" preparationDigest)
|
||||
application_head_digest=$(extract_digest "$result_root/application-prepare.result.json" instanceHeadDigest)
|
||||
application_committed_ms=$((application_prepared_ms + 1))
|
||||
cat >"$command_root/application-commit.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.application.commit","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","planRoot":"$plan_root","reviewRoot":"$review_root","applicationRoot":"$application_root","issuerKeyringPath":"$issuer_keyring","allowRootService":$allow_root_service},"request":{"applicationId":"$APPLICATION_ID","expectedPreparationDigest":"$application_preparation_digest","expectedHeadDigest":"$application_head_digest","committedAtMs":$application_committed_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/application-commit.json"
|
||||
run_deploy reconciliation-application-commit application-commit.json application-commit.result.json
|
||||
grep -q '"state":"reconciliation_application_planned"' "$result_root/application-commit.result.json" || fail 'application plan did not commit'
|
||||
application_plan_digest=$(extract_digest "$result_root/application-commit.result.json" applicationPlanDigest)
|
||||
cat >"$command_root/application-verify.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.application.verify","options":{"deploymentRoot":"$rehearsal_root","captureRoot":"$capture_root","planRoot":"$plan_root","reviewRoot":"$review_root","applicationRoot":"$application_root","issuerKeyringPath":"$issuer_keyring","allowRootService":$allow_root_service},"request":{"applicationId":"$APPLICATION_ID","expectedApplicationPlanDigest":"$application_plan_digest"}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/application-verify.json"
|
||||
run_deploy reconciliation-application-verify application-verify.json application-verify.result.json
|
||||
grep -q '"status":"verified"' "$result_root/application-verify.result.json" || fail 'application plan verification failed'
|
||||
application_head_digest=$(extract_digest "$result_root/application-verify.result.json" instanceHeadDigest)
|
||||
|
||||
legacy_timezone=$(sed -n 's/^.*"legacyTimezone":"\([^"]*\)".*$/\1/p' "$reconciliation_root/summary.json")
|
||||
[ "$legacy_timezone" != none ] || legacy_timezone=
|
||||
[ -n "$legacy_timezone" ] && timezone_json="\"$legacy_timezone\"" || timezone_json=null
|
||||
automation_prepared_ms=$((application_committed_ms + 1))
|
||||
cat >"$command_root/automation-plan.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.automation.plan","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","automationRoot":"$automation_root","allowRootService":$allow_root_service},"request":{"automationId":"$AUTOMATION_ID","applicationId":"$APPLICATION_ID","expectedApplicationPlanDigest":"$application_plan_digest","expectedHeadDigest":"$application_head_digest","decisionFilePath":"$phase_input","projectId":"default","legacyTimezone":$timezone_json,"preparedAtMs":$automation_prepared_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/automation-plan.json"
|
||||
phase 'materialize reviewed Automation row plan'
|
||||
run_deploy reconciliation-automation-plan automation-plan.json automation-plan.result.json "$phase_input"
|
||||
grep -q '"state":"reconciliation_automation_planned"' "$result_root/automation-plan.result.json" || fail 'Automation plan did not materialize'
|
||||
grep -q '"outcome":"ready"' "$result_root/automation-plan.result.json" || fail 'Automation plan requires manual resolution or has no applicable rows'
|
||||
automation_plan_digest=$(extract_digest "$result_root/automation-plan.result.json" automationPlanDigest)
|
||||
automation_head_digest=$(extract_digest "$result_root/automation-plan.result.json" instanceHeadDigest)
|
||||
cat >"$command_root/automation-verify.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.automation.verify","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","automationRoot":"$automation_root","allowRootService":$allow_root_service},"request":{"automationId":"$AUTOMATION_ID","expectedAutomationPlanDigest":"$automation_plan_digest"}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/automation-verify.json"
|
||||
run_deploy reconciliation-automation-verify automation-verify.json automation-verify.result.json
|
||||
grep -q '"status":"verified"' "$result_root/automation-verify.result.json" || fail 'Automation plan verification failed'
|
||||
automation_head_digest=$(extract_digest "$result_root/automation-verify.result.json" instanceHeadDigest)
|
||||
|
||||
decision_prepared_ms=$((automation_prepared_ms + 1))
|
||||
cat >"$command_root/automation-decision-prepare.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.automation.decision.prepare","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","automationRoot":"$automation_root","automationDecisionRoot":"$automation_decision_root","allowRootService":$allow_root_service},"request":{"decisionId":"$AUTOMATION_DECISION_ID","automationId":"$AUTOMATION_ID","expectedAutomationPlanDigest":"$automation_plan_digest","expectedHeadDigest":"$automation_head_digest","preparedAtMs":$decision_prepared_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/automation-decision-prepare.json"
|
||||
run_deploy reconciliation-automation-decision-prepare automation-decision-prepare.json automation-decision-prepare.result.json
|
||||
grep -q '"state":"reconciliation_automation_decision_prepared"' "$result_root/automation-decision-prepare.result.json" || fail 'Automation decision authority was not prepared'
|
||||
decision_preparation_digest=$(extract_digest "$result_root/automation-decision-prepare.result.json" preparationDigest)
|
||||
eligible_count=$(extract_unsigned "$result_root/automation-plan.result.json" eligibleCount)
|
||||
conflict_count=$(extract_unsigned "$result_root/automation-plan.result.json" conflictCount)
|
||||
row_count=$(extract_unsigned "$result_root/automation-plan.result.json" rowCount)
|
||||
[ "$eligible_count" -gt 0 ] && [ "$conflict_count" -eq 0 ] || fail 'Automation plan is not eligible for bounded reviewed application'
|
||||
cat >"$reconciliation_root/summary.json" <<EOF
|
||||
{"schemaVersion":1,"schema":"qinglong/local-alpha-reconciliation-rehearsal-summary@v1","status":"automation_decision_required","profile":"$profile","variant":"$VARIANT","sourceRevision":"$SOURCE_REVISION","architecture":"$ARCHITECTURE","review":{"reviewId":"$REVIEW_ID","reviewDigest":"$review_digest","decisionAuthority":"authenticated_user"},"application":{"applicationId":"$APPLICATION_ID","applicationPlanDigest":"$application_plan_digest"},"automation":{"automationId":"$AUTOMATION_ID","automationPlanDigest":"$automation_plan_digest","rowCount":$row_count,"eligibleCount":$eligible_count,"conflictCount":$conflict_count},"decision":{"decisionId":"$AUTOMATION_DECISION_ID","preparationDigest":"$decision_preparation_digest"},"automaticRowDecision":"not_authorized","next":"supply_external_automation_decisions"}
|
||||
EOF
|
||||
chmod 0600 "$reconciliation_root/summary.json"
|
||||
printf '%s\n' \
|
||||
'Authenticated review and Automation plan are ready.' \
|
||||
"Summary: $reconciliation_root/summary.json" \
|
||||
"Automation plan: $automation_root/$AUTOMATION_ID/plan.ndjson" \
|
||||
'No Automation row decision was generated or applied. Supply an external owner-private NDJSON decision file to the apply-rollback phase.'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
grep -q '"status":"automation_decision_required"' "$reconciliation_root/summary.json" || fail 'apply-rollback phase is detached from Automation decision state'
|
||||
decision_preparation_digest=$(extract_digest "$result_root/automation-decision-prepare.result.json" preparationDigest)
|
||||
decision_head_digest=$(extract_digest "$result_root/automation-decision-prepare.result.json" instanceHeadDigest)
|
||||
if [ -e "$command_root/automation-decision-commit.json" ]; then
|
||||
committed_ms=$(extract_unsigned "$command_root/automation-decision-commit.json" committedAtMs)
|
||||
else
|
||||
committed_ms=$(($(date +%s) * 1000))
|
||||
cat >"$command_root/automation-decision-commit.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.automation.decision.commit","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","automationRoot":"$automation_root","automationDecisionRoot":"$automation_decision_root","targetDatabasePath":"$target_database","ownerPepperKeyringDirectory":"$owner_peppers","credentialFilePath":"$owner_credential","busyTimeoutMs":100,"allowRootService":$allow_root_service},"request":{"decisionId":"$AUTOMATION_DECISION_ID","automationId":"$AUTOMATION_ID","expectedPreparationDigest":"$decision_preparation_digest","expectedHeadDigest":"$decision_head_digest","decisionFilePath":"$phase_input","committedAtMs":$committed_ms,"authorizationLifetimeMs":60000}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/automation-decision-commit.json"
|
||||
fi
|
||||
phase 'commit explicit Automation row decisions with real Owner authentication'
|
||||
run_deploy reconciliation-automation-decision-commit automation-decision-commit.json automation-decision-commit.result.json "$phase_input"
|
||||
grep -q '"state":"reconciliation_automation_reviewed"' "$result_root/automation-decision-commit.result.json" || fail 'Automation decisions did not commit'
|
||||
decision_digest=$(extract_digest "$result_root/automation-decision-commit.result.json" decisionDigest)
|
||||
cat >"$command_root/automation-decision-verify.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.automation.decision.verify","options":{"deploymentRoot":"$rehearsal_root","applicationRoot":"$application_root","automationRoot":"$automation_root","automationDecisionRoot":"$automation_decision_root","allowRootService":$allow_root_service},"request":{"decisionId":"$AUTOMATION_DECISION_ID","automationId":"$AUTOMATION_ID","expectedDecisionDigest":"$decision_digest"}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/automation-decision-verify.json"
|
||||
run_deploy reconciliation-automation-decision-verify automation-decision-verify.json automation-decision-verify.result.json
|
||||
grep -q '"status":"verified"' "$result_root/automation-decision-verify.result.json" || fail 'Automation decision verification failed'
|
||||
decision_head_digest=$(extract_digest "$result_root/automation-decision-verify.result.json" instanceHeadDigest)
|
||||
|
||||
apply_options="\"deploymentRoot\":\"$rehearsal_root\",\"applicationRoot\":\"$application_root\",\"automationRoot\":\"$automation_root\",\"automationDecisionRoot\":\"$automation_decision_root\",\"automationApplyRoot\":\"$automation_apply_root\",\"targetDatabasePath\":\"$target_database\",\"ownerPepperKeyringDirectory\":\"$owner_peppers\",\"credentialFilePath\":\"$owner_credential\",\"busyTimeoutMs\":100,\"allowRootService\":$allow_root_service"
|
||||
if [ -e "$command_root/automation-apply.json" ]; then
|
||||
applied_ms=$(extract_unsigned "$command_root/automation-apply.json" appliedAtMs)
|
||||
else
|
||||
applied_ms=$((committed_ms + 1))
|
||||
cat >"$command_root/automation-apply.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.automation.apply","options":{$apply_options},"request":{"decisionId":"$AUTOMATION_DECISION_ID","automationId":"$AUTOMATION_ID","expectedDecisionDigest":"$decision_digest","expectedHeadDigest":"$decision_head_digest","mutationId":"$AUTOMATION_MUTATION_ID","requestId":"alpha-reconciliation-automation-apply","appliedAtMs":$applied_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/automation-apply.json"
|
||||
fi
|
||||
phase 'apply reviewed Automation rows under the bounded operator envelope'
|
||||
run_deploy reconciliation-automation-apply automation-apply.json automation-apply.result.json
|
||||
grep -q '"state":"reconciliation_automation_applied"' "$result_root/automation-apply.result.json" || fail 'Automation rows did not apply'
|
||||
apply_digest=$(extract_digest "$result_root/automation-apply.result.json" applyDigest)
|
||||
applied_head_digest=$(extract_digest "$result_root/automation-apply.result.json" instanceHeadDigest)
|
||||
cat >"$command_root/automation-apply-verify.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.automation.apply.verify","options":{$apply_options},"request":{"decisionId":"$AUTOMATION_DECISION_ID","automationId":"$AUTOMATION_ID","expectedApplyDigest":"$apply_digest"}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/automation-apply-verify.json"
|
||||
run_deploy reconciliation-automation-apply-verify automation-apply-verify.json automation-apply-verify.result.json
|
||||
grep -q '"state":"reconciliation_automation_applied"' "$result_root/automation-apply-verify.result.json" || fail 'applied Automation verification failed'
|
||||
|
||||
if [ -e "$command_root/automation-rollback.json" ]; then
|
||||
rolled_back_ms=$(extract_unsigned "$command_root/automation-rollback.json" rolledBackAtMs)
|
||||
else
|
||||
rolled_back_ms=$((applied_ms + 1))
|
||||
cat >"$command_root/automation-rollback.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"local.deployment.reconciliation.automation.apply.rollback","options":{$apply_options},"request":{"decisionId":"$AUTOMATION_DECISION_ID","automationId":"$AUTOMATION_ID","expectedApplyDigest":"$apply_digest","expectedHeadDigest":"$applied_head_digest","rolledBackAtMs":$rolled_back_ms}}
|
||||
EOF
|
||||
chmod 0600 "$command_root/automation-rollback.json"
|
||||
fi
|
||||
phase 'explicitly roll back the reviewed Automation application'
|
||||
run_deploy reconciliation-automation-apply-rollback automation-rollback.json automation-rollback.result.json
|
||||
grep -q '"state":"reconciliation_automation_rolled_back"' "$result_root/automation-rollback.result.json" || fail 'Automation rollback did not complete'
|
||||
rollback_head_digest=$(extract_digest "$result_root/automation-rollback.result.json" instanceHeadDigest)
|
||||
run_deploy reconciliation-automation-apply-verify automation-apply-verify.json automation-rollback-verify.result.json
|
||||
grep -q '"state":"reconciliation_automation_rolled_back"' "$result_root/automation-rollback-verify.result.json" || fail 'rolled-back Automation verification failed'
|
||||
adopted_tasks=$(extract_unsigned "$result_root/automation-apply.result.json" adoptedTaskCount)
|
||||
adopted_triggers=$(extract_unsigned "$result_root/automation-apply.result.json" adoptedTriggerCount)
|
||||
cat >"$reconciliation_root/summary.json" <<EOF
|
||||
{"schemaVersion":1,"schema":"qinglong/local-alpha-reconciliation-rehearsal-summary@v1","status":"reconciliation_automation_rolled_back","profile":"$profile","variant":"$VARIANT","sourceRevision":"$SOURCE_REVISION","architecture":"$ARCHITECTURE","review":{"reviewId":"$REVIEW_ID","authority":"authenticated_user"},"automation":{"automationId":"$AUTOMATION_ID","decisionId":"$AUTOMATION_DECISION_ID","decisionDigest":"$decision_digest","applyDigest":"$apply_digest","adoptedTaskCount":$adopted_tasks,"adoptedTriggerCount":$adopted_triggers,"rollbackHeadDigest":"$rollback_head_digest"},"target":"restored_to_pre_automation_snapshot","completion":"not_attempted","targetRestart":"not_attempted","legacyRestart":"not_attempted","next":"review_rollback_evidence"}
|
||||
EOF
|
||||
chmod 0600 "$reconciliation_root/summary.json"
|
||||
printf '%s\n' \
|
||||
'Reviewed Automation application and explicit rollback completed.' \
|
||||
"Summary: $reconciliation_root/summary.json" \
|
||||
"Apply evidence: $result_root/automation-apply-verify.result.json" \
|
||||
"Rollback evidence: $result_root/automation-rollback-verify.result.json" \
|
||||
'No reconciliation completion, target restart, Legacy restart, Secret/Config application, or Run History mutation was attempted.'
|
||||
@@ -82,15 +82,26 @@ gid=$(id -g)
|
||||
run_inspect() {
|
||||
command_file=$1
|
||||
result_file=$2
|
||||
docker run --rm --read-only --user "$uid:$gid" --network none \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
--memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 32 \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \
|
||||
--mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \
|
||||
--mount "type=bind,src=$evidence_root,dst=/var/lib/qinglong3" \
|
||||
"$OPERATOR_IMAGE" adoption run \
|
||||
--command-file "/var/lib/qinglong3/$command_file" \
|
||||
>"$evidence_root/results/$result_file"
|
||||
result_stage="$evidence_root/results/.$result_file.$$"
|
||||
attempt=1
|
||||
while [ "$attempt" -le 2 ]; do
|
||||
if docker run --rm --read-only --user "$uid:$gid" --network none \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
--memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 32 \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \
|
||||
--mount "type=bind,src=$legacy_root,dst=$legacy_root,readonly" \
|
||||
--mount "type=bind,src=$evidence_root,dst=/var/lib/qinglong3" \
|
||||
"$OPERATOR_IMAGE" adoption run \
|
||||
--command-file "/var/lib/qinglong3/$command_file" \
|
||||
>"$result_stage"
|
||||
then
|
||||
chmod 0600 "$result_stage"
|
||||
mv "$result_stage" "$evidence_root/results/$result_file"
|
||||
return 0
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
run_inspect sqlite-inspect.json sqlite-inspect.result.json
|
||||
|
||||
Reference in New Issue
Block a user