feat(ql3): atomically withdraw quarantined automation

This commit is contained in:
whyour
2026-08-13 03:42:38 +08:00
parent a6ad636251
commit 4c2a0b6adf
39 changed files with 983 additions and 264 deletions
@@ -25,6 +25,7 @@ import {
normalizePluginPackageInstallRecord,
type PluginPackageInstallRecord,
} from '@qinglong/runtime-core/plugin-package-install';
import { createPluginPackageAutomationLifecyclePublication } from '@qinglong/runtime-core/plugin-package-automation-publication';
import {
createProjectToolDefinitionSnapshot,
normalizeProjectToolDefinitionSnapshot,
@@ -56,15 +57,14 @@ import {
rollbackPostgresDefinitionTransaction,
} from '../../repository/definitionRepositorySupport';
import { isPostgresAvailabilityError } from '../../connection/pool';
import { PostgresPluginPackageAutomationPublicationRepository } from '../publication/pluginPackageAutomationPublicationRepository';
type Row = Record<string, unknown>;
type Queryable = Pick<PostgresQueryable, 'query'>;
export const CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT = 128;
function unavailable(
cause?: unknown,
): PluginPackageQuarantineUnavailableError {
function unavailable(cause?: unknown): PluginPackageQuarantineUnavailableError {
return new PluginPackageQuarantineUnavailableError({
cause: cause instanceof Error ? cause : undefined,
});
@@ -189,8 +189,10 @@ export class PostgresPluginPackageQuarantineRepository
if (result.rows.length !== 1) throw unavailable();
try {
return normalizePluginPackageQuarantineEvent(
recordJson(result.rows[0]!, 'eventJson') as unknown as
PluginPackageQuarantineEvent,
recordJson(
result.rows[0]!,
'eventJson',
) as unknown as PluginPackageQuarantineEvent,
);
} catch (error) {
if (error instanceof PluginPackageQuarantineUnavailableError) {
@@ -215,8 +217,10 @@ export class PostgresPluginPackageQuarantineRepository
if (result.rows.length !== 1) throw unavailable();
try {
const receipt = normalizePluginPackageWithdrawalReceipt(
recordJson(result.rows[0]!, 'receiptJson') as unknown as
PluginPackageWithdrawalReceipt,
recordJson(
result.rows[0]!,
'receiptJson',
) as unknown as PluginPackageWithdrawalReceipt,
);
assertPluginPackageWithdrawalMatchesEvent(event, receipt);
await this.#assertReceiptRelations(queryable, receipt);
@@ -296,8 +300,10 @@ export class PostgresPluginPackageQuarantineRepository
if (snapshots.rows.length !== 1) throw unavailable();
try {
const snapshot = normalizeProjectToolDefinitionSnapshot(
recordJson(snapshots.rows[0]!, 'snapshotJson') as unknown as
ProjectToolDefinitionSnapshot,
recordJson(
snapshots.rows[0]!,
'snapshotJson',
) as unknown as ProjectToolDefinitionSnapshot,
);
if (
snapshot.sources.length !== receipt.capability.retainedSourceCount ||
@@ -316,6 +322,31 @@ export class PostgresPluginPackageQuarantineRepository
}
throw unavailable(error);
}
const automation = await queryable.query<Row>(
`SELECT state,
lifecycle_event_digest AS "lifecycleEventDigest"
FROM "ql3"."plugin_package_automation_publications"
WHERE project_id = $1 AND package_name = $2
AND installation_id = $3 AND lock_digest = $4
ORDER BY version DESC
LIMIT 1`,
[
receipt.target.projectId,
receipt.target.packageName,
receipt.target.installationId,
receipt.target.lockDigest,
],
);
if (automation.rows.length > 1) throw unavailable();
const publication = automation.rows[0];
if (
publication &&
(text(publication, 'state') === 'active' ||
(publication.lifecycleEventDigest === receipt.eventDigest &&
text(publication, 'state') !== 'withdrawn'))
) {
throw unavailable();
}
}
async #findStored(
@@ -345,9 +376,7 @@ export class PostgresPluginPackageQuarantineRepository
LIMIT $2`,
[lockDigest, CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT + 1],
);
if (
result.rows.length > CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT
) {
if (result.rows.length > CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT) {
throw new PluginPackageQuarantineConflictError(
'matching install targets exceed the Cluster limit',
);
@@ -355,8 +384,10 @@ export class PostgresPluginPackageQuarantineRepository
return Object.freeze(
result.rows.map((row) => {
const record = normalizePluginPackageInstallRecord(
recordJson(row, 'recordJson') as unknown as
PluginPackageInstallRecord,
recordJson(
row,
'recordJson',
) as unknown as PluginPackageInstallRecord,
);
return Object.freeze({
projectId: record.projectId,
@@ -411,8 +442,10 @@ export class PostgresPluginPackageQuarantineRepository
let record: Readonly<PluginPackageInstallRecord>;
try {
record = normalizePluginPackageInstallRecord(
recordJson(result.rows[0]!, 'recordJson') as unknown as
PluginPackageInstallRecord,
recordJson(
result.rows[0]!,
'recordJson',
) as unknown as PluginPackageInstallRecord,
);
} catch (error) {
if (error instanceof PluginPackageQuarantineUnavailableError) {
@@ -436,12 +469,45 @@ export class PostgresPluginPackageQuarantineRepository
return record;
}
async #withdrawAutomation(
client: PostgresClient,
event: Readonly<PluginPackageQuarantineEvent>,
record: Readonly<PluginPackageInstallRecord>,
committedAtMs: number,
): Promise<void> {
const publications =
new PostgresPluginPackageAutomationPublicationRepository(this.pool);
const current = await publications.findCurrentInTransaction(
client,
event.target.projectId,
event.target.packageName,
);
if (!current) return;
if (
current.target.installationId !== event.target.installationId ||
current.target.lockDigest !== event.target.lockDigest ||
current.target.generation !== record.targetGeneration
) {
throw new PluginPackageQuarantineConflictError(
'Workflow/Prompt publication does not match the quarantined Package generation',
);
}
if (current.state === 'absent' || current.state === 'withdrawn') return;
await publications.publishSecurityWithdrawalInTransaction(
client,
createPluginPackageAutomationLifecyclePublication({
previous: current,
state: 'withdrawn',
lifecycleEventDigest: event.eventDigest,
publishedAtMs: committedAtMs,
}),
);
}
async #activeContributions(
queryable: Queryable,
projectId: string,
): Promise<
readonly Readonly<ProjectToolDefinitionSnapshotContribution>[]
> {
): Promise<readonly Readonly<ProjectToolDefinitionSnapshotContribution>[]> {
const result = await queryable.query<Row>(
`SELECT revision.revision_json AS "revisionJson"
FROM "ql3"."plugin_package_install_heads" AS head
@@ -532,9 +598,7 @@ export class PostgresPluginPackageQuarantineRepository
MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS + 1,
],
);
if (
result.rows.length > MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS
) {
if (result.rows.length > MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS) {
throw new PluginPackageQuarantineConflictError(
'owned Tasks exceed the quarantine withdrawal limit',
);
@@ -608,14 +672,11 @@ export class PostgresPluginPackageQuarantineRepository
'event digest is bound to another quarantine',
);
}
const existingReceipt = await this.#receiptByEvent(
client,
existingEvent,
);
const existingReceipt = await this.#receiptByEvent(client, existingEvent);
if (!existingReceipt) throw unavailable();
return Object.freeze({ created: false, receipt: existingReceipt });
}
await this.#install(client, event);
const install = await this.#install(client, event);
const committedAtMs = Math.max(
await this.#databaseNowMs(client),
event.occurredAtMs,
@@ -706,6 +767,9 @@ export class PostgresPluginPackageQuarantineRepository
) {
throw unavailable();
}
if (event.target.installState === 'active') {
await this.#withdrawAutomation(client, event, install, committedAtMs);
}
const stored = await this.#findStored(client, event.eventDigest);
if (!stored || !same(stored, receipt)) throw unavailable();
return Object.freeze({
@@ -1,8 +1,5 @@
// PostgreSQL adapter owned by Plugin Package publication and recovery.
import type {
PostgresClient,
PostgresPool,
} from '@qinglong/runtime-core';
import type { PostgresClient, PostgresPool } from '@qinglong/runtime-core';
import {
InvalidPluginPackageAutomationPublicationError,
MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES,
@@ -49,7 +46,10 @@ function unavailable(
});
}
function targetIdentity(projectId: unknown, packageName: unknown): {
function targetIdentity(
projectId: unknown,
packageName: unknown,
): {
readonly projectId: string;
readonly packageName: string;
} {
@@ -147,8 +147,7 @@ export class PostgresPluginPackageAutomationPublicationRepository
postgresRequiredString(row.generationDigest, unavailable) ||
publication.target.materializedRevisionDigest !==
postgresRequiredString(row.materializedRevisionDigest, unavailable) ||
publication.state !==
postgresRequiredString(row.state, unavailable) ||
publication.state !== postgresRequiredString(row.state, unavailable) ||
publication.version !==
postgresRequiredInteger(row.version, unavailable) ||
publication.publishedAtMs !==
@@ -158,9 +157,7 @@ export class PostgresPluginPackageAutomationPublicationRepository
}
return publication;
} catch (error) {
if (
error instanceof PluginPackageAutomationPublicationUnavailableError
) {
if (error instanceof PluginPackageAutomationPublicationUnavailableError) {
throw error;
}
throw unavailable(error);
@@ -393,9 +390,10 @@ export class PostgresPluginPackageAutomationPublicationRepository
return this.#findCurrent(client, projectId, packageName, true);
}
async publishInTransaction(
async #publishInTransaction(
client: PostgresClient,
value: Readonly<PluginPackageAutomationPublication>,
securityWithdrawal: boolean,
): Promise<
Readonly<{
status: 'created' | 'existing';
@@ -443,10 +441,7 @@ export class PostgresPluginPackageAutomationPublicationRepository
);
}
try {
assertPluginPackageAutomationPublicationSuccessor(
current,
publication,
);
assertPluginPackageAutomationPublicationSuccessor(current, publication);
} catch (error) {
if (error instanceof InvalidPluginPackageAutomationPublicationError) {
throw new PluginPackageAutomationPublicationConflictError(
@@ -469,20 +464,14 @@ export class PostgresPluginPackageAutomationPublicationRepository
);
if (
revision.rows.length !== 1 ||
postgresRequiredString(
revision.rows[0]!.revisionDigest,
unavailable,
) !== publication.target.materializedRevisionDigest ||
postgresRequiredString(revision.rows[0]!.revisionDigest, unavailable) !==
publication.target.materializedRevisionDigest ||
postgresRequiredString(revision.rows[0]!.projectId, unavailable) !==
publication.target.projectId ||
postgresRequiredString(
revision.rows[0]!.packageName,
unavailable,
) !== publication.target.packageName ||
postgresRequiredInteger(
revision.rows[0]!.generation,
unavailable,
) !== publication.target.generation ||
postgresRequiredString(revision.rows[0]!.packageName, unavailable) !==
publication.target.packageName ||
postgresRequiredInteger(revision.rows[0]!.generation, unavailable) !==
publication.target.generation ||
postgresRequiredString(revision.rows[0]!.lockDigest, unavailable) !==
publication.target.lockDigest
) {
@@ -490,41 +479,43 @@ export class PostgresPluginPackageAutomationPublicationRepository
'materialized revision fence does not match publication target',
);
}
const securityFence = await client.query<Row>(
`SELECT
EXISTS (
SELECT 1
FROM "ql3"."plugin_package_quarantine_events" AS quarantine
WHERE quarantine.project_id = $1
AND quarantine.package_name = $2
AND quarantine.installation_id = $3
AND quarantine.lock_digest = $4
) OR EXISTS (
SELECT 1
FROM "ql3"."plugin_package_publisher_provenance" AS provenance
JOIN "ql3"."plugin_package_publisher_revocation_receipts" AS revoked
ON revoked.publisher = provenance.publisher
AND revoked.key_id = provenance.key_id
WHERE provenance.installation_id = $3
AND provenance.lock_digest = $4
) AS "blocked"`,
[
publication.target.projectId,
publication.target.packageName,
publication.target.installationId,
publication.target.lockDigest,
],
);
if (
securityFence.rows.length !== 1 ||
typeof securityFence.rows[0]?.blocked !== 'boolean'
) {
throw unavailable();
}
if (securityFence.rows[0].blocked) {
throw new PluginPackageAutomationPublicationConflictError(
'security-fenced Package generation cannot publish automation',
if (!securityWithdrawal) {
const securityFence = await client.query<Row>(
`SELECT
EXISTS (
SELECT 1
FROM "ql3"."plugin_package_quarantine_events" AS quarantine
WHERE quarantine.project_id = $1
AND quarantine.package_name = $2
AND quarantine.installation_id = $3
AND quarantine.lock_digest = $4
) OR EXISTS (
SELECT 1
FROM "ql3"."plugin_package_publisher_provenance" AS provenance
JOIN "ql3"."plugin_package_publisher_revocation_receipts" AS revoked
ON revoked.publisher = provenance.publisher
AND revoked.key_id = provenance.key_id
WHERE provenance.installation_id = $3
AND provenance.lock_digest = $4
) AS "blocked"`,
[
publication.target.projectId,
publication.target.packageName,
publication.target.installationId,
publication.target.lockDigest,
],
);
if (
securityFence.rows.length !== 1 ||
typeof securityFence.rows[0]?.blocked !== 'boolean'
) {
throw unavailable();
}
if (securityFence.rows[0].blocked) {
throw new PluginPackageAutomationPublicationConflictError(
'security-fenced Package generation cannot publish automation',
);
}
}
await client.query(
`INSERT INTO "ql3"."plugin_package_automation_publications" (
@@ -601,13 +592,41 @@ export class PostgresPluginPackageAutomationPublicationRepository
});
}
async publish(
publishInTransaction(
client: PostgresClient,
value: Readonly<PluginPackageAutomationPublication>,
): Promise<
Readonly<{
status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>;
}>
> {
return this.#publishInTransaction(client, value, false);
}
publishSecurityWithdrawalInTransaction(
client: PostgresClient,
value: Readonly<PluginPackageAutomationPublication>,
): Promise<
Readonly<{
status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>;
}>
> {
const publication = normalizePluginPackageAutomationPublication(value);
if (publication.state !== 'withdrawn') {
throw new PluginPackageAutomationPublicationConflictError(
'security withdrawal must narrow automation state',
);
}
return this.#publishInTransaction(client, publication, true);
}
async publish(value: Readonly<PluginPackageAutomationPublication>): Promise<
Readonly<{
status: 'created' | 'existing';
publication: Readonly<PluginPackageAutomationPublication>;
}>
> {
for (
let attempt = 0;