mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
// PostgreSQL authorized administration authority for Task and Trigger definitions.
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidTaskDefinitionAdministrationReadError,
|
||||
TaskDefinitionAdministrationAuthorizationFenceConflictError,
|
||||
TaskDefinitionAdministrationMutationConflictError,
|
||||
TaskDefinitionAdministrationReadConflictError,
|
||||
normalizeAuthorizedTaskDefinitionInspection,
|
||||
normalizeAuthorizedTaskDefinitionList,
|
||||
normalizeAuthorizedTaskDefinitionRevisionMutation,
|
||||
type AuthorizedTaskDefinitionInspection,
|
||||
type AuthorizedTaskDefinitionList,
|
||||
type AuthorizedTaskDefinitionRevisionMutation,
|
||||
type TaskDefinitionAdministrationRepository,
|
||||
type TaskDefinitionAdministrationSource,
|
||||
} from '@qinglong/runtime-core/task-definition-administration';
|
||||
import {
|
||||
InvalidTriggerAdministrationReadError,
|
||||
TriggerAdministrationAuthorizationFenceConflictError,
|
||||
TriggerAdministrationMutationConflictError,
|
||||
TriggerAdministrationReadConflictError,
|
||||
normalizeAuthorizedTriggerInspection,
|
||||
normalizeAuthorizedTriggerList,
|
||||
normalizeAuthorizedTriggerRevisionMutation,
|
||||
type AuthorizedTriggerInspection,
|
||||
type AuthorizedTriggerList,
|
||||
type AuthorizedTriggerRevisionMutation,
|
||||
type TriggerAdministrationRepository,
|
||||
type TriggerAdministrationSource,
|
||||
} from '@qinglong/runtime-core/trigger-administration';
|
||||
import {
|
||||
TaskDefinitionUnavailableError,
|
||||
type TaskDefinitionPage,
|
||||
type TaskDefinitionRecord,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
TriggerUnavailableError,
|
||||
type TriggerPage,
|
||||
type TriggerRecord,
|
||||
} from '@qinglong/runtime-core/trigger';
|
||||
import {
|
||||
ADMINISTRATION_AUDIT_SELECT,
|
||||
auditFromRow,
|
||||
configureAdministrationTransaction,
|
||||
insertAdministrationAudit,
|
||||
requiredInteger,
|
||||
requiredString,
|
||||
retryableAdministrationError,
|
||||
rollbackAdministrationTransaction,
|
||||
sameAdministrationReplayAudit,
|
||||
type AdministrationAuditRow,
|
||||
type AdministrationRow,
|
||||
} from '../repository/administrationSupport';
|
||||
import {
|
||||
PostgresTaskDefinitionRepository,
|
||||
PostgresTaskDefinitionSource,
|
||||
} from './taskDefinitionRepository';
|
||||
import {
|
||||
PostgresTriggerRepository,
|
||||
PostgresTriggerSource,
|
||||
} from '../scheduling/triggerRepository';
|
||||
|
||||
type FenceConflict = new () => Error;
|
||||
type MutationConflict = new () => Error;
|
||||
type ReadConflict = new () => Error;
|
||||
type StorageUnavailable = new () => Error;
|
||||
type InvalidRead = new (message: string) => Error;
|
||||
|
||||
const AUTHORIZED_READ_TRANSACTION_ATTEMPTS = 3;
|
||||
|
||||
function assertPool(pool: PostgresPool): void {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError('PostgreSQL automation administration pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAuthorizationFence(
|
||||
client: PostgresClient,
|
||||
mutation: Readonly<{
|
||||
command: Readonly<{ projectId: string }>;
|
||||
actor: Readonly<{ type: string; id: string }>;
|
||||
fence: Readonly<{ projectVersion: number; bindingVersion: number | null }>;
|
||||
}>,
|
||||
FenceConflictError: FenceConflict,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const project = await client.query<AdministrationRow>(
|
||||
`SELECT status, version
|
||||
FROM "ql3"."projects"
|
||||
WHERE id = $1`,
|
||||
[mutation.command.projectId],
|
||||
);
|
||||
const binding = await client.query<AdministrationRow>(
|
||||
`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`,
|
||||
[
|
||||
mutation.command.projectId,
|
||||
mutation.actor.type,
|
||||
mutation.actor.id,
|
||||
],
|
||||
);
|
||||
if (
|
||||
project.rows.length !== 1 ||
|
||||
requiredString(project.rows[0]!, 'status') !== 'active' ||
|
||||
requiredInteger(project.rows[0]!, 'version') !==
|
||||
mutation.fence.projectVersion ||
|
||||
binding.rows.length !== 1 ||
|
||||
requiredString(binding.rows[0]!, 'state') !== 'active' ||
|
||||
requiredInteger(binding.rows[0]!, 'version') !==
|
||||
mutation.fence.bindingVersion
|
||||
) {
|
||||
throw new FenceConflictError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof FenceConflictError) throw error;
|
||||
throw new FenceConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmOrAppendAllowedAudit(
|
||||
client: PostgresClient,
|
||||
replay: boolean,
|
||||
audit: AuthorizedTaskDefinitionRevisionMutation['audit'],
|
||||
MutationConflictError: MutationConflict,
|
||||
): Promise<void> {
|
||||
const result = await client.query<AdministrationAuditRow>(
|
||||
`SELECT ${ADMINISTRATION_AUDIT_SELECT}
|
||||
FROM "ql3"."security_audit_events" AS audit
|
||||
WHERE audit.event_id = $1
|
||||
LIMIT 2`,
|
||||
[audit.eventId],
|
||||
);
|
||||
if (replay) {
|
||||
if (result.rows.length !== 1) throw new MutationConflictError();
|
||||
try {
|
||||
if (!sameAdministrationReplayAudit(auditFromRow(result.rows[0]!), audit)) {
|
||||
throw new MutationConflictError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof MutationConflictError) throw error;
|
||||
throw new MutationConflictError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result.rows.length !== 0) throw new MutationConflictError();
|
||||
await insertAdministrationAudit(client, audit);
|
||||
}
|
||||
|
||||
function clientReadPool(client: PostgresClient): PostgresPool {
|
||||
return Object.freeze({
|
||||
query: client.query.bind(client),
|
||||
async connect() {
|
||||
return client;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sqlState(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
async function confirmReadAuthorizationFence(
|
||||
client: PostgresClient,
|
||||
read: Readonly<{
|
||||
projectId: string;
|
||||
actor: Readonly<{ type: string; id: string }>;
|
||||
fence: Readonly<{ projectVersion: number; bindingVersion: number | null }>;
|
||||
}>,
|
||||
FenceConflictError: FenceConflict,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const project = await client.query<AdministrationRow>(
|
||||
`SELECT status, version
|
||||
FROM "ql3"."projects"
|
||||
WHERE id = $1
|
||||
LIMIT 1`,
|
||||
[read.projectId],
|
||||
);
|
||||
const binding = await client.query<AdministrationRow>(
|
||||
`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`,
|
||||
[read.projectId, read.actor.type, read.actor.id],
|
||||
);
|
||||
if (
|
||||
project.rows.length !== 1 ||
|
||||
requiredString(project.rows[0]!, 'status') !== 'active' ||
|
||||
requiredInteger(project.rows[0]!, 'version') !==
|
||||
read.fence.projectVersion ||
|
||||
binding.rows.length !== 1 ||
|
||||
requiredString(binding.rows[0]!, 'state') !== 'active' ||
|
||||
requiredInteger(binding.rows[0]!, 'version') !==
|
||||
read.fence.bindingVersion
|
||||
) {
|
||||
throw new FenceConflictError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof FenceConflictError) throw error;
|
||||
throw new FenceConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
async function assertFreshReadAudit(
|
||||
client: PostgresClient,
|
||||
eventId: string,
|
||||
ReadConflictError: ReadConflict,
|
||||
): Promise<void> {
|
||||
const result = await client.query<AdministrationRow>(
|
||||
`SELECT event_id
|
||||
FROM "ql3"."security_audit_events"
|
||||
WHERE event_id = $1
|
||||
LIMIT 2`,
|
||||
[eventId],
|
||||
);
|
||||
if (result.rows.length !== 0) throw new ReadConflictError();
|
||||
}
|
||||
|
||||
async function executeAuthorizedRead<TResult>(
|
||||
pool: PostgresPool,
|
||||
read: Readonly<{
|
||||
projectId: string;
|
||||
actor: Readonly<{ type: string; id: string }>;
|
||||
fence: Readonly<{ projectVersion: number; bindingVersion: number | null }>;
|
||||
audit: AuthorizedTaskDefinitionInspection['audit'];
|
||||
}>,
|
||||
FenceConflictError: FenceConflict,
|
||||
ReadConflictError: ReadConflict,
|
||||
InvalidReadError: InvalidRead,
|
||||
UnavailableError: StorageUnavailable,
|
||||
operation: (client: PostgresClient) => Promise<TResult>,
|
||||
): Promise<TResult> {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < AUTHORIZED_READ_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await pool.connect();
|
||||
} catch {
|
||||
throw new UnavailableError();
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configureAdministrationTransaction(client);
|
||||
began = true;
|
||||
await confirmReadAuthorizationFence(client, read, FenceConflictError);
|
||||
await assertFreshReadAudit(
|
||||
client,
|
||||
read.audit.eventId,
|
||||
ReadConflictError,
|
||||
);
|
||||
const result = await operation(client);
|
||||
await insertAdministrationAudit(client, read.audit);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (began) await rollbackAdministrationTransaction(client);
|
||||
if (
|
||||
error instanceof FenceConflictError ||
|
||||
error instanceof ReadConflictError ||
|
||||
error instanceof InvalidReadError ||
|
||||
error instanceof UnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (sqlState(error) === '23505') throw new ReadConflictError();
|
||||
if (
|
||||
retryableAdministrationError(error) &&
|
||||
attempt + 1 < AUTHORIZED_READ_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw new UnavailableError();
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw new UnavailableError();
|
||||
}
|
||||
|
||||
/** Least-privilege automation-manager Task mutation adapter. */
|
||||
export class PostgresTaskDefinitionAdministrationRepository
|
||||
implements
|
||||
TaskDefinitionAdministrationRepository,
|
||||
TaskDefinitionAdministrationSource
|
||||
{
|
||||
private readonly taskDefinitions: PostgresTaskDefinitionRepository;
|
||||
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
assertPool(pool);
|
||||
this.taskDefinitions = new PostgresTaskDefinitionRepository(pool);
|
||||
}
|
||||
|
||||
appendAuthorizedTaskDefinitionRevision(
|
||||
input: AuthorizedTaskDefinitionRevisionMutation,
|
||||
) {
|
||||
const mutation = normalizeAuthorizedTaskDefinitionRevisionMutation(input);
|
||||
return this.taskDefinitions.appendTaskDefinitionRevision(
|
||||
mutation.command,
|
||||
async (client, { replay }) => {
|
||||
await confirmAuthorizationFence(
|
||||
client,
|
||||
mutation,
|
||||
TaskDefinitionAdministrationAuthorizationFenceConflictError,
|
||||
);
|
||||
await confirmOrAppendAllowedAudit(
|
||||
client,
|
||||
replay !== null,
|
||||
mutation.audit,
|
||||
TaskDefinitionAdministrationMutationConflictError,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
findAuthorizedCurrentTaskDefinition(
|
||||
input: AuthorizedTaskDefinitionInspection,
|
||||
): Promise<TaskDefinitionRecord | null> {
|
||||
const inspection = normalizeAuthorizedTaskDefinitionInspection(input);
|
||||
return executeAuthorizedRead(
|
||||
this.pool,
|
||||
inspection,
|
||||
TaskDefinitionAdministrationAuthorizationFenceConflictError,
|
||||
TaskDefinitionAdministrationReadConflictError,
|
||||
InvalidTaskDefinitionAdministrationReadError,
|
||||
TaskDefinitionUnavailableError,
|
||||
(client) =>
|
||||
new PostgresTaskDefinitionSource(
|
||||
clientReadPool(client),
|
||||
).findCurrentTaskDefinition(inspection.projectId, inspection.taskId),
|
||||
);
|
||||
}
|
||||
|
||||
listAuthorizedTaskDefinitions(
|
||||
input: AuthorizedTaskDefinitionList,
|
||||
): Promise<TaskDefinitionPage> {
|
||||
const query = normalizeAuthorizedTaskDefinitionList(input);
|
||||
return executeAuthorizedRead(
|
||||
this.pool,
|
||||
query,
|
||||
TaskDefinitionAdministrationAuthorizationFenceConflictError,
|
||||
TaskDefinitionAdministrationReadConflictError,
|
||||
InvalidTaskDefinitionAdministrationReadError,
|
||||
TaskDefinitionUnavailableError,
|
||||
(client) =>
|
||||
new PostgresTaskDefinitionSource(clientReadPool(client)).listTaskDefinitions(
|
||||
{
|
||||
projectId: query.projectId,
|
||||
limit: query.limit,
|
||||
...(query.after ? { after: query.after } : {}),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Least-privilege automation-manager Trigger mutation adapter. */
|
||||
export class PostgresTriggerAdministrationRepository
|
||||
implements TriggerAdministrationRepository, TriggerAdministrationSource
|
||||
{
|
||||
private readonly triggers: PostgresTriggerRepository;
|
||||
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
assertPool(pool);
|
||||
this.triggers = new PostgresTriggerRepository(pool);
|
||||
}
|
||||
|
||||
appendAuthorizedTriggerRevision(input: AuthorizedTriggerRevisionMutation) {
|
||||
const mutation = normalizeAuthorizedTriggerRevisionMutation(input);
|
||||
return this.triggers.appendTriggerRevision(
|
||||
mutation.command,
|
||||
async (client, { replay }) => {
|
||||
await confirmAuthorizationFence(
|
||||
client,
|
||||
mutation,
|
||||
TriggerAdministrationAuthorizationFenceConflictError,
|
||||
);
|
||||
await confirmOrAppendAllowedAudit(
|
||||
client,
|
||||
replay !== null,
|
||||
mutation.audit,
|
||||
TriggerAdministrationMutationConflictError,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
findAuthorizedCurrentTrigger(
|
||||
input: AuthorizedTriggerInspection,
|
||||
): Promise<TriggerRecord | null> {
|
||||
const inspection = normalizeAuthorizedTriggerInspection(input);
|
||||
return executeAuthorizedRead(
|
||||
this.pool,
|
||||
inspection,
|
||||
TriggerAdministrationAuthorizationFenceConflictError,
|
||||
TriggerAdministrationReadConflictError,
|
||||
InvalidTriggerAdministrationReadError,
|
||||
TriggerUnavailableError,
|
||||
(client) =>
|
||||
new PostgresTriggerSource(clientReadPool(client)).findCurrentTrigger(
|
||||
inspection.projectId,
|
||||
inspection.triggerId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
listAuthorizedTriggers(input: AuthorizedTriggerList): Promise<TriggerPage> {
|
||||
const query = normalizeAuthorizedTriggerList(input);
|
||||
return executeAuthorizedRead(
|
||||
this.pool,
|
||||
query,
|
||||
TriggerAdministrationAuthorizationFenceConflictError,
|
||||
TriggerAdministrationReadConflictError,
|
||||
InvalidTriggerAdministrationReadError,
|
||||
TriggerUnavailableError,
|
||||
(client) =>
|
||||
new PostgresTriggerSource(clientReadPool(client)).listTriggers({
|
||||
projectId: query.projectId,
|
||||
limit: query.limit,
|
||||
...(query.after ? { after: query.after } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
// PostgreSQL authority for Task Definition publication and execution revisions.
|
||||
import {
|
||||
InvalidTaskDefinitionError,
|
||||
TaskDefinitionConflictError,
|
||||
TaskDefinitionUnavailableError,
|
||||
assertTaskDefinitionIdentifier,
|
||||
assertTaskDefinitionPageSize,
|
||||
assertTaskDefinitionRevision,
|
||||
createTaskDefinitionRecord,
|
||||
normalizeAppendTaskDefinitionRevisionCommand,
|
||||
normalizeTaskDefinitionCursor,
|
||||
normalizeTaskDefinitionRecord,
|
||||
type AppendTaskDefinitionRevisionCommand,
|
||||
type TaskDefinitionPage,
|
||||
type TaskDefinitionRecord,
|
||||
type TaskDefinitionRepository,
|
||||
type TaskDefinitionSource,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
TaskSpecSemanticRegistry,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} from '@qinglong/runtime-core/task-spec-semantic';
|
||||
import {
|
||||
compileClusterCommandTaskDefinition,
|
||||
normalizeClusterTaskExecutionRevision,
|
||||
type ClusterTaskExecutionRevision,
|
||||
type ClusterTaskExecutionRevisionSource,
|
||||
} from '@qinglong/runtime-core/cluster-execution-revision';
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
PostgresQueryResult,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredBoolean,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../repository/definitionRepositorySupport';
|
||||
|
||||
type TaskDefinitionRow = Record<string, unknown>;
|
||||
type Queryable = Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>;
|
||||
|
||||
export interface PostgresTaskDefinitionRevisionTransactionContext {
|
||||
readonly command: Readonly<AppendTaskDefinitionRevisionCommand>;
|
||||
readonly replay: Readonly<TaskDefinitionRecord> | null;
|
||||
readonly record: Readonly<TaskDefinitionRecord>;
|
||||
}
|
||||
|
||||
export type PostgresTaskDefinitionRevisionTransactionHook = (
|
||||
client: PostgresClient,
|
||||
context: Readonly<PostgresTaskDefinitionRevisionTransactionContext>,
|
||||
) => Promise<void>;
|
||||
|
||||
const SELECT_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(): TaskDefinitionUnavailableError {
|
||||
return new TaskDefinitionUnavailableError();
|
||||
}
|
||||
|
||||
function taskDefinitionRecord(
|
||||
row: TaskDefinitionRow,
|
||||
): 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 TaskDefinitionUnavailableError) throw error;
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function sameRecord(
|
||||
left: TaskDefinitionRecord,
|
||||
right: TaskDefinitionRecord,
|
||||
): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function executionPlanJson(
|
||||
revision: ClusterTaskExecutionRevision,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return Object.freeze({
|
||||
command: revision.command,
|
||||
environment: revision.environment,
|
||||
...(revision.workingDirectory === undefined
|
||||
? {}
|
||||
: { workingDirectory: revision.workingDirectory }),
|
||||
...(revision.timeoutMs === undefined
|
||||
? {}
|
||||
: { timeoutMs: revision.timeoutMs }),
|
||||
...(revision.placement === undefined
|
||||
? {}
|
||||
: { placement: revision.placement }),
|
||||
});
|
||||
}
|
||||
|
||||
function executionRevision(row: TaskDefinitionRow): ClusterTaskExecutionRevision {
|
||||
try {
|
||||
const plan = postgresRequiredJsonObject(row.planJson, 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: postgresRequiredString(row.projectId, unavailable),
|
||||
taskId: postgresRequiredString(row.taskId, unavailable),
|
||||
sourceRevision: postgresRequiredInteger(row.sourceRevision, unavailable),
|
||||
taskRevision: postgresRequiredString(row.taskRevision, unavailable),
|
||||
sourceContentDigest: postgresRequiredString(
|
||||
row.sourceContentDigest,
|
||||
unavailable,
|
||||
),
|
||||
executorType: postgresRequiredString(
|
||||
row.executorType,
|
||||
unavailable,
|
||||
) as ClusterTaskExecutionRevision['executorType'],
|
||||
planSchema: postgresRequiredString(
|
||||
row.planSchema,
|
||||
unavailable,
|
||||
) 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: postgresRequiredString(row.contentDigest, unavailable),
|
||||
createdAtMs: postgresRequiredInteger(row.createdAtMs, unavailable),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TaskDefinitionUnavailableError) throw error;
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
export async function findExecutionRevision(
|
||||
queryable: Queryable,
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
sourceRevision: number,
|
||||
): Promise<ClusterTaskExecutionRevision | null> {
|
||||
const result = await queryable.query<TaskDefinitionRow>(
|
||||
`SELECT project_id AS "projectId", task_id AS "taskId",
|
||||
source_revision AS "sourceRevision",
|
||||
task_revision AS "taskRevision",
|
||||
source_content_digest AS "sourceContentDigest",
|
||||
executor_type AS "executorType", plan_schema AS "planSchema",
|
||||
plan_json AS "planJson", content_digest AS "contentDigest",
|
||||
created_at_ms AS "createdAtMs"
|
||||
FROM "ql3"."task_execution_revisions"
|
||||
WHERE project_id = $1 AND task_id = $2 AND source_revision = $3
|
||||
AND executor_type = 'remote_worker'
|
||||
LIMIT 2`,
|
||||
[projectId, taskId, sourceRevision],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return executionRevision(result.rows[0]!);
|
||||
}
|
||||
|
||||
function sameExecutionRevision(
|
||||
left: ClusterTaskExecutionRevision | null,
|
||||
right: ClusterTaskExecutionRevision | null,
|
||||
): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function mappedError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof TaskDefinitionConflictError ||
|
||||
error instanceof TaskDefinitionUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new TaskDefinitionConflictError();
|
||||
}
|
||||
return unavailable();
|
||||
}
|
||||
|
||||
async function findCurrent(
|
||||
queryable: Queryable,
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
): Promise<TaskDefinitionRecord | null> {
|
||||
const result = await queryable.query<TaskDefinitionRow>(
|
||||
`SELECT ${SELECT_FIELDS}
|
||||
FROM "ql3"."task_definitions" AS head
|
||||
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
|
||||
WHERE head.project_id = $1 AND head.task_id = $2
|
||||
LIMIT 2`,
|
||||
[projectId, taskId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return taskDefinitionRecord(result.rows[0]!);
|
||||
}
|
||||
|
||||
async function findRevision(
|
||||
queryable: Queryable,
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
revision: number,
|
||||
): Promise<TaskDefinitionRecord | null> {
|
||||
const result = await queryable.query<TaskDefinitionRow>(
|
||||
`SELECT ${SELECT_FIELDS}
|
||||
FROM "ql3"."task_definitions" AS head
|
||||
JOIN "ql3"."task_definition_revisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
WHERE head.project_id = $1 AND head.task_id = $2
|
||||
AND revision.revision = $3
|
||||
LIMIT 2`,
|
||||
[projectId, taskId, revision],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return taskDefinitionRecord(result.rows[0]!);
|
||||
}
|
||||
|
||||
async function findByMutation(
|
||||
queryable: Queryable,
|
||||
mutationId: string,
|
||||
): Promise<TaskDefinitionRecord | null> {
|
||||
const result = await queryable.query<TaskDefinitionRow>(
|
||||
`SELECT ${SELECT_FIELDS}
|
||||
FROM "ql3"."task_definition_revisions" AS revision
|
||||
JOIN "ql3"."task_definitions" AS head
|
||||
ON head.project_id = revision.project_id
|
||||
AND head.task_id = revision.task_id
|
||||
WHERE revision.mutation_id = $1
|
||||
LIMIT 2`,
|
||||
[mutationId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return taskDefinitionRecord(result.rows[0]!);
|
||||
}
|
||||
|
||||
/** Read-only PostgreSQL TaskDefinition port for the runtime role. */
|
||||
export class PostgresTaskDefinitionSource implements TaskDefinitionSource {
|
||||
constructor(protected readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError('PostgreSQL TaskDefinition pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async findCurrentTaskDefinition(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
): Promise<TaskDefinitionRecord | null> {
|
||||
assertTaskDefinitionIdentifier(projectId, 'projectId');
|
||||
assertTaskDefinitionIdentifier(taskId, 'taskId');
|
||||
try {
|
||||
return await findCurrent(this.pool, projectId, taskId);
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findTaskDefinitionRevision(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
revision: number,
|
||||
): Promise<TaskDefinitionRecord | null> {
|
||||
assertTaskDefinitionIdentifier(projectId, 'projectId');
|
||||
assertTaskDefinitionIdentifier(taskId, 'taskId');
|
||||
assertTaskDefinitionRevision(revision);
|
||||
try {
|
||||
return await findRevision(this.pool, projectId, taskId, revision);
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listTaskDefinitions(options: {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: { readonly taskId: string };
|
||||
}): Promise<TaskDefinitionPage> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new InvalidTaskDefinitionError('list options are invalid');
|
||||
}
|
||||
const keys = Object.keys(options);
|
||||
if (
|
||||
!keys.includes('limit') ||
|
||||
!keys.includes('projectId') ||
|
||||
keys.some((key) => !['after', 'limit', 'projectId'].includes(key))
|
||||
) {
|
||||
throw new InvalidTaskDefinitionError(
|
||||
'list options have an invalid shape',
|
||||
);
|
||||
}
|
||||
assertTaskDefinitionIdentifier(options.projectId, 'projectId');
|
||||
assertTaskDefinitionPageSize(options.limit);
|
||||
const after = options.after
|
||||
? normalizeTaskDefinitionCursor(options.after)
|
||||
: undefined;
|
||||
try {
|
||||
const result = await this.pool.query<TaskDefinitionRow>(
|
||||
`SELECT ${SELECT_FIELDS}
|
||||
FROM "ql3"."task_definitions" AS head
|
||||
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
|
||||
WHERE head.project_id = $1 AND head.task_id > $2
|
||||
ORDER BY head.task_id
|
||||
LIMIT $3`,
|
||||
[options.projectId, after?.taskId ?? '', options.limit + 1],
|
||||
);
|
||||
const truncated = result.rows.length > options.limit;
|
||||
const definitions = Object.freeze(
|
||||
result.rows.slice(0, options.limit).map(taskDefinitionRecord),
|
||||
);
|
||||
const last = definitions.at(-1);
|
||||
return Object.freeze({
|
||||
definitions,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? { next: Object.freeze({ taskId: last.taskId }) }
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only, digest-verifying remote Worker execution revision source. */
|
||||
export class PostgresTaskExecutionRevisionSource
|
||||
implements ClusterTaskExecutionRevisionSource
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError('PostgreSQL Task execution revision pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async resolveClusterTaskExecutionRevision(identity: {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly sourceRevision: number;
|
||||
}): Promise<ClusterTaskExecutionRevision | null> {
|
||||
if (!identity || typeof identity !== 'object' || Array.isArray(identity)) {
|
||||
throw new TypeError('Cluster Task execution revision identity is invalid');
|
||||
}
|
||||
assertTaskDefinitionIdentifier(identity.projectId, 'projectId');
|
||||
assertTaskDefinitionIdentifier(identity.taskId, 'taskId');
|
||||
assertTaskDefinitionRevision(identity.sourceRevision);
|
||||
try {
|
||||
return await findExecutionRevision(
|
||||
this.pool,
|
||||
identity.projectId,
|
||||
identity.taskId,
|
||||
identity.sourceRevision,
|
||||
);
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Administration-only TaskDefinition publisher; execution revisions are separate. */
|
||||
export class PostgresTaskDefinitionRepository
|
||||
extends PostgresTaskDefinitionSource
|
||||
implements TaskDefinitionRepository
|
||||
{
|
||||
constructor(
|
||||
pool: PostgresPool,
|
||||
private readonly semanticRegistry: TaskSpecSemanticRegistry =
|
||||
createBuiltInTaskSpecSemanticRegistry(),
|
||||
) {
|
||||
super(pool);
|
||||
}
|
||||
|
||||
async appendTaskDefinitionRevision(
|
||||
input: AppendTaskDefinitionRevisionCommand,
|
||||
transactionHook?: PostgresTaskDefinitionRevisionTransactionHook,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'updated' | 'existing';
|
||||
definition: TaskDefinitionRecord;
|
||||
}>
|
||||
> {
|
||||
if (
|
||||
transactionHook !== undefined &&
|
||||
typeof transactionHook !== 'function'
|
||||
) {
|
||||
throw new InvalidTaskDefinitionError('transaction hook is invalid');
|
||||
}
|
||||
const normalized = normalizeAppendTaskDefinitionRevisionCommand(input);
|
||||
const command = Object.freeze({
|
||||
...normalized,
|
||||
spec: this.semanticRegistry.normalize({
|
||||
projectId: normalized.projectId,
|
||||
taskId: normalized.taskId,
|
||||
kind: normalized.kind,
|
||||
spec: normalized.spec,
|
||||
}),
|
||||
});
|
||||
const compiledExecution =
|
||||
command.enabled &&
|
||||
command.kind === 'command' &&
|
||||
command.spec.schema === BUILT_IN_COMMAND_TASK_SPEC_SCHEMA
|
||||
? compileClusterCommandTaskDefinition(
|
||||
createTaskDefinitionRecord(
|
||||
command,
|
||||
command.occurredAtMs,
|
||||
),
|
||||
this.semanticRegistry,
|
||||
)
|
||||
: null;
|
||||
|
||||
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;
|
||||
let transactionHookError: unknown;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const ownership = await client.query(
|
||||
`SELECT 1
|
||||
FROM "ql3"."plugin_package_task_ownerships"
|
||||
WHERE project_id = $1 AND task_id = $2`,
|
||||
[command.projectId, command.taskId],
|
||||
);
|
||||
if (ownership.rows.length !== 0) {
|
||||
throw new TaskDefinitionConflictError();
|
||||
}
|
||||
const replay = await findByMutation(client, command.mutationId);
|
||||
if (replay) {
|
||||
const expected = createTaskDefinitionRecord(
|
||||
command,
|
||||
replay.createdAtMs,
|
||||
);
|
||||
if (!sameRecord(replay, expected)) {
|
||||
throw new TaskDefinitionConflictError();
|
||||
}
|
||||
const persistedExecution = await findExecutionRevision(
|
||||
client,
|
||||
replay.projectId,
|
||||
replay.taskId,
|
||||
replay.revision,
|
||||
);
|
||||
if (!sameExecutionRevision(persistedExecution, compiledExecution)) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (transactionHook) {
|
||||
try {
|
||||
const hookResult = await transactionHook(
|
||||
client,
|
||||
Object.freeze({ command, replay, record: replay }),
|
||||
);
|
||||
if (hookResult !== undefined) {
|
||||
throw new InvalidTaskDefinitionError(
|
||||
'transaction hook must not return a value',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
transactionHookError = error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({ status: 'existing', definition: replay });
|
||||
}
|
||||
|
||||
const project = await client.query<{ status: unknown }>(
|
||||
`SELECT status FROM "ql3"."projects"
|
||||
WHERE id = $1`,
|
||||
[command.projectId],
|
||||
);
|
||||
if (
|
||||
project.rows.length !== 1 ||
|
||||
project.rows[0]?.status !== 'active'
|
||||
) {
|
||||
throw new TaskDefinitionConflictError();
|
||||
}
|
||||
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO "ql3"."task_definitions" (
|
||||
project_id, task_id, current_revision, created_at_ms, updated_at_ms
|
||||
) VALUES ($1, $2, 1, $3, $3)
|
||||
ON CONFLICT (project_id, task_id) DO NOTHING
|
||||
RETURNING task_id`,
|
||||
[command.projectId, command.taskId, command.occurredAtMs],
|
||||
);
|
||||
if (inserted.rows.length > 1) throw unavailable();
|
||||
const created = inserted.rows.length === 1;
|
||||
const head = await client.query<TaskDefinitionRow>(
|
||||
`SELECT current_revision AS "currentRevision",
|
||||
created_at_ms AS "createdAtMs",
|
||||
updated_at_ms AS "updatedAtMs"
|
||||
FROM "ql3"."task_definitions"
|
||||
WHERE project_id = $1 AND task_id = $2
|
||||
FOR UPDATE`,
|
||||
[command.projectId, command.taskId],
|
||||
);
|
||||
if (head.rows.length !== 1) throw unavailable();
|
||||
const currentRevision = postgresRequiredInteger(
|
||||
head.rows[0]!.currentRevision,
|
||||
unavailable,
|
||||
);
|
||||
const createdAtMs = postgresRequiredInteger(
|
||||
head.rows[0]!.createdAtMs,
|
||||
unavailable,
|
||||
);
|
||||
const previousUpdatedAtMs = postgresRequiredInteger(
|
||||
head.rows[0]!.updatedAtMs,
|
||||
unavailable,
|
||||
);
|
||||
if (
|
||||
(created ? command.expectedRevision !== null :
|
||||
currentRevision !== command.expectedRevision) ||
|
||||
command.occurredAtMs < previousUpdatedAtMs
|
||||
) {
|
||||
throw new TaskDefinitionConflictError();
|
||||
}
|
||||
const definition = createTaskDefinitionRecord(command, createdAtMs);
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."task_definition_revisions" (
|
||||
project_id, task_id, revision, mutation_id, name, description,
|
||||
kind, spec_json, labels_json, enabled, content_digest, created_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb,
|
||||
$10, $11, $12)`,
|
||||
[
|
||||
definition.projectId,
|
||||
definition.taskId,
|
||||
definition.revision,
|
||||
definition.mutationId,
|
||||
definition.name,
|
||||
definition.description ?? null,
|
||||
definition.kind,
|
||||
JSON.stringify(definition.spec),
|
||||
JSON.stringify(definition.labels),
|
||||
definition.enabled,
|
||||
definition.contentDigest,
|
||||
definition.updatedAtMs,
|
||||
],
|
||||
);
|
||||
if (compiledExecution) {
|
||||
await client.query(
|
||||
`INSERT INTO "ql3"."task_execution_revisions" (
|
||||
project_id, task_id, source_revision, task_revision,
|
||||
source_content_digest, executor_type, plan_schema, plan_json,
|
||||
content_digest, created_at_ms
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10)`,
|
||||
[
|
||||
compiledExecution.projectId,
|
||||
compiledExecution.taskId,
|
||||
compiledExecution.sourceRevision,
|
||||
compiledExecution.taskRevision,
|
||||
compiledExecution.sourceContentDigest,
|
||||
compiledExecution.executorType,
|
||||
compiledExecution.planSchema,
|
||||
JSON.stringify(executionPlanJson(compiledExecution)),
|
||||
compiledExecution.contentDigest,
|
||||
compiledExecution.createdAtMs,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (!created) {
|
||||
const update = await client.query(
|
||||
`UPDATE "ql3"."task_definitions"
|
||||
SET current_revision = $1, updated_at_ms = $2
|
||||
WHERE project_id = $3 AND task_id = $4
|
||||
AND current_revision = $5`,
|
||||
[
|
||||
definition.revision,
|
||||
definition.updatedAtMs,
|
||||
definition.projectId,
|
||||
definition.taskId,
|
||||
command.expectedRevision,
|
||||
],
|
||||
);
|
||||
if (update.rowCount !== 1) throw new TaskDefinitionConflictError();
|
||||
}
|
||||
if (transactionHook) {
|
||||
try {
|
||||
const hookResult = await transactionHook(
|
||||
client,
|
||||
Object.freeze({ command, replay: null, record: definition }),
|
||||
);
|
||||
if (hookResult !== undefined) {
|
||||
throw new InvalidTaskDefinitionError(
|
||||
'transaction hook must not return a value',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
transactionHookError = error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return Object.freeze({
|
||||
status: created ? 'created' : 'updated',
|
||||
definition,
|
||||
});
|
||||
} 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;
|
||||
}
|
||||
if (error === transactionHookError && error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw mappedError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user