mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): rehearse reviewed automation rollback
This commit is contained in:
+18
-5
@@ -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');
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user