feat(ql3): rehearse reviewed automation rollback

This commit is contained in:
whyour
2026-09-01 23:20:13 +08:00
parent 7ed7a08211
commit 8580b25ecc
24 changed files with 1794 additions and 57 deletions
@@ -209,12 +209,25 @@ function options(
fail('authentication material must be below deploymentRoot');
}
}
const targetRelative = path.relative(
normalized.deploymentRoot,
normalized.targetDatabasePath,
);
if (
roots.some(
(root) =>
overlaps(root, normalized.targetDatabasePath) ||
overlaps(normalized.targetDatabasePath, root),
)
!targetRelative ||
targetRelative.startsWith('..') ||
path.isAbsolute(targetRelative)
) {
fail('targetDatabasePath must be below deploymentRoot');
}
if (
roots
.slice(1)
.some(
(root) =>
overlaps(root, normalized.targetDatabasePath) ||
overlaps(normalized.targetDatabasePath, root),
)
) {
fail('targetDatabasePath overlaps an authority root');
}
@@ -210,7 +210,7 @@ async function authenticate(
databasePath: options.targetDatabasePath,
ownerPepperKeyringDirectory: options.ownerPepperKeyringDirectory,
credentialFilePath: options.credentialFilePath,
authenticationNamespace: 'local_reconciliation_automation_apply',
authenticationNamespace: 'reconcile_automation_apply',
now: () => atMs,
});
assertReviewer(selected, reviewer, atMs);
@@ -691,7 +691,7 @@ async function authorization(
selected.application.review.authorization.header.reviewer,
command.request.committedAtMs,
);
return issueReconciliationAutomationDecision({
return await issueReconciliationAutomationDecision({
...common,
reviewFilePath: command.request.decisionFilePath,
reviewer,
@@ -321,14 +321,25 @@ function normalizeOptions(
}
if (
automation !== null &&
roots.some(
(root) =>
overlaps(root, automation.targetDatabasePath) ||
overlaps(automation.targetDatabasePath, root),
)
roots
.slice(1)
.some(
(root) =>
overlaps(root, automation.targetDatabasePath) ||
overlaps(automation.targetDatabasePath, root),
)
) {
fail('targetDatabasePath overlaps an authority root');
}
if (automation !== null) {
const relative = path.relative(
normalized.deploymentRoot,
automation.targetDatabasePath,
);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
fail('Automation targetDatabasePath must be below deploymentRoot');
}
}
if (
secretConfig !== null &&
roots.some(
@@ -257,6 +257,7 @@ function maxAuthorizationBytes(profile: 'edge' | 'standalone'): number {
function strongPrincipal(
authenticated: Readonly<AuthenticatedLocalCommand>,
committedAtMs: number,
authorizationExpiresAtMs: number,
): Readonly<LocalReconciliationReviewAuthorizationHeader['reviewer']> {
const principal = authenticated.principal;
if (
@@ -266,7 +267,9 @@ function strongPrincipal(
) ||
principal.authenticatedAtMs > committedAtMs ||
committedAtMs - principal.authenticatedAtMs > MAX_AUTHENTICATION_AGE_MS ||
principal.expiresAtMs <= committedAtMs
principal.expiresAtMs <= committedAtMs ||
!Number.isSafeInteger(authorizationExpiresAtMs) ||
principal.expiresAtMs < authorizationExpiresAtMs
) {
configurationError(
'review commit requires a recent strongly authenticated User',
@@ -615,9 +618,12 @@ async function publishAuthorization(
dependencies: LocalReconciliationReviewCompletionDependencies,
uid: number,
): Promise<Readonly<LocalReconciliationReviewAuthorizationEvidence>> {
const authorizationExpiresAtMs =
command.request.committedAtMs + command.request.authorizationLifetimeMs;
const reviewer = strongPrincipal(
authenticated,
command.request.committedAtMs,
authorizationExpiresAtMs,
);
ensureLocalReconciliationReviewIssuerKeyring(
command.options.issuerKeyringPath,
@@ -638,8 +644,7 @@ async function publishAuthorization(
preparedHeadDigest: head.headDigest,
reviewer,
issuedAtMs: command.request.committedAtMs,
expiresAtMs:
command.request.committedAtMs + command.request.authorizationLifetimeMs,
expiresAtMs: authorizationExpiresAtMs,
});
return publishLocalReconciliationReviewAuthorization({
targetPath: selected.authorization,
@@ -0,0 +1,51 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
normalizeLocalReconciliationAutomationApplyCommand,
} = require('../dist/deployment/reconciliation/application/automation/applyContract.js');
test('Automation apply requires the target database below deployment authority', () => {
const command = {
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.apply',
options: {
deploymentRoot: '/authority/deployment',
applicationRoot: '/authority/application',
automationRoot: '/authority/automation',
automationDecisionRoot: '/authority/automation-decision',
automationApplyRoot: '/authority/automation-apply',
targetDatabasePath: '/authority/deployment/sqlite/qinglong3.sqlite',
ownerPepperKeyringDirectory: '/authority/deployment/owner-peppers',
credentialFilePath: '/authority/deployment/owner-credential.json',
allowRootService:
typeof process.getuid === 'function' && process.getuid() === 0,
},
request: {
decisionId: '019f8680-143d-7000-8000-000000000471',
automationId: '019f8680-143d-4000-8000-000000000461',
expectedDecisionDigest: '1'.repeat(64),
expectedHeadDigest: '2'.repeat(64),
mutationId: '019f8680-143d-4000-8000-000000000481',
requestId: 'bounded-automation-apply',
appliedAtMs: 1,
},
};
assert.equal(
normalizeLocalReconciliationAutomationApplyCommand(command).options
.targetDatabasePath,
command.options.targetDatabasePath,
);
assert.throws(
() =>
normalizeLocalReconciliationAutomationApplyCommand({
...command,
options: {
...command.options,
targetDatabasePath: '/authority/outside.sqlite',
},
}),
/must be below deploymentRoot/,
);
});
@@ -126,6 +126,7 @@ function fixture(
profile = 'edge',
createDefaultSidecars = true,
useAdoptedTargetBaseline = false,
targetInsideDeploymentRoot = false,
initializeDatabases,
mutateTarget,
} = {},
@@ -136,6 +137,7 @@ function fixture(
fs.chmodSync(root, 0o700);
t.after(() => removeFixtureRoot(root));
const deploymentRoot = path.join(root, 'runtime');
const sqliteRoot = path.join(deploymentRoot, 'sqlite');
const serviceRoot = path.join(deploymentRoot, 'service');
const cutoverId = 'capture-cutover-1';
const journal = path.join(serviceRoot, 'cutovers', cutoverId);
@@ -143,6 +145,7 @@ function fixture(
const captureRoot = path.join(root, 'capture-root');
for (const directory of [
deploymentRoot,
sqliteRoot,
serviceRoot,
path.dirname(journal),
journal,
@@ -152,7 +155,9 @@ function fixture(
if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 });
}
const legacySourcePath = path.join(root, 'database.sqlite');
const targetDatabasePath = path.join(root, 'database.ql3.sqlite');
const targetDatabasePath = targetInsideDeploymentRoot
? path.join(sqliteRoot, 'database.ql3.sqlite')
: path.join(root, 'database.ql3.sqlite');
const recoveryPath = path.join(root, 'database.recovery.sqlite');
const manifestPath = path.join(root, 'adoption-manifest.json');
const activationPath = path.join(root, 'activation.json');
@@ -995,6 +1000,7 @@ function mutatePlanningTarget({ targetDatabasePath }) {
function preparedPlan(t, options = {}) {
const state = preparedCapture(t, {
createDefaultSidecars: options.createDefaultSidecars ?? false,
targetInsideDeploymentRoot: options.targetInsideDeploymentRoot ?? false,
initializeDatabases:
options.initializeDatabases ?? planningDatabaseInitializer(options),
mutateTarget: options.mutateTarget ?? mutatePlanningTarget,
@@ -1229,7 +1235,7 @@ function reviewCommitFixture(t, options = {}) {
expectedHeadDigest: prepared.instanceHeadDigest,
decisionFilePath: reviewFile.filePath,
committedAtMs,
authorizationLifetimeMs: 30 * 60 * 1_000,
authorizationLifetimeMs: 60_000,
},
};
let authentications = 0;
@@ -1408,6 +1414,7 @@ async function plannedAutomationFixture(t, options = {}) {
applicationId: options.applicationId,
reviewSuffix: `automation-decision-${suffix}`,
createDefaultSidecars: false,
targetInsideDeploymentRoot: true,
initializeDatabases:
options.readyTarget === true
? automationReadyDatabaseInitializer()
@@ -1571,6 +1578,7 @@ function automationDecisionCommitFixture(
let authentications = 0;
let confirmations = 0;
let databaseCloses = 0;
let databaseClosed = true;
const command = {
schemaVersion: 1,
operation: 'local.deployment.reconciliation.automation.decision.commit',
@@ -1594,8 +1602,10 @@ function automationDecisionCommitFixture(
const dependencies = {
now: () => committedAtMs,
async openAuthenticationDatabase() {
databaseClosed = false;
return {
async close() {
databaseClosed = true;
databaseCloses += 1;
},
};
@@ -1624,6 +1634,7 @@ function automationDecisionCommitFixture(
pepperVersion: 1,
},
async confirm() {
assert.equal(databaseClosed, false);
confirmations += 1;
},
};
@@ -3377,6 +3388,17 @@ test('review commit rejects weak principals, oversized Edge streams and decision
/recent strongly authenticated User/,
);
const overlong = reviewCommitFixture(t, {
planId: '00000000-0000-4000-8000-000000000392',
reviewId: '00000000-0000-4000-8000-000000000393',
reviewSuffix: 'overlong',
});
overlong.command.request.authorizationLifetimeMs = 60_001;
await assert.rejects(
commitLocalReconciliationReview(overlong.command, overlong.dependencies),
/recent strongly authenticated User/,
);
const oversized = reviewCommitFixture(t, {
planId: '00000000-0000-4000-8000-000000000367',
reviewId: '00000000-0000-4000-8000-000000000368',
@@ -5658,7 +5680,7 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec
async authenticate(_database, options) {
assert.equal(
options.authenticationNamespace,
'local_reconciliation_automation_apply',
'reconcile_automation_apply',
);
const authenticatedAtMs = options.now();
return {