mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+1392
File diff suppressed because it is too large
Load Diff
+222
@@ -0,0 +1,222 @@
|
||||
// PostgreSQL post-install resource materialization and recovery authority.
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageResourceMaterializationError,
|
||||
MAX_PLUGIN_PACKAGE_MATERIALIZED_REVISION_JSON_BYTES,
|
||||
PluginPackageResourceMaterializationConflictError,
|
||||
PluginPackageResourceMaterializationUnavailableError,
|
||||
normalizePluginPackageMaterializedRevision,
|
||||
type PluginPackageMaterializedRevision,
|
||||
type PluginPackageMaterializedRevisionRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import {
|
||||
TaskSpecSemanticRegistry,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} from '@qinglong/runtime-core/task-spec-semantic';
|
||||
|
||||
import {
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackageResourceMaterializationError(message);
|
||||
}
|
||||
|
||||
function unavailable(): PluginPackageResourceMaterializationUnavailableError {
|
||||
return new PluginPackageResourceMaterializationUnavailableError();
|
||||
}
|
||||
|
||||
function generationDigest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
||||
return invalid('generation digest is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function serialize(
|
||||
revision: Readonly<PluginPackageMaterializedRevision>,
|
||||
): string {
|
||||
const value = JSON.stringify(revision);
|
||||
if (
|
||||
Buffer.byteLength(value, 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_MATERIALIZED_REVISION_JSON_BYTES
|
||||
) {
|
||||
return invalid('materialized revision exceeds the durable JSON budget');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageResourceMaterializationError ||
|
||||
error instanceof PluginPackageResourceMaterializationConflictError ||
|
||||
error instanceof PluginPackageResourceMaterializationUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new PluginPackageResourceMaterializationConflictError(
|
||||
'durable revision identity is already bound',
|
||||
);
|
||||
}
|
||||
return new PluginPackageResourceMaterializationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageMaterializedRevisionRepository
|
||||
implements PluginPackageMaterializedRevisionRepository
|
||||
{
|
||||
private readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
|
||||
|
||||
constructor(
|
||||
private readonly pool: Pick<PostgresPool, 'query'>,
|
||||
taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry(),
|
||||
) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
!(taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL materialized revision repository options are invalid',
|
||||
);
|
||||
}
|
||||
this.taskSpecSemanticRegistry = taskSpecSemanticRegistry;
|
||||
}
|
||||
|
||||
private parse(row: Row): Readonly<PluginPackageMaterializedRevision> {
|
||||
try {
|
||||
const revision = normalizePluginPackageMaterializedRevision(
|
||||
postgresRequiredJsonObject(
|
||||
row.revisionJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageMaterializedRevision,
|
||||
this.taskSpecSemanticRegistry,
|
||||
);
|
||||
if (
|
||||
revision.generation.generationDigest !==
|
||||
postgresRequiredString(row.generationDigest, unavailable) ||
|
||||
revision.generation.projectId !==
|
||||
postgresRequiredString(row.projectId, unavailable) ||
|
||||
revision.generation.packageName !==
|
||||
postgresRequiredString(row.packageName, unavailable) ||
|
||||
revision.generation.generation !==
|
||||
postgresRequiredInteger(row.generation, unavailable) ||
|
||||
revision.generation.lockDigest !==
|
||||
postgresRequiredString(row.lockDigest, unavailable) ||
|
||||
revision.manifestDigest !==
|
||||
postgresRequiredString(row.manifestDigest, unavailable) ||
|
||||
revision.revisionDigest !==
|
||||
postgresRequiredString(row.revisionDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
const createdAtMs = postgresRequiredInteger(row.createdAtMs, unavailable);
|
||||
if (createdAtMs < 0) throw unavailable();
|
||||
return revision;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageResourceMaterializationUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private async findStored(
|
||||
digest: string,
|
||||
): Promise<Readonly<PluginPackageMaterializedRevision> | null> {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT generation_digest AS "generationDigest",
|
||||
project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
generation,
|
||||
lock_digest AS "lockDigest",
|
||||
manifest_digest AS "manifestDigest",
|
||||
revision_digest AS "revisionDigest",
|
||||
revision_json AS "revisionJson",
|
||||
created_at_ms AS "createdAtMs"
|
||||
FROM "ql3"."plugin_package_materialized_revisions"
|
||||
WHERE generation_digest = $1
|
||||
LIMIT 2`,
|
||||
[digest],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return this.parse(result.rows[0]!);
|
||||
}
|
||||
|
||||
async find(
|
||||
digest: string,
|
||||
): Promise<Readonly<PluginPackageMaterializedRevision> | null> {
|
||||
try {
|
||||
return await this.findStored(generationDigest(digest));
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async publish(
|
||||
value: Readonly<PluginPackageMaterializedRevision>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
revision: Readonly<PluginPackageMaterializedRevision>;
|
||||
}>
|
||||
> {
|
||||
const revision = normalizePluginPackageMaterializedRevision(
|
||||
value,
|
||||
this.taskSpecSemanticRegistry,
|
||||
);
|
||||
const revisionJson = serialize(revision);
|
||||
try {
|
||||
const inserted = await this.pool.query(
|
||||
`INSERT INTO "ql3"."plugin_package_materialized_revisions" (
|
||||
generation_digest, project_id, package_name, generation,
|
||||
lock_digest, manifest_digest, revision_digest, revision_json,
|
||||
created_at_ms
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb,
|
||||
floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint)
|
||||
ON CONFLICT (generation_digest) DO NOTHING
|
||||
RETURNING generation_digest`,
|
||||
[
|
||||
revision.generation.generationDigest,
|
||||
revision.generation.projectId,
|
||||
revision.generation.packageName,
|
||||
revision.generation.generation,
|
||||
revision.generation.lockDigest,
|
||||
revision.manifestDigest,
|
||||
revision.revisionDigest,
|
||||
revisionJson,
|
||||
],
|
||||
);
|
||||
const stored = await this.findStored(
|
||||
revision.generation.generationDigest,
|
||||
);
|
||||
if (!stored) throw unavailable();
|
||||
if (
|
||||
stored.revisionDigest !== revision.revisionDigest ||
|
||||
JSON.stringify(stored) !== revisionJson
|
||||
) {
|
||||
throw new PluginPackageResourceMaterializationConflictError(
|
||||
'generation digest is bound to another semantic revision',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: inserted.rows.length === 1 ? 'created' : 'existing',
|
||||
revision: stored,
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
// PostgreSQL review proposal authority for Plugin Package installation.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
PluginPackageInstallProposalConflictError,
|
||||
PluginPackageInstallProposalUnavailableError,
|
||||
normalizePluginPackageInstallProposal,
|
||||
type CreatePluginPackageInstallProposalCommand,
|
||||
type CreatePluginPackageInstallProposalResult,
|
||||
type PluginPackageInstallProposal,
|
||||
type PluginPackageInstallProposalRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-proposal';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredBoolean,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type Queryable = Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>;
|
||||
|
||||
function unavailable(): PluginPackageInstallProposalUnavailableError {
|
||||
return new PluginPackageInstallProposalUnavailableError();
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | null {
|
||||
return value === null ? null : postgresRequiredString(value, unavailable);
|
||||
}
|
||||
|
||||
function nullableInteger(value: unknown): number | null {
|
||||
return value === null ? null : postgresRequiredInteger(value, unavailable);
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function parseProposal(row: Row): Readonly<PluginPackageInstallProposal> {
|
||||
try {
|
||||
const proposal = normalizePluginPackageInstallProposal(
|
||||
postgresRequiredJsonObject(
|
||||
row.proposalJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageInstallProposal,
|
||||
);
|
||||
if (
|
||||
proposal.proposalDigest !==
|
||||
postgresRequiredString(row.proposalDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return proposal;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageInstallProposalUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function parseAudit(row: Row): Readonly<SecurityAuditRecord> {
|
||||
try {
|
||||
const subjectType = nullableString(row.subjectType);
|
||||
const subjectId = nullableString(row.subjectId);
|
||||
const projectVersion = nullableInteger(row.projectVersion);
|
||||
if (!Array.isArray(row.reasons)) throw unavailable();
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: postgresRequiredString(row.eventId, unavailable),
|
||||
requestId: postgresRequiredString(row.requestId, unavailable),
|
||||
operationId: postgresRequiredString(row.operationId, unavailable),
|
||||
projectId: nullableString(row.projectId),
|
||||
subject:
|
||||
subjectType === null || subjectId === null
|
||||
? null
|
||||
: { type: subjectType, id: subjectId },
|
||||
authenticationId: nullableString(row.authenticationId),
|
||||
outcome: postgresRequiredString(row.outcome, unavailable),
|
||||
reasons: row.reasons,
|
||||
fence:
|
||||
projectVersion === null
|
||||
? null
|
||||
: {
|
||||
projectVersion,
|
||||
bindingVersion: nullableInteger(row.bindingVersion),
|
||||
},
|
||||
occurredAtMs: postgresRequiredInteger(row.occurredAtMs, unavailable),
|
||||
} as SecurityAuditRecord);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageInstallProposalUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function mappedError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof PluginPackageInstallProposalConflictError ||
|
||||
error instanceof PluginPackageInstallProposalUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new PluginPackageInstallProposalConflictError();
|
||||
}
|
||||
return new PluginPackageInstallProposalUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function proposalByActionRef(
|
||||
queryable: Queryable,
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackageInstallProposal> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT proposal_json AS "proposalJson",
|
||||
proposal_digest AS "proposalDigest"
|
||||
FROM "ql3"."plugin_package_install_proposals"
|
||||
WHERE action_ref = $1
|
||||
LIMIT 2`,
|
||||
[actionRef],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return parseProposal(result.rows[0]!);
|
||||
}
|
||||
|
||||
export function findPostgresPluginPackageInstallProposal(
|
||||
queryable: Queryable,
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackageInstallProposal> | null> {
|
||||
return proposalByActionRef(queryable, actionRef);
|
||||
}
|
||||
|
||||
async function auditById(
|
||||
queryable: Queryable,
|
||||
eventId: string,
|
||||
): Promise<Readonly<SecurityAuditRecord> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT event_id AS "eventId", request_id AS "requestId",
|
||||
operation_id AS "operationId", project_id AS "projectId",
|
||||
subject_type AS "subjectType", subject_id AS "subjectId",
|
||||
authentication_id AS "authenticationId", outcome,
|
||||
reasons, project_version AS "projectVersion",
|
||||
binding_version AS "bindingVersion",
|
||||
occurred_at_ms AS "occurredAtMs"
|
||||
FROM "ql3"."security_audit_events"
|
||||
WHERE event_id = $1
|
||||
LIMIT 2`,
|
||||
[eventId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return parseAudit(result.rows[0]!);
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageInstallProposalRepository
|
||||
implements PluginPackageInstallProposalRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError('PostgreSQL Package proposal pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async #transaction<T>(
|
||||
work: (client: PostgresClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state &&
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mappedError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
async findProposalByActionRef(
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackageInstallProposal> | null> {
|
||||
try {
|
||||
return await proposalByActionRef(this.pool, actionRef);
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
createProposal(
|
||||
command: CreatePluginPackageInstallProposalCommand,
|
||||
): Promise<Readonly<CreatePluginPackageInstallProposalResult>> {
|
||||
const proposal = normalizePluginPackageInstallProposal(command.proposal);
|
||||
const audit = normalizeSecurityAuditRecord(command.audit);
|
||||
if (
|
||||
audit.requestId !== proposal.actionRef ||
|
||||
audit.operationId !== 'plugin_package.propose' ||
|
||||
audit.projectId !== proposal.projectId ||
|
||||
audit.subject?.type !== proposal.proposedBy.type ||
|
||||
audit.subject.id !== proposal.proposedBy.id ||
|
||||
audit.authenticationId === null ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
!same(audit.reasons, ['package_proposal']) ||
|
||||
audit.fence?.projectVersion !== proposal.proposalFence.projectVersion ||
|
||||
audit.fence.bindingVersion !== proposal.proposalFence.bindingVersion ||
|
||||
audit.occurredAtMs !== proposal.createdAtMs
|
||||
) {
|
||||
throw new PluginPackageInstallProposalConflictError();
|
||||
}
|
||||
return this.#transaction(async (client) => {
|
||||
const existing = await proposalByActionRef(client, proposal.actionRef);
|
||||
if (existing) {
|
||||
const existingAudit = await auditById(client, audit.eventId);
|
||||
if (
|
||||
!same(existing, proposal) ||
|
||||
!existingAudit ||
|
||||
!same(existingAudit, audit)
|
||||
) {
|
||||
throw new PluginPackageInstallProposalConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
proposal,
|
||||
});
|
||||
}
|
||||
const fence = await client.query<Row>(
|
||||
`SELECT "ql3"."lock_approval_policy_fence"(
|
||||
$1::varchar, $2::varchar, $3::varchar, $4::integer, $5::integer
|
||||
) AS "matches"`,
|
||||
[
|
||||
proposal.projectId,
|
||||
proposal.proposedBy.type,
|
||||
proposal.proposedBy.id,
|
||||
proposal.proposalFence.projectVersion,
|
||||
proposal.proposalFence.bindingVersion,
|
||||
],
|
||||
);
|
||||
if (
|
||||
fence.rows.length !== 1 ||
|
||||
!postgresRequiredBoolean(fence.rows[0]!.matches, unavailable)
|
||||
) {
|
||||
throw new PluginPackageInstallProposalConflictError();
|
||||
}
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO "ql3"."plugin_package_install_proposals" (
|
||||
action_ref, project_id, action_type, permission, action_digest,
|
||||
preview_digest, proposed_by_type, proposed_by_id,
|
||||
fence_project_version, fence_binding_version, created_at_ms,
|
||||
proposal_json, proposal_digest
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13
|
||||
)`,
|
||||
[
|
||||
proposal.actionRef,
|
||||
proposal.projectId,
|
||||
proposal.actionType,
|
||||
proposal.permission,
|
||||
proposal.actionDigest,
|
||||
proposal.previewDigest,
|
||||
proposal.proposedBy.type,
|
||||
proposal.proposedBy.id,
|
||||
proposal.proposalFence.projectVersion,
|
||||
proposal.proposalFence.bindingVersion,
|
||||
proposal.createdAtMs,
|
||||
JSON.stringify(proposal),
|
||||
proposal.proposalDigest,
|
||||
],
|
||||
);
|
||||
if (inserted.rowCount !== 1) throw unavailable();
|
||||
const auditInserted = await client.query(
|
||||
`INSERT INTO "ql3"."security_audit_events" (
|
||||
event_id, request_id, operation_id, project_id, subject_type,
|
||||
subject_id, authentication_id, outcome, reasons,
|
||||
project_version, binding_version, occurred_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12
|
||||
)`,
|
||||
[
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
],
|
||||
);
|
||||
if (auditInserted.rowCount !== 1) throw unavailable();
|
||||
return Object.freeze({ status: 'created' as const, proposal });
|
||||
});
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
// PostgreSQL adapter owned by the Plugin Package lifecycle capability.
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageLifecyclePlanError,
|
||||
PluginPackageLifecyclePlanConflictError,
|
||||
PluginPackageLifecyclePlanUnavailableError,
|
||||
normalizePluginPackageLifecyclePlan,
|
||||
type CreatePluginPackageLifecyclePlanResult,
|
||||
type PluginPackageLifecyclePlan,
|
||||
type PluginPackageLifecyclePlanRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle-plan';
|
||||
|
||||
import {
|
||||
postgresRequiredJsonObject,
|
||||
postgresSqlState,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackageLifecyclePlanUnavailableError {
|
||||
return new PluginPackageLifecyclePlanUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageLifecyclePlanError ||
|
||||
error instanceof PluginPackageLifecyclePlanConflictError ||
|
||||
error instanceof PluginPackageLifecyclePlanUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new PluginPackageLifecyclePlanConflictError(
|
||||
'plan identity or target is already bound',
|
||||
);
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function normalizeRow(row: Row): Readonly<PluginPackageLifecyclePlan> {
|
||||
try {
|
||||
return normalizePluginPackageLifecyclePlan(
|
||||
postgresRequiredJsonObject(
|
||||
row.planJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageLifecyclePlan,
|
||||
);
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function validateActionRef(value: string): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
throw new InvalidPluginPackageLifecyclePlanError(
|
||||
'actionRef is invalid',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageLifecyclePlanReader {
|
||||
constructor(protected readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool !== 'object' ||
|
||||
typeof pool.query !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL Plugin Package lifecycle plan reader is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async findByActionRef(
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackageLifecyclePlan> | null> {
|
||||
validateActionRef(actionRef);
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT plan_json AS "planJson"
|
||||
FROM "ql3"."plugin_package_lifecycle_plans"
|
||||
WHERE action_ref = $1
|
||||
LIMIT 2`,
|
||||
[actionRef],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
const plan = normalizeRow(result.rows[0]!);
|
||||
if (plan.actionRef !== actionRef) throw unavailable();
|
||||
return plan;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageLifecyclePlanRepository
|
||||
extends PostgresPluginPackageLifecyclePlanReader
|
||||
implements PluginPackageLifecyclePlanRepository
|
||||
{
|
||||
async create(
|
||||
planValue: Readonly<PluginPackageLifecyclePlan>,
|
||||
): Promise<Readonly<CreatePluginPackageLifecyclePlanResult>> {
|
||||
const plan = normalizePluginPackageLifecyclePlan(planValue);
|
||||
try {
|
||||
const inserted = await this.pool.query<Row>(
|
||||
`INSERT INTO "ql3"."plugin_package_lifecycle_plans" (
|
||||
action_ref, plan_digest, action, project_id, package_name,
|
||||
installation_id, lock_digest, impact_digest, requested_by_type,
|
||||
requested_by_id, planned_at_ms, expires_at_ms, plan_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING action_ref AS "actionRef"`,
|
||||
[
|
||||
plan.actionRef,
|
||||
plan.planDigest,
|
||||
plan.impact.action,
|
||||
plan.impact.target.projectId,
|
||||
plan.impact.target.packageName,
|
||||
plan.impact.target.installationId,
|
||||
plan.impact.target.lockDigest,
|
||||
plan.impact.impactDigest,
|
||||
plan.requestedBy.type,
|
||||
plan.requestedBy.id,
|
||||
plan.plannedAtMs,
|
||||
plan.expiresAtMs,
|
||||
JSON.stringify(plan),
|
||||
],
|
||||
);
|
||||
const stored = await this.findByActionRef(plan.actionRef);
|
||||
if (!stored || !same(stored, plan)) {
|
||||
throw new PluginPackageLifecyclePlanConflictError(
|
||||
'plan identity is already bound to another impact',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status:
|
||||
inserted.rows.length === 1 ? ('created' as const) : ('existing' as const),
|
||||
plan: stored,
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1321
File diff suppressed because it is too large
Load Diff
+787
@@ -0,0 +1,787 @@
|
||||
// PostgreSQL adapter owned by the Plugin Package lifecycle capability.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
PostgresQueryable,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageQuarantineError,
|
||||
MAX_PLUGIN_PACKAGE_QUARANTINE_RETAINED_SOURCES,
|
||||
MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS,
|
||||
PluginPackageQuarantineConflictError,
|
||||
PluginPackageQuarantineUnavailableError,
|
||||
assertPluginPackageWithdrawalMatchesEvent,
|
||||
createPluginPackageWithdrawalReceipt,
|
||||
normalizePluginPackageQuarantineEvent,
|
||||
normalizePluginPackageWithdrawalReceipt,
|
||||
pluginPackageQuarantineTaskMutationId,
|
||||
type PluginPackageQuarantineEvent,
|
||||
type PluginPackageQuarantineRepository,
|
||||
type PluginPackageQuarantineTarget,
|
||||
type PluginPackageQuarantineTaskWithdrawal,
|
||||
type PluginPackageWithdrawalReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-quarantine';
|
||||
import {
|
||||
normalizePluginPackageInstallRecord,
|
||||
type PluginPackageInstallRecord,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import {
|
||||
createProjectToolDefinitionSnapshot,
|
||||
normalizeProjectToolDefinitionSnapshot,
|
||||
projectToolDefinitionActiveVectorDigest,
|
||||
projectToolDefinitionSnapshotContribution,
|
||||
type ProjectToolDefinitionSnapshot,
|
||||
type ProjectToolDefinitionSnapshotContribution,
|
||||
type ProjectToolDefinitionSnapshotSource,
|
||||
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||
import {
|
||||
createTaskDefinitionRecord,
|
||||
normalizeTaskDefinitionRecord,
|
||||
type TaskDefinitionRecord,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
TaskSpecSemanticRegistry,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} from '@qinglong/runtime-core/task-spec-semantic';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredBoolean,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
import { isPostgresAvailabilityError } from '../../connection/pool';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type Queryable = Pick<PostgresQueryable, 'query'>;
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT = 128;
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackageQuarantineUnavailableError {
|
||||
return new PluginPackageQuarantineUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageQuarantineError ||
|
||||
error instanceof PluginPackageQuarantineConflictError ||
|
||||
error instanceof PluginPackageQuarantineUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new PluginPackageQuarantineConflictError(
|
||||
'durable quarantine identity or target state conflicts',
|
||||
);
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
function recordJson(row: Row, key: string): Readonly<Record<string, unknown>> {
|
||||
return postgresRequiredJsonObject(row[key], unavailable);
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = postgresRequiredInteger(row[key], unavailable);
|
||||
if (value < 0) throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
return postgresRequiredString(row[key], unavailable);
|
||||
}
|
||||
|
||||
function taskRecord(row: Row): Readonly<TaskDefinitionRecord> {
|
||||
try {
|
||||
const description = row.description;
|
||||
if (description !== null && typeof description !== 'string') {
|
||||
throw unavailable();
|
||||
}
|
||||
return normalizeTaskDefinitionRecord({
|
||||
projectId: text(row, 'projectId'),
|
||||
taskId: text(row, 'taskId'),
|
||||
revision: integer(row, 'revision'),
|
||||
mutationId: text(row, 'mutationId'),
|
||||
name: text(row, 'name'),
|
||||
...(description === null ? {} : { description }),
|
||||
kind: text(row, 'kind') as TaskDefinitionRecord['kind'],
|
||||
spec: recordJson(
|
||||
row,
|
||||
'specJson',
|
||||
) as unknown as TaskDefinitionRecord['spec'],
|
||||
labels: recordJson(row, 'labelsJson') as TaskDefinitionRecord['labels'],
|
||||
enabled: postgresRequiredBoolean(row.enabled, unavailable),
|
||||
contentDigest: text(row, 'contentDigest'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
updatedAtMs: integer(row, 'updatedAtMs'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageQuarantineUnavailableError) throw error;
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function activeSource(
|
||||
contribution: Readonly<ProjectToolDefinitionSnapshotContribution>,
|
||||
): Readonly<ProjectToolDefinitionSnapshotSource> {
|
||||
return Object.freeze({
|
||||
installationId: contribution.generation.installationId,
|
||||
packageName: contribution.generation.packageName,
|
||||
generation: contribution.generation.generation,
|
||||
generationDigest: contribution.generation.generationDigest,
|
||||
lockDigest: contribution.generation.lockDigest,
|
||||
revisionDigest: contribution.revisionDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageQuarantineRepository
|
||||
implements PluginPackageQuarantineRepository
|
||||
{
|
||||
readonly #registry: TaskSpecSemanticRegistry;
|
||||
|
||||
constructor(
|
||||
private readonly pool: PostgresPool,
|
||||
options: Readonly<{ registry?: TaskSpecSemanticRegistry }> = {},
|
||||
) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL Plugin Package quarantine repository is invalid',
|
||||
);
|
||||
}
|
||||
const registry =
|
||||
options.registry ?? createBuiltInTaskSpecSemanticRegistry();
|
||||
if (!(registry instanceof TaskSpecSemanticRegistry)) {
|
||||
throw new TypeError('TaskSpec semantic registry is invalid');
|
||||
}
|
||||
this.#registry = registry;
|
||||
}
|
||||
|
||||
async #eventByDigest(
|
||||
queryable: Queryable,
|
||||
eventDigest: string,
|
||||
): Promise<Readonly<PluginPackageQuarantineEvent> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT event_json AS "eventJson"
|
||||
FROM "ql3"."plugin_package_quarantine_events"
|
||||
WHERE event_digest = $1
|
||||
LIMIT 2`,
|
||||
[eventDigest],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
try {
|
||||
return normalizePluginPackageQuarantineEvent(
|
||||
recordJson(result.rows[0]!, 'eventJson') as unknown as
|
||||
PluginPackageQuarantineEvent,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageQuarantineUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #receiptByEvent(
|
||||
queryable: Queryable,
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
): Promise<Readonly<PluginPackageWithdrawalReceipt> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT receipt_json AS "receiptJson"
|
||||
FROM "ql3"."plugin_package_withdrawal_receipts"
|
||||
WHERE event_digest = $1
|
||||
LIMIT 2`,
|
||||
[event.eventDigest],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
try {
|
||||
const receipt = normalizePluginPackageWithdrawalReceipt(
|
||||
recordJson(result.rows[0]!, 'receiptJson') as unknown as
|
||||
PluginPackageWithdrawalReceipt,
|
||||
);
|
||||
assertPluginPackageWithdrawalMatchesEvent(event, receipt);
|
||||
await this.#assertReceiptRelations(queryable, receipt);
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageQuarantineUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #assertReceiptRelations(
|
||||
queryable: Queryable,
|
||||
receipt: Readonly<PluginPackageWithdrawalReceipt>,
|
||||
): Promise<void> {
|
||||
const tasks = await queryable.query<Row>(
|
||||
`SELECT
|
||||
item.project_id AS "projectId",
|
||||
item.task_id AS "taskId",
|
||||
item.previous_revision AS "previousRevision",
|
||||
item.disabled_revision AS "disabledRevision",
|
||||
item.previous_content_digest AS "previousContentDigest",
|
||||
item.disabled_content_digest AS "disabledContentDigest",
|
||||
previous.content_digest AS "storedPreviousContentDigest",
|
||||
disabled.content_digest AS "storedDisabledContentDigest"
|
||||
FROM "ql3"."plugin_package_withdrawal_tasks" AS item
|
||||
JOIN "ql3"."task_definition_revisions" AS previous
|
||||
ON previous.project_id = item.project_id
|
||||
AND previous.task_id = item.task_id
|
||||
AND previous.revision = item.previous_revision
|
||||
JOIN "ql3"."task_definition_revisions" AS disabled
|
||||
ON disabled.project_id = item.project_id
|
||||
AND disabled.task_id = item.task_id
|
||||
AND disabled.revision = item.disabled_revision
|
||||
WHERE item.event_digest = $1
|
||||
ORDER BY item.task_id`,
|
||||
[receipt.eventDigest],
|
||||
);
|
||||
const withdrawals = Object.freeze(
|
||||
tasks.rows.map((row) =>
|
||||
Object.freeze({
|
||||
taskId: text(row, 'taskId'),
|
||||
previousRevision: integer(row, 'previousRevision'),
|
||||
disabledRevision: integer(row, 'disabledRevision'),
|
||||
previousContentDigest: text(row, 'previousContentDigest'),
|
||||
disabledContentDigest: text(row, 'disabledContentDigest'),
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (
|
||||
tasks.rows.some(
|
||||
(row) =>
|
||||
text(row, 'projectId') !== receipt.target.projectId ||
|
||||
text(row, 'previousContentDigest') !==
|
||||
text(row, 'storedPreviousContentDigest') ||
|
||||
text(row, 'disabledContentDigest') !==
|
||||
text(row, 'storedDisabledContentDigest'),
|
||||
) ||
|
||||
!same(withdrawals, receipt.capability.taskWithdrawals)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (receipt.capability.status === 'not_active') return;
|
||||
const snapshots = await queryable.query<Row>(
|
||||
`SELECT snapshot_json AS "snapshotJson"
|
||||
FROM "ql3"."project_tool_definition_snapshots"
|
||||
WHERE project_id = $1 AND active_vector_digest = $2
|
||||
AND snapshot_digest = $3
|
||||
LIMIT 2`,
|
||||
[
|
||||
receipt.target.projectId,
|
||||
receipt.capability.currentActiveVectorDigest,
|
||||
receipt.capability.currentToolSnapshotDigest,
|
||||
],
|
||||
);
|
||||
if (snapshots.rows.length !== 1) throw unavailable();
|
||||
try {
|
||||
const snapshot = normalizeProjectToolDefinitionSnapshot(
|
||||
recordJson(snapshots.rows[0]!, 'snapshotJson') as unknown as
|
||||
ProjectToolDefinitionSnapshot,
|
||||
);
|
||||
if (
|
||||
snapshot.sources.length !== receipt.capability.retainedSourceCount ||
|
||||
snapshot.sources.some(
|
||||
(source) =>
|
||||
source.packageName === receipt.target.packageName &&
|
||||
source.installationId === receipt.target.installationId &&
|
||||
source.lockDigest === receipt.target.lockDigest,
|
||||
)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageQuarantineUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #findStored(
|
||||
queryable: Queryable,
|
||||
eventDigest: string,
|
||||
): Promise<Readonly<PluginPackageWithdrawalReceipt> | null> {
|
||||
const event = await this.#eventByDigest(queryable, eventDigest);
|
||||
if (!event) return null;
|
||||
const receipt = await this.#receiptByEvent(queryable, event);
|
||||
if (!receipt) throw unavailable();
|
||||
return receipt;
|
||||
}
|
||||
|
||||
async findTargetsByLockDigest(
|
||||
lockDigest: string,
|
||||
): Promise<readonly Readonly<PluginPackageQuarantineTarget>[]> {
|
||||
if (typeof lockDigest !== 'string' || !/^[0-9a-f]{64}$/.test(lockDigest)) {
|
||||
throw new InvalidPluginPackageQuarantineError('lockDigest is invalid');
|
||||
}
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT record_json AS "recordJson"
|
||||
FROM "ql3"."plugin_package_installs"
|
||||
WHERE lock_digest = $1
|
||||
AND state IN ('queued', 'staged', 'activating', 'active')
|
||||
ORDER BY project_id, package_name, installation_id
|
||||
LIMIT $2`,
|
||||
[lockDigest, CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT + 1],
|
||||
);
|
||||
if (
|
||||
result.rows.length > CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT
|
||||
) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'matching install targets exceed the Cluster limit',
|
||||
);
|
||||
}
|
||||
return Object.freeze(
|
||||
result.rows.map((row) => {
|
||||
const record = normalizePluginPackageInstallRecord(
|
||||
recordJson(row, 'recordJson') as unknown as
|
||||
PluginPackageInstallRecord,
|
||||
);
|
||||
return Object.freeze({
|
||||
projectId: record.projectId,
|
||||
packageName: record.packageName,
|
||||
installationId: record.installationId,
|
||||
lockDigest: record.lockDigest,
|
||||
installState:
|
||||
record.state as PluginPackageQuarantineTarget['installState'],
|
||||
installVersion: record.version,
|
||||
installRecordDigest: record.recordDigest,
|
||||
activeLockDigest: record.activeLockDigest,
|
||||
});
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findByEventDigest(
|
||||
eventDigest: string,
|
||||
): Promise<Readonly<PluginPackageWithdrawalReceipt> | null> {
|
||||
if (
|
||||
typeof eventDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(eventDigest)
|
||||
) {
|
||||
throw new InvalidPluginPackageQuarantineError('eventDigest is invalid');
|
||||
}
|
||||
try {
|
||||
return await this.#findStored(this.pool, eventDigest);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #install(
|
||||
queryable: Queryable,
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
): Promise<Readonly<PluginPackageInstallRecord>> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT record_json AS "recordJson"
|
||||
FROM "ql3"."plugin_package_installs"
|
||||
WHERE installation_id = $1
|
||||
LIMIT 2`,
|
||||
[event.target.installationId],
|
||||
);
|
||||
if (result.rows.length !== 1) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'target install is absent',
|
||||
);
|
||||
}
|
||||
let record: Readonly<PluginPackageInstallRecord>;
|
||||
try {
|
||||
record = normalizePluginPackageInstallRecord(
|
||||
recordJson(result.rows[0]!, 'recordJson') as unknown as
|
||||
PluginPackageInstallRecord,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageQuarantineUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (
|
||||
record.projectId !== event.target.projectId ||
|
||||
record.packageName !== event.target.packageName ||
|
||||
record.lockDigest !== event.target.lockDigest ||
|
||||
record.state !== event.target.installState ||
|
||||
record.version !== event.target.installVersion ||
|
||||
record.recordDigest !== event.target.installRecordDigest ||
|
||||
record.activeLockDigest !== event.target.activeLockDigest
|
||||
) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'target install advanced or drifted',
|
||||
);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async #activeContributions(
|
||||
queryable: Queryable,
|
||||
projectId: string,
|
||||
): Promise<
|
||||
readonly Readonly<ProjectToolDefinitionSnapshotContribution>[]
|
||||
> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT revision.revision_json AS "revisionJson"
|
||||
FROM "ql3"."plugin_package_install_heads" AS head
|
||||
JOIN "ql3"."plugin_package_installs" AS head_install
|
||||
ON head_install.installation_id = head.installation_id
|
||||
JOIN "ql3"."plugin_package_installs" AS active_install
|
||||
ON active_install.project_id = head.project_id
|
||||
AND active_install.package_name = head.package_name
|
||||
AND active_install.lock_digest = head_install.active_lock_digest
|
||||
JOIN "ql3"."plugin_package_materialized_revisions" AS revision
|
||||
ON revision.project_id = active_install.project_id
|
||||
AND revision.package_name = active_install.package_name
|
||||
AND revision.generation = active_install.target_generation
|
||||
AND revision.lock_digest = active_install.lock_digest
|
||||
LEFT JOIN "ql3"."plugin_package_quarantine_events" AS quarantine
|
||||
ON quarantine.project_id = active_install.project_id
|
||||
AND quarantine.package_name = active_install.package_name
|
||||
AND quarantine.installation_id = active_install.installation_id
|
||||
AND quarantine.lock_digest = active_install.lock_digest
|
||||
WHERE head.project_id = $1
|
||||
AND head_install.active_lock_digest IS NOT NULL
|
||||
AND active_install.state = 'active'
|
||||
AND quarantine.event_digest IS NULL
|
||||
ORDER BY head.package_name
|
||||
LIMIT $2`,
|
||||
[projectId, MAX_PLUGIN_PACKAGE_QUARANTINE_RETAINED_SOURCES + 2],
|
||||
);
|
||||
if (
|
||||
result.rows.length >
|
||||
MAX_PLUGIN_PACKAGE_QUARANTINE_RETAINED_SOURCES + 1
|
||||
) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'active Package sources exceed the Cluster quarantine limit',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return Object.freeze(
|
||||
result.rows.map((row) =>
|
||||
projectToolDefinitionSnapshotContribution(
|
||||
recordJson(row, 'revisionJson') as never,
|
||||
this.#registry,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageQuarantineUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #enabledOwnedTasks(
|
||||
queryable: Queryable,
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
): Promise<readonly Readonly<TaskDefinitionRecord>[]> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT
|
||||
head.project_id AS "projectId",
|
||||
head.task_id AS "taskId",
|
||||
revision.revision,
|
||||
revision.mutation_id::text AS "mutationId",
|
||||
revision.name,
|
||||
revision.description,
|
||||
revision.kind,
|
||||
revision.spec_json AS "specJson",
|
||||
revision.labels_json AS "labelsJson",
|
||||
revision.enabled,
|
||||
revision.content_digest AS "contentDigest",
|
||||
head.created_at_ms AS "createdAtMs",
|
||||
revision.created_at_ms AS "updatedAtMs"
|
||||
FROM "ql3"."plugin_package_task_ownerships" AS ownership
|
||||
JOIN "ql3"."task_definitions" AS head
|
||||
ON head.project_id = ownership.project_id
|
||||
AND head.task_id = ownership.task_id
|
||||
JOIN "ql3"."task_definition_revisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
AND revision.revision = head.current_revision
|
||||
WHERE ownership.project_id = $1
|
||||
AND ownership.package_name = $2
|
||||
AND revision.enabled = true
|
||||
ORDER BY ownership.task_id
|
||||
LIMIT $3`,
|
||||
[
|
||||
event.target.projectId,
|
||||
event.target.packageName,
|
||||
MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS + 1,
|
||||
],
|
||||
);
|
||||
if (
|
||||
result.rows.length > MAX_PLUGIN_PACKAGE_QUARANTINE_TASK_WITHDRAWALS
|
||||
) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'owned Tasks exceed the quarantine withdrawal limit',
|
||||
);
|
||||
}
|
||||
return Object.freeze(result.rows.map(taskRecord));
|
||||
}
|
||||
|
||||
#disabledTask(
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
current: Readonly<TaskDefinitionRecord>,
|
||||
committedAtMs: number,
|
||||
): Readonly<{
|
||||
disabled: Readonly<TaskDefinitionRecord>;
|
||||
withdrawal: Readonly<PluginPackageQuarantineTaskWithdrawal>;
|
||||
}> {
|
||||
const disabled = createTaskDefinitionRecord(
|
||||
{
|
||||
projectId: current.projectId,
|
||||
taskId: current.taskId,
|
||||
expectedRevision: current.revision,
|
||||
mutationId: pluginPackageQuarantineTaskMutationId(
|
||||
event.eventDigest,
|
||||
current.taskId,
|
||||
),
|
||||
name: current.name,
|
||||
...(current.description === undefined
|
||||
? {}
|
||||
: { description: current.description }),
|
||||
kind: current.kind,
|
||||
spec: current.spec,
|
||||
labels: current.labels,
|
||||
enabled: false,
|
||||
occurredAtMs: committedAtMs,
|
||||
},
|
||||
current.createdAtMs,
|
||||
);
|
||||
return Object.freeze({
|
||||
disabled,
|
||||
withdrawal: Object.freeze({
|
||||
taskId: current.taskId,
|
||||
previousRevision: current.revision,
|
||||
disabledRevision: disabled.revision,
|
||||
previousContentDigest: current.contentDigest,
|
||||
disabledContentDigest: disabled.contentDigest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async #databaseNowMs(queryable: Queryable): Promise<number> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
AS "nowMs"`,
|
||||
);
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return integer(result.rows[0]!, 'nowMs');
|
||||
}
|
||||
|
||||
async #commit(
|
||||
client: PostgresClient,
|
||||
event: Readonly<PluginPackageQuarantineEvent>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
created: boolean;
|
||||
receipt: Readonly<PluginPackageWithdrawalReceipt>;
|
||||
}>
|
||||
> {
|
||||
const existingEvent = await this.#eventByDigest(client, event.eventDigest);
|
||||
if (existingEvent) {
|
||||
if (!same(existingEvent, event)) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'event digest is bound to another quarantine',
|
||||
);
|
||||
}
|
||||
const existingReceipt = await this.#receiptByEvent(
|
||||
client,
|
||||
existingEvent,
|
||||
);
|
||||
if (!existingReceipt) throw unavailable();
|
||||
return Object.freeze({ created: false, receipt: existingReceipt });
|
||||
}
|
||||
await this.#install(client, event);
|
||||
const committedAtMs = Math.max(
|
||||
await this.#databaseNowMs(client),
|
||||
event.occurredAtMs,
|
||||
);
|
||||
let snapshot: Readonly<ProjectToolDefinitionSnapshot> | null = null;
|
||||
let taskWrites: readonly Readonly<{
|
||||
disabled: Readonly<TaskDefinitionRecord>;
|
||||
withdrawal: Readonly<PluginPackageQuarantineTaskWithdrawal>;
|
||||
}>[] = Object.freeze([]);
|
||||
let receipt: Readonly<PluginPackageWithdrawalReceipt>;
|
||||
|
||||
if (event.target.installState !== 'active') {
|
||||
receipt = createPluginPackageWithdrawalReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
target: event.target,
|
||||
capability: {
|
||||
status: 'not_active',
|
||||
taskWithdrawals: [],
|
||||
previousActiveVectorDigest: null,
|
||||
currentActiveVectorDigest: null,
|
||||
currentToolSnapshotDigest: null,
|
||||
retainedSourceCount: 0,
|
||||
},
|
||||
committedAtMs,
|
||||
});
|
||||
} else {
|
||||
const previousContributions = await this.#activeContributions(
|
||||
client,
|
||||
event.target.projectId,
|
||||
);
|
||||
const targetIndex = previousContributions.findIndex(
|
||||
({ generation }) =>
|
||||
generation.packageName === event.target.packageName &&
|
||||
generation.installationId === event.target.installationId &&
|
||||
generation.lockDigest === event.target.lockDigest,
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
throw new PluginPackageQuarantineConflictError(
|
||||
'active target is not a complete Tool source',
|
||||
);
|
||||
}
|
||||
const previousSources = Object.freeze(
|
||||
previousContributions.map(activeSource),
|
||||
);
|
||||
const retainedContributions = Object.freeze(
|
||||
previousContributions.filter((_, index) => index !== targetIndex),
|
||||
);
|
||||
snapshot = createProjectToolDefinitionSnapshot({
|
||||
projectId: event.target.projectId,
|
||||
contributions: retainedContributions,
|
||||
});
|
||||
const tasks = await this.#enabledOwnedTasks(client, event);
|
||||
taskWrites = Object.freeze(
|
||||
tasks.map((task) => this.#disabledTask(event, task, committedAtMs)),
|
||||
);
|
||||
receipt = createPluginPackageWithdrawalReceipt({
|
||||
eventDigest: event.eventDigest,
|
||||
target: event.target,
|
||||
capability: {
|
||||
status: 'withdrawn',
|
||||
taskWithdrawals: taskWrites.map(({ withdrawal }) => withdrawal),
|
||||
previousActiveVectorDigest: projectToolDefinitionActiveVectorDigest(
|
||||
event.target.projectId,
|
||||
previousSources,
|
||||
),
|
||||
currentActiveVectorDigest: snapshot.activeVectorDigest,
|
||||
currentToolSnapshotDigest: snapshot.snapshotDigest,
|
||||
retainedSourceCount: snapshot.sources.length,
|
||||
},
|
||||
committedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
const committed = await client.query<Row>(
|
||||
`SELECT "ql3"."commit_plugin_package_quarantine"(
|
||||
$1::jsonb, $2::jsonb, $3::jsonb, $4::jsonb
|
||||
) AS "created"`,
|
||||
[
|
||||
JSON.stringify(event),
|
||||
JSON.stringify(receipt),
|
||||
JSON.stringify(taskWrites),
|
||||
JSON.stringify(snapshot),
|
||||
],
|
||||
);
|
||||
if (
|
||||
committed.rows.length !== 1 ||
|
||||
typeof committed.rows[0]?.created !== 'boolean'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
const stored = await this.#findStored(client, event.eventDigest);
|
||||
if (!stored || !same(stored, receipt)) throw unavailable();
|
||||
return Object.freeze({
|
||||
created: committed.rows[0].created,
|
||||
receipt: stored,
|
||||
});
|
||||
}
|
||||
|
||||
async quarantine(
|
||||
eventValue: Readonly<PluginPackageQuarantineEvent>,
|
||||
confirmAuthorization: () => void | Promise<void>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
receipt: Readonly<PluginPackageWithdrawalReceipt>;
|
||||
}>
|
||||
> {
|
||||
const event = normalizePluginPackageQuarantineEvent(eventValue);
|
||||
if (typeof confirmAuthorization !== 'function') {
|
||||
throw new InvalidPluginPackageQuarantineError(
|
||||
'confirmAuthorization is invalid',
|
||||
);
|
||||
}
|
||||
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
if (attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS) continue;
|
||||
throw unavailable(error);
|
||||
}
|
||||
let began = false;
|
||||
let authorizationFailure = false;
|
||||
const authorize = async (): Promise<void> => {
|
||||
try {
|
||||
await confirmAuthorization();
|
||||
} catch (error) {
|
||||
authorizationFailure = true;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
await authorize();
|
||||
const result = await this.#commit(client, event);
|
||||
await authorize();
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: result.created ? 'created' : 'existing',
|
||||
receipt: result.receipt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
if (authorizationFailure) throw error;
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS &&
|
||||
(isPostgresAvailabilityError(error) ||
|
||||
!state ||
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) ||
|
||||
state.startsWith('08'))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
+650
@@ -0,0 +1,650 @@
|
||||
// PostgreSQL adapter owned by Plugin Package publication and recovery.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageAutomationPublicationError,
|
||||
MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES,
|
||||
PluginPackageAutomationPublicationConflictError,
|
||||
PluginPackageAutomationPublicationUnavailableError,
|
||||
assertPluginPackageAutomationPublicationRecoveryPageSize,
|
||||
assertPluginPackageAutomationPublicationSuccessor,
|
||||
normalizePluginPackageAutomationPublication,
|
||||
normalizePluginPackageAutomationPublicationRecoveryCursor,
|
||||
type PluginPackageAutomationPublication,
|
||||
type PluginPackageAutomationPublicationRecoveryPage,
|
||||
type PluginPackageAutomationPublicationRepository,
|
||||
type PluginPackageAutomationPublicationRecoverySource,
|
||||
type PluginPackageAutomationPublicationStartGuard,
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type Queryable = Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>;
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackageAutomationPublicationError(message);
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
error?: unknown,
|
||||
): PluginPackageAutomationPublicationUnavailableError {
|
||||
return new PluginPackageAutomationPublicationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function targetIdentity(projectId: unknown, packageName: unknown): {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
} {
|
||||
if (typeof projectId !== 'string' || !IDENTIFIER.test(projectId)) {
|
||||
return invalid('projectId is invalid');
|
||||
}
|
||||
if (typeof packageName !== 'string' || !PACKAGE_NAME.test(packageName)) {
|
||||
return invalid('packageName is invalid');
|
||||
}
|
||||
return { projectId, packageName };
|
||||
}
|
||||
|
||||
function publicationDigest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
||||
return invalid('publicationDigest is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function serialize(
|
||||
publication: Readonly<PluginPackageAutomationPublication>,
|
||||
): string {
|
||||
const value = JSON.stringify(publication);
|
||||
if (
|
||||
Buffer.byteLength(value, 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES
|
||||
) {
|
||||
return invalid('publication exceeds the durable JSON budget');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageAutomationPublicationError ||
|
||||
error instanceof PluginPackageAutomationPublicationConflictError ||
|
||||
error instanceof PluginPackageAutomationPublicationUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state === '23503' ||
|
||||
state === '23505' ||
|
||||
state === '23514' ||
|
||||
state === '40001'
|
||||
) {
|
||||
return new PluginPackageAutomationPublicationConflictError(
|
||||
'durable publication chain changed',
|
||||
);
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageAutomationPublicationRepository
|
||||
implements
|
||||
PluginPackageAutomationPublicationRepository,
|
||||
PluginPackageAutomationPublicationRecoverySource,
|
||||
PluginPackageAutomationPublicationStartGuard
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL automation publication repository is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#parse(row: Row): Readonly<PluginPackageAutomationPublication> {
|
||||
try {
|
||||
const publication = normalizePluginPackageAutomationPublication(
|
||||
postgresRequiredJsonObject(
|
||||
row.publicationJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageAutomationPublication,
|
||||
);
|
||||
if (
|
||||
publication.publicationDigest !==
|
||||
postgresRequiredString(row.publicationDigest, unavailable) ||
|
||||
publication.target.projectId !==
|
||||
postgresRequiredString(row.projectId, unavailable) ||
|
||||
publication.target.packageName !==
|
||||
postgresRequiredString(row.packageName, unavailable) ||
|
||||
publication.target.installationId !==
|
||||
postgresRequiredString(row.installationId, unavailable) ||
|
||||
publication.target.lockDigest !==
|
||||
postgresRequiredString(row.lockDigest, unavailable) ||
|
||||
publication.target.generation !==
|
||||
postgresRequiredInteger(row.generation, unavailable) ||
|
||||
publication.target.generationDigest !==
|
||||
postgresRequiredString(row.generationDigest, unavailable) ||
|
||||
publication.target.materializedRevisionDigest !==
|
||||
postgresRequiredString(row.materializedRevisionDigest, unavailable) ||
|
||||
publication.state !==
|
||||
postgresRequiredString(row.state, unavailable) ||
|
||||
publication.version !==
|
||||
postgresRequiredInteger(row.version, unavailable) ||
|
||||
publication.publishedAtMs !==
|
||||
postgresRequiredInteger(row.publishedAtMs, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return publication;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PluginPackageAutomationPublicationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #findByDigest(
|
||||
queryable: Queryable,
|
||||
digest: string,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT publication_digest AS "publicationDigest",
|
||||
project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
installation_id AS "installationId",
|
||||
lock_digest AS "lockDigest",
|
||||
generation,
|
||||
generation_digest AS "generationDigest",
|
||||
materialized_revision_digest AS "materializedRevisionDigest",
|
||||
state,
|
||||
version,
|
||||
published_at_ms AS "publishedAtMs",
|
||||
publication_json AS "publicationJson"
|
||||
FROM "ql3"."plugin_package_automation_publications"
|
||||
WHERE publication_digest = $1
|
||||
LIMIT 2`,
|
||||
[digest],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return this.#parse(result.rows[0]!);
|
||||
}
|
||||
|
||||
async #findCurrent(
|
||||
queryable: Queryable,
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
lock = false,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT publication.publication_digest AS "publicationDigest",
|
||||
publication.project_id AS "projectId",
|
||||
publication.package_name AS "packageName",
|
||||
publication.installation_id AS "installationId",
|
||||
publication.lock_digest AS "lockDigest",
|
||||
publication.generation,
|
||||
publication.generation_digest AS "generationDigest",
|
||||
publication.materialized_revision_digest
|
||||
AS "materializedRevisionDigest",
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.published_at_ms AS "publishedAtMs",
|
||||
publication.publication_json AS "publicationJson"
|
||||
FROM "ql3"."plugin_package_automation_publication_heads" AS head
|
||||
JOIN "ql3"."plugin_package_automation_publications" AS publication
|
||||
ON publication.publication_digest = head.publication_digest
|
||||
WHERE head.project_id = $1 AND head.package_name = $2
|
||||
LIMIT 2${lock ? ' FOR UPDATE OF head' : ''}`,
|
||||
[projectId, packageName],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return this.#parse(result.rows[0]!);
|
||||
}
|
||||
|
||||
async findCurrent(
|
||||
projectIdValue: string,
|
||||
packageNameValue: string,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const { projectId, packageName } = targetIdentity(
|
||||
projectIdValue,
|
||||
packageNameValue,
|
||||
);
|
||||
try {
|
||||
return await this.#findCurrent(this.pool, projectId, packageName);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findByDigest(
|
||||
publicationDigestValue: string,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const digest = publicationDigest(publicationDigestValue);
|
||||
try {
|
||||
return await this.#findByDigest(this.pool, digest);
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async isStartAllowed(
|
||||
projectIdValue: string,
|
||||
packageNameValue: string,
|
||||
publicationDigestValue: string,
|
||||
): Promise<boolean> {
|
||||
const { projectId, packageName } = targetIdentity(
|
||||
projectIdValue,
|
||||
packageNameValue,
|
||||
);
|
||||
const digest = publicationDigest(publicationDigestValue);
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT "ql3"."plugin_package_automation_start_allowed"(
|
||||
$1::varchar, $2::varchar, $3::char(64)
|
||||
) AS "allowed"`,
|
||||
[projectId, packageName, digest],
|
||||
);
|
||||
if (
|
||||
result.rows.length !== 1 ||
|
||||
typeof result.rows[0]?.allowed !== 'boolean'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return result.rows[0].allowed;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listPendingPage(options: {
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
}>;
|
||||
}): Promise<Readonly<PluginPackageAutomationPublicationRecoveryPage>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new InvalidPluginPackageAutomationPublicationError(
|
||||
'pending page options are invalid',
|
||||
);
|
||||
}
|
||||
assertPluginPackageAutomationPublicationRecoveryPageSize(options.limit);
|
||||
const after =
|
||||
options.after === undefined
|
||||
? undefined
|
||||
: normalizePluginPackageAutomationPublicationRecoveryCursor(
|
||||
options.after,
|
||||
);
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT install.project_id AS "projectId",
|
||||
install.package_name AS "packageName"
|
||||
FROM "ql3"."plugin_package_install_heads" AS install_head
|
||||
JOIN "ql3"."plugin_package_installs" AS install
|
||||
ON install.installation_id = install_head.installation_id
|
||||
JOIN "ql3"."plugin_package_materialized_revisions" AS revision
|
||||
ON revision.project_id = install.project_id
|
||||
AND revision.package_name = install.package_name
|
||||
AND revision.generation = install.target_generation
|
||||
AND revision.lock_digest = install.lock_digest
|
||||
LEFT JOIN
|
||||
"ql3"."plugin_package_automation_publication_heads" AS publication
|
||||
ON publication.project_id = install.project_id
|
||||
AND publication.package_name = install.package_name
|
||||
WHERE install.state = 'active'
|
||||
AND install.active_lock_digest = install.lock_digest
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_quarantine_events" AS quarantine
|
||||
WHERE quarantine.project_id = install.project_id
|
||||
AND quarantine.package_name = install.package_name
|
||||
AND quarantine.installation_id = install.installation_id
|
||||
AND quarantine.lock_digest = install.lock_digest
|
||||
)
|
||||
AND (
|
||||
publication.generation_digest IS NULL OR
|
||||
publication.generation_digest <> revision.generation_digest
|
||||
)
|
||||
AND NOT 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 = install.installation_id
|
||||
AND provenance.lock_digest = install.lock_digest
|
||||
)
|
||||
AND (
|
||||
$1::varchar IS NULL OR install.project_id > $1 OR
|
||||
(install.project_id = $1 AND install.package_name > $2)
|
||||
)
|
||||
ORDER BY install.project_id, install.package_name
|
||||
LIMIT $3`,
|
||||
[
|
||||
after?.projectId ?? null,
|
||||
after?.packageName ?? null,
|
||||
options.limit + 1,
|
||||
],
|
||||
);
|
||||
const truncated = result.rows.length > options.limit;
|
||||
const candidates = result.rows.slice(0, options.limit).map((row) =>
|
||||
Object.freeze({
|
||||
projectId: postgresRequiredString(row.projectId, unavailable),
|
||||
packageName: postgresRequiredString(row.packageName, unavailable),
|
||||
}),
|
||||
);
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates: Object.freeze(candidates),
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
projectId: last.projectId,
|
||||
packageName: last.packageName,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findCurrentInTransaction(
|
||||
client: PostgresClient,
|
||||
projectIdValue: string,
|
||||
packageNameValue: string,
|
||||
): Promise<Readonly<PluginPackageAutomationPublication> | null> {
|
||||
const { projectId, packageName } = targetIdentity(
|
||||
projectIdValue,
|
||||
packageNameValue,
|
||||
);
|
||||
if (!client || typeof client.query !== 'function') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL automation publication transaction is invalid',
|
||||
);
|
||||
}
|
||||
return this.#findCurrent(client, projectId, packageName, true);
|
||||
}
|
||||
|
||||
async publishInTransaction(
|
||||
client: PostgresClient,
|
||||
value: Readonly<PluginPackageAutomationPublication>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
publication: Readonly<PluginPackageAutomationPublication>;
|
||||
}>
|
||||
> {
|
||||
if (!client || typeof client.query !== 'function') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL automation publication transaction is invalid',
|
||||
);
|
||||
}
|
||||
const publication = normalizePluginPackageAutomationPublication(value);
|
||||
const publicationJson = serialize(publication);
|
||||
const existing = await this.#findByDigest(
|
||||
client,
|
||||
publication.publicationDigest,
|
||||
);
|
||||
if (existing) {
|
||||
if (serialize(existing) !== publicationJson) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'publication digest is bound to another semantic publication',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
publication: existing,
|
||||
});
|
||||
}
|
||||
const current = await this.#findCurrent(
|
||||
client,
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
true,
|
||||
);
|
||||
if (publication.version === 1) {
|
||||
if (current) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'Package already has an automation publication head',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!current) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'previous automation publication head is absent',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertPluginPackageAutomationPublicationSuccessor(
|
||||
current,
|
||||
publication,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidPluginPackageAutomationPublicationError) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'automation publication does not succeed the current head',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const revision = await client.query<Row>(
|
||||
`SELECT revision_digest AS "revisionDigest",
|
||||
project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
generation,
|
||||
lock_digest AS "lockDigest"
|
||||
FROM "ql3"."plugin_package_materialized_revisions"
|
||||
WHERE generation_digest = $1
|
||||
LIMIT 2`,
|
||||
[publication.target.generationDigest],
|
||||
);
|
||||
if (
|
||||
revision.rows.length !== 1 ||
|
||||
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]!.lockDigest, unavailable) !==
|
||||
publication.target.lockDigest
|
||||
) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'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',
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."plugin_package_automation_publications" (
|
||||
publication_digest, project_id, package_name, installation_id,
|
||||
lock_digest, generation, generation_digest,
|
||||
materialized_revision_digest, state, version,
|
||||
previous_publication_digest, lifecycle_event_digest,
|
||||
published_at_ms, publication_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14::jsonb
|
||||
)`,
|
||||
[
|
||||
publication.publicationDigest,
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
publication.target.installationId,
|
||||
publication.target.lockDigest,
|
||||
publication.target.generation,
|
||||
publication.target.generationDigest,
|
||||
publication.target.materializedRevisionDigest,
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.previousPublicationDigest,
|
||||
publication.lifecycleEventDigest,
|
||||
publication.publishedAtMs,
|
||||
publicationJson,
|
||||
],
|
||||
);
|
||||
if (!current) {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."plugin_package_automation_publication_heads" (
|
||||
project_id, package_name, publication_digest,
|
||||
generation_digest, state, version, updated_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
publication.publicationDigest,
|
||||
publication.target.generationDigest,
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.publishedAtMs,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
const updated = await client.query(
|
||||
`UPDATE "ql3"."plugin_package_automation_publication_heads"
|
||||
SET publication_digest = $1, generation_digest = $2, state = $3,
|
||||
version = $4, updated_at_ms = $5
|
||||
WHERE project_id = $6 AND package_name = $7
|
||||
AND publication_digest = $8 AND version = $9`,
|
||||
[
|
||||
publication.publicationDigest,
|
||||
publication.target.generationDigest,
|
||||
publication.state,
|
||||
publication.version,
|
||||
publication.publishedAtMs,
|
||||
publication.target.projectId,
|
||||
publication.target.packageName,
|
||||
current.publicationDigest,
|
||||
current.version,
|
||||
],
|
||||
);
|
||||
if (updated.rowCount !== 1) {
|
||||
throw new PluginPackageAutomationPublicationConflictError(
|
||||
'automation publication head changed',
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
publication,
|
||||
});
|
||||
}
|
||||
|
||||
async publish(
|
||||
value: Readonly<PluginPackageAutomationPublication>,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
publication: Readonly<PluginPackageAutomationPublication>;
|
||||
}>
|
||||
> {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
if (attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS) continue;
|
||||
throw unavailable(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const result = await this.publishInTransaction(client, value);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS &&
|
||||
(state === undefined ||
|
||||
state.startsWith('08') ||
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
+598
@@ -0,0 +1,598 @@
|
||||
// PostgreSQL adapter owned by Plugin Package publication and recovery.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageTaskReconciliationError,
|
||||
PluginPackageTaskReconciliationConflictError,
|
||||
PluginPackageTaskReconciliationUnavailableError,
|
||||
normalizePluginPackageTaskReconciliationReceipt,
|
||||
planPluginPackageTaskReconciliation,
|
||||
pluginPackageTaskReconciliationTaskIds,
|
||||
type PluginPackageTaskOwnershipFact,
|
||||
type PluginPackageTaskReconciliationReceipt,
|
||||
type PluginPackageTaskReconciliationRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-task-reconciliation';
|
||||
import {
|
||||
assertPluginPackageTaskPublicationRecoveryPageSize,
|
||||
normalizePluginPackageTaskPublicationRecoveryCursor,
|
||||
type PluginPackageTaskPublicationRecoveryPage,
|
||||
type PluginPackageTaskPublicationRecoverySource,
|
||||
} from '@qinglong/runtime-core/plugin-package-task-publication';
|
||||
import {
|
||||
normalizePluginPackageMaterializedRevision,
|
||||
type PluginPackageMaterializedRevision,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import {
|
||||
normalizePluginPackageResourceGeneration,
|
||||
type PluginPackageResourceGenerationSource,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import {
|
||||
normalizeTaskDefinitionRecord,
|
||||
type TaskDefinitionRecord,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
compileClusterCommandTaskDefinition,
|
||||
type ClusterTaskExecutionRevision,
|
||||
} from '@qinglong/runtime-core/cluster-execution-revision';
|
||||
import {
|
||||
BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
TaskSpecSemanticRegistry,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} from '@qinglong/runtime-core/task-spec-semantic';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredBoolean,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const SELECT_TASK_FIELDS = `
|
||||
head.project_id AS "projectId",
|
||||
head.task_id AS "taskId",
|
||||
revision.revision,
|
||||
revision.mutation_id AS "mutationId",
|
||||
revision.name,
|
||||
revision.description,
|
||||
revision.kind,
|
||||
revision.spec_json AS "specJson",
|
||||
revision.labels_json AS "labelsJson",
|
||||
revision.enabled,
|
||||
revision.content_digest AS "contentDigest",
|
||||
head.created_at_ms AS "createdAtMs",
|
||||
revision.created_at_ms AS "updatedAtMs"`;
|
||||
|
||||
function unavailable(): PluginPackageTaskReconciliationUnavailableError {
|
||||
return new PluginPackageTaskReconciliationUnavailableError();
|
||||
}
|
||||
|
||||
function taskRecord(row: Row): Readonly<TaskDefinitionRecord> {
|
||||
try {
|
||||
const description = row.description;
|
||||
if (description !== null && typeof description !== 'string') {
|
||||
throw unavailable();
|
||||
}
|
||||
return normalizeTaskDefinitionRecord({
|
||||
projectId: postgresRequiredString(row.projectId, unavailable),
|
||||
taskId: postgresRequiredString(row.taskId, unavailable),
|
||||
revision: postgresRequiredInteger(row.revision, unavailable),
|
||||
mutationId: postgresRequiredString(row.mutationId, unavailable),
|
||||
name: postgresRequiredString(row.name, unavailable),
|
||||
...(description === null ? {} : { description }),
|
||||
kind: postgresRequiredString(
|
||||
row.kind,
|
||||
unavailable,
|
||||
) as TaskDefinitionRecord['kind'],
|
||||
spec: postgresRequiredJsonObject(
|
||||
row.specJson,
|
||||
unavailable,
|
||||
) as unknown as TaskDefinitionRecord['spec'],
|
||||
labels: postgresRequiredJsonObject(
|
||||
row.labelsJson,
|
||||
unavailable,
|
||||
) as TaskDefinitionRecord['labels'],
|
||||
enabled: postgresRequiredBoolean(row.enabled, unavailable),
|
||||
contentDigest: postgresRequiredString(row.contentDigest, unavailable),
|
||||
createdAtMs: postgresRequiredInteger(row.createdAtMs, unavailable),
|
||||
updatedAtMs: postgresRequiredInteger(row.updatedAtMs, unavailable),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageTaskReconciliationUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function executionPlanJson(
|
||||
value: Readonly<ClusterTaskExecutionRevision>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
command: value.command,
|
||||
environment: value.environment,
|
||||
...(value.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: value.workingDirectory }),
|
||||
...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }),
|
||||
...(value.placement === undefined
|
||||
? {}
|
||||
: { placement: value.placement }),
|
||||
});
|
||||
}
|
||||
|
||||
function executionJson(
|
||||
value: Readonly<ClusterTaskExecutionRevision>,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
projectId: value.projectId,
|
||||
taskId: value.taskId,
|
||||
sourceRevision: value.sourceRevision,
|
||||
taskRevision: value.taskRevision,
|
||||
sourceContentDigest: value.sourceContentDigest,
|
||||
executorType: value.executorType,
|
||||
planSchema: value.planSchema,
|
||||
planJson: executionPlanJson(value),
|
||||
contentDigest: value.contentDigest,
|
||||
createdAtMs: value.createdAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function mappedError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageTaskReconciliationError ||
|
||||
error instanceof PluginPackageTaskReconciliationConflictError ||
|
||||
error instanceof PluginPackageTaskReconciliationUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state === '23503' ||
|
||||
state === '23505' ||
|
||||
state === '23514' ||
|
||||
state === '40001'
|
||||
) {
|
||||
return new PluginPackageTaskReconciliationConflictError(
|
||||
'durable generation or TaskDefinition fence changed',
|
||||
);
|
||||
}
|
||||
return new PluginPackageTaskReconciliationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageTaskReconciliationRepository
|
||||
implements
|
||||
PluginPackageTaskReconciliationRepository,
|
||||
PluginPackageTaskPublicationRecoverySource
|
||||
{
|
||||
readonly #registry: TaskSpecSemanticRegistry;
|
||||
|
||||
constructor(
|
||||
private readonly pool: PostgresPool,
|
||||
registry = createBuiltInTaskSpecSemanticRegistry(),
|
||||
) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function' ||
|
||||
!(registry instanceof TaskSpecSemanticRegistry)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL Package Task reconciliation repository options are invalid',
|
||||
);
|
||||
}
|
||||
this.#registry = registry;
|
||||
}
|
||||
|
||||
async #findStored(
|
||||
queryable: Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>,
|
||||
generationDigest: string,
|
||||
): Promise<Readonly<PluginPackageTaskReconciliationReceipt> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT receipt_json AS "receiptJson"
|
||||
FROM "ql3"."plugin_package_task_reconciliations"
|
||||
WHERE generation_digest = $1
|
||||
LIMIT 2`,
|
||||
[generationDigest],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
try {
|
||||
return normalizePluginPackageTaskReconciliationReceipt(
|
||||
postgresRequiredJsonObject(
|
||||
result.rows[0]!.receiptJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageTaskReconciliationReceipt,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageTaskReconciliationUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async find(
|
||||
generationDigest: string,
|
||||
): Promise<Readonly<PluginPackageTaskReconciliationReceipt> | null> {
|
||||
if (
|
||||
typeof generationDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(generationDigest)
|
||||
) {
|
||||
throw new InvalidPluginPackageTaskReconciliationError(
|
||||
'generationDigest is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await this.#findStored(this.pool, generationDigest);
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listPendingPage(options: {
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
}>;
|
||||
}): Promise<Readonly<PluginPackageTaskPublicationRecoveryPage>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new InvalidPluginPackageTaskReconciliationError(
|
||||
'pending page options are invalid',
|
||||
);
|
||||
}
|
||||
assertPluginPackageTaskPublicationRecoveryPageSize(options.limit);
|
||||
const after =
|
||||
options.after === undefined
|
||||
? undefined
|
||||
: normalizePluginPackageTaskPublicationRecoveryCursor(options.after);
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT head.project_id AS "projectId",
|
||||
head.package_name AS "packageName"
|
||||
FROM "ql3"."plugin_package_install_heads" AS head
|
||||
JOIN "ql3"."plugin_package_installs" AS install
|
||||
ON install.installation_id = head.installation_id
|
||||
LEFT JOIN "ql3"."plugin_package_task_reconciliations" AS receipt
|
||||
ON receipt.project_id = install.project_id
|
||||
AND receipt.package_name = install.package_name
|
||||
AND receipt.generation = install.target_generation
|
||||
AND receipt.lock_digest = install.lock_digest
|
||||
WHERE install.state = 'active'
|
||||
AND install.active_lock_digest = install.lock_digest
|
||||
AND receipt.generation_digest IS NULL
|
||||
AND NOT 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 = install.installation_id
|
||||
AND provenance.lock_digest = install.lock_digest
|
||||
)
|
||||
AND (
|
||||
$1::varchar IS NULL OR head.project_id > $1 OR
|
||||
(head.project_id = $1 AND head.package_name > $2)
|
||||
)
|
||||
ORDER BY head.project_id, head.package_name
|
||||
LIMIT $3`,
|
||||
[
|
||||
after?.projectId ?? null,
|
||||
after?.packageName ?? null,
|
||||
options.limit + 1,
|
||||
],
|
||||
);
|
||||
const truncated = result.rows.length > options.limit;
|
||||
const candidates = result.rows.slice(0, options.limit).map((row) =>
|
||||
Object.freeze({
|
||||
projectId: postgresRequiredString(row.projectId, unavailable),
|
||||
packageName: postgresRequiredString(row.packageName, unavailable),
|
||||
}),
|
||||
);
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates: Object.freeze(candidates),
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
projectId: last.projectId,
|
||||
packageName: last.packageName,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async reconcile(
|
||||
revisionValue: Readonly<PluginPackageMaterializedRevision>,
|
||||
activeGenerationSource: PluginPackageResourceGenerationSource,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
receipt: Readonly<PluginPackageTaskReconciliationReceipt>;
|
||||
}>
|
||||
> {
|
||||
const revision = normalizePluginPackageMaterializedRevision(
|
||||
revisionValue,
|
||||
this.#registry,
|
||||
);
|
||||
if (
|
||||
!activeGenerationSource ||
|
||||
typeof activeGenerationSource.findActiveResourceGeneration !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageTaskReconciliationError(
|
||||
'active generation source is invalid',
|
||||
);
|
||||
}
|
||||
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const existing = await this.#findStored(
|
||||
client,
|
||||
revision.generation.generationDigest,
|
||||
);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.materializedRevisionDigest !== revision.revisionDigest ||
|
||||
existing.projectId !== revision.generation.projectId ||
|
||||
existing.packageName !== revision.generation.packageName
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'generation is bound to another materialized revision',
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
receipt: existing,
|
||||
});
|
||||
}
|
||||
|
||||
const materialized = await client.query<Row>(
|
||||
`SELECT revision_digest AS "revisionDigest"
|
||||
FROM "ql3"."plugin_package_materialized_revisions"
|
||||
WHERE generation_digest = $1`,
|
||||
[revision.generation.generationDigest],
|
||||
);
|
||||
if (
|
||||
materialized.rows.length !== 1 ||
|
||||
postgresRequiredString(
|
||||
materialized.rows[0]!.revisionDigest,
|
||||
unavailable,
|
||||
) !== revision.revisionDigest
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'materialized revision is not durably published',
|
||||
);
|
||||
}
|
||||
const install = await client.query<Row>(
|
||||
`SELECT install.installation_id AS "installationId",
|
||||
install.state,
|
||||
install.target_generation AS "targetGeneration",
|
||||
install.lock_digest AS "lockDigest",
|
||||
install.previous_active_lock_digest AS "previousLockDigest"
|
||||
FROM "ql3"."plugin_package_install_heads" AS head
|
||||
JOIN "ql3"."plugin_package_installs" AS install
|
||||
ON install.installation_id = head.installation_id
|
||||
WHERE head.project_id = $1 AND head.package_name = $2
|
||||
AND NOT 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 = install.installation_id
|
||||
AND provenance.lock_digest = install.lock_digest
|
||||
)
|
||||
FOR UPDATE OF install`,
|
||||
[
|
||||
revision.generation.projectId,
|
||||
revision.generation.packageName,
|
||||
],
|
||||
);
|
||||
const installRow = install.rows[0];
|
||||
if (
|
||||
install.rows.length !== 1 ||
|
||||
!installRow ||
|
||||
postgresRequiredString(installRow.installationId, unavailable) !==
|
||||
revision.generation.installationId ||
|
||||
postgresRequiredString(installRow.state, unavailable) !== 'active' ||
|
||||
postgresRequiredInteger(installRow.targetGeneration, unavailable) !==
|
||||
revision.generation.generation ||
|
||||
postgresRequiredString(installRow.lockDigest, unavailable) !==
|
||||
revision.generation.lockDigest ||
|
||||
(installRow.previousLockDigest === null
|
||||
? null
|
||||
: postgresRequiredString(
|
||||
installRow.previousLockDigest,
|
||||
unavailable,
|
||||
)) !== revision.generation.previousActiveLockDigest
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'Package install head is not the materialized generation',
|
||||
);
|
||||
}
|
||||
const previousResult =
|
||||
revision.generation.generation === 1
|
||||
? { rows: [] as Row[] }
|
||||
: await client.query<Row>(
|
||||
`SELECT receipt_json AS "receiptJson"
|
||||
FROM "ql3"."plugin_package_task_reconciliations"
|
||||
WHERE project_id = $1 AND package_name = $2
|
||||
AND generation = $3`,
|
||||
[
|
||||
revision.generation.projectId,
|
||||
revision.generation.packageName,
|
||||
revision.generation.generation - 1,
|
||||
],
|
||||
);
|
||||
const previous =
|
||||
previousResult.rows.length === 0
|
||||
? null
|
||||
: normalizePluginPackageTaskReconciliationReceipt(
|
||||
postgresRequiredJsonObject(
|
||||
previousResult.rows[0]!.receiptJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageTaskReconciliationReceipt,
|
||||
);
|
||||
if (
|
||||
previousResult.rows.length > 1 ||
|
||||
(revision.generation.generation > 1 && previous === null)
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'previous generation receipt is missing',
|
||||
);
|
||||
}
|
||||
const taskIds = pluginPackageTaskReconciliationTaskIds(
|
||||
revision,
|
||||
previous,
|
||||
this.#registry,
|
||||
);
|
||||
const facts: PluginPackageTaskOwnershipFact[] = [];
|
||||
for (const taskId of taskIds) {
|
||||
const result = await client.query<Row>(
|
||||
`SELECT
|
||||
${SELECT_TASK_FIELDS},
|
||||
ownership.package_name AS "ownerPackageName"
|
||||
FROM (SELECT 1) AS seed
|
||||
LEFT JOIN "ql3"."task_definitions" AS head
|
||||
ON head.project_id = $1 AND head.task_id = $2
|
||||
LEFT JOIN "ql3"."task_definition_revisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
AND revision.revision = head.current_revision
|
||||
LEFT JOIN "ql3"."plugin_package_task_ownerships" AS ownership
|
||||
ON ownership.project_id = $1 AND ownership.task_id = $2`,
|
||||
[revision.generation.projectId, taskId],
|
||||
);
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
const row = result.rows[0]!;
|
||||
facts.push(
|
||||
Object.freeze({
|
||||
taskId,
|
||||
packageName:
|
||||
row.ownerPackageName === null
|
||||
? null
|
||||
: postgresRequiredString(
|
||||
row.ownerPackageName,
|
||||
unavailable,
|
||||
),
|
||||
current: row.revision === null ? null : taskRecord(row),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const clock = await client.query<Row>(
|
||||
`SELECT floor(
|
||||
extract(epoch FROM clock_timestamp()) * 1000
|
||||
)::bigint AS "nowMs"`,
|
||||
);
|
||||
if (clock.rows.length !== 1) throw unavailable();
|
||||
const plan = planPluginPackageTaskReconciliation({
|
||||
revision,
|
||||
previousReceipt: previous,
|
||||
facts: Object.freeze(facts),
|
||||
committedAtMs: postgresRequiredInteger(
|
||||
clock.rows[0]!.nowMs,
|
||||
unavailable,
|
||||
),
|
||||
taskSpecSemanticRegistry: this.#registry,
|
||||
});
|
||||
const activeValue =
|
||||
await activeGenerationSource.findActiveResourceGeneration(
|
||||
revision.generation.projectId,
|
||||
revision.generation.packageName,
|
||||
);
|
||||
if (
|
||||
activeValue === null ||
|
||||
normalizePluginPackageResourceGeneration(activeValue)
|
||||
.generationDigest !== revision.generation.generationDigest
|
||||
) {
|
||||
throw new PluginPackageTaskReconciliationConflictError(
|
||||
'active generation changed during reconciliation',
|
||||
);
|
||||
}
|
||||
const executions = plan.writes.flatMap(({ definition }) =>
|
||||
definition.enabled &&
|
||||
definition.kind === 'command' &&
|
||||
definition.spec.schema === BUILT_IN_COMMAND_TASK_SPEC_SCHEMA
|
||||
? [
|
||||
executionJson(
|
||||
compileClusterCommandTaskDefinition(
|
||||
definition,
|
||||
this.#registry,
|
||||
),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const committed = await client.query<Row>(
|
||||
`SELECT "ql3"."commit_plugin_package_task_reconciliation"(
|
||||
$1::char(64), $2::char(64), $3::jsonb, $4::jsonb, $5::jsonb
|
||||
) AS "created"`,
|
||||
[
|
||||
revision.generation.generationDigest,
|
||||
revision.revisionDigest,
|
||||
JSON.stringify(plan.receipt),
|
||||
JSON.stringify(plan.writes),
|
||||
JSON.stringify(executions),
|
||||
],
|
||||
);
|
||||
if (
|
||||
committed.rows.length !== 1 ||
|
||||
typeof committed.rows[0]!.created !== 'boolean'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: committed.rows[0]!.created ? 'created' : 'existing',
|
||||
receipt: plan.receipt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state &&
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mappedError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
+1275
File diff suppressed because it is too large
Load Diff
+484
@@ -0,0 +1,484 @@
|
||||
// PostgreSQL Plugin Package publisher revocation proposal authority.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
approvalRequestDigest,
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovalRequestRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
PluginPackagePublisherRevocationProposalConflictError,
|
||||
PluginPackagePublisherRevocationProposalUnavailableError,
|
||||
normalizePluginPackagePublisherRevocationProposal,
|
||||
type CreatePluginPackagePublisherRevocationProposalCommand,
|
||||
type CreatePluginPackagePublisherRevocationProposalResult,
|
||||
type PluginPackagePublisherRevocationProposal,
|
||||
type PluginPackagePublisherRevocationProposalRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-revocation-proposal';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredBoolean,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type Queryable = Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>;
|
||||
// Must stay byte-for-byte compatible with publisher provenance mutations.
|
||||
const SIGNER_ADVISORY_LOCK_SEED = 774635229;
|
||||
|
||||
async function publisherSignerLock(
|
||||
queryable: Queryable,
|
||||
publisher: string,
|
||||
keyId: string,
|
||||
): Promise<void> {
|
||||
await queryable.query(
|
||||
`SELECT pg_advisory_xact_lock(hashtextextended($1, $2))`,
|
||||
[JSON.stringify([publisher, keyId]), SIGNER_ADVISORY_LOCK_SEED],
|
||||
);
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackagePublisherRevocationProposalUnavailableError {
|
||||
return new PluginPackagePublisherRevocationProposalUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | null {
|
||||
return value === null ? null : postgresRequiredString(value, unavailable);
|
||||
}
|
||||
|
||||
function nullableInteger(value: unknown): number | null {
|
||||
return value === null ? null : postgresRequiredInteger(value, unavailable);
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function mappedError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof PluginPackagePublisherRevocationProposalConflictError ||
|
||||
error instanceof PluginPackagePublisherRevocationProposalUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new PluginPackagePublisherRevocationProposalConflictError();
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
function parseProposal(
|
||||
row: Row,
|
||||
): Readonly<PluginPackagePublisherRevocationProposal> {
|
||||
try {
|
||||
const proposal = normalizePluginPackagePublisherRevocationProposal(
|
||||
postgresRequiredJsonObject(
|
||||
row.proposalJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackagePublisherRevocationProposal,
|
||||
);
|
||||
if (
|
||||
proposal.proposalDigest !==
|
||||
postgresRequiredString(row.proposalDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return proposal;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackagePublisherRevocationProposalUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseApprovalRequest(
|
||||
row: Row,
|
||||
): Readonly<ApprovalRequestRecord> {
|
||||
try {
|
||||
const request = normalizeApprovalRequestRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.requestJson,
|
||||
unavailable,
|
||||
) as unknown as ApprovalRequestRecord,
|
||||
);
|
||||
if (
|
||||
approvalRequestDigest(request) !==
|
||||
postgresRequiredString(row.requestDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return request;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackagePublisherRevocationProposalUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAudit(row: Row): Readonly<SecurityAuditRecord> {
|
||||
try {
|
||||
const subjectType = nullableString(row.subjectType);
|
||||
const subjectId = nullableString(row.subjectId);
|
||||
const projectVersion = nullableInteger(row.projectVersion);
|
||||
if (!Array.isArray(row.reasons)) throw unavailable();
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: postgresRequiredString(row.eventId, unavailable),
|
||||
requestId: postgresRequiredString(row.requestId, unavailable),
|
||||
operationId: postgresRequiredString(row.operationId, unavailable),
|
||||
projectId: nullableString(row.projectId),
|
||||
subject:
|
||||
subjectType === null || subjectId === null
|
||||
? null
|
||||
: { type: subjectType, id: subjectId },
|
||||
authenticationId: nullableString(row.authenticationId),
|
||||
outcome: postgresRequiredString(row.outcome, unavailable),
|
||||
reasons: row.reasons,
|
||||
fence:
|
||||
projectVersion === null
|
||||
? null
|
||||
: {
|
||||
projectVersion,
|
||||
bindingVersion: nullableInteger(row.bindingVersion),
|
||||
},
|
||||
occurredAtMs: postgresRequiredInteger(row.occurredAtMs, unavailable),
|
||||
} as SecurityAuditRecord);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackagePublisherRevocationProposalUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function proposalByActionRef(
|
||||
queryable: Queryable,
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackagePublisherRevocationProposal> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT proposal_json AS "proposalJson",
|
||||
proposal_digest AS "proposalDigest"
|
||||
FROM "ql3"."plugin_package_publisher_revocation_proposals"
|
||||
WHERE action_ref = $1
|
||||
LIMIT 2`,
|
||||
[actionRef],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return parseProposal(result.rows[0]!);
|
||||
}
|
||||
|
||||
async function auditById(
|
||||
queryable: Queryable,
|
||||
eventId: string,
|
||||
): Promise<Readonly<SecurityAuditRecord> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT event_id AS "eventId", request_id AS "requestId",
|
||||
operation_id AS "operationId", project_id AS "projectId",
|
||||
subject_type AS "subjectType", subject_id AS "subjectId",
|
||||
authentication_id AS "authenticationId", outcome,
|
||||
reasons, project_version AS "projectVersion",
|
||||
binding_version AS "bindingVersion",
|
||||
occurred_at_ms AS "occurredAtMs"
|
||||
FROM "ql3"."security_audit_events"
|
||||
WHERE event_id = $1
|
||||
LIMIT 2`,
|
||||
[eventId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return parseAudit(result.rows[0]!);
|
||||
}
|
||||
|
||||
export function findPostgresPluginPackagePublisherRevocationProposal(
|
||||
queryable: Queryable,
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackagePublisherRevocationProposal> | null> {
|
||||
return proposalByActionRef(queryable, actionRef);
|
||||
}
|
||||
|
||||
export class PostgresPluginPackagePublisherRevocationProposalRepository
|
||||
implements PluginPackagePublisherRevocationProposalRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL publisher revocation proposal pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #transaction<T>(
|
||||
work: (client: PostgresClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state &&
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mappedError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
async findProposalByActionRef(
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackagePublisherRevocationProposal> | null> {
|
||||
try {
|
||||
return await proposalByActionRef(this.pool, actionRef);
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listApprovedRequests(
|
||||
limitValue: number,
|
||||
): Promise<readonly Readonly<ApprovalRequestRecord>[]> {
|
||||
if (
|
||||
!Number.isSafeInteger(limitValue) ||
|
||||
limitValue < 1 ||
|
||||
limitValue > 64
|
||||
) {
|
||||
throw new TypeError(
|
||||
'publisher revocation approval page limit is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT request.request_json AS "requestJson",
|
||||
request.request_digest AS "requestDigest"
|
||||
FROM "ql3"."approval_requests" AS request
|
||||
JOIN "ql3"."plugin_package_publisher_revocation_proposals"
|
||||
AS proposal
|
||||
ON proposal.action_ref = request.action_ref
|
||||
WHERE request.state = 'approved'
|
||||
AND request.action_type =
|
||||
'plugin_package.publisher_key.revoke'
|
||||
ORDER BY request.updated_at_ms, request.request_id
|
||||
LIMIT $1`,
|
||||
[limitValue],
|
||||
);
|
||||
if (result.rows.length > limitValue) throw unavailable();
|
||||
return Object.freeze(result.rows.map(parseApprovalRequest));
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
createProposal(
|
||||
command: CreatePluginPackagePublisherRevocationProposalCommand,
|
||||
): Promise<
|
||||
Readonly<CreatePluginPackagePublisherRevocationProposalResult>
|
||||
> {
|
||||
const proposal = normalizePluginPackagePublisherRevocationProposal(
|
||||
command.proposal,
|
||||
);
|
||||
const audit = normalizeSecurityAuditRecord(command.audit);
|
||||
if (
|
||||
audit.requestId !== proposal.actionRef ||
|
||||
audit.operationId !==
|
||||
'plugin_package.publisher_revocation.propose' ||
|
||||
audit.projectId !== proposal.projectId ||
|
||||
audit.subject?.type !== proposal.proposedBy.type ||
|
||||
audit.subject.id !== proposal.proposedBy.id ||
|
||||
audit.authenticationId === null ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
!same(audit.reasons, ['publisher_revocation_proposal']) ||
|
||||
audit.fence?.projectVersion !==
|
||||
proposal.proposalFence.projectVersion ||
|
||||
audit.fence.bindingVersion !==
|
||||
proposal.proposalFence.bindingVersion ||
|
||||
audit.occurredAtMs !== proposal.createdAtMs
|
||||
) {
|
||||
throw new PluginPackagePublisherRevocationProposalConflictError();
|
||||
}
|
||||
return this.#transaction(async (client) => {
|
||||
const existing = await proposalByActionRef(
|
||||
client,
|
||||
proposal.actionRef,
|
||||
);
|
||||
if (existing) {
|
||||
const existingAudit = await auditById(client, audit.eventId);
|
||||
if (
|
||||
!same(existing, proposal) ||
|
||||
!existingAudit ||
|
||||
!same(existingAudit, audit)
|
||||
) {
|
||||
throw new PluginPackagePublisherRevocationProposalConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
proposal,
|
||||
});
|
||||
}
|
||||
await publisherSignerLock(
|
||||
client,
|
||||
proposal.actionInput.publisher,
|
||||
proposal.actionInput.keyId,
|
||||
);
|
||||
const trust = await client.query<Row>(
|
||||
`SELECT generation,
|
||||
effective_trust_digest AS "effectiveTrustDigest"
|
||||
FROM "ql3"."plugin_package_publisher_trust_heads"
|
||||
WHERE authority_id = $1
|
||||
LIMIT 2`,
|
||||
[proposal.actionInput.trustAuthorityId],
|
||||
);
|
||||
if (
|
||||
trust.rows.length !== 1 ||
|
||||
postgresRequiredInteger(
|
||||
trust.rows[0]!.generation,
|
||||
unavailable,
|
||||
) !== proposal.actionInput.trustGeneration ||
|
||||
postgresRequiredString(
|
||||
trust.rows[0]!.effectiveTrustDigest,
|
||||
unavailable,
|
||||
) !== proposal.actionInput.previousTrustDigest
|
||||
) {
|
||||
throw new PluginPackagePublisherRevocationProposalConflictError();
|
||||
}
|
||||
const fence = await client.query<Row>(
|
||||
`SELECT "ql3"."lock_approval_policy_fence"(
|
||||
$1::varchar, $2::varchar, $3::varchar, $4::integer, $5::integer
|
||||
) AS "matches"`,
|
||||
[
|
||||
proposal.projectId,
|
||||
proposal.proposedBy.type,
|
||||
proposal.proposedBy.id,
|
||||
proposal.proposalFence.projectVersion,
|
||||
proposal.proposalFence.bindingVersion,
|
||||
],
|
||||
);
|
||||
if (
|
||||
fence.rows.length !== 1 ||
|
||||
!postgresRequiredBoolean(fence.rows[0]!.matches, unavailable)
|
||||
) {
|
||||
throw new PluginPackagePublisherRevocationProposalConflictError();
|
||||
}
|
||||
const action = proposal.actionInput;
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO
|
||||
"ql3"."plugin_package_publisher_revocation_proposals" (
|
||||
action_ref, project_id, authority_id, trust_generation,
|
||||
publisher, key_id, previous_trust_digest,
|
||||
current_trust_digest, action_type, permission, action_digest,
|
||||
preview_digest, authorization_mode, reason_code,
|
||||
proposed_by_type, proposed_by_id, proposer_assurance,
|
||||
fence_project_version, fence_binding_version, created_at_ms,
|
||||
proposal_json, proposal_digest
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21::jsonb, $22
|
||||
)`,
|
||||
[
|
||||
proposal.actionRef,
|
||||
proposal.projectId,
|
||||
action.trustAuthorityId,
|
||||
action.trustGeneration,
|
||||
action.publisher,
|
||||
action.keyId,
|
||||
action.previousTrustDigest,
|
||||
action.currentTrustDigest,
|
||||
proposal.actionType,
|
||||
proposal.permission,
|
||||
proposal.actionDigest,
|
||||
proposal.previewDigest,
|
||||
action.authorizationMode,
|
||||
action.reasonCode,
|
||||
proposal.proposedBy.type,
|
||||
proposal.proposedBy.id,
|
||||
proposal.proposerAssurance,
|
||||
proposal.proposalFence.projectVersion,
|
||||
proposal.proposalFence.bindingVersion,
|
||||
proposal.createdAtMs,
|
||||
JSON.stringify(proposal),
|
||||
proposal.proposalDigest,
|
||||
],
|
||||
);
|
||||
if (inserted.rowCount !== 1) throw unavailable();
|
||||
const auditInserted = await client.query(
|
||||
`INSERT INTO "ql3"."security_audit_events" (
|
||||
event_id, request_id, operation_id, project_id, subject_type,
|
||||
subject_id, authentication_id, outcome, reasons,
|
||||
project_version, binding_version, occurred_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12
|
||||
)`,
|
||||
[
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
],
|
||||
);
|
||||
if (auditInserted.rowCount !== 1) throw unavailable();
|
||||
return Object.freeze({ status: 'created' as const, proposal });
|
||||
});
|
||||
}
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
// PostgreSQL Plugin Package publisher trust observation authority.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
PluginPackagePublisherTrustAuthorityConflictError,
|
||||
PluginPackagePublisherTrustAuthorityUnavailableError,
|
||||
createPluginPackagePublisherTrustHead,
|
||||
normalizePluginPackagePublisherTrustHead,
|
||||
normalizePluginPackagePublisherTrustSnapshot,
|
||||
type ObservePluginPackagePublisherTrustSnapshotInput,
|
||||
type ObservePluginPackagePublisherTrustSnapshotResult,
|
||||
type PluginPackagePublisherTrustAuthorityRepository,
|
||||
type PluginPackagePublisherTrustAuthorityState,
|
||||
type PluginPackagePublisherTrustHead,
|
||||
type PluginPackagePublisherTrustSnapshot,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-trust';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type Queryable = Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>;
|
||||
|
||||
const AUTHORITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackagePublisherTrustAuthorityUnavailableError {
|
||||
return new PluginPackagePublisherTrustAuthorityUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function authorityId(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !AUTHORITY_PATTERN.test(value)) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new TypeError('publisher trust observation time is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function mappedError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof PluginPackagePublisherTrustAuthorityConflictError ||
|
||||
error instanceof PluginPackagePublisherTrustAuthorityUnavailableError ||
|
||||
error instanceof TypeError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new PluginPackagePublisherTrustAuthorityConflictError();
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
function parseHead(row: Row): Readonly<PluginPackagePublisherTrustHead> {
|
||||
try {
|
||||
const head = normalizePluginPackagePublisherTrustHead(
|
||||
postgresRequiredJsonObject(
|
||||
row.headJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackagePublisherTrustHead,
|
||||
);
|
||||
if (
|
||||
head.headDigest !==
|
||||
postgresRequiredString(row.headDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return head;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackagePublisherTrustAuthorityUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSnapshot(
|
||||
row: Row,
|
||||
): Readonly<PluginPackagePublisherTrustSnapshot> {
|
||||
try {
|
||||
const snapshot = normalizePluginPackagePublisherTrustSnapshot(
|
||||
postgresRequiredJsonObject(
|
||||
row.snapshotJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackagePublisherTrustSnapshot,
|
||||
);
|
||||
if (
|
||||
snapshot.snapshotDigest !==
|
||||
postgresRequiredString(row.snapshotDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackagePublisherTrustAuthorityUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function authorityById(
|
||||
queryable: Queryable,
|
||||
value: string,
|
||||
forUpdate = false,
|
||||
): Promise<Readonly<PluginPackagePublisherTrustAuthorityState> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT head.head_json AS "headJson",
|
||||
head.head_digest AS "headDigest",
|
||||
snapshot.snapshot_json AS "snapshotJson",
|
||||
snapshot.snapshot_digest AS "snapshotDigest"
|
||||
FROM "ql3"."plugin_package_publisher_trust_heads" AS head
|
||||
JOIN "ql3"."plugin_package_publisher_trust_snapshots" AS snapshot
|
||||
ON snapshot.snapshot_digest = head.effective_trust_digest
|
||||
WHERE head.authority_id = $1
|
||||
LIMIT 2${forUpdate ? ' FOR UPDATE OF head' : ''}`,
|
||||
[value],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return Object.freeze({
|
||||
head: parseHead(result.rows[0]!),
|
||||
effectiveSnapshot: parseSnapshot(result.rows[0]!),
|
||||
});
|
||||
}
|
||||
|
||||
export class PostgresPluginPackagePublisherTrustAuthorityRepository
|
||||
implements PluginPackagePublisherTrustAuthorityRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError('PostgreSQL publisher trust pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async #transaction<T>(
|
||||
work: (client: PostgresClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state &&
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mappedError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
async findAuthority(
|
||||
authorityIdValue: string,
|
||||
): Promise<Readonly<PluginPackagePublisherTrustAuthorityState> | null> {
|
||||
const id = authorityId(authorityIdValue, 'publisher trust authorityId');
|
||||
try {
|
||||
return await authorityById(this.pool, id);
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
observeSnapshot(
|
||||
input: ObservePluginPackagePublisherTrustSnapshotInput,
|
||||
): Promise<Readonly<ObservePluginPackagePublisherTrustSnapshotResult>> {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
return Promise.reject(
|
||||
new TypeError('publisher trust observation is invalid'),
|
||||
);
|
||||
}
|
||||
const id = authorityId(input.authorityId, 'publisher trust authorityId');
|
||||
const observedBy = authorityId(
|
||||
input.observedBy,
|
||||
'publisher trust observer',
|
||||
);
|
||||
const observedAtMs = timestamp(input.observedAtMs);
|
||||
const snapshot = normalizePluginPackagePublisherTrustSnapshot(
|
||||
input.snapshot,
|
||||
);
|
||||
if (snapshot.keys.length < 1) {
|
||||
return Promise.reject(
|
||||
new TypeError('publisher base trust snapshot must contain one key'),
|
||||
);
|
||||
}
|
||||
const initialHead = createPluginPackagePublisherTrustHead(
|
||||
id,
|
||||
snapshot,
|
||||
observedAtMs,
|
||||
);
|
||||
return this.#transaction(async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."plugin_package_publisher_trust_snapshots" (
|
||||
snapshot_digest, key_count, observed_by, observed_at_ms,
|
||||
snapshot_json
|
||||
) VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
ON CONFLICT (snapshot_digest) DO NOTHING`,
|
||||
[
|
||||
snapshot.snapshotDigest,
|
||||
snapshot.keys.length,
|
||||
observedBy,
|
||||
observedAtMs,
|
||||
JSON.stringify(snapshot),
|
||||
],
|
||||
);
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO "ql3"."plugin_package_publisher_trust_heads" (
|
||||
authority_id, generation, base_snapshot_digest,
|
||||
effective_trust_digest, updated_at_ms, head_digest, head_json
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
ON CONFLICT (authority_id) DO NOTHING`,
|
||||
[
|
||||
initialHead.authorityId,
|
||||
initialHead.generation,
|
||||
initialHead.baseSnapshotDigest,
|
||||
initialHead.effectiveTrustDigest,
|
||||
initialHead.updatedAtMs,
|
||||
initialHead.headDigest,
|
||||
JSON.stringify(initialHead),
|
||||
],
|
||||
);
|
||||
if (inserted.rowCount === 1) {
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
head: initialHead,
|
||||
effectiveSnapshot: snapshot,
|
||||
});
|
||||
}
|
||||
if (inserted.rowCount !== 0) throw unavailable();
|
||||
const existing = await authorityById(client, id);
|
||||
if (!existing) {
|
||||
throw new PluginPackagePublisherTrustAuthorityConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status:
|
||||
existing.head.baseSnapshotDigest === snapshot.snapshotDigest
|
||||
? ('existing' as const)
|
||||
: ('candidate' as const),
|
||||
...existing,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+594
@@ -0,0 +1,594 @@
|
||||
// PostgreSQL Plugin Package publisher trust-transition proposal authority.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
approvalRequestDigest,
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovalRequestRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
createPluginPackagePublisherTrustOverlapAdditionSnapshot,
|
||||
createPluginPackagePublisherTrustRetirementSnapshot,
|
||||
normalizePluginPackagePublisherTrustSnapshot,
|
||||
type PluginPackagePublisherTrustSnapshot,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-trust';
|
||||
import {
|
||||
PluginPackagePublisherTrustTransitionConflictError,
|
||||
PluginPackagePublisherTrustTransitionUnavailableError,
|
||||
normalizePluginPackagePublisherTrustTransitionProposal,
|
||||
type CreatePluginPackagePublisherTrustTransitionProposalCommand,
|
||||
type CreatePluginPackagePublisherTrustTransitionProposalResult,
|
||||
type PluginPackagePublisherTrustTransitionProposal,
|
||||
type PluginPackagePublisherTrustTransitionProposalRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-trust-transition-proposal';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredBoolean,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type Queryable = Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>;
|
||||
|
||||
// Must stay byte-for-byte compatible with provenance and revocation mutations.
|
||||
const SIGNER_ADVISORY_LOCK_SEED = 774635229;
|
||||
|
||||
async function publisherSignerLock(
|
||||
queryable: Queryable,
|
||||
publisher: string,
|
||||
keyId: string,
|
||||
): Promise<void> {
|
||||
await queryable.query(
|
||||
`SELECT pg_advisory_xact_lock(hashtextextended($1, $2))`,
|
||||
[JSON.stringify([publisher, keyId]), SIGNER_ADVISORY_LOCK_SEED],
|
||||
);
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackagePublisherTrustTransitionUnavailableError {
|
||||
return new PluginPackagePublisherTrustTransitionUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | null {
|
||||
return value === null ? null : postgresRequiredString(value, unavailable);
|
||||
}
|
||||
|
||||
function nullableInteger(value: unknown): number | null {
|
||||
return value === null ? null : postgresRequiredInteger(value, unavailable);
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function mappedError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof PluginPackagePublisherTrustTransitionConflictError ||
|
||||
error instanceof PluginPackagePublisherTrustTransitionUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
function parseProposal(
|
||||
row: Row,
|
||||
): Readonly<PluginPackagePublisherTrustTransitionProposal> {
|
||||
try {
|
||||
const proposal = normalizePluginPackagePublisherTrustTransitionProposal(
|
||||
postgresRequiredJsonObject(
|
||||
row.proposalJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackagePublisherTrustTransitionProposal,
|
||||
);
|
||||
if (
|
||||
proposal.proposalDigest !==
|
||||
postgresRequiredString(row.proposalDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return proposal;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackagePublisherTrustTransitionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSnapshot(
|
||||
row: Row,
|
||||
): Readonly<PluginPackagePublisherTrustSnapshot> {
|
||||
try {
|
||||
const snapshot = normalizePluginPackagePublisherTrustSnapshot(
|
||||
postgresRequiredJsonObject(
|
||||
row.snapshotJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackagePublisherTrustSnapshot,
|
||||
);
|
||||
if (
|
||||
snapshot.snapshotDigest !==
|
||||
postgresRequiredString(row.snapshotDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackagePublisherTrustTransitionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseApprovalRequest(
|
||||
row: Row,
|
||||
): Readonly<ApprovalRequestRecord> {
|
||||
try {
|
||||
const request = normalizeApprovalRequestRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.requestJson,
|
||||
unavailable,
|
||||
) as unknown as ApprovalRequestRecord,
|
||||
);
|
||||
if (
|
||||
approvalRequestDigest(request) !==
|
||||
postgresRequiredString(row.requestDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return request;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackagePublisherTrustTransitionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAudit(row: Row): Readonly<SecurityAuditRecord> {
|
||||
try {
|
||||
const subjectType = nullableString(row.subjectType);
|
||||
const subjectId = nullableString(row.subjectId);
|
||||
const projectVersion = nullableInteger(row.projectVersion);
|
||||
if (!Array.isArray(row.reasons)) throw unavailable();
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: postgresRequiredString(row.eventId, unavailable),
|
||||
requestId: postgresRequiredString(row.requestId, unavailable),
|
||||
operationId: postgresRequiredString(row.operationId, unavailable),
|
||||
projectId: nullableString(row.projectId),
|
||||
subject:
|
||||
subjectType === null || subjectId === null
|
||||
? null
|
||||
: { type: subjectType, id: subjectId },
|
||||
authenticationId: nullableString(row.authenticationId),
|
||||
outcome: postgresRequiredString(row.outcome, unavailable),
|
||||
reasons: row.reasons,
|
||||
fence:
|
||||
projectVersion === null
|
||||
? null
|
||||
: {
|
||||
projectVersion,
|
||||
bindingVersion: nullableInteger(row.bindingVersion),
|
||||
},
|
||||
occurredAtMs: postgresRequiredInteger(row.occurredAtMs, unavailable),
|
||||
} as SecurityAuditRecord);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackagePublisherTrustTransitionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function proposalByActionRef(
|
||||
queryable: Queryable,
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackagePublisherTrustTransitionProposal> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT proposal_json AS "proposalJson",
|
||||
proposal_digest AS "proposalDigest"
|
||||
FROM "ql3"."plugin_package_publisher_trust_transition_proposals"
|
||||
WHERE action_ref = $1
|
||||
LIMIT 2`,
|
||||
[actionRef],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return parseProposal(result.rows[0]!);
|
||||
}
|
||||
|
||||
async function snapshotByDigest(
|
||||
queryable: Queryable,
|
||||
snapshotDigest: string,
|
||||
): Promise<Readonly<PluginPackagePublisherTrustSnapshot> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT snapshot_json AS "snapshotJson",
|
||||
snapshot_digest AS "snapshotDigest"
|
||||
FROM "ql3"."plugin_package_publisher_trust_snapshots"
|
||||
WHERE snapshot_digest = $1
|
||||
LIMIT 2`,
|
||||
[snapshotDigest],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return parseSnapshot(result.rows[0]!);
|
||||
}
|
||||
|
||||
async function auditById(
|
||||
queryable: Queryable,
|
||||
eventId: string,
|
||||
): Promise<Readonly<SecurityAuditRecord> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT event_id AS "eventId", request_id AS "requestId",
|
||||
operation_id AS "operationId", project_id AS "projectId",
|
||||
subject_type AS "subjectType", subject_id AS "subjectId",
|
||||
authentication_id AS "authenticationId", outcome,
|
||||
reasons, project_version AS "projectVersion",
|
||||
binding_version AS "bindingVersion",
|
||||
occurred_at_ms AS "occurredAtMs"
|
||||
FROM "ql3"."security_audit_events"
|
||||
WHERE event_id = $1
|
||||
LIMIT 2`,
|
||||
[eventId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return parseAudit(result.rows[0]!);
|
||||
}
|
||||
|
||||
export function findPostgresPluginPackagePublisherTrustTransitionProposal(
|
||||
queryable: Queryable,
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackagePublisherTrustTransitionProposal> | null> {
|
||||
return proposalByActionRef(queryable, actionRef);
|
||||
}
|
||||
|
||||
export class PostgresPluginPackagePublisherTrustTransitionProposalRepository
|
||||
implements PluginPackagePublisherTrustTransitionProposalRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL publisher trust transition proposal pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #transaction<T>(
|
||||
work: (client: PostgresClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state &&
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mappedError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
async findProposalByActionRef(
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackagePublisherTrustTransitionProposal> | null> {
|
||||
try {
|
||||
return await proposalByActionRef(this.pool, actionRef);
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listApprovedRequests(
|
||||
limitValue: number,
|
||||
): Promise<readonly Readonly<ApprovalRequestRecord>[]> {
|
||||
if (
|
||||
!Number.isSafeInteger(limitValue) ||
|
||||
limitValue < 1 ||
|
||||
limitValue > 64
|
||||
) {
|
||||
throw new TypeError(
|
||||
'publisher trust transition approval page limit is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT request.request_json AS "requestJson",
|
||||
request.request_digest AS "requestDigest"
|
||||
FROM "ql3"."approval_requests" AS request
|
||||
JOIN "ql3"."plugin_package_publisher_trust_transition_proposals"
|
||||
AS proposal
|
||||
ON proposal.action_ref = request.action_ref
|
||||
WHERE request.state = 'approved'
|
||||
AND request.request_json ->> 'decisionMode' =
|
||||
'separation_of_duty'
|
||||
AND request.action_type IN (
|
||||
'plugin_package.publisher_key.overlap_add',
|
||||
'plugin_package.publisher_key.safe_retire'
|
||||
)
|
||||
ORDER BY request.updated_at_ms, request.request_id
|
||||
LIMIT $1`,
|
||||
[limitValue],
|
||||
);
|
||||
if (result.rows.length > limitValue) throw unavailable();
|
||||
return Object.freeze(result.rows.map(parseApprovalRequest));
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
createProposal(
|
||||
command: CreatePluginPackagePublisherTrustTransitionProposalCommand,
|
||||
): Promise<
|
||||
Readonly<CreatePluginPackagePublisherTrustTransitionProposalResult>
|
||||
> {
|
||||
const proposal =
|
||||
normalizePluginPackagePublisherTrustTransitionProposal(
|
||||
command.proposal,
|
||||
);
|
||||
const candidateSnapshot =
|
||||
normalizePluginPackagePublisherTrustSnapshot(
|
||||
command.candidateSnapshot,
|
||||
);
|
||||
const audit = normalizeSecurityAuditRecord(command.audit);
|
||||
if (
|
||||
candidateSnapshot.snapshotDigest !==
|
||||
proposal.actionInput.currentTrustDigest ||
|
||||
audit.requestId !== proposal.actionRef ||
|
||||
audit.operationId !==
|
||||
'plugin_package.publisher_trust_transition.propose' ||
|
||||
audit.projectId !== proposal.projectId ||
|
||||
audit.subject?.type !== proposal.proposedBy.type ||
|
||||
audit.subject.id !== proposal.proposedBy.id ||
|
||||
audit.authenticationId === null ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
!same(audit.reasons, ['publisher_trust_transition_proposal']) ||
|
||||
audit.fence?.projectVersion !==
|
||||
proposal.proposalFence.projectVersion ||
|
||||
audit.fence.bindingVersion !==
|
||||
proposal.proposalFence.bindingVersion ||
|
||||
audit.occurredAtMs !== proposal.createdAtMs
|
||||
) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
return this.#transaction(async (client) => {
|
||||
const existing = await proposalByActionRef(
|
||||
client,
|
||||
proposal.actionRef,
|
||||
);
|
||||
if (existing) {
|
||||
const [existingAudit, existingCandidate] = await Promise.all([
|
||||
auditById(client, audit.eventId),
|
||||
snapshotByDigest(client, candidateSnapshot.snapshotDigest),
|
||||
]);
|
||||
if (
|
||||
!same(existing, proposal) ||
|
||||
!existingAudit ||
|
||||
!same(existingAudit, audit) ||
|
||||
!existingCandidate ||
|
||||
!same(existingCandidate, candidateSnapshot)
|
||||
) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
proposal,
|
||||
});
|
||||
}
|
||||
await publisherSignerLock(
|
||||
client,
|
||||
proposal.actionInput.publisher,
|
||||
proposal.actionInput.keyId,
|
||||
);
|
||||
const trust = await client.query<Row>(
|
||||
`SELECT head.generation,
|
||||
head.effective_trust_digest AS "effectiveTrustDigest",
|
||||
snapshot.snapshot_json AS "snapshotJson",
|
||||
snapshot.snapshot_digest AS "snapshotDigest"
|
||||
FROM "ql3"."plugin_package_publisher_trust_heads" AS head
|
||||
JOIN "ql3"."plugin_package_publisher_trust_snapshots" AS snapshot
|
||||
ON snapshot.snapshot_digest = head.effective_trust_digest
|
||||
WHERE head.authority_id = $1
|
||||
LIMIT 2`,
|
||||
[proposal.actionInput.trustAuthorityId],
|
||||
);
|
||||
if (
|
||||
trust.rows.length !== 1 ||
|
||||
postgresRequiredInteger(
|
||||
trust.rows[0]!.generation,
|
||||
unavailable,
|
||||
) !== proposal.actionInput.trustGeneration ||
|
||||
postgresRequiredString(
|
||||
trust.rows[0]!.effectiveTrustDigest,
|
||||
unavailable,
|
||||
) !== proposal.actionInput.previousTrustDigest
|
||||
) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
const effectiveSnapshot = parseSnapshot(trust.rows[0]!);
|
||||
let expectedCandidate: Readonly<PluginPackagePublisherTrustSnapshot>;
|
||||
try {
|
||||
expectedCandidate =
|
||||
proposal.actionInput.mode === 'overlap_add'
|
||||
? createPluginPackagePublisherTrustOverlapAdditionSnapshot(
|
||||
effectiveSnapshot,
|
||||
candidateSnapshot,
|
||||
proposal.actionInput.publisher,
|
||||
proposal.actionInput.keyId,
|
||||
proposal.createdAtMs,
|
||||
)
|
||||
: createPluginPackagePublisherTrustRetirementSnapshot(
|
||||
effectiveSnapshot,
|
||||
proposal.actionInput.publisher,
|
||||
proposal.actionInput.keyId,
|
||||
proposal.createdAtMs,
|
||||
);
|
||||
} catch {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
if (!same(expectedCandidate, candidateSnapshot)) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
const fence = await client.query<Row>(
|
||||
`SELECT "ql3"."lock_approval_policy_fence"(
|
||||
$1::varchar, $2::varchar, $3::varchar, $4::integer, $5::integer
|
||||
) AS "matches"`,
|
||||
[
|
||||
proposal.projectId,
|
||||
proposal.proposedBy.type,
|
||||
proposal.proposedBy.id,
|
||||
proposal.proposalFence.projectVersion,
|
||||
proposal.proposalFence.bindingVersion,
|
||||
],
|
||||
);
|
||||
if (
|
||||
fence.rows.length !== 1 ||
|
||||
!postgresRequiredBoolean(fence.rows[0]!.matches, unavailable)
|
||||
) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
const snapshotInsert = await client.query(
|
||||
`INSERT INTO "ql3"."plugin_package_publisher_trust_snapshots" (
|
||||
snapshot_digest, key_count, observed_by, observed_at_ms,
|
||||
snapshot_json
|
||||
) VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
ON CONFLICT (snapshot_digest) DO NOTHING`,
|
||||
[
|
||||
candidateSnapshot.snapshotDigest,
|
||||
candidateSnapshot.keys.length,
|
||||
'cluster-package-manager',
|
||||
proposal.createdAtMs,
|
||||
JSON.stringify(candidateSnapshot),
|
||||
],
|
||||
);
|
||||
if (
|
||||
snapshotInsert.rowCount !== 0 &&
|
||||
snapshotInsert.rowCount !== 1
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
const storedCandidate = await snapshotByDigest(
|
||||
client,
|
||||
candidateSnapshot.snapshotDigest,
|
||||
);
|
||||
if (!storedCandidate || !same(storedCandidate, candidateSnapshot)) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
const action = proposal.actionInput;
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO
|
||||
"ql3"."plugin_package_publisher_trust_transition_proposals" (
|
||||
action_ref, project_id, authority_id, trust_generation, mode,
|
||||
publisher, key_id, previous_trust_digest,
|
||||
current_trust_digest, action_type, permission, action_digest,
|
||||
preview_digest, proposed_by_type, proposed_by_id,
|
||||
proposer_assurance, fence_project_version,
|
||||
fence_binding_version, created_at_ms, proposal_json,
|
||||
proposal_digest
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20::jsonb, $21
|
||||
)`,
|
||||
[
|
||||
proposal.actionRef,
|
||||
proposal.projectId,
|
||||
action.trustAuthorityId,
|
||||
action.trustGeneration,
|
||||
action.mode,
|
||||
action.publisher,
|
||||
action.keyId,
|
||||
action.previousTrustDigest,
|
||||
action.currentTrustDigest,
|
||||
proposal.actionType,
|
||||
proposal.permission,
|
||||
proposal.actionDigest,
|
||||
proposal.previewDigest,
|
||||
proposal.proposedBy.type,
|
||||
proposal.proposedBy.id,
|
||||
proposal.proposerAssurance,
|
||||
proposal.proposalFence.projectVersion,
|
||||
proposal.proposalFence.bindingVersion,
|
||||
proposal.createdAtMs,
|
||||
JSON.stringify(proposal),
|
||||
proposal.proposalDigest,
|
||||
],
|
||||
);
|
||||
if (inserted.rowCount !== 1) throw unavailable();
|
||||
const auditInserted = await client.query(
|
||||
`INSERT INTO "ql3"."security_audit_events" (
|
||||
event_id, request_id, operation_id, project_id, subject_type,
|
||||
subject_id, authentication_id, outcome, reasons,
|
||||
project_version, binding_version, occurred_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12
|
||||
)`,
|
||||
[
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
],
|
||||
);
|
||||
if (auditInserted.rowCount !== 1) throw unavailable();
|
||||
return Object.freeze({ status: 'created' as const, proposal });
|
||||
});
|
||||
}
|
||||
}
|
||||
+583
@@ -0,0 +1,583 @@
|
||||
// PostgreSQL Plugin Package publisher trust-transition execution authority.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
approvalRequestDigest,
|
||||
approvedActionDispatchDigest,
|
||||
normalizeApprovalRequestRecord,
|
||||
normalizeApprovedActionDispatchRecord,
|
||||
type ApprovalRequestRecord,
|
||||
type ApprovedActionDispatchRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
advancePluginPackagePublisherTrustHead,
|
||||
createPluginPackagePublisherTrustOverlapAdditionSnapshot,
|
||||
createPluginPackagePublisherTrustRetirementSnapshot,
|
||||
normalizePluginPackagePublisherTrustHead,
|
||||
normalizePluginPackagePublisherTrustSnapshot,
|
||||
type PluginPackagePublisherTrustHead,
|
||||
type PluginPackagePublisherTrustSnapshot,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-trust';
|
||||
import {
|
||||
PluginPackagePublisherTrustTransitionConflictError,
|
||||
PluginPackagePublisherTrustTransitionUnavailableError,
|
||||
normalizePluginPackagePublisherTrustTransitionProposal,
|
||||
normalizePluginPackagePublisherTrustTransitionReceipt,
|
||||
resolvePluginPackagePublisherTrustTransitionProposal,
|
||||
type PluginPackagePublisherTrustTransitionProposal,
|
||||
type PluginPackagePublisherTrustTransitionReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-publisher-trust-transition-proposal';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type Queryable = Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>;
|
||||
|
||||
const SIGNER_ADVISORY_LOCK_SEED = 774635229;
|
||||
|
||||
export interface ApplyPostgresPluginPackagePublisherTrustTransitionInput {
|
||||
readonly dispatch: ApprovedActionDispatchRecord;
|
||||
readonly executedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ApplyPostgresPluginPackagePublisherTrustTransitionResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly receipt: Readonly<PluginPackagePublisherTrustTransitionReceipt>;
|
||||
readonly head: Readonly<PluginPackagePublisherTrustHead>;
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackagePublisherTrustTransitionUnavailableError {
|
||||
return new PluginPackagePublisherTrustTransitionUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function mappedError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof PluginPackagePublisherTrustTransitionConflictError ||
|
||||
error instanceof PluginPackagePublisherTrustTransitionUnavailableError ||
|
||||
error instanceof TypeError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new TypeError('publisher trust transition execution time is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function parseProposal(
|
||||
row: Row,
|
||||
): Readonly<PluginPackagePublisherTrustTransitionProposal> {
|
||||
try {
|
||||
const proposal = normalizePluginPackagePublisherTrustTransitionProposal(
|
||||
postgresRequiredJsonObject(
|
||||
row.proposalJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackagePublisherTrustTransitionProposal,
|
||||
);
|
||||
if (
|
||||
proposal.proposalDigest !==
|
||||
postgresRequiredString(row.proposalDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return proposal;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackagePublisherTrustTransitionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDispatch(
|
||||
row: Row,
|
||||
): Readonly<ApprovedActionDispatchRecord> {
|
||||
try {
|
||||
const dispatch = normalizeApprovedActionDispatchRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.dispatchJson,
|
||||
unavailable,
|
||||
) as unknown as ApprovedActionDispatchRecord,
|
||||
);
|
||||
if (
|
||||
approvedActionDispatchDigest(dispatch) !==
|
||||
postgresRequiredString(row.dispatchDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return dispatch;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackagePublisherTrustTransitionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseApproval(
|
||||
row: Row,
|
||||
): Readonly<ApprovalRequestRecord> {
|
||||
try {
|
||||
const approval = normalizeApprovalRequestRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.approvalJson,
|
||||
unavailable,
|
||||
) as unknown as ApprovalRequestRecord,
|
||||
);
|
||||
if (
|
||||
approvalRequestDigest(approval) !==
|
||||
postgresRequiredString(row.approvalDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return approval;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackagePublisherTrustTransitionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseHead(row: Row): Readonly<PluginPackagePublisherTrustHead> {
|
||||
try {
|
||||
const head = normalizePluginPackagePublisherTrustHead(
|
||||
postgresRequiredJsonObject(
|
||||
row.headJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackagePublisherTrustHead,
|
||||
);
|
||||
if (
|
||||
head.headDigest !== postgresRequiredString(row.headDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return head;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackagePublisherTrustTransitionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSnapshot(
|
||||
row: Row,
|
||||
jsonKey: string,
|
||||
digestKey: string,
|
||||
): Readonly<PluginPackagePublisherTrustSnapshot> {
|
||||
try {
|
||||
const snapshot = normalizePluginPackagePublisherTrustSnapshot(
|
||||
postgresRequiredJsonObject(
|
||||
row[jsonKey],
|
||||
unavailable,
|
||||
) as unknown as PluginPackagePublisherTrustSnapshot,
|
||||
);
|
||||
if (
|
||||
snapshot.snapshotDigest !==
|
||||
postgresRequiredString(row[digestKey], unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackagePublisherTrustTransitionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseReceipt(
|
||||
row: Row,
|
||||
): Readonly<PluginPackagePublisherTrustTransitionReceipt> {
|
||||
try {
|
||||
const receipt = normalizePluginPackagePublisherTrustTransitionReceipt(
|
||||
postgresRequiredJsonObject(
|
||||
row.receiptJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackagePublisherTrustTransitionReceipt,
|
||||
);
|
||||
if (
|
||||
receipt.receiptDigest !==
|
||||
postgresRequiredString(row.receiptDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackagePublisherTrustTransitionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function publisherSignerLock(
|
||||
queryable: Queryable,
|
||||
publisher: string,
|
||||
keyId: string,
|
||||
): Promise<void> {
|
||||
await queryable.query(
|
||||
`SELECT pg_advisory_xact_lock(hashtextextended($1, $2))`,
|
||||
[JSON.stringify([publisher, keyId]), SIGNER_ADVISORY_LOCK_SEED],
|
||||
);
|
||||
}
|
||||
|
||||
export class PostgresPluginPackagePublisherTrustTransitionRepository {
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL publisher trust transition pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #transaction<T>(
|
||||
work: (client: PostgresClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state &&
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mappedError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
applyApprovedTransition(
|
||||
input: ApplyPostgresPluginPackagePublisherTrustTransitionInput,
|
||||
): Promise<
|
||||
Readonly<ApplyPostgresPluginPackagePublisherTrustTransitionResult>
|
||||
> {
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== 'object' ||
|
||||
Array.isArray(input) ||
|
||||
Object.keys(input).length !== 2 ||
|
||||
!Object.hasOwn(input, 'dispatch') ||
|
||||
!Object.hasOwn(input, 'executedAtMs')
|
||||
) {
|
||||
throw new TypeError('publisher trust transition execution is invalid');
|
||||
}
|
||||
const dispatch = normalizeApprovedActionDispatchRecord(input.dispatch);
|
||||
const executedAtMs = timestamp(input.executedAtMs);
|
||||
return this.#transaction(async (client) => {
|
||||
const authority = await client.query<Row>(
|
||||
`SELECT proposal.proposal_json AS "proposalJson",
|
||||
proposal.proposal_digest AS "proposalDigest",
|
||||
dispatch.dispatch_json AS "dispatchJson",
|
||||
dispatch.dispatch_digest AS "dispatchDigest",
|
||||
request.request_json AS "approvalJson",
|
||||
request.request_digest AS "approvalDigest"
|
||||
FROM "ql3"."approved_action_dispatches" AS dispatch
|
||||
JOIN "ql3"."plugin_package_publisher_trust_transition_proposals"
|
||||
AS proposal
|
||||
ON proposal.action_ref = dispatch.action_ref
|
||||
JOIN "ql3"."approval_requests" AS request
|
||||
ON request.dispatch_id = dispatch.dispatch_id
|
||||
WHERE dispatch.dispatch_id = $1
|
||||
LIMIT 2`,
|
||||
[dispatch.id],
|
||||
);
|
||||
if (authority.rows.length !== 1) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
const proposal = parseProposal(authority.rows[0]!);
|
||||
const durableDispatch = parseDispatch(authority.rows[0]!);
|
||||
const approval = parseApproval(authority.rows[0]!);
|
||||
if (
|
||||
!same(durableDispatch, dispatch) ||
|
||||
approval.state !== 'consumed' ||
|
||||
approval.version !== 3 ||
|
||||
approval.decisionMode !== 'separation_of_duty' ||
|
||||
approval.decision !== 'approved' ||
|
||||
approval.id !== durableDispatch.approvalRequestId ||
|
||||
approval.dispatchId !== durableDispatch.id ||
|
||||
approval.projectId !== durableDispatch.projectId ||
|
||||
!same(approval.action, durableDispatch.action) ||
|
||||
!same(approval.requestedBy, durableDispatch.requestedBy) ||
|
||||
!same(approval.consumedBy, durableDispatch.consumedBy) ||
|
||||
!same(approval.decidedBy, durableDispatch.approvedBy) ||
|
||||
approval.decisionAuthenticationId !==
|
||||
durableDispatch.approvalAuthenticationId ||
|
||||
approval.decisionAssurance !==
|
||||
durableDispatch.approvalAssurance ||
|
||||
approval.decidedAtMs !== durableDispatch.approvedAtMs ||
|
||||
approval.expiresAtMs !== durableDispatch.expiresAtMs ||
|
||||
!same(approval.decisionFence, durableDispatch.approvalFence) ||
|
||||
approval.consumedAtMs !== durableDispatch.createdAtMs
|
||||
) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
await publisherSignerLock(
|
||||
client,
|
||||
proposal.actionInput.publisher,
|
||||
proposal.actionInput.keyId,
|
||||
);
|
||||
const existingReceipt = await client.query<Row>(
|
||||
`SELECT receipt_json AS "receiptJson",
|
||||
receipt_digest AS "receiptDigest"
|
||||
FROM "ql3"."plugin_package_publisher_trust_transition_receipts"
|
||||
WHERE mutation_id = $1 OR proposal_digest = $2
|
||||
ORDER BY receipt_digest
|
||||
LIMIT 2`,
|
||||
[dispatch.id, proposal.proposalDigest],
|
||||
);
|
||||
if (existingReceipt.rows.length > 0) {
|
||||
if (existingReceipt.rows.length !== 1) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
const receipt = parseReceipt(existingReceipt.rows[0]!);
|
||||
const expected = resolvePluginPackagePublisherTrustTransitionProposal(
|
||||
proposal,
|
||||
dispatch,
|
||||
executedAtMs,
|
||||
proposal.actionInput.mode === 'safe_retire' ? 0 : null,
|
||||
);
|
||||
if (!same(receipt, expected)) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
const current = await client.query<Row>(
|
||||
`SELECT head_json AS "headJson", head_digest AS "headDigest"
|
||||
FROM "ql3"."plugin_package_publisher_trust_heads"
|
||||
WHERE authority_id = $1
|
||||
LIMIT 2`,
|
||||
[receipt.trustAuthorityId],
|
||||
);
|
||||
if (current.rows.length !== 1) throw unavailable();
|
||||
const head = parseHead(current.rows[0]!);
|
||||
if (
|
||||
head.generation !== receipt.currentGeneration ||
|
||||
head.effectiveTrustDigest !== receipt.currentTrustDigest
|
||||
) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
receipt,
|
||||
head,
|
||||
});
|
||||
}
|
||||
const trust = await client.query<Row>(
|
||||
`SELECT head.head_json AS "headJson",
|
||||
head.head_digest AS "headDigest",
|
||||
effective.snapshot_json AS "effectiveSnapshotJson",
|
||||
effective.snapshot_digest AS "effectiveSnapshotDigest",
|
||||
candidate.snapshot_json AS "candidateSnapshotJson",
|
||||
candidate.snapshot_digest AS "candidateSnapshotDigest"
|
||||
FROM "ql3"."plugin_package_publisher_trust_heads" AS head
|
||||
JOIN "ql3"."plugin_package_publisher_trust_snapshots" AS effective
|
||||
ON effective.snapshot_digest = head.effective_trust_digest
|
||||
JOIN "ql3"."plugin_package_publisher_trust_snapshots" AS candidate
|
||||
ON candidate.snapshot_digest = $2
|
||||
WHERE head.authority_id = $1
|
||||
LIMIT 2
|
||||
FOR UPDATE OF head`,
|
||||
[
|
||||
proposal.actionInput.trustAuthorityId,
|
||||
proposal.actionInput.currentTrustDigest,
|
||||
],
|
||||
);
|
||||
if (trust.rows.length !== 1) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
const head = parseHead(trust.rows[0]!);
|
||||
const effectiveSnapshot = parseSnapshot(
|
||||
trust.rows[0]!,
|
||||
'effectiveSnapshotJson',
|
||||
'effectiveSnapshotDigest',
|
||||
);
|
||||
const candidateSnapshot = parseSnapshot(
|
||||
trust.rows[0]!,
|
||||
'candidateSnapshotJson',
|
||||
'candidateSnapshotDigest',
|
||||
);
|
||||
if (
|
||||
head.generation !== proposal.actionInput.trustGeneration ||
|
||||
head.effectiveTrustDigest !==
|
||||
proposal.actionInput.previousTrustDigest ||
|
||||
effectiveSnapshot.snapshotDigest !==
|
||||
proposal.actionInput.previousTrustDigest ||
|
||||
candidateSnapshot.snapshotDigest !==
|
||||
proposal.actionInput.currentTrustDigest
|
||||
) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
let expectedCandidate: Readonly<PluginPackagePublisherTrustSnapshot>;
|
||||
try {
|
||||
expectedCandidate =
|
||||
proposal.actionInput.mode === 'overlap_add'
|
||||
? createPluginPackagePublisherTrustOverlapAdditionSnapshot(
|
||||
effectiveSnapshot,
|
||||
candidateSnapshot,
|
||||
proposal.actionInput.publisher,
|
||||
proposal.actionInput.keyId,
|
||||
executedAtMs,
|
||||
)
|
||||
: createPluginPackagePublisherTrustRetirementSnapshot(
|
||||
effectiveSnapshot,
|
||||
proposal.actionInput.publisher,
|
||||
proposal.actionInput.keyId,
|
||||
executedAtMs,
|
||||
);
|
||||
} catch {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
if (!same(expectedCandidate, candidateSnapshot)) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
let retirementMatchingInstallations: 0 | null = null;
|
||||
if (proposal.actionInput.mode === 'safe_retire') {
|
||||
const matching = await client.query<Row>(
|
||||
`SELECT provenance.installation_id AS "installationId"
|
||||
FROM "ql3"."plugin_package_publisher_provenance" AS provenance
|
||||
JOIN "ql3"."plugin_package_install_heads" AS head
|
||||
ON head.project_id = provenance.project_id
|
||||
AND head.package_name = provenance.package_name
|
||||
AND head.installation_id = provenance.installation_id
|
||||
JOIN "ql3"."plugin_package_installs" AS install
|
||||
ON install.installation_id = provenance.installation_id
|
||||
WHERE provenance.publisher = $1 AND provenance.key_id = $2
|
||||
AND install.state IN ('staged', 'activating', 'active')
|
||||
LIMIT 1`,
|
||||
[proposal.actionInput.publisher, proposal.actionInput.keyId],
|
||||
);
|
||||
if (matching.rows.length !== 0) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
retirementMatchingInstallations = 0;
|
||||
}
|
||||
const receipt = resolvePluginPackagePublisherTrustTransitionProposal(
|
||||
proposal,
|
||||
dispatch,
|
||||
executedAtMs,
|
||||
retirementMatchingInstallations,
|
||||
);
|
||||
const nextHead = advancePluginPackagePublisherTrustHead(
|
||||
head,
|
||||
candidateSnapshot,
|
||||
executedAtMs,
|
||||
);
|
||||
const headUpdate = await client.query(
|
||||
`UPDATE "ql3"."plugin_package_publisher_trust_heads"
|
||||
SET generation = $2, effective_trust_digest = $3,
|
||||
updated_at_ms = $4, head_digest = $5, head_json = $6::jsonb
|
||||
WHERE authority_id = $1 AND generation = $7
|
||||
AND effective_trust_digest = $8 AND head_digest = $9`,
|
||||
[
|
||||
nextHead.authorityId,
|
||||
nextHead.generation,
|
||||
nextHead.effectiveTrustDigest,
|
||||
nextHead.updatedAtMs,
|
||||
nextHead.headDigest,
|
||||
JSON.stringify(nextHead),
|
||||
head.generation,
|
||||
head.effectiveTrustDigest,
|
||||
head.headDigest,
|
||||
],
|
||||
);
|
||||
if (headUpdate.rowCount !== 1) {
|
||||
throw new PluginPackagePublisherTrustTransitionConflictError();
|
||||
}
|
||||
const receiptInsert = await client.query(
|
||||
`INSERT INTO
|
||||
"ql3"."plugin_package_publisher_trust_transition_receipts" (
|
||||
mutation_id, proposal_digest, authority_id,
|
||||
previous_generation, current_generation, mode, publisher,
|
||||
key_id, previous_trust_digest, current_trust_digest,
|
||||
proposer_type, proposer_id, confirmer_type, confirmer_id,
|
||||
retirement_matching_installations, executed_at_ms,
|
||||
receipt_json, receipt_digest
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17::jsonb, $18
|
||||
)`,
|
||||
[
|
||||
receipt.mutationId,
|
||||
receipt.proposalDigest,
|
||||
receipt.trustAuthorityId,
|
||||
receipt.previousGeneration,
|
||||
receipt.currentGeneration,
|
||||
receipt.mode,
|
||||
receipt.publisher,
|
||||
receipt.keyId,
|
||||
receipt.previousTrustDigest,
|
||||
receipt.currentTrustDigest,
|
||||
receipt.proposer.type,
|
||||
receipt.proposer.id,
|
||||
receipt.confirmer.type,
|
||||
receipt.confirmer.id,
|
||||
receipt.retirementMatchingInstallations,
|
||||
receipt.executedAtMs,
|
||||
JSON.stringify(receipt),
|
||||
receipt.receiptDigest,
|
||||
],
|
||||
);
|
||||
if (receiptInsert.rowCount !== 1) throw unavailable();
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
receipt,
|
||||
head: nextHead,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+840
@@ -0,0 +1,840 @@
|
||||
// PostgreSQL authorization authority for Plugin Package Workflow admission.
|
||||
import {
|
||||
RUN_CANCELLATION_REASONS,
|
||||
RUN_STATUSES,
|
||||
type PostgresClient,
|
||||
type PostgresPool,
|
||||
type RunCancellationReason,
|
||||
type RunStatus,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA,
|
||||
PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA,
|
||||
PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_SCHEMA,
|
||||
PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA,
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError,
|
||||
PluginPackageWorkflowAdministrationMutationConflictError,
|
||||
normalizeAuthorizedPluginPackageWorkflowRunEventList,
|
||||
normalizeAuthorizedPluginPackageWorkflowRunInspection,
|
||||
normalizeAuthorizedPluginPackageWorkflowRunList,
|
||||
normalizeAuthorizedPluginPackageWorkflowStepRunList,
|
||||
normalizeAuthorizedPluginPackageWorkflowAdmission,
|
||||
normalizePluginPackageWorkflowRunEventListResult,
|
||||
normalizePluginPackageWorkflowRunInspectionResult,
|
||||
normalizePluginPackageWorkflowRunListResult,
|
||||
normalizePluginPackageWorkflowStepRunListResult,
|
||||
type AuthorizedPluginPackageWorkflowAdmission,
|
||||
type AuthorizedPluginPackageWorkflowRunEventList,
|
||||
type AuthorizedPluginPackageWorkflowRunInspection,
|
||||
type AuthorizedPluginPackageWorkflowRunList,
|
||||
type AuthorizedPluginPackageWorkflowStepRunList,
|
||||
type PluginPackageWorkflowAdministrationRepository,
|
||||
type PluginPackageWorkflowRunEventListRepository,
|
||||
type PluginPackageWorkflowRunEventListResult,
|
||||
type PluginPackageWorkflowRunInspectionRepository,
|
||||
type PluginPackageWorkflowRunInspectionResult,
|
||||
type PluginPackageWorkflowRunListRepository,
|
||||
type PluginPackageWorkflowRunListResult,
|
||||
type PluginPackageWorkflowStepRunListItem,
|
||||
type PluginPackageWorkflowStepRunListRepository,
|
||||
type PluginPackageWorkflowStepRunListResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-administration';
|
||||
import type { PluginPackageWorkflowExecutionPlan } from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
import {
|
||||
STEP_RUN_STATUSES,
|
||||
type StepRunStatus,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import { PostgresPluginPackageWorkflowAdmissionRepository } from './pluginPackageWorkflowAdmissionRepository';
|
||||
import {
|
||||
configureAdministrationTransaction,
|
||||
insertAdministrationAudit,
|
||||
requiredInteger,
|
||||
requiredString,
|
||||
rollbackAdministrationTransaction,
|
||||
} from '../../repository/administrationSupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const API_CREDENTIAL_AUTHENTICATION =
|
||||
/^api_credential:([A-Za-z0-9][A-Za-z0-9._:-]{0,63}):([1-9]\d*)$/;
|
||||
|
||||
interface WorkflowAuthorizationContext {
|
||||
readonly projectId: string;
|
||||
readonly actor: AuthorizedPluginPackageWorkflowAdmission['actor'];
|
||||
readonly fence: AuthorizedPluginPackageWorkflowAdmission['fence'];
|
||||
readonly audit: AuthorizedPluginPackageWorkflowAdmission['audit'];
|
||||
}
|
||||
|
||||
function integer(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value)) return value;
|
||||
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nullableInteger(row: Row, name: string): number | null {
|
||||
if (row[name] === null) return null;
|
||||
return requiredInteger(row, name);
|
||||
}
|
||||
|
||||
function nullableString(row: Row, name: string): string | null {
|
||||
if (row[name] === null) return null;
|
||||
return requiredString(row, name);
|
||||
}
|
||||
|
||||
function mutationConflict(): never {
|
||||
throw new PluginPackageWorkflowAdministrationMutationConflictError();
|
||||
}
|
||||
|
||||
function fenceConflict(): never {
|
||||
throw new PluginPackageWorkflowAdministrationAuthorizationFenceConflictError();
|
||||
}
|
||||
|
||||
async function confirmCredential(
|
||||
client: PostgresClient,
|
||||
authorization: Readonly<WorkflowAuthorizationContext>,
|
||||
): Promise<void> {
|
||||
const match = API_CREDENTIAL_AUTHENTICATION.exec(
|
||||
authorization.audit.authenticationId ?? '',
|
||||
);
|
||||
const credentialVersion = integer(match?.[2]);
|
||||
if (!match || credentialVersion === null || credentialVersion < 1) {
|
||||
return fenceConflict();
|
||||
}
|
||||
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [
|
||||
`ql3-api-credential:${match[1]}`,
|
||||
]);
|
||||
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [
|
||||
`ql3-identity:${authorization.actor.type}:${authorization.actor.id}`,
|
||||
]);
|
||||
const result = await client.query<Row>(
|
||||
`SELECT credential.version,
|
||||
credential.state,
|
||||
credential.subject_type AS "subjectType",
|
||||
credential.subject_id AS "subjectId",
|
||||
credential.not_before_at_ms AS "notBeforeAtMs",
|
||||
credential.expires_at_ms AS "expiresAtMs",
|
||||
subject.status AS "subjectStatus",
|
||||
floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint
|
||||
AS "nowMs"
|
||||
FROM "ql3"."api_credentials" AS credential
|
||||
JOIN "ql3"."identity_subjects" AS subject
|
||||
ON subject.subject_type = credential.subject_type
|
||||
AND subject.subject_id = credential.subject_id
|
||||
WHERE credential.credential_id = $1
|
||||
ORDER BY credential.version DESC
|
||||
LIMIT 1`,
|
||||
[match[1]],
|
||||
);
|
||||
const row = result.rows.length === 1 ? result.rows[0]! : null;
|
||||
const nowMs = integer(row?.nowMs);
|
||||
if (
|
||||
!row ||
|
||||
result.rows.length !== 1 ||
|
||||
integer(row.version) !== credentialVersion ||
|
||||
row.state !== 'active' ||
|
||||
row.subjectStatus !== 'active' ||
|
||||
row.subjectType !== authorization.actor.type ||
|
||||
row.subjectId !== authorization.actor.id ||
|
||||
nowMs === null ||
|
||||
(integer(row.notBeforeAtMs) ?? Number.MAX_SAFE_INTEGER) > nowMs ||
|
||||
(integer(row.expiresAtMs) ?? -1) <= nowMs
|
||||
) {
|
||||
return fenceConflict();
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmProjectPolicyFence(
|
||||
client: PostgresClient,
|
||||
authorization: Readonly<WorkflowAuthorizationContext>,
|
||||
): Promise<void> {
|
||||
const project = await client.query<Row>(
|
||||
`SELECT status, version
|
||||
FROM "ql3"."projects"
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
FOR SHARE`,
|
||||
[authorization.projectId],
|
||||
);
|
||||
const projectRow = project.rows.length === 1 ? project.rows[0]! : null;
|
||||
if (
|
||||
!projectRow ||
|
||||
projectRow.status !== 'active' ||
|
||||
integer(projectRow.version) !== authorization.fence.projectVersion
|
||||
) {
|
||||
return fenceConflict();
|
||||
}
|
||||
const binding = await client.query<Row>(
|
||||
`SELECT version, state
|
||||
FROM "ql3"."project_role_bindings"
|
||||
WHERE project_id = $1
|
||||
AND subject_type = $2
|
||||
AND subject_id = $3
|
||||
ORDER BY version DESC
|
||||
LIMIT 1`,
|
||||
[authorization.projectId, authorization.actor.type, authorization.actor.id],
|
||||
);
|
||||
const bindingRow = binding.rows.length === 1 ? binding.rows[0]! : null;
|
||||
if (
|
||||
!bindingRow ||
|
||||
bindingRow.state !== 'active' ||
|
||||
integer(bindingRow.version) !== authorization.fence.bindingVersion
|
||||
) {
|
||||
return fenceConflict();
|
||||
}
|
||||
}
|
||||
|
||||
async function insertAtomicAudit(
|
||||
client: PostgresClient,
|
||||
admission: Readonly<AuthorizedPluginPackageWorkflowAdmission>,
|
||||
replay: boolean,
|
||||
): Promise<void> {
|
||||
if (replay) return;
|
||||
const audit = admission.audit;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."security_audit_events" (
|
||||
event_id, request_id, operation_id, project_id,
|
||||
subject_type, subject_id, authentication_id, outcome, reasons,
|
||||
project_version, binding_version, occurred_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12
|
||||
)`,
|
||||
[
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds API credential, Project Policy fence and mutation-audit checks to the
|
||||
* PostgreSQL Workflow admission transaction used by cluster-control.
|
||||
*/
|
||||
export class PostgresAuthorizedPluginPackageWorkflowAdmissionRepository
|
||||
implements PluginPackageWorkflowAdministrationRepository
|
||||
{
|
||||
private readonly admissions: PostgresPluginPackageWorkflowAdmissionRepository;
|
||||
|
||||
constructor(pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
|
||||
'PostgreSQL pool is invalid',
|
||||
);
|
||||
}
|
||||
this.admissions = new PostgresPluginPackageWorkflowAdmissionRepository(
|
||||
pool,
|
||||
);
|
||||
}
|
||||
|
||||
findPlanByPlanId(
|
||||
planId: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowExecutionPlan> | null> {
|
||||
return this.admissions.findPlanByPlanId(planId);
|
||||
}
|
||||
|
||||
async admitAuthorized(input: AuthorizedPluginPackageWorkflowAdmission) {
|
||||
const admission = normalizeAuthorizedPluginPackageWorkflowAdmission(input);
|
||||
const authorization = Object.freeze({
|
||||
projectId: admission.plan.target.projectId,
|
||||
actor: admission.actor,
|
||||
fence: admission.fence,
|
||||
audit: admission.audit,
|
||||
});
|
||||
return this.admissions.admit(admission.plan, async ({ client, replay }) => {
|
||||
await confirmCredential(client, authorization);
|
||||
await confirmProjectPolicyFence(client, authorization);
|
||||
await insertAtomicAudit(client, admission, replay);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the low-sensitive Package-bound Workflow Run projection from one
|
||||
* serializable PostgreSQL snapshot after revalidating the credential and the
|
||||
* latest Project Policy fence. It deliberately does not expose plan, task,
|
||||
* attempt, executor, error, input/output or Secret material.
|
||||
*/
|
||||
export class PostgresAuthorizedPluginPackageWorkflowRunInspectionRepository
|
||||
implements PluginPackageWorkflowRunInspectionRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
|
||||
'PostgreSQL pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async inspectRunAuthorized(
|
||||
input: AuthorizedPluginPackageWorkflowRunInspection,
|
||||
): Promise<Readonly<PluginPackageWorkflowRunInspectionResult>> {
|
||||
const inspection =
|
||||
normalizeAuthorizedPluginPackageWorkflowRunInspection(input);
|
||||
let client: PostgresClient | undefined;
|
||||
let began = false;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
await configureAdministrationTransaction(client);
|
||||
began = true;
|
||||
await confirmCredential(client, inspection);
|
||||
await confirmProjectPolicyFence(client, inspection);
|
||||
|
||||
const targetRows = await client.query<Row>(
|
||||
`SELECT admission.workflow_id AS "workflowId",
|
||||
admission.step_count AS "stepCount",
|
||||
run.status AS "runStatus",
|
||||
run.version AS "runVersion",
|
||||
run.event_sequence AS "eventSequence",
|
||||
run.created_at_ms AS "createdAtMs",
|
||||
run.queued_at_ms AS "queuedAtMs",
|
||||
run.started_at_ms AS "startedAtMs",
|
||||
run.finished_at_ms AS "finishedAtMs",
|
||||
run.cancel_requested_at_ms AS "cancelRequestedAtMs",
|
||||
run.cancel_reason AS "cancelReason"
|
||||
FROM "ql3"."plugin_package_workflow_admissions" AS admission
|
||||
JOIN "ql3"."runs" AS run
|
||||
ON run.id = admission.run_id
|
||||
AND run.project_id = admission.project_id
|
||||
WHERE admission.run_id = $1
|
||||
AND admission.project_id = $2
|
||||
AND admission.package_name = $3
|
||||
AND admission.workflow_id = $4
|
||||
LIMIT 2`,
|
||||
[
|
||||
inspection.runId,
|
||||
inspection.projectId,
|
||||
inspection.packageName,
|
||||
inspection.workflowId,
|
||||
],
|
||||
);
|
||||
if (targetRows.rows.length === 0) {
|
||||
await insertAdministrationAudit(client, inspection.audit);
|
||||
const missing = normalizePluginPackageWorkflowRunInspectionResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA,
|
||||
found: false,
|
||||
projectId: inspection.projectId,
|
||||
packageName: inspection.packageName,
|
||||
workflowId: inspection.workflowId,
|
||||
runId: inspection.runId,
|
||||
run: null,
|
||||
stepCount: null,
|
||||
stepStatusCounts: null,
|
||||
});
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return missing;
|
||||
}
|
||||
if (targetRows.rows.length !== 1) mutationConflict();
|
||||
const row = targetRows.rows[0]!;
|
||||
if (requiredString(row, 'workflowId') !== inspection.workflowId) {
|
||||
mutationConflict();
|
||||
}
|
||||
const stepCount = requiredInteger(row, 'stepCount');
|
||||
if (stepCount < 1 || stepCount > 128) mutationConflict();
|
||||
|
||||
const statusRows = await client.query<Row>(
|
||||
`SELECT status AS "stepStatus", COUNT(*) AS "statusCount"
|
||||
FROM "ql3"."step_runs"
|
||||
WHERE run_id = $1
|
||||
GROUP BY status
|
||||
ORDER BY status`,
|
||||
[inspection.runId],
|
||||
);
|
||||
const stepStatusCounts = Object.fromEntries(
|
||||
STEP_RUN_STATUSES.map((status) => [status, 0]),
|
||||
) as Record<StepRunStatus, number>;
|
||||
const observedStatuses = new Set<StepRunStatus>();
|
||||
for (const statusRow of statusRows.rows) {
|
||||
const status = requiredString(statusRow, 'stepStatus') as StepRunStatus;
|
||||
if (
|
||||
!STEP_RUN_STATUSES.includes(status) ||
|
||||
observedStatuses.has(status)
|
||||
) {
|
||||
mutationConflict();
|
||||
}
|
||||
observedStatuses.add(status);
|
||||
stepStatusCounts[status] = requiredInteger(statusRow, 'statusCount');
|
||||
}
|
||||
if (
|
||||
Object.values(stepStatusCounts).reduce(
|
||||
(total, count) => total + count,
|
||||
0,
|
||||
) !== stepCount
|
||||
) {
|
||||
mutationConflict();
|
||||
}
|
||||
|
||||
const runStatus = requiredString(row, 'runStatus') as RunStatus;
|
||||
const cancelReason = nullableString(
|
||||
row,
|
||||
'cancelReason',
|
||||
) as RunCancellationReason | null;
|
||||
if (
|
||||
!RUN_STATUSES.includes(runStatus) ||
|
||||
(cancelReason !== null &&
|
||||
!RUN_CANCELLATION_REASONS.includes(cancelReason))
|
||||
) {
|
||||
mutationConflict();
|
||||
}
|
||||
const result = normalizePluginPackageWorkflowRunInspectionResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA,
|
||||
found: true,
|
||||
projectId: inspection.projectId,
|
||||
packageName: inspection.packageName,
|
||||
workflowId: inspection.workflowId,
|
||||
runId: inspection.runId,
|
||||
run: {
|
||||
status: runStatus,
|
||||
version: requiredInteger(row, 'runVersion'),
|
||||
eventSequence: requiredInteger(row, 'eventSequence'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
queuedAtMs: nullableInteger(row, 'queuedAtMs'),
|
||||
startedAtMs: nullableInteger(row, 'startedAtMs'),
|
||||
finishedAtMs: nullableInteger(row, 'finishedAtMs'),
|
||||
cancelRequestedAtMs: nullableInteger(row, 'cancelRequestedAtMs'),
|
||||
cancelReason,
|
||||
},
|
||||
stepCount,
|
||||
stepStatusCounts,
|
||||
});
|
||||
await insertAdministrationAudit(client, inspection.audit);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (client && began) await rollbackAdministrationTransaction(client);
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationMutationConflictError ||
|
||||
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageWorkflowAdministrationMutationConflictError();
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists one newest-first, low-sensitive Workflow Run history page from a
|
||||
* serializable authorization snapshot. The dedicated target/time index keeps
|
||||
* the query bounded even when a Package owns many other Workflows.
|
||||
*/
|
||||
export class PostgresAuthorizedPluginPackageWorkflowRunListRepository
|
||||
implements PluginPackageWorkflowRunListRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
|
||||
'PostgreSQL pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listRunsAuthorized(
|
||||
input: AuthorizedPluginPackageWorkflowRunList,
|
||||
): Promise<Readonly<PluginPackageWorkflowRunListResult>> {
|
||||
const query = normalizeAuthorizedPluginPackageWorkflowRunList(input);
|
||||
let client: PostgresClient | undefined;
|
||||
let began = false;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
await configureAdministrationTransaction(client);
|
||||
began = true;
|
||||
await confirmCredential(client, query);
|
||||
await confirmProjectPolicyFence(client, query);
|
||||
|
||||
const page = await client.query<Row>(
|
||||
`SELECT admission.run_id AS "runId",
|
||||
admission.step_count AS "stepCount",
|
||||
admission.admitted_at_ms AS "admittedAtMs",
|
||||
run.status AS "runStatus",
|
||||
run.version AS "runVersion",
|
||||
run.event_sequence AS "eventSequence",
|
||||
run.queued_at_ms AS "queuedAtMs",
|
||||
run.started_at_ms AS "startedAtMs",
|
||||
run.finished_at_ms AS "finishedAtMs",
|
||||
run.cancel_requested_at_ms AS "cancelRequestedAtMs",
|
||||
run.cancel_reason AS "cancelReason"
|
||||
FROM "ql3"."plugin_package_workflow_admissions" AS admission
|
||||
JOIN "ql3"."runs" AS run
|
||||
ON run.id = admission.run_id
|
||||
AND run.project_id = admission.project_id
|
||||
WHERE admission.project_id = $1
|
||||
AND admission.package_name = $2
|
||||
AND admission.workflow_id = $3
|
||||
AND ($4::bigint IS NULL OR admission.admitted_at_ms < $4 OR
|
||||
(admission.admitted_at_ms = $4 AND admission.run_id < $5))
|
||||
ORDER BY admission.admitted_at_ms DESC, admission.run_id DESC
|
||||
LIMIT $6`,
|
||||
[
|
||||
query.projectId,
|
||||
query.packageName,
|
||||
query.workflowId,
|
||||
query.after?.admittedAtMs ?? null,
|
||||
query.after?.runId ?? null,
|
||||
query.limit + 1,
|
||||
],
|
||||
);
|
||||
const truncated = page.rows.length > query.limit;
|
||||
const runs = page.rows.slice(0, query.limit).map((row) => ({
|
||||
runId: requiredString(row, 'runId'),
|
||||
status: requiredString(row, 'runStatus') as RunStatus,
|
||||
version: requiredInteger(row, 'runVersion'),
|
||||
eventSequence: requiredInteger(row, 'eventSequence'),
|
||||
stepCount: requiredInteger(row, 'stepCount'),
|
||||
admittedAtMs: requiredInteger(row, 'admittedAtMs'),
|
||||
queuedAtMs: nullableInteger(row, 'queuedAtMs'),
|
||||
startedAtMs: nullableInteger(row, 'startedAtMs'),
|
||||
finishedAtMs: nullableInteger(row, 'finishedAtMs'),
|
||||
cancelRequestedAtMs: nullableInteger(row, 'cancelRequestedAtMs'),
|
||||
cancelReason: nullableString(
|
||||
row,
|
||||
'cancelReason',
|
||||
) as RunCancellationReason | null,
|
||||
}));
|
||||
const last = runs.at(-1);
|
||||
const result = normalizePluginPackageWorkflowRunListResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_SCHEMA,
|
||||
projectId: query.projectId,
|
||||
packageName: query.packageName,
|
||||
workflowId: query.workflowId,
|
||||
after: query.after,
|
||||
runs,
|
||||
truncated,
|
||||
next:
|
||||
truncated && last
|
||||
? { admittedAtMs: last.admittedAtMs, runId: last.runId }
|
||||
: null,
|
||||
});
|
||||
await insertAdministrationAudit(client, query.audit);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (client && began) await rollbackAdministrationTransaction(client);
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationMutationConflictError ||
|
||||
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageWorkflowAdministrationMutationConflictError();
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists one bounded low-sensitive StepRun page behind the exact Package-bound
|
||||
* Workflow target and current authorization fence. The runtime role only
|
||||
* appends the allowed audit and never receives audit read authority.
|
||||
*/
|
||||
export class PostgresAuthorizedPluginPackageWorkflowStepRunListRepository
|
||||
implements PluginPackageWorkflowStepRunListRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
|
||||
'PostgreSQL pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listStepRunsAuthorized(
|
||||
input: AuthorizedPluginPackageWorkflowStepRunList,
|
||||
): Promise<Readonly<PluginPackageWorkflowStepRunListResult>> {
|
||||
const query = normalizeAuthorizedPluginPackageWorkflowStepRunList(input);
|
||||
let client: PostgresClient | undefined;
|
||||
let began = false;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
await configureAdministrationTransaction(client);
|
||||
began = true;
|
||||
await confirmCredential(client, query);
|
||||
await confirmProjectPolicyFence(client, query);
|
||||
|
||||
const targets = await client.query<Row>(
|
||||
`SELECT admission.step_count AS "stepCount",
|
||||
(SELECT COUNT(*) FROM "ql3"."step_runs" AS observed
|
||||
WHERE observed.run_id = admission.run_id) AS "observedStepCount"
|
||||
FROM "ql3"."plugin_package_workflow_admissions" AS admission
|
||||
JOIN "ql3"."runs" AS run
|
||||
ON run.id = admission.run_id
|
||||
AND run.project_id = admission.project_id
|
||||
WHERE admission.run_id = $1
|
||||
AND admission.project_id = $2
|
||||
AND admission.package_name = $3
|
||||
AND admission.workflow_id = $4
|
||||
LIMIT 2`,
|
||||
[query.runId, query.projectId, query.packageName, query.workflowId],
|
||||
);
|
||||
if (targets.rows.length === 0) {
|
||||
await insertAdministrationAudit(client, query.audit);
|
||||
const missing = normalizePluginPackageWorkflowStepRunListResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA,
|
||||
found: false,
|
||||
projectId: query.projectId,
|
||||
packageName: query.packageName,
|
||||
workflowId: query.workflowId,
|
||||
runId: query.runId,
|
||||
stepRuns: [],
|
||||
truncated: false,
|
||||
next: null,
|
||||
});
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return missing;
|
||||
}
|
||||
if (
|
||||
targets.rows.length !== 1 ||
|
||||
requiredInteger(targets.rows[0]!, 'stepCount') !==
|
||||
requiredInteger(targets.rows[0]!, 'observedStepCount')
|
||||
) {
|
||||
mutationConflict();
|
||||
}
|
||||
|
||||
const page = await client.query<Row>(
|
||||
`SELECT id AS "id",
|
||||
parent_step_run_id AS "parentStepRunId",
|
||||
step_key AS "stepKey",
|
||||
kind AS "kind",
|
||||
required AS "required",
|
||||
status AS "status",
|
||||
version AS "version",
|
||||
attempt_count AS "attemptCount",
|
||||
ready_at_ms AS "readyAtMs",
|
||||
started_at_ms AS "startedAtMs",
|
||||
finished_at_ms AS "finishedAtMs",
|
||||
result_code AS "resultCode",
|
||||
created_at_ms AS "createdAtMs",
|
||||
updated_at_ms AS "updatedAtMs"
|
||||
FROM "ql3"."step_runs"
|
||||
WHERE run_id = $1
|
||||
AND ($2::varchar IS NULL OR step_key > $3 OR
|
||||
(step_key = $3 AND id > $2))
|
||||
ORDER BY step_key, id
|
||||
LIMIT $4`,
|
||||
[
|
||||
query.runId,
|
||||
query.after?.id ?? null,
|
||||
query.after?.stepKey ?? '',
|
||||
query.limit + 1,
|
||||
],
|
||||
);
|
||||
const truncated = page.rows.length > query.limit;
|
||||
const stepRuns = page.rows.slice(0, query.limit).map((row) => {
|
||||
if (typeof row.required !== 'boolean') mutationConflict();
|
||||
return {
|
||||
id: requiredString(row, 'id'),
|
||||
parentStepRunId: nullableString(row, 'parentStepRunId'),
|
||||
stepKey: requiredString(row, 'stepKey'),
|
||||
kind: requiredString(
|
||||
row,
|
||||
'kind',
|
||||
) as PluginPackageWorkflowStepRunListItem['kind'],
|
||||
required: row.required,
|
||||
status: requiredString(row, 'status') as StepRunStatus,
|
||||
version: requiredInteger(row, 'version'),
|
||||
attemptCount: requiredInteger(row, 'attemptCount'),
|
||||
readyAtMs: nullableInteger(row, 'readyAtMs'),
|
||||
startedAtMs: nullableInteger(row, 'startedAtMs'),
|
||||
finishedAtMs: nullableInteger(row, 'finishedAtMs'),
|
||||
resultCode: nullableString(row, 'resultCode'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
updatedAtMs: requiredInteger(row, 'updatedAtMs'),
|
||||
};
|
||||
});
|
||||
const last = stepRuns.at(-1);
|
||||
const result = normalizePluginPackageWorkflowStepRunListResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA,
|
||||
found: true,
|
||||
projectId: query.projectId,
|
||||
packageName: query.packageName,
|
||||
workflowId: query.workflowId,
|
||||
runId: query.runId,
|
||||
stepRuns,
|
||||
truncated,
|
||||
next: truncated && last ? { stepKey: last.stepKey, id: last.id } : null,
|
||||
});
|
||||
await insertAdministrationAudit(client, query.audit);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (client && began) await rollbackAdministrationTransaction(client);
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationMutationConflictError ||
|
||||
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageWorkflowAdministrationMutationConflictError();
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists one bounded content-free RunEvent page behind the exact Package-bound
|
||||
* Workflow target and current authorization fence.
|
||||
*/
|
||||
export class PostgresAuthorizedPluginPackageWorkflowRunEventListRepository
|
||||
implements PluginPackageWorkflowRunEventListRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
|
||||
'PostgreSQL pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listRunEventsAuthorized(
|
||||
input: AuthorizedPluginPackageWorkflowRunEventList,
|
||||
): Promise<Readonly<PluginPackageWorkflowRunEventListResult>> {
|
||||
const query = normalizeAuthorizedPluginPackageWorkflowRunEventList(input);
|
||||
let client: PostgresClient | undefined;
|
||||
let began = false;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
await configureAdministrationTransaction(client);
|
||||
began = true;
|
||||
await confirmCredential(client, query);
|
||||
await confirmProjectPolicyFence(client, query);
|
||||
|
||||
const targets = await client.query<Row>(
|
||||
`SELECT run.event_sequence AS "headSequence"
|
||||
FROM "ql3"."plugin_package_workflow_admissions" AS admission
|
||||
JOIN "ql3"."runs" AS run
|
||||
ON run.id = admission.run_id
|
||||
AND run.project_id = admission.project_id
|
||||
WHERE admission.run_id = $1
|
||||
AND admission.project_id = $2
|
||||
AND admission.package_name = $3
|
||||
AND admission.workflow_id = $4
|
||||
LIMIT 2`,
|
||||
[query.runId, query.projectId, query.packageName, query.workflowId],
|
||||
);
|
||||
if (targets.rows.length === 0) {
|
||||
await insertAdministrationAudit(client, query.audit);
|
||||
const missing = normalizePluginPackageWorkflowRunEventListResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA,
|
||||
found: false,
|
||||
projectId: query.projectId,
|
||||
packageName: query.packageName,
|
||||
workflowId: query.workflowId,
|
||||
runId: query.runId,
|
||||
afterSequence: query.afterSequence,
|
||||
headSequence: null,
|
||||
events: [],
|
||||
truncated: false,
|
||||
nextAfterSequence: null,
|
||||
});
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return missing;
|
||||
}
|
||||
if (targets.rows.length !== 1) mutationConflict();
|
||||
const headSequence = requiredInteger(targets.rows[0]!, 'headSequence');
|
||||
const page = await client.query<Row>(
|
||||
`SELECT id AS "id",
|
||||
sequence AS "sequence",
|
||||
type AS "type",
|
||||
step_run_id AS "stepRunId",
|
||||
created_at_ms AS "createdAtMs"
|
||||
FROM "ql3"."run_events"
|
||||
WHERE run_id = $1 AND sequence > $2
|
||||
ORDER BY sequence, id
|
||||
LIMIT $3`,
|
||||
[query.runId, query.afterSequence, query.limit + 1],
|
||||
);
|
||||
const truncated = page.rows.length > query.limit;
|
||||
const events = page.rows.slice(0, query.limit).map((row) => ({
|
||||
id: requiredString(row, 'id'),
|
||||
sequence: requiredInteger(row, 'sequence'),
|
||||
type: requiredString(row, 'type'),
|
||||
stepRunId: nullableString(row, 'stepRunId'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
}));
|
||||
const lastSequence = events.at(-1)?.sequence ?? null;
|
||||
const result = normalizePluginPackageWorkflowRunEventListResult({
|
||||
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA,
|
||||
found: true,
|
||||
projectId: query.projectId,
|
||||
packageName: query.packageName,
|
||||
workflowId: query.workflowId,
|
||||
runId: query.runId,
|
||||
afterSequence: query.afterSequence,
|
||||
headSequence,
|
||||
events,
|
||||
truncated,
|
||||
nextAfterSequence: truncated ? lastSequence : null,
|
||||
});
|
||||
await insertAdministrationAudit(client, query.audit);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (client && began) await rollbackAdministrationTransaction(client);
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationMutationConflictError ||
|
||||
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageWorkflowAdministrationMutationConflictError();
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+843
@@ -0,0 +1,843 @@
|
||||
// PostgreSQL authority for atomic Plugin Package Workflow admission.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
PostgresQueryable,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageWorkflowAdministrationMutationError,
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError,
|
||||
PluginPackageWorkflowAdministrationMutationConflictError,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-administration';
|
||||
import {
|
||||
normalizePluginPackageAutomationPublication,
|
||||
type PluginPackageAutomationPublication,
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import {
|
||||
createPluginPackageWorkflowAdmissionBundle,
|
||||
InvalidPluginPackageWorkflowExecutionPlanError,
|
||||
normalizePluginPackageWorkflowAdmissionReceipt,
|
||||
normalizePluginPackageWorkflowExecutionPlan,
|
||||
pluginPackageWorkflowDefinitionDigest,
|
||||
PluginPackageWorkflowAdmissionConflictError,
|
||||
PluginPackageWorkflowAdmissionNotAllowedError,
|
||||
PluginPackageWorkflowAdmissionUnavailableError,
|
||||
type PluginPackageWorkflowAdmissionBundle,
|
||||
type PluginPackageWorkflowAdmissionReceipt,
|
||||
type PluginPackageWorkflowAdmissionRepository,
|
||||
type PluginPackageWorkflowExecutionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
type StepRunRecord,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import {
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredJsonObject,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const WORKFLOW_RUN_STATUSES = new Set([
|
||||
'running',
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
]);
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackageWorkflowAdmissionUnavailableError {
|
||||
return new PluginPackageWorkflowAdmissionUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY.test(value)) {
|
||||
throw new InvalidPluginPackageWorkflowExecutionPlanError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError ||
|
||||
error instanceof PluginPackageWorkflowAdministrationMutationConflictError ||
|
||||
error instanceof InvalidPluginPackageWorkflowExecutionPlanError ||
|
||||
error instanceof PluginPackageWorkflowAdmissionConflictError ||
|
||||
error instanceof PluginPackageWorkflowAdmissionNotAllowedError ||
|
||||
error instanceof PluginPackageWorkflowAdmissionUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (['23503', '23505', '23514'].includes(postgresSqlState(error) ?? '')) {
|
||||
return new PluginPackageWorkflowAdmissionConflictError(
|
||||
'durable Run, plan, StepRun, event, or receipt identity changed',
|
||||
);
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
|
||||
export interface PostgresPluginPackageWorkflowAdmissionTransactionContext {
|
||||
readonly client: PostgresClient;
|
||||
readonly replay: boolean;
|
||||
readonly plan: Readonly<PluginPackageWorkflowExecutionPlan>;
|
||||
readonly receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
|
||||
}
|
||||
|
||||
export type PostgresPluginPackageWorkflowAdmissionTransactionGuard = (
|
||||
context: Readonly<PostgresPluginPackageWorkflowAdmissionTransactionContext>,
|
||||
) => void | Promise<void>;
|
||||
|
||||
function json(value: unknown): string {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'string'
|
||||
) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => json(entry ?? null)).join(',')}]`;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const record = value as Readonly<Record<string, unknown>>;
|
||||
return `{${Object.keys(record)
|
||||
.filter((key) => record[key] !== undefined)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${json(record[key])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalText(row: Row, key: string): string | undefined {
|
||||
const value = row[key];
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value !== 'string') throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isSafeInteger(parsed)) return parsed;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
function optionalInteger(row: Row, key: string): number | undefined {
|
||||
if (row[key] === null || row[key] === undefined) return undefined;
|
||||
return integer(row, key);
|
||||
}
|
||||
|
||||
function exactArray(
|
||||
left: readonly string[],
|
||||
right: readonly string[],
|
||||
): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((value, index) => value === right[index])
|
||||
);
|
||||
}
|
||||
|
||||
function parseStored(row: Row): Readonly<{
|
||||
plan: Readonly<PluginPackageWorkflowExecutionPlan>;
|
||||
receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>;
|
||||
}> {
|
||||
try {
|
||||
const plan = normalizePluginPackageWorkflowExecutionPlan(
|
||||
postgresRequiredJsonObject(
|
||||
row.planJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageWorkflowExecutionPlan,
|
||||
);
|
||||
const receipt = normalizePluginPackageWorkflowAdmissionReceipt(
|
||||
postgresRequiredJsonObject(
|
||||
row.receiptJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageWorkflowAdmissionReceipt,
|
||||
);
|
||||
const bundle = createPluginPackageWorkflowAdmissionBundle(plan);
|
||||
if (
|
||||
plan.planDigest !== text(row, 'planDigest') ||
|
||||
plan.planId !== text(row, 'planId') ||
|
||||
plan.runId !== text(row, 'runId') ||
|
||||
plan.target.projectId !== text(row, 'projectId') ||
|
||||
plan.target.packageName !== text(row, 'packageName') ||
|
||||
plan.target.installationId !== text(row, 'installationId') ||
|
||||
plan.target.lockDigest !== text(row, 'lockDigest') ||
|
||||
plan.target.generation !== integer(row, 'generation') ||
|
||||
plan.target.generationDigest !== text(row, 'generationDigest') ||
|
||||
plan.target.materializedRevisionDigest !==
|
||||
text(row, 'materializedRevisionDigest') ||
|
||||
plan.target.publicationDigest !== text(row, 'publicationDigest') ||
|
||||
plan.target.workflowId !== text(row, 'workflowId') ||
|
||||
plan.target.workflowDefinitionDigest !==
|
||||
text(row, 'workflowDefinitionDigest') ||
|
||||
plan.steps.length !== integer(row, 'stepCount') ||
|
||||
receipt.admittedAtMs !== integer(row, 'admittedAtMs') ||
|
||||
receipt.finalRunVersion !== integer(row, 'finalRunVersion') ||
|
||||
receipt.finalRunEventSequence !== integer(row, 'finalRunEventSequence') ||
|
||||
receipt.receiptDigest !== text(row, 'receiptDigest') ||
|
||||
json(bundle.receipt) !== json(receipt)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return Object.freeze({ plan, receipt, bundle });
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageWorkflowAdmissionUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertStoredEvidence(
|
||||
queryable: PostgresQueryable,
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): Promise<void> {
|
||||
const run = bundle.run;
|
||||
const storedRun = await queryable.query<Row>(
|
||||
`SELECT project_id AS "projectId", task_id AS "taskId",
|
||||
task_revision AS "taskRevision",
|
||||
task_snapshot_ref AS "taskSnapshotRef",
|
||||
trigger_type AS "triggerType",
|
||||
execution_origin AS "executionOrigin",
|
||||
execution_owner AS "executionOwner",
|
||||
request_id AS "requestId", status, version,
|
||||
event_sequence AS "eventSequence", priority,
|
||||
idempotency_key AS "idempotencyKey",
|
||||
created_at_ms AS "createdAtMs",
|
||||
started_at_ms AS "startedAtMs"
|
||||
FROM "ql3"."runs" WHERE id = $1`,
|
||||
[run.id],
|
||||
);
|
||||
const runRow = storedRun.rows.length === 1 ? storedRun.rows[0]! : null;
|
||||
const storedRunVersion = runRow ? integer(runRow, 'version') : -1;
|
||||
const storedEventSequence = runRow ? integer(runRow, 'eventSequence') : -1;
|
||||
if (
|
||||
!runRow ||
|
||||
text(runRow, 'projectId') !== run.projectId ||
|
||||
text(runRow, 'taskId') !== run.taskId ||
|
||||
text(runRow, 'taskRevision') !== run.taskRevision ||
|
||||
optionalText(runRow, 'taskSnapshotRef') !== run.taskSnapshotRef ||
|
||||
text(runRow, 'triggerType') !== run.triggerType ||
|
||||
text(runRow, 'executionOrigin') !== run.executionOrigin ||
|
||||
text(runRow, 'executionOwner') !== run.executionOwner ||
|
||||
optionalText(runRow, 'requestId') !== run.requestId ||
|
||||
!WORKFLOW_RUN_STATUSES.has(text(runRow, 'status')) ||
|
||||
storedRunVersion < run.version ||
|
||||
storedEventSequence < run.eventSequence ||
|
||||
storedRunVersion !== storedEventSequence ||
|
||||
integer(runRow, 'priority') !== run.priority ||
|
||||
optionalText(runRow, 'idempotencyKey') !== run.idempotencyKey ||
|
||||
integer(runRow, 'createdAtMs') !== run.createdAtMs ||
|
||||
optionalInteger(runRow, 'startedAtMs') !== run.startedAtMs
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
const storedEvents = await queryable.query<Row>(
|
||||
`SELECT id, sequence, type, dedupe_key AS "dedupeKey",
|
||||
actor_type AS "actorType", actor_id AS "actorId",
|
||||
step_run_id AS "stepRunId", payload,
|
||||
created_at_ms AS "createdAtMs"
|
||||
FROM "ql3"."run_events"
|
||||
WHERE run_id = $1 AND sequence <= $2
|
||||
ORDER BY sequence`,
|
||||
[run.id, bundle.receipt.finalRunEventSequence],
|
||||
);
|
||||
const expectedEvents = [
|
||||
bundle.admissionEvent,
|
||||
...bundle.stepMutations.map(({ event }) => event),
|
||||
];
|
||||
if (
|
||||
storedEvents.rows.length !== expectedEvents.length ||
|
||||
storedEvents.rows.some((row, index) => {
|
||||
const event = expectedEvents[index]!;
|
||||
return (
|
||||
text(row, 'id') !== event.id ||
|
||||
integer(row, 'sequence') !== event.sequence ||
|
||||
text(row, 'type') !== event.type ||
|
||||
optionalText(row, 'dedupeKey') !== event.dedupeKey ||
|
||||
text(row, 'actorType') !== event.actorType ||
|
||||
optionalText(row, 'actorId') !== event.actorId ||
|
||||
optionalText(row, 'stepRunId') !== event.stepRunId ||
|
||||
json(row.payload) !== json(event.payload) ||
|
||||
integer(row, 'createdAtMs') !== event.createdAtMs
|
||||
);
|
||||
})
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
for (const mutation of bundle.stepMutations) {
|
||||
const stored = await queryable.query<Row>(
|
||||
`SELECT
|
||||
admission_step.step_run_id AS "stepRunId",
|
||||
admission_step.task_id AS "taskId",
|
||||
admission_step.task_definition_ref AS "taskDefinitionRef",
|
||||
admission_step.task_definition_digest AS "taskDefinitionDigest",
|
||||
admission_step.needs_json AS "needsJson",
|
||||
admission_step.initial_status AS "initialStatus",
|
||||
admission_step.mutation_id AS "mutationId",
|
||||
admission_step.event_id AS "eventId",
|
||||
runtime.step_key AS "currentStepKey",
|
||||
runtime.kind AS "currentKind",
|
||||
runtime.definition_ref AS "currentDefinitionRef",
|
||||
runtime.definition_digest AS "currentDefinitionDigest",
|
||||
runtime.required AS "currentRequired",
|
||||
runtime.status AS "currentStatus",
|
||||
runtime.version AS "currentVersion",
|
||||
runtime.last_mutation_id AS "currentLastMutationId",
|
||||
runtime.step_run_digest AS "currentStepRunDigest",
|
||||
runtime.step_run_json AS "currentStepRunJson",
|
||||
mutation.mutation_digest AS "mutationDigest",
|
||||
mutation.event_sequence AS "eventSequence",
|
||||
mutation.run_version AS "runVersion",
|
||||
mutation.step_run_digest AS "initialStepRunDigest",
|
||||
mutation.step_run_json AS "initialStepRunJson"
|
||||
FROM "ql3"."plugin_package_workflow_admission_steps"
|
||||
AS admission_step
|
||||
JOIN "ql3"."step_runs" AS runtime
|
||||
ON runtime.run_id = admission_step.run_id
|
||||
AND runtime.id = admission_step.step_run_id
|
||||
JOIN "ql3"."step_run_mutations" AS mutation
|
||||
ON mutation.mutation_id = admission_step.mutation_id
|
||||
WHERE admission_step.plan_digest = $1
|
||||
AND admission_step.step_key = $2`,
|
||||
[bundle.plan.planDigest, mutation.stepRun.stepKey],
|
||||
);
|
||||
const row = stored.rows.length === 1 ? stored.rows[0]! : null;
|
||||
const planStep = bundle.plan.steps.find(
|
||||
({ stepKey }) => stepKey === mutation.stepRun.stepKey,
|
||||
);
|
||||
let currentStepRun: Readonly<StepRunRecord> | null = null;
|
||||
if (row) {
|
||||
try {
|
||||
currentStepRun = normalizeStepRunRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.currentStepRunJson,
|
||||
unavailable,
|
||||
) as unknown as StepRunRecord,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
if (
|
||||
!row ||
|
||||
!planStep ||
|
||||
!currentStepRun ||
|
||||
text(row, 'stepRunId') !== mutation.stepRun.id ||
|
||||
text(row, 'taskId') !== planStep.taskId ||
|
||||
text(row, 'taskDefinitionRef') !== planStep.taskDefinitionRef ||
|
||||
text(row, 'taskDefinitionDigest') !== planStep.taskDefinitionDigest ||
|
||||
json(row.needsJson) !== json(planStep.needs) ||
|
||||
text(row, 'initialStatus') !== planStep.initialStatus ||
|
||||
text(row, 'mutationId') !== mutation.mutationId ||
|
||||
text(row, 'eventId') !== mutation.event.id ||
|
||||
text(row, 'mutationDigest') !== mutation.mutationDigest ||
|
||||
integer(row, 'eventSequence') !== mutation.event.sequence ||
|
||||
integer(row, 'runVersion') !== mutation.expectedRunVersion + 1 ||
|
||||
text(row, 'initialStepRunDigest') !== mutation.stepRun.stepRunDigest ||
|
||||
json(row.initialStepRunJson) !== json(mutation.stepRun) ||
|
||||
currentStepRun.id !== mutation.stepRun.id ||
|
||||
currentStepRun.runId !== mutation.runId ||
|
||||
currentStepRun.stepKey !== planStep.stepKey ||
|
||||
currentStepRun.kind !== 'task' ||
|
||||
currentStepRun.definitionRef !== planStep.taskDefinitionRef ||
|
||||
currentStepRun.definitionDigest !== planStep.taskDefinitionDigest ||
|
||||
currentStepRun.required !== planStep.required ||
|
||||
currentStepRun.version < mutation.stepRun.version ||
|
||||
text(row, 'currentStepKey') !== currentStepRun.stepKey ||
|
||||
text(row, 'currentKind') !== currentStepRun.kind ||
|
||||
text(row, 'currentDefinitionRef') !== currentStepRun.definitionRef ||
|
||||
text(row, 'currentDefinitionDigest') !==
|
||||
currentStepRun.definitionDigest ||
|
||||
row.currentRequired !== currentStepRun.required ||
|
||||
text(row, 'currentStatus') !== currentStepRun.status ||
|
||||
integer(row, 'currentVersion') !== currentStepRun.version ||
|
||||
text(row, 'currentLastMutationId') !== currentStepRun.lastMutationId ||
|
||||
text(row, 'currentStepRunDigest') !== currentStepRun.stepRunDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findStored(
|
||||
queryable: PostgresQueryable,
|
||||
column: 'plan_id' | 'run_id',
|
||||
value: string,
|
||||
): Promise<ReturnType<typeof parseStored> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT
|
||||
plan_digest AS "planDigest", plan_id AS "planId",
|
||||
run_id AS "runId", project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
installation_id AS "installationId",
|
||||
lock_digest AS "lockDigest", generation,
|
||||
generation_digest AS "generationDigest",
|
||||
materialized_revision_digest AS "materializedRevisionDigest",
|
||||
publication_digest AS "publicationDigest",
|
||||
workflow_id AS "workflowId",
|
||||
workflow_definition_digest AS "workflowDefinitionDigest",
|
||||
step_count AS "stepCount", admitted_at_ms AS "admittedAtMs",
|
||||
final_run_version AS "finalRunVersion",
|
||||
final_run_event_sequence AS "finalRunEventSequence",
|
||||
receipt_digest AS "receiptDigest",
|
||||
plan_json AS "planJson", receipt_json AS "receiptJson"
|
||||
FROM "ql3"."plugin_package_workflow_admissions"
|
||||
WHERE ${column} = $1
|
||||
LIMIT 2`,
|
||||
[value],
|
||||
);
|
||||
if (result.rows.length > 1) throw unavailable();
|
||||
if (!result.rows[0]) return null;
|
||||
const stored = parseStored(result.rows[0]);
|
||||
await assertStoredEvidence(queryable, stored.bundle);
|
||||
return stored;
|
||||
}
|
||||
|
||||
function assertSnapshot(
|
||||
plan: Readonly<PluginPackageWorkflowExecutionPlan>,
|
||||
row: Row,
|
||||
): void {
|
||||
let publication: Readonly<PluginPackageAutomationPublication>;
|
||||
let resources: unknown;
|
||||
try {
|
||||
publication = normalizePluginPackageAutomationPublication(
|
||||
postgresRequiredJsonObject(
|
||||
row.publicationJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageAutomationPublication,
|
||||
);
|
||||
resources = postgresRequiredJsonObject(
|
||||
row.revisionJson,
|
||||
unavailable,
|
||||
).resources;
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
if (!Array.isArray(resources)) throw unavailable();
|
||||
const target = plan.target;
|
||||
const workflow = publication.definitions.workflows.find(
|
||||
({ id }) => id === target.workflowId,
|
||||
);
|
||||
if (
|
||||
publication.publicationDigest !== target.publicationDigest ||
|
||||
publication.target.projectId !== target.projectId ||
|
||||
publication.target.packageName !== target.packageName ||
|
||||
publication.target.installationId !== target.installationId ||
|
||||
publication.target.lockDigest !== target.lockDigest ||
|
||||
publication.target.generation !== target.generation ||
|
||||
publication.target.generationDigest !== target.generationDigest ||
|
||||
publication.target.materializedRevisionDigest !==
|
||||
target.materializedRevisionDigest ||
|
||||
publication.state !== 'active' ||
|
||||
!workflow ||
|
||||
!workflow.enabled ||
|
||||
pluginPackageWorkflowDefinitionDigest(workflow) !==
|
||||
target.workflowDefinitionDigest ||
|
||||
workflow.steps.length !== plan.steps.length
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionConflictError(
|
||||
'the exact Workflow publication drifted',
|
||||
);
|
||||
}
|
||||
for (const step of plan.steps) {
|
||||
const workflowStep = workflow.steps.find(({ id }) => id === step.stepKey);
|
||||
const matches = resources.filter((resource) => {
|
||||
if (!resource || typeof resource !== 'object') return false;
|
||||
const candidate = resource as {
|
||||
kind?: unknown;
|
||||
sourceDigest?: unknown;
|
||||
value?: { id?: unknown; enabled?: unknown };
|
||||
};
|
||||
return (
|
||||
candidate.kind === 'task' &&
|
||||
candidate.sourceDigest === step.taskDefinitionDigest &&
|
||||
candidate.value?.id === step.taskId &&
|
||||
candidate.value.enabled === true
|
||||
);
|
||||
});
|
||||
if (
|
||||
!workflowStep ||
|
||||
workflowStep.task !== step.taskId ||
|
||||
!exactArray(workflowStep.needs, step.needs) ||
|
||||
step.initialStatus !==
|
||||
(workflowStep.needs.length === 0 ? 'ready' : 'pending') ||
|
||||
step.taskDefinitionRef !==
|
||||
`plugin-package:${target.materializedRevisionDigest}:task:${step.taskId}` ||
|
||||
matches.length !== 1
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionConflictError(
|
||||
'the exact Workflow step or Task evidence drifted',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function insertRun(
|
||||
client: PostgresClient,
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): Promise<void> {
|
||||
const run = bundle.run;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."runs" (
|
||||
id, project_id, task_id, task_revision, task_snapshot_ref,
|
||||
trigger_type, execution_origin, execution_owner, request_id,
|
||||
status, version, event_sequence, priority, idempotency_key,
|
||||
created_at_ms, started_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
|
||||
$15, $16
|
||||
)`,
|
||||
[
|
||||
run.id,
|
||||
run.projectId,
|
||||
run.taskId,
|
||||
run.taskRevision,
|
||||
run.taskSnapshotRef ?? null,
|
||||
run.triggerType,
|
||||
run.executionOrigin,
|
||||
run.executionOwner,
|
||||
run.requestId ?? null,
|
||||
run.status,
|
||||
run.version,
|
||||
run.eventSequence,
|
||||
run.priority,
|
||||
run.idempotencyKey ?? null,
|
||||
run.createdAtMs,
|
||||
run.startedAtMs ?? null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function insertEvent(
|
||||
client: PostgresClient,
|
||||
event: Readonly<PluginPackageWorkflowAdmissionBundle['admissionEvent']>,
|
||||
stepRunId: string | null,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."run_events" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9::jsonb, $10)`,
|
||||
[
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
stepRunId,
|
||||
json(event.payload),
|
||||
event.createdAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function insertStepEvidence(
|
||||
client: PostgresClient,
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): Promise<void> {
|
||||
for (const mutation of bundle.stepMutations) {
|
||||
const step = mutation.stepRun;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."step_runs" (
|
||||
id, run_id, parent_step_run_id, step_key, kind, definition_ref,
|
||||
definition_digest, required, status, version, attempt_count,
|
||||
input_ref, output_ref, approval_request_id, ready_at_ms,
|
||||
started_at_ms, finished_at_ms, result_code, error_summary,
|
||||
created_at_ms, updated_at_ms, last_mutation_id, step_run_digest,
|
||||
step_run_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
|
||||
$15, $16, $17, $18, $19, $20, $21, $22, $23, $24::jsonb
|
||||
)`,
|
||||
[
|
||||
step.id,
|
||||
step.runId,
|
||||
step.parentStepRunId,
|
||||
step.stepKey,
|
||||
step.kind,
|
||||
step.definitionRef,
|
||||
step.definitionDigest,
|
||||
step.required,
|
||||
step.status,
|
||||
step.version,
|
||||
step.attemptCount,
|
||||
step.inputRef,
|
||||
step.outputRef,
|
||||
step.approvalRequestId,
|
||||
step.readyAtMs,
|
||||
step.startedAtMs,
|
||||
step.finishedAtMs,
|
||||
step.resultCode,
|
||||
step.errorSummary,
|
||||
step.createdAtMs,
|
||||
step.updatedAtMs,
|
||||
step.lastMutationId,
|
||||
step.stepRunDigest,
|
||||
json(step),
|
||||
],
|
||||
);
|
||||
await insertEvent(client, mutation.event, step.id);
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."step_run_mutations" (
|
||||
mutation_id, mutation_digest, run_id, step_run_id,
|
||||
step_run_digest, event_id, event_sequence, run_version,
|
||||
step_run_json, committed_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
|
||||
[
|
||||
mutation.mutationId,
|
||||
mutation.mutationDigest,
|
||||
mutation.runId,
|
||||
step.id,
|
||||
step.stepRunDigest,
|
||||
mutation.event.id,
|
||||
mutation.event.sequence,
|
||||
mutation.expectedRunVersion + 1,
|
||||
json(step),
|
||||
bundle.receipt.admittedAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function insertAdmission(
|
||||
client: PostgresClient,
|
||||
bundle: Readonly<PluginPackageWorkflowAdmissionBundle>,
|
||||
): Promise<void> {
|
||||
const { plan, receipt } = bundle;
|
||||
const target = plan.target;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."plugin_package_workflow_admissions" (
|
||||
plan_digest, plan_id, run_id, project_id, package_name,
|
||||
installation_id, lock_digest, generation, generation_digest,
|
||||
materialized_revision_digest, publication_digest, workflow_id,
|
||||
workflow_definition_digest, step_count, admitted_at_ms,
|
||||
final_run_version, final_run_event_sequence, receipt_digest,
|
||||
plan_json, receipt_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
|
||||
$15, $16, $17, $18, $19::jsonb, $20::jsonb
|
||||
)`,
|
||||
[
|
||||
plan.planDigest,
|
||||
plan.planId,
|
||||
plan.runId,
|
||||
target.projectId,
|
||||
target.packageName,
|
||||
target.installationId,
|
||||
target.lockDigest,
|
||||
target.generation,
|
||||
target.generationDigest,
|
||||
target.materializedRevisionDigest,
|
||||
target.publicationDigest,
|
||||
target.workflowId,
|
||||
target.workflowDefinitionDigest,
|
||||
plan.steps.length,
|
||||
receipt.admittedAtMs,
|
||||
receipt.finalRunVersion,
|
||||
receipt.finalRunEventSequence,
|
||||
receipt.receiptDigest,
|
||||
json(plan),
|
||||
json(receipt),
|
||||
],
|
||||
);
|
||||
for (const step of plan.steps) {
|
||||
const mutation = bundle.stepMutations.find(
|
||||
({ stepRun }) => stepRun.stepKey === step.stepKey,
|
||||
);
|
||||
if (!mutation) throw unavailable();
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."plugin_package_workflow_admission_steps" (
|
||||
plan_digest, run_id, step_key, step_run_id, task_id,
|
||||
task_definition_ref, task_definition_digest, needs_json,
|
||||
initial_status, mutation_id, event_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11
|
||||
)`,
|
||||
[
|
||||
plan.planDigest,
|
||||
plan.runId,
|
||||
step.stepKey,
|
||||
step.stepRunId,
|
||||
step.taskId,
|
||||
step.taskDefinitionRef,
|
||||
step.taskDefinitionDigest,
|
||||
json(step.needs),
|
||||
step.initialStatus,
|
||||
mutation.mutationId,
|
||||
mutation.event.id,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageWorkflowAdmissionRepository
|
||||
implements PluginPackageWorkflowAdmissionRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async findByPlanId(
|
||||
planIdValue: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowAdmissionReceipt> | null> {
|
||||
const planId = identity(planIdValue, 'planId');
|
||||
try {
|
||||
return (await findStored(this.pool, 'plan_id', planId))?.receipt ?? null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findByRunId(
|
||||
runIdValue: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowAdmissionReceipt> | null> {
|
||||
const runId = identity(runIdValue, 'runId');
|
||||
try {
|
||||
return (await findStored(this.pool, 'run_id', runId))?.receipt ?? null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findPlanByPlanId(
|
||||
planIdValue: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowExecutionPlan> | null> {
|
||||
const planId = identity(planIdValue, 'planId');
|
||||
try {
|
||||
return (await findStored(this.pool, 'plan_id', planId))?.plan ?? null;
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async admit(
|
||||
planValue: Readonly<PluginPackageWorkflowExecutionPlan>,
|
||||
transactionGuard?: PostgresPluginPackageWorkflowAdmissionTransactionGuard,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
|
||||
}>
|
||||
> {
|
||||
if (
|
||||
transactionGuard !== undefined &&
|
||||
typeof transactionGuard !== 'function'
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowExecutionPlanError(
|
||||
'transaction guard is invalid',
|
||||
);
|
||||
}
|
||||
const plan = normalizePluginPackageWorkflowExecutionPlan(planValue);
|
||||
const bundle = createPluginPackageWorkflowAdmissionBundle(plan);
|
||||
let client: PostgresClient | undefined;
|
||||
let began = false;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const existing = await findStored(client, 'plan_id', plan.planId);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.plan.planDigest !== plan.planDigest ||
|
||||
json(existing.plan) !== json(plan)
|
||||
) {
|
||||
throw new PluginPackageWorkflowAdmissionConflictError(
|
||||
'planId is already bound to another plan',
|
||||
);
|
||||
}
|
||||
await transactionGuard?.(
|
||||
Object.freeze({
|
||||
client,
|
||||
replay: true,
|
||||
plan: existing.plan,
|
||||
receipt: existing.receipt,
|
||||
}),
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
receipt: existing.receipt,
|
||||
});
|
||||
}
|
||||
await transactionGuard?.(
|
||||
Object.freeze({
|
||||
client,
|
||||
replay: false,
|
||||
plan,
|
||||
receipt: bundle.receipt,
|
||||
}),
|
||||
);
|
||||
const snapshot = await client.query<Row>(
|
||||
`SELECT publication_json AS "publicationJson",
|
||||
revision_json AS "revisionJson"
|
||||
FROM "ql3"."plugin_package_workflow_admission_snapshot"(
|
||||
$1, $2, $3
|
||||
)`,
|
||||
[
|
||||
plan.target.projectId,
|
||||
plan.target.packageName,
|
||||
plan.target.publicationDigest,
|
||||
],
|
||||
);
|
||||
if (snapshot.rows.length === 0) {
|
||||
throw new PluginPackageWorkflowAdmissionNotAllowedError();
|
||||
}
|
||||
if (snapshot.rows.length !== 1) throw unavailable();
|
||||
assertSnapshot(plan, snapshot.rows[0]!);
|
||||
await insertRun(client, bundle);
|
||||
await insertEvent(client, bundle.admissionEvent, null);
|
||||
await insertStepEvidence(client, bundle);
|
||||
await insertAdmission(client, bundle);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
receipt: bundle.receipt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client && began) await rollbackPostgresDefinitionTransaction(client);
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+736
@@ -0,0 +1,736 @@
|
||||
// PostgreSQL authority for advancing the Plugin Package Workflow frontier.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
RunRecord,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageWorkflowFrontierError,
|
||||
MAX_PLUGIN_PACKAGE_WORKFLOW_FRONTIER_PAGE_SIZE,
|
||||
PluginPackageWorkflowFrontierConflictError,
|
||||
PluginPackageWorkflowFrontierUnavailableError,
|
||||
resolvePluginPackageWorkflowFrontier,
|
||||
type PluginPackageWorkflowFrontierAdvanceResult,
|
||||
type PluginPackageWorkflowFrontierCandidate,
|
||||
type PluginPackageWorkflowFrontierCursor,
|
||||
type PluginPackageWorkflowFrontierPage,
|
||||
type PluginPackageWorkflowFrontierRepository,
|
||||
type PluginPackageWorkflowTerminalStatus,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-frontier';
|
||||
import {
|
||||
normalizePluginPackageWorkflowExecutionPlan,
|
||||
type PluginPackageWorkflowExecutionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
type StepRunMutation,
|
||||
type StepRunRecord,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredBoolean,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const TERMINAL_RUN_STATUSES = new Set<PluginPackageWorkflowTerminalStatus>([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'timed_out',
|
||||
]);
|
||||
|
||||
const RUN_SELECT = `
|
||||
id, project_id AS "projectId", task_id AS "taskId",
|
||||
task_revision AS "taskRevision", task_name AS "taskName",
|
||||
task_snapshot_ref AS "taskSnapshotRef", legacy_cron_id AS "legacyCronId",
|
||||
parent_run_id AS "parentRunId", retry_of_run_id AS "retryOfRunId",
|
||||
trigger_id AS "triggerId", trigger_type AS "triggerType",
|
||||
execution_origin AS "executionOrigin",
|
||||
execution_owner AS "executionOwner", triggered_by AS "triggeredBy",
|
||||
request_id AS "requestId", scheduled_for_ms AS "scheduledForMs",
|
||||
status, version, event_sequence AS "eventSequence", priority,
|
||||
idempotency_key AS "idempotencyKey", input_ref AS "inputRef",
|
||||
output_ref AS "outputRef", created_at_ms AS "createdAtMs",
|
||||
queued_at_ms AS "queuedAtMs", started_at_ms AS "startedAtMs",
|
||||
finished_at_ms AS "finishedAtMs",
|
||||
cancel_requested_at_ms AS "cancelRequestedAtMs",
|
||||
cancel_reason AS "cancelReason", error_code AS "errorCode",
|
||||
error_summary AS "errorSummary"
|
||||
`.trim();
|
||||
|
||||
const STEP_RUN_SELECT = `
|
||||
id, run_id AS "runId", parent_step_run_id AS "parentStepRunId",
|
||||
step_key AS "stepKey", kind, definition_ref AS "definitionRef",
|
||||
definition_digest AS "definitionDigest", required, status, version,
|
||||
attempt_count AS "attemptCount", input_ref AS "inputRef",
|
||||
output_ref AS "outputRef", approval_request_id AS "approvalRequestId",
|
||||
ready_at_ms AS "readyAtMs", started_at_ms AS "startedAtMs",
|
||||
finished_at_ms AS "finishedAtMs", result_code AS "resultCode",
|
||||
error_summary AS "errorSummary", created_at_ms AS "createdAtMs",
|
||||
updated_at_ms AS "updatedAtMs", last_mutation_id AS "lastMutationId",
|
||||
step_run_digest AS "stepRunDigest", step_run_json AS "stepRunJson"
|
||||
`.trim();
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackageWorkflowFrontierUnavailableError {
|
||||
return new PluginPackageWorkflowFrontierUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
return postgresRequiredString(row[key], unavailable);
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = postgresRequiredInteger(row[key], unavailable);
|
||||
if (value < 0) throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalText(row: Row, key: string): string | undefined {
|
||||
if (row[key] === null || row[key] === undefined) return undefined;
|
||||
return text(row, key);
|
||||
}
|
||||
|
||||
function optionalInteger(row: Row, key: string): number | undefined {
|
||||
if (row[key] === null || row[key] === undefined) return undefined;
|
||||
return integer(row, key);
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY.test(value)) {
|
||||
throw new InvalidPluginPackageWorkflowFrontierError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageWorkflowFrontierError ||
|
||||
error instanceof PluginPackageWorkflowFrontierConflictError ||
|
||||
error instanceof PluginPackageWorkflowFrontierUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
return ['23503', '23505', '23514'].includes(
|
||||
postgresSqlState(error) ?? '',
|
||||
)
|
||||
? new PluginPackageWorkflowFrontierConflictError()
|
||||
: unavailable(error);
|
||||
}
|
||||
|
||||
function pageLimit(value: unknown): number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > MAX_PLUGIN_PACKAGE_WORKFLOW_FRONTIER_PAGE_SIZE
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowFrontierError(
|
||||
`page limit must be between 1 and ${MAX_PLUGIN_PACKAGE_WORKFLOW_FRONTIER_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function cursor(
|
||||
value: Readonly<PluginPackageWorkflowFrontierCursor> | undefined,
|
||||
): Readonly<PluginPackageWorkflowFrontierCursor> | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Reflect.ownKeys(value).length !== 2 ||
|
||||
!Reflect.has(value, 'admittedAtMs') ||
|
||||
!Reflect.has(value, 'planDigest') ||
|
||||
!Number.isSafeInteger(value.admittedAtMs) ||
|
||||
value.admittedAtMs < 0 ||
|
||||
!DIGEST.test(value.planDigest)
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowFrontierError(
|
||||
'frontier cursor is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
admittedAtMs: value.admittedAtMs,
|
||||
planDigest: value.planDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function runFromRow(row: Row): Readonly<RunRecord> {
|
||||
const run: RunRecord = {
|
||||
id: text(row, 'id'),
|
||||
projectId: text(row, 'projectId'),
|
||||
taskId: text(row, 'taskId'),
|
||||
taskRevision: text(row, 'taskRevision'),
|
||||
triggerType: text(row, 'triggerType'),
|
||||
executionOrigin: text(
|
||||
row,
|
||||
'executionOrigin',
|
||||
) as RunRecord['executionOrigin'],
|
||||
executionOwner: text(row, 'executionOwner') as RunRecord['executionOwner'],
|
||||
status: text(row, 'status') as RunRecord['status'],
|
||||
version: integer(row, 'version'),
|
||||
eventSequence: integer(row, 'eventSequence'),
|
||||
priority: integer(row, 'priority'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
};
|
||||
const optionalTexts = [
|
||||
['taskName', 'taskName'],
|
||||
['taskSnapshotRef', 'taskSnapshotRef'],
|
||||
['parentRunId', 'parentRunId'],
|
||||
['retryOfRunId', 'retryOfRunId'],
|
||||
['triggerId', 'triggerId'],
|
||||
['triggeredBy', 'triggeredBy'],
|
||||
['requestId', 'requestId'],
|
||||
['idempotencyKey', 'idempotencyKey'],
|
||||
['inputRef', 'inputRef'],
|
||||
['outputRef', 'outputRef'],
|
||||
['errorCode', 'errorCode'],
|
||||
['errorSummary', 'errorSummary'],
|
||||
] as const;
|
||||
for (const [property, key] of optionalTexts) {
|
||||
const value = optionalText(row, key);
|
||||
if (value !== undefined) {
|
||||
(run as unknown as Record<string, unknown>)[property] = value;
|
||||
}
|
||||
}
|
||||
const optionalIntegers = [
|
||||
['legacyCronId', 'legacyCronId'],
|
||||
['scheduledForMs', 'scheduledForMs'],
|
||||
['queuedAtMs', 'queuedAtMs'],
|
||||
['startedAtMs', 'startedAtMs'],
|
||||
['finishedAtMs', 'finishedAtMs'],
|
||||
['cancelRequestedAtMs', 'cancelRequestedAtMs'],
|
||||
] as const;
|
||||
for (const [property, key] of optionalIntegers) {
|
||||
const value = optionalInteger(row, key);
|
||||
if (value !== undefined) {
|
||||
(run as unknown as Record<string, unknown>)[property] = value;
|
||||
}
|
||||
}
|
||||
const cancelReason = optionalText(row, 'cancelReason');
|
||||
if (cancelReason !== undefined) {
|
||||
run.cancelReason =
|
||||
cancelReason as NonNullable<RunRecord['cancelReason']>;
|
||||
}
|
||||
return Object.freeze(run);
|
||||
}
|
||||
|
||||
function stepRunFromRow(row: Row): Readonly<StepRunRecord> {
|
||||
let stepRun: Readonly<StepRunRecord>;
|
||||
try {
|
||||
stepRun = normalizeStepRunRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.stepRunJson,
|
||||
unavailable,
|
||||
) as unknown as StepRunRecord,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (
|
||||
text(row, 'id') !== stepRun.id ||
|
||||
text(row, 'runId') !== stepRun.runId ||
|
||||
optionalText(row, 'parentStepRunId') !==
|
||||
(stepRun.parentStepRunId ?? undefined) ||
|
||||
text(row, 'stepKey') !== stepRun.stepKey ||
|
||||
text(row, 'kind') !== stepRun.kind ||
|
||||
text(row, 'definitionRef') !== stepRun.definitionRef ||
|
||||
text(row, 'definitionDigest') !== stepRun.definitionDigest ||
|
||||
postgresRequiredBoolean(row.required, unavailable) !== stepRun.required ||
|
||||
text(row, 'status') !== stepRun.status ||
|
||||
integer(row, 'version') !== stepRun.version ||
|
||||
integer(row, 'attemptCount') !== stepRun.attemptCount ||
|
||||
optionalText(row, 'inputRef') !== (stepRun.inputRef ?? undefined) ||
|
||||
optionalText(row, 'outputRef') !== (stepRun.outputRef ?? undefined) ||
|
||||
optionalText(row, 'approvalRequestId') !==
|
||||
(stepRun.approvalRequestId ?? undefined) ||
|
||||
optionalInteger(row, 'readyAtMs') !==
|
||||
(stepRun.readyAtMs ?? undefined) ||
|
||||
optionalInteger(row, 'startedAtMs') !==
|
||||
(stepRun.startedAtMs ?? undefined) ||
|
||||
optionalInteger(row, 'finishedAtMs') !==
|
||||
(stepRun.finishedAtMs ?? undefined) ||
|
||||
optionalText(row, 'resultCode') !==
|
||||
(stepRun.resultCode ?? undefined) ||
|
||||
optionalText(row, 'errorSummary') !==
|
||||
(stepRun.errorSummary ?? undefined) ||
|
||||
integer(row, 'createdAtMs') !== stepRun.createdAtMs ||
|
||||
integer(row, 'updatedAtMs') !== stepRun.updatedAtMs ||
|
||||
text(row, 'lastMutationId') !== stepRun.lastMutationId ||
|
||||
text(row, 'stepRunDigest') !== stepRun.stepRunDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return stepRun;
|
||||
}
|
||||
|
||||
function assertRunIdentity(
|
||||
run: Readonly<RunRecord>,
|
||||
plan: Readonly<PluginPackageWorkflowExecutionPlan>,
|
||||
): void {
|
||||
if (
|
||||
run.id !== plan.runId ||
|
||||
run.projectId !== plan.target.projectId ||
|
||||
run.taskId !== plan.target.workflowId ||
|
||||
run.taskRevision !== plan.target.publicationDigest ||
|
||||
run.triggerType !== 'plugin_package_workflow' ||
|
||||
run.executionOrigin !== 'system' ||
|
||||
run.executionOwner !== 'runtime' ||
|
||||
run.requestId !== plan.planId ||
|
||||
run.idempotencyKey !== `plugin-package-workflow:${plan.planId}` ||
|
||||
run.version !== run.eventSequence
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStepRun(
|
||||
client: PostgresClient,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
): Promise<void> {
|
||||
const stepRun = mutation.stepRun;
|
||||
const updated = await client.query(
|
||||
`UPDATE "ql3"."step_runs"
|
||||
SET status = $1, version = $2, attempt_count = $3, output_ref = $4,
|
||||
approval_request_id = $5, ready_at_ms = $6, started_at_ms = $7,
|
||||
finished_at_ms = $8, result_code = $9, error_summary = $10,
|
||||
updated_at_ms = $11, last_mutation_id = $12,
|
||||
step_run_digest = $13, step_run_json = $14::jsonb
|
||||
WHERE id = $15 AND run_id = $16 AND version = $17
|
||||
AND step_run_digest = $18 AND status = $19`,
|
||||
[
|
||||
stepRun.status,
|
||||
stepRun.version,
|
||||
stepRun.attemptCount,
|
||||
stepRun.outputRef,
|
||||
stepRun.approvalRequestId,
|
||||
stepRun.readyAtMs,
|
||||
stepRun.startedAtMs,
|
||||
stepRun.finishedAtMs,
|
||||
stepRun.resultCode,
|
||||
stepRun.errorSummary,
|
||||
stepRun.updatedAtMs,
|
||||
stepRun.lastMutationId,
|
||||
stepRun.stepRunDigest,
|
||||
JSON.stringify(stepRun),
|
||||
stepRun.id,
|
||||
stepRun.runId,
|
||||
mutation.expectedStepRunVersion,
|
||||
mutation.expectedStepRunDigest,
|
||||
mutation.previousStatus,
|
||||
],
|
||||
);
|
||||
if (updated.rowCount !== 1) {
|
||||
throw new PluginPackageWorkflowFrontierConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
async function insertStepMutation(
|
||||
client: PostgresClient,
|
||||
mutation: Readonly<StepRunMutation>,
|
||||
committedAtMs: number,
|
||||
): Promise<void> {
|
||||
const event = mutation.event;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."run_events" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9::jsonb, $10)`,
|
||||
[
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
mutation.stepRun.id,
|
||||
JSON.stringify(event.payload),
|
||||
event.createdAtMs,
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."step_run_mutations" (
|
||||
mutation_id, mutation_digest, run_id, step_run_id, step_run_digest,
|
||||
event_id, event_sequence, run_version, step_run_json, committed_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
|
||||
[
|
||||
mutation.mutationId,
|
||||
mutation.mutationDigest,
|
||||
mutation.runId,
|
||||
mutation.stepRun.id,
|
||||
mutation.stepRun.stepRunDigest,
|
||||
event.id,
|
||||
event.sequence,
|
||||
mutation.expectedRunVersion + 1,
|
||||
JSON.stringify(mutation.stepRun),
|
||||
committedAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageWorkflowFrontierRepository
|
||||
implements PluginPackageWorkflowFrontierRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async listCandidates(queryValue: Readonly<{
|
||||
limit: number;
|
||||
after?: Readonly<PluginPackageWorkflowFrontierCursor>;
|
||||
}>): Promise<Readonly<PluginPackageWorkflowFrontierPage>> {
|
||||
if (
|
||||
!queryValue ||
|
||||
typeof queryValue !== 'object' ||
|
||||
Array.isArray(queryValue) ||
|
||||
!Reflect.has(queryValue, 'limit') ||
|
||||
Reflect.ownKeys(queryValue).some(
|
||||
(key) => key !== 'limit' && key !== 'after',
|
||||
)
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowFrontierError(
|
||||
'page query is invalid',
|
||||
);
|
||||
}
|
||||
const limit = pageLimit(queryValue.limit);
|
||||
const after = cursor(queryValue.after);
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT admission.run_id AS "runId",
|
||||
admission.plan_digest AS "planDigest",
|
||||
admission.admitted_at_ms AS "admittedAtMs"
|
||||
FROM "ql3"."plugin_package_workflow_admissions" AS admission
|
||||
JOIN "ql3"."runs" AS run ON run.id = admission.run_id
|
||||
WHERE run.status = 'running'
|
||||
AND run.cancel_requested_at_ms IS NULL
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_workflow_admission_steps" AS step
|
||||
JOIN "ql3"."step_runs" AS current
|
||||
ON current.run_id = step.run_id
|
||||
AND current.id = step.step_run_id
|
||||
WHERE step.plan_digest = admission.plan_digest
|
||||
AND current.status = 'pending'
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(step.needs_json)
|
||||
AS need(value)
|
||||
LEFT JOIN
|
||||
"ql3"."plugin_package_workflow_admission_steps"
|
||||
AS dependency_step
|
||||
ON dependency_step.plan_digest = step.plan_digest
|
||||
AND dependency_step.step_key = need.value
|
||||
LEFT JOIN "ql3"."step_runs" AS dependency
|
||||
ON dependency.run_id = dependency_step.run_id
|
||||
AND dependency.id = dependency_step.step_run_id
|
||||
WHERE dependency.id IS NULL
|
||||
OR dependency.status <> 'succeeded'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(step.needs_json)
|
||||
AS need(value)
|
||||
JOIN "ql3"."plugin_package_workflow_admission_steps"
|
||||
AS dependency_step
|
||||
ON dependency_step.plan_digest = step.plan_digest
|
||||
AND dependency_step.step_key = need.value
|
||||
JOIN "ql3"."step_runs" AS dependency
|
||||
ON dependency.run_id = dependency_step.run_id
|
||||
AND dependency.id = dependency_step.step_run_id
|
||||
WHERE dependency.status IN (
|
||||
'failed', 'skipped', 'cancelled', 'timed_out'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_workflow_admission_steps" AS step
|
||||
JOIN "ql3"."step_runs" AS current
|
||||
ON current.run_id = step.run_id
|
||||
AND current.id = step.step_run_id
|
||||
WHERE step.plan_digest = admission.plan_digest
|
||||
AND current.status NOT IN (
|
||||
'succeeded', 'failed', 'skipped', 'cancelled', 'timed_out'
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
$1::bigint IS NULL OR admission.admitted_at_ms > $1 OR
|
||||
(admission.admitted_at_ms = $1
|
||||
AND admission.plan_digest > $2)
|
||||
)
|
||||
ORDER BY admission.admitted_at_ms, admission.plan_digest
|
||||
LIMIT $3`,
|
||||
[
|
||||
after?.admittedAtMs ?? null,
|
||||
after?.planDigest ?? '',
|
||||
limit + 1,
|
||||
],
|
||||
);
|
||||
const mapped = result.rows.map(
|
||||
(row): Readonly<PluginPackageWorkflowFrontierCandidate> =>
|
||||
Object.freeze({
|
||||
runId: identity(text(row, 'runId'), 'candidate runId'),
|
||||
planDigest: digest(row.planDigest),
|
||||
admittedAtMs: integer(row, 'admittedAtMs'),
|
||||
}),
|
||||
);
|
||||
const truncated = mapped.length > limit;
|
||||
const candidates = Object.freeze(mapped.slice(0, limit));
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
admittedAtMs: last.admittedAtMs,
|
||||
planDigest: last.planDigest,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async advance(
|
||||
runIdValue: string,
|
||||
): Promise<Readonly<PluginPackageWorkflowFrontierAdvanceResult>> {
|
||||
const runId = identity(runIdValue, 'runId');
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const admission = await client.query<Row>(
|
||||
`SELECT plan_digest AS "planDigest", plan_json AS "planJson"
|
||||
FROM "ql3"."plugin_package_workflow_admissions"
|
||||
WHERE run_id = $1 LIMIT 2`,
|
||||
[runId],
|
||||
);
|
||||
if (admission.rows.length !== 1) {
|
||||
throw new PluginPackageWorkflowFrontierConflictError();
|
||||
}
|
||||
let plan: Readonly<PluginPackageWorkflowExecutionPlan>;
|
||||
try {
|
||||
plan = normalizePluginPackageWorkflowExecutionPlan(
|
||||
postgresRequiredJsonObject(
|
||||
admission.rows[0]!.planJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageWorkflowExecutionPlan,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (plan.planDigest !== digest(admission.rows[0]!.planDigest)) {
|
||||
throw unavailable();
|
||||
}
|
||||
const runRows = await client.query<Row>(
|
||||
`SELECT ${RUN_SELECT}
|
||||
FROM "ql3"."runs" WHERE id = $1 LIMIT 2 FOR UPDATE`,
|
||||
[runId],
|
||||
);
|
||||
if (runRows.rows.length !== 1) {
|
||||
throw new PluginPackageWorkflowFrontierConflictError();
|
||||
}
|
||||
const run = runFromRow(runRows.rows[0]!);
|
||||
assertRunIdentity(run, plan);
|
||||
const stepRows = await client.query<Row>(
|
||||
`SELECT ${STEP_RUN_SELECT}
|
||||
FROM "ql3"."step_runs" WHERE run_id = $1
|
||||
ORDER BY step_key, id FOR UPDATE`,
|
||||
[runId],
|
||||
);
|
||||
const stepRuns = stepRows.rows.map(stepRunFromRow);
|
||||
const clock = await client.query<Row>(
|
||||
`SELECT floor(
|
||||
extract(epoch FROM transaction_timestamp()) * 1000
|
||||
)::bigint AS "observedAtMs"`,
|
||||
);
|
||||
if (clock.rows.length !== 1) throw unavailable();
|
||||
const observedAtMs = integer(clock.rows[0]!, 'observedAtMs');
|
||||
const currentStatus = run.status;
|
||||
const resolution = resolvePluginPackageWorkflowFrontier({
|
||||
plan,
|
||||
run: {
|
||||
...run,
|
||||
...(TERMINAL_RUN_STATUSES.has(
|
||||
currentStatus as PluginPackageWorkflowTerminalStatus,
|
||||
)
|
||||
? { status: 'running' as const }
|
||||
: {}),
|
||||
},
|
||||
stepRuns,
|
||||
observedAtMs,
|
||||
});
|
||||
if (
|
||||
TERMINAL_RUN_STATUSES.has(
|
||||
currentStatus as PluginPackageWorkflowTerminalStatus,
|
||||
)
|
||||
) {
|
||||
if (
|
||||
resolution.stepMutations.length !== 0 ||
|
||||
resolution.terminalStatus !== currentStatus
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'settled' as const,
|
||||
runId,
|
||||
planDigest: plan.planDigest,
|
||||
stepMutationCount: 0,
|
||||
readyStepRunIds: Object.freeze([]),
|
||||
terminalStatus:
|
||||
currentStatus as PluginPackageWorkflowTerminalStatus,
|
||||
runVersion: run.version,
|
||||
runEventSequence: run.eventSequence,
|
||||
observedAtMs,
|
||||
});
|
||||
}
|
||||
if (currentStatus !== 'running') throw unavailable();
|
||||
const increment =
|
||||
resolution.stepMutations.length +
|
||||
(resolution.terminalTransition === null ? 0 : 1);
|
||||
if (increment === 0) {
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'unchanged' as const,
|
||||
runId,
|
||||
planDigest: plan.planDigest,
|
||||
stepMutationCount: 0,
|
||||
readyStepRunIds: resolution.readyStepRunIds,
|
||||
terminalStatus: null,
|
||||
runVersion: run.version,
|
||||
runEventSequence: run.eventSequence,
|
||||
observedAtMs,
|
||||
});
|
||||
}
|
||||
for (const mutation of resolution.stepMutations) {
|
||||
await updateStepRun(client, mutation);
|
||||
}
|
||||
const terminal = resolution.terminalTransition;
|
||||
const updatedRun = await client.query(
|
||||
`UPDATE "ql3"."runs"
|
||||
SET status = $1, version = version + $2,
|
||||
event_sequence = event_sequence + $2,
|
||||
finished_at_ms = $3, error_code = $4, error_summary = NULL
|
||||
WHERE id = $5 AND status = 'running'
|
||||
AND version = $6 AND event_sequence = $7`,
|
||||
[
|
||||
terminal?.status ?? 'running',
|
||||
increment,
|
||||
terminal?.finishedAtMs ?? null,
|
||||
terminal?.errorCode ?? null,
|
||||
runId,
|
||||
run.version,
|
||||
run.eventSequence,
|
||||
],
|
||||
);
|
||||
if (updatedRun.rowCount !== 1) {
|
||||
throw new PluginPackageWorkflowFrontierConflictError();
|
||||
}
|
||||
for (const mutation of resolution.stepMutations) {
|
||||
await insertStepMutation(client, mutation, observedAtMs);
|
||||
}
|
||||
if (terminal) {
|
||||
const event = terminal.event;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."run_events" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, NULL, NULL, $8::jsonb, $9
|
||||
)`,
|
||||
[
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
JSON.stringify(event.payload),
|
||||
event.createdAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: terminal ? ('terminal' as const) : ('advanced' as const),
|
||||
runId,
|
||||
planDigest: plan.planDigest,
|
||||
stepMutationCount: resolution.stepMutations.length,
|
||||
readyStepRunIds: terminal
|
||||
? Object.freeze([])
|
||||
: resolution.readyStepRunIds,
|
||||
terminalStatus: terminal?.status ?? null,
|
||||
runVersion: run.version + increment,
|
||||
runEventSequence: run.eventSequence + increment,
|
||||
observedAtMs,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state &&
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
+777
@@ -0,0 +1,777 @@
|
||||
// PostgreSQL authority for admitting Plugin Package Workflow task attempts.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
RunRecord,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizeClusterTaskExecutionRevision,
|
||||
type ClusterTaskExecutionRevision,
|
||||
} from '@qinglong/runtime-core/cluster-execution-revision';
|
||||
import {
|
||||
createPluginPackageWorkflowTaskAttemptAdmission,
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
MAX_PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_PAGE_SIZE,
|
||||
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt,
|
||||
PluginPackageWorkflowTaskAttemptAdmissionConflictError,
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionCandidate,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionCursor,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionPage,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionReceipt,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionRepository,
|
||||
type PluginPackageWorkflowTaskAttemptAdmissionResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-task-attempt-admission';
|
||||
import {
|
||||
normalizePluginPackageTaskReconciliationReceipt,
|
||||
type PluginPackageTaskReconciliationReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-task-reconciliation';
|
||||
import {
|
||||
normalizePluginPackageWorkflowExecutionPlan,
|
||||
type PluginPackageWorkflowExecutionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-workflow-execution-plan';
|
||||
import {
|
||||
normalizeStepRunRecord,
|
||||
type StepRunRecord,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredBoolean,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
const RUN_SELECT = `
|
||||
id, project_id AS "projectId", task_id AS "taskId",
|
||||
task_revision AS "taskRevision", task_name AS "taskName",
|
||||
task_snapshot_ref AS "taskSnapshotRef", legacy_cron_id AS "legacyCronId",
|
||||
parent_run_id AS "parentRunId", retry_of_run_id AS "retryOfRunId",
|
||||
trigger_id AS "triggerId", trigger_type AS "triggerType",
|
||||
execution_origin AS "executionOrigin",
|
||||
execution_owner AS "executionOwner", triggered_by AS "triggeredBy",
|
||||
request_id AS "requestId", scheduled_for_ms AS "scheduledForMs",
|
||||
status, version, event_sequence AS "eventSequence", priority,
|
||||
idempotency_key AS "idempotencyKey", input_ref AS "inputRef",
|
||||
output_ref AS "outputRef", created_at_ms AS "createdAtMs",
|
||||
queued_at_ms AS "queuedAtMs", started_at_ms AS "startedAtMs",
|
||||
finished_at_ms AS "finishedAtMs",
|
||||
cancel_requested_at_ms AS "cancelRequestedAtMs",
|
||||
cancel_reason AS "cancelReason", error_code AS "errorCode",
|
||||
error_summary AS "errorSummary"
|
||||
`.trim();
|
||||
|
||||
const STEP_RUN_SELECT = `
|
||||
id, run_id AS "runId", parent_step_run_id AS "parentStepRunId",
|
||||
step_key AS "stepKey", kind, definition_ref AS "definitionRef",
|
||||
definition_digest AS "definitionDigest", required, status, version,
|
||||
attempt_count AS "attemptCount", input_ref AS "inputRef",
|
||||
output_ref AS "outputRef", approval_request_id AS "approvalRequestId",
|
||||
ready_at_ms AS "readyAtMs", started_at_ms AS "startedAtMs",
|
||||
finished_at_ms AS "finishedAtMs", result_code AS "resultCode",
|
||||
error_summary AS "errorSummary", created_at_ms AS "createdAtMs",
|
||||
updated_at_ms AS "updatedAtMs", last_mutation_id AS "lastMutationId",
|
||||
step_run_digest AS "stepRunDigest", step_run_json AS "stepRunJson"
|
||||
`.trim();
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): PluginPackageWorkflowTaskAttemptAdmissionUnavailableError {
|
||||
return new PluginPackageWorkflowTaskAttemptAdmissionUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
return postgresRequiredString(row[key], unavailable);
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = postgresRequiredInteger(row[key], unavailable);
|
||||
if (value < 0) throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalText(row: Row, key: string): string | undefined {
|
||||
if (row[key] === null || row[key] === undefined) return undefined;
|
||||
return text(row, key);
|
||||
}
|
||||
|
||||
function optionalInteger(row: Row, key: string): number | undefined {
|
||||
if (row[key] === null || row[key] === undefined) return undefined;
|
||||
return integer(row, key);
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTITY.test(value)) {
|
||||
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) throw unavailable();
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowTaskAttemptAdmissionConflictError ||
|
||||
error instanceof
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
return ['23503', '23505', '23514'].includes(
|
||||
postgresSqlState(error) ?? '',
|
||||
)
|
||||
? new PluginPackageWorkflowTaskAttemptAdmissionConflictError()
|
||||
: unavailable(error);
|
||||
}
|
||||
|
||||
function pageLimit(value: unknown): number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) >
|
||||
MAX_PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_PAGE_SIZE
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(
|
||||
`page limit must be between 1 and ${MAX_PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function cursor(
|
||||
value:
|
||||
| Readonly<PluginPackageWorkflowTaskAttemptAdmissionCursor>
|
||||
| undefined,
|
||||
):
|
||||
| Readonly<PluginPackageWorkflowTaskAttemptAdmissionCursor>
|
||||
| undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Reflect.ownKeys(value).length !== 2 ||
|
||||
!Reflect.has(value, 'readyAtMs') ||
|
||||
!Reflect.has(value, 'stepRunId') ||
|
||||
!Number.isSafeInteger(value.readyAtMs) ||
|
||||
value.readyAtMs < 0 ||
|
||||
typeof value.stepRunId !== 'string' ||
|
||||
!IDENTITY.test(value.stepRunId)
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(
|
||||
'candidate cursor is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
readyAtMs: value.readyAtMs,
|
||||
stepRunId: value.stepRunId,
|
||||
});
|
||||
}
|
||||
|
||||
function runFromRow(row: Row): Readonly<RunRecord> {
|
||||
const run: RunRecord = {
|
||||
id: text(row, 'id'),
|
||||
projectId: text(row, 'projectId'),
|
||||
taskId: text(row, 'taskId'),
|
||||
taskRevision: text(row, 'taskRevision'),
|
||||
triggerType: text(row, 'triggerType'),
|
||||
executionOrigin: text(
|
||||
row,
|
||||
'executionOrigin',
|
||||
) as RunRecord['executionOrigin'],
|
||||
executionOwner: text(row, 'executionOwner') as RunRecord['executionOwner'],
|
||||
status: text(row, 'status') as RunRecord['status'],
|
||||
version: integer(row, 'version'),
|
||||
eventSequence: integer(row, 'eventSequence'),
|
||||
priority: integer(row, 'priority'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
};
|
||||
const optionalTexts = [
|
||||
['taskName', 'taskName'],
|
||||
['taskSnapshotRef', 'taskSnapshotRef'],
|
||||
['parentRunId', 'parentRunId'],
|
||||
['retryOfRunId', 'retryOfRunId'],
|
||||
['triggerId', 'triggerId'],
|
||||
['triggeredBy', 'triggeredBy'],
|
||||
['requestId', 'requestId'],
|
||||
['idempotencyKey', 'idempotencyKey'],
|
||||
['inputRef', 'inputRef'],
|
||||
['outputRef', 'outputRef'],
|
||||
['errorCode', 'errorCode'],
|
||||
['errorSummary', 'errorSummary'],
|
||||
] as const;
|
||||
for (const [property, key] of optionalTexts) {
|
||||
const value = optionalText(row, key);
|
||||
if (value !== undefined) {
|
||||
(run as unknown as Record<string, unknown>)[property] = value;
|
||||
}
|
||||
}
|
||||
const optionalIntegers = [
|
||||
['legacyCronId', 'legacyCronId'],
|
||||
['scheduledForMs', 'scheduledForMs'],
|
||||
['queuedAtMs', 'queuedAtMs'],
|
||||
['startedAtMs', 'startedAtMs'],
|
||||
['finishedAtMs', 'finishedAtMs'],
|
||||
['cancelRequestedAtMs', 'cancelRequestedAtMs'],
|
||||
] as const;
|
||||
for (const [property, key] of optionalIntegers) {
|
||||
const value = optionalInteger(row, key);
|
||||
if (value !== undefined) {
|
||||
(run as unknown as Record<string, unknown>)[property] = value;
|
||||
}
|
||||
}
|
||||
const cancelReason = optionalText(row, 'cancelReason');
|
||||
if (cancelReason !== undefined) {
|
||||
run.cancelReason =
|
||||
cancelReason as NonNullable<RunRecord['cancelReason']>;
|
||||
}
|
||||
return Object.freeze(run);
|
||||
}
|
||||
|
||||
function stepRunFromRow(row: Row): Readonly<StepRunRecord> {
|
||||
let stepRun: Readonly<StepRunRecord>;
|
||||
try {
|
||||
stepRun = normalizeStepRunRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.stepRunJson,
|
||||
unavailable,
|
||||
) as unknown as StepRunRecord,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
if (
|
||||
text(row, 'id') !== stepRun.id ||
|
||||
text(row, 'runId') !== stepRun.runId ||
|
||||
optionalText(row, 'parentStepRunId') !==
|
||||
(stepRun.parentStepRunId ?? undefined) ||
|
||||
text(row, 'stepKey') !== stepRun.stepKey ||
|
||||
text(row, 'kind') !== stepRun.kind ||
|
||||
text(row, 'definitionRef') !== stepRun.definitionRef ||
|
||||
text(row, 'definitionDigest') !== stepRun.definitionDigest ||
|
||||
postgresRequiredBoolean(row.required, unavailable) !== stepRun.required ||
|
||||
text(row, 'status') !== stepRun.status ||
|
||||
integer(row, 'version') !== stepRun.version ||
|
||||
integer(row, 'attemptCount') !== stepRun.attemptCount ||
|
||||
optionalText(row, 'inputRef') !== (stepRun.inputRef ?? undefined) ||
|
||||
optionalText(row, 'outputRef') !== (stepRun.outputRef ?? undefined) ||
|
||||
optionalText(row, 'approvalRequestId') !==
|
||||
(stepRun.approvalRequestId ?? undefined) ||
|
||||
optionalInteger(row, 'readyAtMs') !==
|
||||
(stepRun.readyAtMs ?? undefined) ||
|
||||
optionalInteger(row, 'startedAtMs') !==
|
||||
(stepRun.startedAtMs ?? undefined) ||
|
||||
optionalInteger(row, 'finishedAtMs') !==
|
||||
(stepRun.finishedAtMs ?? undefined) ||
|
||||
optionalText(row, 'resultCode') !==
|
||||
(stepRun.resultCode ?? undefined) ||
|
||||
optionalText(row, 'errorSummary') !==
|
||||
(stepRun.errorSummary ?? undefined) ||
|
||||
integer(row, 'createdAtMs') !== stepRun.createdAtMs ||
|
||||
integer(row, 'updatedAtMs') !== stepRun.updatedAtMs ||
|
||||
text(row, 'lastMutationId') !== stepRun.lastMutationId ||
|
||||
text(row, 'stepRunDigest') !== stepRun.stepRunDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return stepRun;
|
||||
}
|
||||
|
||||
function planFromRow(row: Row): Readonly<PluginPackageWorkflowExecutionPlan> {
|
||||
try {
|
||||
return normalizePluginPackageWorkflowExecutionPlan(
|
||||
postgresRequiredJsonObject(
|
||||
row.planJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageWorkflowExecutionPlan,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function reconciliationFromRow(
|
||||
row: Row,
|
||||
): Readonly<PluginPackageTaskReconciliationReceipt> {
|
||||
try {
|
||||
return normalizePluginPackageTaskReconciliationReceipt(
|
||||
postgresRequiredJsonObject(
|
||||
row.reconciliationJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageTaskReconciliationReceipt,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function executionFromRow(
|
||||
row: Row,
|
||||
): Readonly<ClusterTaskExecutionRevision> {
|
||||
try {
|
||||
const plan = postgresRequiredJsonObject(row.executionPlanJson, unavailable);
|
||||
const keys = Object.keys(plan);
|
||||
if (
|
||||
!keys.includes('command') ||
|
||||
!keys.includes('environment') ||
|
||||
keys.some(
|
||||
(key) =>
|
||||
![
|
||||
'command',
|
||||
'environment',
|
||||
'placement',
|
||||
'timeoutMs',
|
||||
'workingDirectory',
|
||||
].includes(key),
|
||||
)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return normalizeClusterTaskExecutionRevision({
|
||||
projectId: text(row, 'executionProjectId'),
|
||||
taskId: text(row, 'executionTaskId'),
|
||||
sourceRevision: integer(row, 'executionSourceRevision'),
|
||||
taskRevision: text(row, 'executionTaskRevision'),
|
||||
sourceContentDigest: text(row, 'executionSourceContentDigest'),
|
||||
executorType: text(
|
||||
row,
|
||||
'executionExecutorType',
|
||||
) as ClusterTaskExecutionRevision['executorType'],
|
||||
planSchema: text(
|
||||
row,
|
||||
'executionPlanSchema',
|
||||
) as ClusterTaskExecutionRevision['planSchema'],
|
||||
command: plan.command as ClusterTaskExecutionRevision['command'],
|
||||
environment:
|
||||
plan.environment as ClusterTaskExecutionRevision['environment'],
|
||||
...(plan.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: plan.workingDirectory as string }),
|
||||
...(plan.timeoutMs === undefined
|
||||
? {}
|
||||
: { timeoutMs: plan.timeoutMs as number }),
|
||||
...(plan.placement === undefined
|
||||
? {}
|
||||
: {
|
||||
placement:
|
||||
plan.placement as unknown as NonNullable<
|
||||
ClusterTaskExecutionRevision['placement']
|
||||
>,
|
||||
}),
|
||||
contentDigest: text(row, 'executionContentDigest'),
|
||||
createdAtMs: integer(row, 'executionCreatedAtMs'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function receiptFromRow(
|
||||
row: Row,
|
||||
): Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt> {
|
||||
try {
|
||||
const receipt =
|
||||
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt(
|
||||
postgresRequiredJsonObject(
|
||||
row.receiptJson,
|
||||
unavailable,
|
||||
) as unknown as PluginPackageWorkflowTaskAttemptAdmissionReceipt,
|
||||
);
|
||||
if (
|
||||
row.receiptDigest !== undefined &&
|
||||
text(row, 'receiptDigest') !== receipt.receiptDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageWorkflowTaskAttemptAdmissionRepository
|
||||
implements PluginPackageWorkflowTaskAttemptAdmissionRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (!pool || typeof pool.connect !== 'function') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL Workflow Task Attempt admission pool is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listCandidates(queryValue: Readonly<{
|
||||
limit: number;
|
||||
after?: Readonly<PluginPackageWorkflowTaskAttemptAdmissionCursor>;
|
||||
}>): Promise<
|
||||
Readonly<PluginPackageWorkflowTaskAttemptAdmissionPage>
|
||||
> {
|
||||
if (
|
||||
!queryValue ||
|
||||
typeof queryValue !== 'object' ||
|
||||
Array.isArray(queryValue) ||
|
||||
!Reflect.has(queryValue, 'limit') ||
|
||||
Reflect.ownKeys(queryValue).some(
|
||||
(key) => key !== 'limit' && key !== 'after',
|
||||
)
|
||||
) {
|
||||
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(
|
||||
'page query is invalid',
|
||||
);
|
||||
}
|
||||
const limit = pageLimit(queryValue.limit);
|
||||
const after = cursor(queryValue.after);
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
try {
|
||||
const result = await client.query<Row>(
|
||||
`SELECT current.run_id AS "runId",
|
||||
current.id AS "stepRunId",
|
||||
current.ready_at_ms AS "readyAtMs",
|
||||
admission.plan_digest AS "planDigest"
|
||||
FROM "ql3"."step_runs" AS current
|
||||
JOIN "ql3"."plugin_package_workflow_admission_steps" AS source
|
||||
ON source.run_id = current.run_id
|
||||
AND source.step_run_id = current.id
|
||||
JOIN "ql3"."plugin_package_workflow_admissions" AS admission
|
||||
ON admission.plan_digest = source.plan_digest
|
||||
AND admission.run_id = source.run_id
|
||||
JOIN "ql3"."runs" AS run ON run.id = current.run_id
|
||||
WHERE run.status = 'running'
|
||||
AND run.cancel_requested_at_ms IS NULL
|
||||
AND current.kind = 'task'
|
||||
AND current.status = 'ready'
|
||||
AND current.ready_at_ms IS NOT NULL
|
||||
AND current.attempt_count < 64
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "ql3"."plugin_package_workflow_task_attempt_admissions"
|
||||
AS task_attempt
|
||||
WHERE task_attempt.run_id = current.run_id
|
||||
AND task_attempt.step_run_id = current.id
|
||||
AND task_attempt.step_run_version = current.version
|
||||
)
|
||||
AND (
|
||||
$1::varchar IS NULL OR current.ready_at_ms > $2 OR
|
||||
(current.ready_at_ms = $2 AND current.id > $1)
|
||||
)
|
||||
ORDER BY current.ready_at_ms, current.id
|
||||
LIMIT $3`,
|
||||
[
|
||||
after?.stepRunId ?? null,
|
||||
after?.readyAtMs ?? 0,
|
||||
limit + 1,
|
||||
],
|
||||
);
|
||||
const mapped = result.rows.map(
|
||||
(row): Readonly<PluginPackageWorkflowTaskAttemptAdmissionCandidate> =>
|
||||
Object.freeze({
|
||||
runId: identity(text(row, 'runId'), 'candidate runId'),
|
||||
stepRunId: identity(
|
||||
text(row, 'stepRunId'),
|
||||
'candidate stepRunId',
|
||||
),
|
||||
readyAtMs: integer(row, 'readyAtMs'),
|
||||
planDigest: digest(row.planDigest),
|
||||
}),
|
||||
);
|
||||
const truncated = mapped.length > limit;
|
||||
const candidates = Object.freeze(mapped.slice(0, limit));
|
||||
const last = candidates.at(-1);
|
||||
return Object.freeze({
|
||||
candidates,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
readyAtMs: last.readyAtMs,
|
||||
stepRunId: last.stepRunId,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async admit(
|
||||
runIdValue: string,
|
||||
stepRunIdValue: string,
|
||||
): Promise<
|
||||
Readonly<PluginPackageWorkflowTaskAttemptAdmissionResult>
|
||||
> {
|
||||
const runId = identity(runIdValue, 'runId');
|
||||
const stepRunId = identity(stepRunIdValue, 'stepRunId');
|
||||
for (
|
||||
let transactionAttempt = 0;
|
||||
transactionAttempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
transactionAttempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw unavailable(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const runRows = await client.query<Row>(
|
||||
`SELECT ${RUN_SELECT}
|
||||
FROM "ql3"."runs" WHERE id = $1 LIMIT 2 FOR UPDATE`,
|
||||
[runId],
|
||||
);
|
||||
if (runRows.rows.length !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const run = runFromRow(runRows.rows[0]!);
|
||||
const stepRows = await client.query<Row>(
|
||||
`SELECT ${STEP_RUN_SELECT}
|
||||
FROM "ql3"."step_runs"
|
||||
WHERE run_id = $1 AND id = $2 LIMIT 2 FOR UPDATE`,
|
||||
[runId, stepRunId],
|
||||
);
|
||||
if (stepRows.rows.length !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const stepRun = stepRunFromRow(stepRows.rows[0]!);
|
||||
const existing = await client.query<Row>(
|
||||
`SELECT receipt_digest AS "receiptDigest",
|
||||
receipt_json AS "receiptJson"
|
||||
FROM "ql3"."plugin_package_workflow_task_attempt_admissions"
|
||||
WHERE run_id = $1 AND step_run_id = $2
|
||||
AND step_run_version = $3
|
||||
LIMIT 2`,
|
||||
[runId, stepRunId, stepRun.version],
|
||||
);
|
||||
if (existing.rows.length > 1) throw unavailable();
|
||||
if (existing.rows.length === 1) {
|
||||
const receipt = receiptFromRow(existing.rows[0]!);
|
||||
if (
|
||||
receipt.runId !== runId ||
|
||||
receipt.stepRunId !== stepRunId ||
|
||||
receipt.stepRunVersion !== stepRun.version ||
|
||||
receipt.stepRunDigest !== stepRun.stepRunDigest
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
receipt,
|
||||
});
|
||||
}
|
||||
if (stepRun.status !== 'ready') {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const snapshot = await client.query<Row>(
|
||||
`SELECT plan_json AS "planJson",
|
||||
reconciliation_json AS "reconciliationJson",
|
||||
execution_project_id AS "executionProjectId",
|
||||
execution_task_id AS "executionTaskId",
|
||||
execution_source_revision AS "executionSourceRevision",
|
||||
execution_task_revision AS "executionTaskRevision",
|
||||
execution_source_content_digest
|
||||
AS "executionSourceContentDigest",
|
||||
execution_executor_type AS "executionExecutorType",
|
||||
execution_plan_schema AS "executionPlanSchema",
|
||||
execution_plan_json AS "executionPlanJson",
|
||||
execution_content_digest AS "executionContentDigest",
|
||||
execution_created_at_ms AS "executionCreatedAtMs"
|
||||
FROM "ql3"."plugin_package_workflow_task_attempt_snapshot"($1, $2)`,
|
||||
[runId, stepRunId],
|
||||
);
|
||||
if (snapshot.rows.length !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const snapshotRow = snapshot.rows[0]!;
|
||||
const plan = planFromRow(snapshotRow);
|
||||
const taskReconciliation = reconciliationFromRow(snapshotRow);
|
||||
const execution = executionFromRow(snapshotRow);
|
||||
const clock = await client.query<Row>(
|
||||
`SELECT floor(
|
||||
extract(epoch FROM transaction_timestamp()) * 1000
|
||||
)::bigint AS "admittedAtMs"`,
|
||||
);
|
||||
if (clock.rows.length !== 1) throw unavailable();
|
||||
const attemptNumberRows = await client.query<Row>(
|
||||
`SELECT COALESCE(MAX(attempt), 0) + 1 AS "attemptNumber"
|
||||
FROM "ql3"."run_attempts" WHERE run_id = $1`,
|
||||
[runId],
|
||||
);
|
||||
if (attemptNumberRows.rows.length !== 1) throw unavailable();
|
||||
const bundle = createPluginPackageWorkflowTaskAttemptAdmission({
|
||||
plan,
|
||||
run,
|
||||
stepRun,
|
||||
taskReconciliation,
|
||||
execution,
|
||||
attemptNumber: integer(
|
||||
attemptNumberRows.rows[0]!,
|
||||
'attemptNumber',
|
||||
),
|
||||
admittedAtMs: integer(clock.rows[0]!, 'admittedAtMs'),
|
||||
});
|
||||
const updated = await client.query(
|
||||
`UPDATE "ql3"."runs"
|
||||
SET version = $1, event_sequence = $2
|
||||
WHERE id = $3 AND status = 'running'
|
||||
AND cancel_requested_at_ms IS NULL
|
||||
AND version = $4 AND event_sequence = $5`,
|
||||
[
|
||||
bundle.run.version,
|
||||
bundle.run.eventSequence,
|
||||
run.id,
|
||||
run.version,
|
||||
run.eventSequence,
|
||||
],
|
||||
);
|
||||
if (updated.rowCount !== 1) {
|
||||
throw new PluginPackageWorkflowTaskAttemptAdmissionConflictError();
|
||||
}
|
||||
const attempt = bundle.attempt;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."run_attempts" (
|
||||
id, run_id, step_run_id, attempt, status, executor_type,
|
||||
callback_sequence, created_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
attempt.id,
|
||||
attempt.runId,
|
||||
attempt.stepRunId ?? null,
|
||||
attempt.attempt,
|
||||
attempt.status,
|
||||
attempt.executorType,
|
||||
attempt.callbackSequence,
|
||||
attempt.createdAtMs,
|
||||
],
|
||||
);
|
||||
const event = bundle.event;
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."run_events" (
|
||||
id, run_id, sequence, type, dedupe_key, actor_type, actor_id,
|
||||
attempt_id, step_run_id, payload, created_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11
|
||||
)`,
|
||||
[
|
||||
event.id,
|
||||
event.runId,
|
||||
event.sequence,
|
||||
event.type,
|
||||
event.dedupeKey ?? null,
|
||||
event.actorType,
|
||||
event.actorId ?? null,
|
||||
event.attemptId ?? null,
|
||||
event.stepRunId ?? null,
|
||||
JSON.stringify(event.payload),
|
||||
event.createdAtMs,
|
||||
],
|
||||
);
|
||||
const receipt = bundle.receipt;
|
||||
await client.query(
|
||||
`INSERT INTO
|
||||
"ql3"."plugin_package_workflow_task_attempt_admissions" (
|
||||
receipt_digest, attempt_id, plan_digest, run_id,
|
||||
step_run_id, step_run_version, step_run_digest,
|
||||
generation_digest, resource_task_id,
|
||||
task_reconciliation_receipt_digest, project_id, task_id,
|
||||
source_revision, task_revision, task_definition_digest,
|
||||
executor_type, execution_digest, attempt_number, event_id,
|
||||
run_version, run_event_sequence, admitted_at_ms, receipt_json
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23::jsonb
|
||||
)`,
|
||||
[
|
||||
receipt.receiptDigest,
|
||||
receipt.attemptId,
|
||||
receipt.planDigest,
|
||||
receipt.runId,
|
||||
receipt.stepRunId,
|
||||
receipt.stepRunVersion,
|
||||
receipt.stepRunDigest,
|
||||
plan.target.generationDigest,
|
||||
receipt.resourceTaskId,
|
||||
receipt.taskReconciliationReceiptDigest,
|
||||
execution.projectId,
|
||||
receipt.taskId,
|
||||
execution.sourceRevision,
|
||||
receipt.taskRevision,
|
||||
receipt.taskDefinitionDigest,
|
||||
receipt.executorType,
|
||||
receipt.executionDigest,
|
||||
receipt.attemptNumber,
|
||||
receipt.eventId,
|
||||
receipt.runVersion,
|
||||
receipt.runEventSequence,
|
||||
receipt.admittedAtMs,
|
||||
JSON.stringify(receipt),
|
||||
],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: 'created' as const,
|
||||
receipt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state &&
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
|
||||
transactionAttempt + 1 <
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mapStorageError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user