feat(local): apply signed automation decisions atomically

This commit is contained in:
whyour
2026-08-22 19:34:16 +08:00
parent e6089fb77c
commit 2af3ccc158
3 changed files with 206 additions and 103 deletions
@@ -48,6 +48,20 @@ export interface PublishReviewedLegacyCrontabAdoptionOptions {
) => void | Promise<void>; ) => void | Promise<void>;
} }
export type PublishVerifiedLegacyCrontabAdoptionOptions = Pick<
PublishReviewedLegacyCrontabAdoptionOptions,
| 'sourceClient'
| 'targetPath'
| 'profile'
| 'timezone'
| 'projectId'
| 'mutationId'
| 'requestId'
| 'observedAtMs'
| 'confirmSourceIdentity'
| 'confirmReviewerAuthority'
>;
export class LegacyCrontabPublicationAuthorizationError extends Error { export class LegacyCrontabPublicationAuthorizationError extends Error {
readonly code = 'LEGACY_CRONTAB_PUBLICATION_NOT_AUTHORIZED'; readonly code = 'LEGACY_CRONTAB_PUBLICATION_NOT_AUTHORIZED';
constructor() { constructor() {
@@ -65,7 +79,7 @@ export class LegacyCrontabPublicationUnavailableError extends Error {
} }
function auditRecord( function auditRecord(
options: PublishReviewedLegacyCrontabAdoptionOptions, options: PublishVerifiedLegacyCrontabAdoptionOptions,
scope: VerifiedLegacyCrontabDecisionAuthorizationFileScope, scope: VerifiedLegacyCrontabDecisionAuthorizationFileScope,
decision: Readonly<SecurityPolicyDecision> | null, decision: Readonly<SecurityPolicyDecision> | null,
outcome: SecurityAuditRecord['outcome'], outcome: SecurityAuditRecord['outcome'],
@@ -157,7 +171,27 @@ export async function publishReviewedLegacyCrontabAdoption(
options.observedAtMs, options.observedAtMs,
), ),
}, },
async (scope) => { (scope) => publishVerifiedLegacyCrontabAdoption(options, scope),
);
}
export async function publishVerifiedLegacyCrontabAdoption(
options: PublishVerifiedLegacyCrontabAdoptionOptions,
scope: VerifiedLegacyCrontabDecisionAuthorizationFileScope,
): Promise<PublishLocalLegacyAdoptionResult> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
typeof options.confirmSourceIdentity !== 'function' ||
!scope ||
typeof scope !== 'object' ||
typeof scope.confirmIdentity !== 'function'
) {
throw new LegacyCrontabPublicationUnavailableError(
'Verified Legacy publication scope is invalid',
);
}
options.confirmSourceIdentity(); options.confirmSourceIdentity();
await options.confirmReviewerAuthority?.(scope.result.receipt.reviewer); await options.confirmReviewerAuthority?.(scope.result.receipt.reviewer);
const target = await openLocalSqliteAdoptionDatabase({ const target = await openLocalSqliteAdoptionDatabase({
@@ -247,6 +281,4 @@ export async function publishReviewedLegacyCrontabAdoption(
} finally { } finally {
await target.close(); await target.close();
} }
},
);
} }
@@ -2,6 +2,11 @@
import type { DatabaseSync } from 'node:sqlite'; import type { DatabaseSync } from 'node:sqlite';
import type { LocalSecretKeyProvider } from '@qinglong/runtime-core/local-secret'; import type { LocalSecretKeyProvider } from '@qinglong/runtime-core/local-secret';
import type { SecurityPrincipal } from '@qinglong/runtime-core/security'; import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
import {
publishVerifiedLegacyCrontabAdoption,
type PublishReviewedLegacyCrontabAdoptionResult,
type PublishVerifiedLegacyCrontabAdoptionOptions,
} from './legacyCrontabPublisher';
import { import {
publishLegacyCrontabDecisionAuthorizationFile, publishLegacyCrontabDecisionAuthorizationFile,
withVerifiedLegacyCrontabDecisionAuthorizationFile, withVerifiedLegacyCrontabDecisionAuthorizationFile,
@@ -35,7 +40,7 @@ export interface ReconciliationAutomationDecisionIdentity {
readonly inventoryDigest: string; readonly inventoryDigest: string;
} }
interface ReconciliationAutomationDecisionVerificationOptions export interface ReconciliationAutomationDecisionVerificationOptions
extends ReconciliationAutomationDecisionIdentity { extends ReconciliationAutomationDecisionIdentity {
readonly authorizationPath: string; readonly authorizationPath: string;
readonly sourceClient: DatabaseSync; readonly sourceClient: DatabaseSync;
@@ -47,6 +52,13 @@ interface ReconciliationAutomationDecisionVerificationOptions
readonly allowedParentModes?: readonly (0o500 | 0o700)[]; readonly allowedParentModes?: readonly (0o500 | 0o700)[];
} }
export interface ApplyReconciliationAutomationDecisionOptions
extends ReconciliationAutomationDecisionVerificationOptions,
Omit<
PublishVerifiedLegacyCrontabAdoptionOptions,
'sourceClient' | 'profile' | 'timezone'
> {}
export interface IssueReconciliationAutomationDecisionOptions export interface IssueReconciliationAutomationDecisionOptions
extends ReconciliationAutomationDecisionVerificationOptions { extends ReconciliationAutomationDecisionVerificationOptions {
readonly reviewFilePath: string; readonly reviewFilePath: string;
@@ -409,3 +421,29 @@ export async function verifyReconciliationAutomationDecision(
return scope.result; return scope.result;
}); });
} }
export async function applyReconciliationAutomationDecision(
options: ApplyReconciliationAutomationDecisionOptions,
): Promise<PublishReviewedLegacyCrontabAdoptionResult> {
return withVerifiedReconciliationAutomationDecision(options, (scope) =>
publishVerifiedLegacyCrontabAdoption(
{
sourceClient: options.sourceClient,
targetPath: options.targetPath,
profile: options.profile,
timezone: options.timezone,
projectId: options.projectId,
mutationId: options.mutationId,
requestId: options.requestId,
observedAtMs: options.observedAtMs,
confirmSourceIdentity: options.confirmSourceIdentity,
...(options.confirmReviewerAuthority === undefined
? {}
: {
confirmReviewerAuthority: options.confirmReviewerAuthority,
}),
},
scope,
),
);
}
@@ -24,6 +24,9 @@ const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
const { const {
visitLegacyCrontabAdoptionInspections, visitLegacyCrontabAdoptionInspections,
} = require('../dist/legacy-adoption/legacyCrontabAdoption'); } = require('../dist/legacy-adoption/legacyCrontabAdoption');
const {
applyReconciliationAutomationDecision,
} = require('../dist/legacy-adoption/reconciliationAutomationDecision');
const REVIEWED_AT_MS = 1_760_000_000_000; const REVIEWED_AT_MS = 1_760_000_000_000;
const REVIEWER = Object.freeze({ const REVIEWER = Object.freeze({
@@ -442,6 +445,7 @@ test('publishes one reviewed legacy task set into the target atomically', async
authorizationDirectory, authorizationDirectory,
`${decisionId}.ndjson`, `${decisionId}.ndjson`,
); );
const authorization =
await publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({ await publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
sourcePath: value.sourcePath, sourcePath: value.sourcePath,
profile: 'edge', profile: 'edge',
@@ -492,13 +496,42 @@ test('publishes one reviewed legacy task set into the target atomically', async
reviewerAuthorityChecks += 1; reviewerAuthorityChecks += 1;
}, },
}; };
const inserted = await publishReviewedLegacyCrontabAdoption( const publicationSource = new DatabaseSync(value.sourcePath, {
publicationOptions, readOnly: true,
); });
let sourceIdentityChecks = 0;
const inserted = await applyReconciliationAutomationDecision({
authorizationPath,
decisionId,
profile: 'edge',
automationPlanDigest: plan.planDigest,
inventoryDigest: authorization.receipt.inventoryDigest,
sourceClient: publicationSource,
timezone: 'UTC',
keyProvider: authorizationKeyProvider(),
observedAtMs: REVIEWED_AT_MS + 1,
openRequirements: () =>
page.diagnostics.map((diagnostic) => ({
rowOrdinal: diagnostic.rowOrdinal,
sourceDigest: diagnostic.sourceDigest,
classification: diagnostic.classification,
requirement: 'review_adopt',
})),
targetPath: value.targetPath,
projectId: 'default',
mutationId,
requestId: 'reconciliation-automation-publication-test',
confirmSourceIdentity() {
sourceIdentityChecks += 1;
},
confirmReviewerAuthority: publicationOptions.confirmReviewerAuthority,
});
publicationSource.close();
assert.equal(inserted.status, 'inserted'); assert.equal(inserted.status, 'inserted');
assert.equal(inserted.adoption.adoptedTaskCount, 1); assert.equal(inserted.adoption.adoptedTaskCount, 1);
assert.equal(inserted.adoption.adoptedTriggerCount, 1); assert.equal(inserted.adoption.adoptedTriggerCount, 1);
assert.equal(reviewerAuthorityChecks, 2); assert.equal(reviewerAuthorityChecks, 2);
assert.equal(sourceIdentityChecks, 2);
assert.equal( assert.equal(
(await publishReviewedLegacyCrontabAdoption(publicationOptions)).status, (await publishReviewedLegacyCrontabAdoption(publicationOptions)).status,
'existing', 'existing',