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:
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
normalizeLocalExecutionContextRecipe,
|
||||
normalizeLocalTaskExecutionRevision,
|
||||
type LocalExecutionContextRecipe,
|
||||
type LocalTaskExecutionRevision,
|
||||
} from '@qinglong/runtime-core/local-dispatch';
|
||||
import type { LocalCommandTaskExecutionPlan } from '@qinglong/runtime-core/task-definition-execution-compiler';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export class LocalSqliteDispatchDefinitionConflictError extends Error {
|
||||
constructor() {
|
||||
super('Local SQLite dispatch definition identity already has other content');
|
||||
this.name = 'LocalSqliteDispatchDefinitionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
function sqliteConstraint(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
if (
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
'errcode' in error &&
|
||||
Number.isSafeInteger(error.errcode) &&
|
||||
((error.errcode as number) & 0xff) === 19
|
||||
);
|
||||
}
|
||||
|
||||
function requiredString(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeError(`Stored local dispatch ${key} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(row: Row, key: string): string | undefined {
|
||||
const value = row[key];
|
||||
if (value === null) return undefined;
|
||||
return requiredString(row, key);
|
||||
}
|
||||
|
||||
function requiredInteger(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new TypeError(`Stored local dispatch ${key} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function optionalInteger(row: Row, key: string): number | undefined {
|
||||
const value = row[key];
|
||||
if (value === null) return undefined;
|
||||
return requiredInteger(row, key);
|
||||
}
|
||||
|
||||
function requiredJson(row: Row, key: string): unknown {
|
||||
try {
|
||||
return JSON.parse(requiredString(row, key)) as unknown;
|
||||
} catch {
|
||||
throw new TypeError(`Stored local dispatch ${key} is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function sameRecipeContent(
|
||||
left: LocalExecutionContextRecipe,
|
||||
right: LocalExecutionContextRecipe,
|
||||
): boolean {
|
||||
return (
|
||||
left.contextRef === right.contextRef &&
|
||||
left.contentDigest === right.contentDigest &&
|
||||
JSON.stringify(left.environment) === JSON.stringify(right.environment)
|
||||
);
|
||||
}
|
||||
|
||||
function sameRevisionContent(
|
||||
left: LocalTaskExecutionRevision,
|
||||
right: LocalTaskExecutionRevision,
|
||||
): boolean {
|
||||
return (
|
||||
left.projectId === right.projectId &&
|
||||
left.taskId === right.taskId &&
|
||||
left.taskRevision === right.taskRevision &&
|
||||
left.contentDigest === right.contentDigest
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous transaction-scoped storage primitive. Public repositories own
|
||||
* admission/error mapping; this class never opens a transaction or queue.
|
||||
*/
|
||||
export class LocalSqliteDispatchDefinitionStore {
|
||||
constructor(private readonly client: DatabaseSync) {}
|
||||
|
||||
resolveRecipe(contextRef: string): LocalExecutionContextRecipe | null {
|
||||
const row = this.client
|
||||
.prepare(
|
||||
`SELECT "context_ref" AS "contextRef",
|
||||
"environment_json" AS "environmentJson",
|
||||
"content_digest" AS "contentDigest",
|
||||
"created_at_ms" AS "createdAtMs"
|
||||
FROM "QingLong3LocalExecutionContextRecipes"
|
||||
WHERE "context_ref" = ?`,
|
||||
)
|
||||
.get(contextRef) as Row | undefined;
|
||||
if (!row) return null;
|
||||
return normalizeLocalExecutionContextRecipe({
|
||||
contextRef: requiredString(row, 'contextRef'),
|
||||
environment: requiredJson(
|
||||
row,
|
||||
'environmentJson',
|
||||
) as LocalExecutionContextRecipe['environment'],
|
||||
contentDigest: requiredString(row, 'contentDigest'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
resolveRevision(identity: {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly taskRevision: string;
|
||||
}): LocalTaskExecutionRevision | null {
|
||||
const row = this.client
|
||||
.prepare(
|
||||
`SELECT "project_id" AS "projectId", "task_id" AS "taskId",
|
||||
"task_revision" AS "taskRevision",
|
||||
"executor_type" AS "executorType",
|
||||
"command_json" AS "commandJson",
|
||||
"working_directory" AS "workingDirectory",
|
||||
"timeout_ms" AS "timeoutMs", "context_ref" AS "contextRef",
|
||||
"content_digest" AS "contentDigest",
|
||||
"created_at_ms" AS "createdAtMs"
|
||||
FROM "QingLong3LocalTaskExecutionRevisions"
|
||||
WHERE "project_id" = ? AND "task_id" = ? AND "task_revision" = ?`,
|
||||
)
|
||||
.get(identity.projectId, identity.taskId, identity.taskRevision) as
|
||||
| Row
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
const executorType = requiredString(row, 'executorType');
|
||||
if (executorType !== 'local_process') {
|
||||
throw new TypeError('Stored local dispatch executorType is invalid');
|
||||
}
|
||||
const workingDirectory = optionalString(row, 'workingDirectory');
|
||||
const timeoutMs = optionalInteger(row, 'timeoutMs');
|
||||
return normalizeLocalTaskExecutionRevision({
|
||||
projectId: requiredString(row, 'projectId'),
|
||||
taskId: requiredString(row, 'taskId'),
|
||||
taskRevision: requiredString(row, 'taskRevision'),
|
||||
executorType,
|
||||
command: requiredJson(
|
||||
row,
|
||||
'commandJson',
|
||||
) as LocalTaskExecutionRevision['command'],
|
||||
...(workingDirectory === undefined ? {} : { workingDirectory }),
|
||||
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
||||
contextRef: requiredString(row, 'contextRef'),
|
||||
contentDigest: requiredString(row, 'contentDigest'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
appendRecipe(
|
||||
value: LocalExecutionContextRecipe,
|
||||
): 'inserted' | 'existing' {
|
||||
const recipe = normalizeLocalExecutionContextRecipe(value);
|
||||
try {
|
||||
this.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalExecutionContextRecipes" (
|
||||
"context_ref", "environment_json", "content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
recipe.contextRef,
|
||||
JSON.stringify(recipe.environment),
|
||||
recipe.contentDigest,
|
||||
recipe.createdAtMs,
|
||||
);
|
||||
return 'inserted';
|
||||
} catch (error) {
|
||||
if (!sqliteConstraint(error)) throw error;
|
||||
const existing = this.resolveRecipe(recipe.contextRef);
|
||||
if (existing && sameRecipeContent(existing, recipe)) return 'existing';
|
||||
throw new LocalSqliteDispatchDefinitionConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
appendRevision(
|
||||
value: LocalTaskExecutionRevision,
|
||||
): 'inserted' | 'existing' {
|
||||
const revision = normalizeLocalTaskExecutionRevision(value);
|
||||
try {
|
||||
this.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalTaskExecutionRevisions" (
|
||||
"project_id", "task_id", "task_revision", "executor_type",
|
||||
"command_json", "working_directory", "timeout_ms",
|
||||
"context_ref", "content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
revision.projectId,
|
||||
revision.taskId,
|
||||
revision.taskRevision,
|
||||
revision.executorType,
|
||||
JSON.stringify(revision.command),
|
||||
revision.workingDirectory ?? null,
|
||||
revision.timeoutMs ?? null,
|
||||
revision.contextRef,
|
||||
revision.contentDigest,
|
||||
revision.createdAtMs,
|
||||
);
|
||||
return 'inserted';
|
||||
} catch (error) {
|
||||
if (!sqliteConstraint(error)) throw error;
|
||||
const existing = this.resolveRevision(revision);
|
||||
if (existing && sameRevisionContent(existing, revision)) {
|
||||
return 'existing';
|
||||
}
|
||||
throw new LocalSqliteDispatchDefinitionConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
appendPlan(plan: LocalCommandTaskExecutionPlan): Readonly<{
|
||||
recipe: 'inserted' | 'existing';
|
||||
revision: 'inserted' | 'existing';
|
||||
}> {
|
||||
const recipe = normalizeLocalExecutionContextRecipe(plan.contextRecipe);
|
||||
const revision = normalizeLocalTaskExecutionRevision(
|
||||
plan.executionRevision,
|
||||
);
|
||||
if (
|
||||
revision.projectId !== plan.source.projectId ||
|
||||
revision.taskId !== plan.source.taskId ||
|
||||
revision.taskRevision !== plan.source.taskRevision ||
|
||||
revision.contextRef !== recipe.contextRef
|
||||
) {
|
||||
throw new TypeError('Local command execution plan identities do not match');
|
||||
}
|
||||
return Object.freeze({
|
||||
recipe: this.appendRecipe(recipe),
|
||||
revision: this.appendRevision(revision),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { ApiCredentialRepository } from '@qinglong/runtime-core/api-credential';
|
||||
import type { LocalOwnerPepperRepository } from '@qinglong/runtime-core/local-owner-pepper';
|
||||
import type { ProjectPolicyRepository } from '@qinglong/runtime-core/project-policy';
|
||||
import type { SecuritySubject } from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditSink } from '@qinglong/runtime-core/security-audit';
|
||||
import type { TaskDefinitionSource } from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
TaskDefinitionAdministrationAuthorizationFenceConflictError,
|
||||
TaskDefinitionAdministrationMutationConflictError,
|
||||
normalizeAuthorizedTaskDefinitionRevisionMutation,
|
||||
type AuthorizedTaskDefinitionRevisionMutation,
|
||||
type TaskDefinitionAdministrationRepository,
|
||||
} from '@qinglong/runtime-core/task-definition-administration';
|
||||
|
||||
import { LocalSqliteApiCredentialRepository } from '../security/apiCredentialRepository';
|
||||
import {
|
||||
assertLocalSqliteOptions,
|
||||
assertLocalSqlitePathBoundary,
|
||||
openLocalSqliteClient,
|
||||
type LocalSqliteDatabaseOptions,
|
||||
type LocalSqliteProfile,
|
||||
} from '../storage/config';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository';
|
||||
import {
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence,
|
||||
LocalSqliteAuthenticatedManagementFenceError,
|
||||
type LocalSqliteAuthenticatedUserCredentialFence,
|
||||
} from '../administration/packageManagement';
|
||||
import {
|
||||
auditLocalSqliteReadiness,
|
||||
type LocalSqliteReadinessEvidence,
|
||||
} from '../readiness/readiness';
|
||||
import {
|
||||
LOCAL_ROLE_BINDING_SELECT,
|
||||
insertLocalSecurityAudit,
|
||||
localRoleBindingFromRow,
|
||||
localSecurityAuditFromRow,
|
||||
sameSecurityAuditSemantic,
|
||||
} from '../security/securityPersistence';
|
||||
import { LocalSqliteSecurityAuthorityStore } from '../security/securityAuthorityStore';
|
||||
import { LocalSqliteTaskDefinitionRepository } from './taskDefinitionRepository';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const AUDIT_SELECT = `
|
||||
"event_id" AS "eventId",
|
||||
"request_id" AS "requestId",
|
||||
"operation_id" AS "operationId",
|
||||
"project_id" AS "auditProjectId",
|
||||
"subject_type" AS "subjectType",
|
||||
"subject_id" AS "subjectId",
|
||||
"authentication_id" AS "authenticationId",
|
||||
"outcome" AS "outcome",
|
||||
"reasons_json" AS "reasonsJson",
|
||||
"fence_project_version" AS "fenceProjectVersion",
|
||||
"fence_binding_version" AS "fenceBindingVersion",
|
||||
"occurred_at_ms" AS "occurredAtMs"`;
|
||||
|
||||
export interface LocalSqliteTaskDefinitionAdministrationDatabase {
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly readiness: LocalSqliteReadinessEvidence;
|
||||
readonly apiCredentials: ApiCredentialRepository;
|
||||
readonly ownerPepper: Pick<LocalOwnerPepperRepository, 'resolveKey'>;
|
||||
readonly projectPolicy: ProjectPolicyRepository;
|
||||
readonly taskDefinitions: TaskDefinitionSource;
|
||||
readonly taskDefinitionAdministration: TaskDefinitionAdministrationRepository;
|
||||
readonly securityAudit: SecurityAuditSink;
|
||||
activateUserCredentialFence(
|
||||
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function integer(row: Row | undefined, key: string): number {
|
||||
const value = row?.[key];
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new TaskDefinitionAdministrationAuthorizationFenceConflictError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function sameCredentialFence(
|
||||
left: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
right: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
): boolean {
|
||||
return (
|
||||
left.credentialId === right.credentialId &&
|
||||
left.credentialVersion === right.credentialVersion &&
|
||||
left.pepperKeyId === right.pepperKeyId &&
|
||||
left.materialDigest === right.materialDigest &&
|
||||
left.subjectType === right.subjectType &&
|
||||
left.subjectId === right.subjectId &&
|
||||
left.secretDigest === right.secretDigest &&
|
||||
left.notBeforeAtMs === right.notBeforeAtMs &&
|
||||
left.expiresAtMs === right.expiresAtMs
|
||||
);
|
||||
}
|
||||
|
||||
class LocalSqliteTaskDefinitionAdministrationRepository
|
||||
implements TaskDefinitionAdministrationRepository
|
||||
{
|
||||
constructor(
|
||||
private readonly authority: LocalSqliteOperationAuthority,
|
||||
private readonly taskDefinitions: LocalSqliteTaskDefinitionRepository,
|
||||
private readonly beforeMutation: (actor: Readonly<SecuritySubject>) => void,
|
||||
) {}
|
||||
|
||||
private confirmAuthorization(
|
||||
mutation: Readonly<AuthorizedTaskDefinitionRevisionMutation>,
|
||||
): void {
|
||||
this.beforeMutation(mutation.actor);
|
||||
const client = this.authority.client;
|
||||
const project = client
|
||||
.prepare(
|
||||
`SELECT "status" AS "status", "version" AS "version"
|
||||
FROM "QingLong3Projects" WHERE "id" = ?`,
|
||||
)
|
||||
.get(mutation.command.projectId) as Row | undefined;
|
||||
const bindingRow = client
|
||||
.prepare(
|
||||
`SELECT ${LOCAL_ROLE_BINDING_SELECT}
|
||||
FROM "QingLong3ProjectRoleBindings"
|
||||
WHERE "project_id" = ? AND "subject_type" = ?
|
||||
AND "subject_id" = ?
|
||||
ORDER BY "version" DESC LIMIT 1`,
|
||||
)
|
||||
.get(
|
||||
mutation.command.projectId,
|
||||
mutation.actor.type,
|
||||
mutation.actor.id,
|
||||
) as Row | undefined;
|
||||
if (
|
||||
!project ||
|
||||
project.status !== 'active' ||
|
||||
integer(project, 'version') !== mutation.fence.projectVersion ||
|
||||
!bindingRow
|
||||
) {
|
||||
throw new TaskDefinitionAdministrationAuthorizationFenceConflictError();
|
||||
}
|
||||
const binding = localRoleBindingFromRow(bindingRow);
|
||||
if (
|
||||
binding.version !== mutation.fence.bindingVersion ||
|
||||
binding.state !== 'active'
|
||||
) {
|
||||
throw new TaskDefinitionAdministrationAuthorizationFenceConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
appendAuthorizedTaskDefinitionRevision(
|
||||
input: AuthorizedTaskDefinitionRevisionMutation,
|
||||
) {
|
||||
const mutation = normalizeAuthorizedTaskDefinitionRevisionMutation(input);
|
||||
return this.taskDefinitions.appendTaskDefinitionRevision(
|
||||
mutation.command,
|
||||
({ replay }) => {
|
||||
this.confirmAuthorization(mutation);
|
||||
const auditRow = this.authority.client
|
||||
.prepare(
|
||||
`SELECT ${AUDIT_SELECT}
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = ?`,
|
||||
)
|
||||
.get(mutation.audit.eventId) as Row | undefined;
|
||||
if (replay) {
|
||||
if (
|
||||
!auditRow ||
|
||||
!sameSecurityAuditSemantic(
|
||||
localSecurityAuditFromRow(auditRow),
|
||||
mutation.audit,
|
||||
)
|
||||
) {
|
||||
throw new TaskDefinitionAdministrationMutationConflictError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (auditRow) {
|
||||
throw new TaskDefinitionAdministrationMutationConflictError();
|
||||
}
|
||||
insertLocalSecurityAudit(this.authority.client, mutation.audit);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openLocalSqliteTaskDefinitionAdministrationDatabase(
|
||||
options: LocalSqliteDatabaseOptions,
|
||||
): Promise<LocalSqliteTaskDefinitionAdministrationDatabase> {
|
||||
assertLocalSqliteOptions(options);
|
||||
assertLocalSqlitePathBoundary(options.databasePath, false);
|
||||
const client = openLocalSqliteClient(options, false);
|
||||
try {
|
||||
const readiness = await auditLocalSqliteReadiness(client);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
|
||||
const taskRepository = new LocalSqliteTaskDefinitionRepository(authority);
|
||||
let activeFence:
|
||||
| Readonly<LocalSqliteAuthenticatedUserCredentialFence>
|
||||
| undefined;
|
||||
const projectPolicy: ProjectPolicyRepository = Object.freeze({
|
||||
resolve: (
|
||||
...[projectId, subject]: Parameters<ProjectPolicyRepository['resolve']>
|
||||
) => securityAuthority.resolve(projectId, subject),
|
||||
append: (...[command]: Parameters<ProjectPolicyRepository['append']>) =>
|
||||
securityAuthority.append(command),
|
||||
});
|
||||
const taskDefinitionAdministration =
|
||||
new LocalSqliteTaskDefinitionAdministrationRepository(
|
||||
authority,
|
||||
taskRepository,
|
||||
(actor) => {
|
||||
if (
|
||||
!activeFence ||
|
||||
actor.type !== activeFence.subjectType ||
|
||||
actor.id !== activeFence.subjectId
|
||||
) {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
}
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(
|
||||
authority,
|
||||
activeFence,
|
||||
);
|
||||
},
|
||||
);
|
||||
let closePromise: Promise<void> | undefined;
|
||||
return Object.freeze({
|
||||
profile: options.profile,
|
||||
readiness,
|
||||
apiCredentials: new LocalSqliteApiCredentialRepository(authority),
|
||||
ownerPepper: new LocalSqliteOwnerPepperRepository(authority),
|
||||
projectPolicy,
|
||||
taskDefinitions: Object.freeze({
|
||||
findCurrentTaskDefinition:
|
||||
taskRepository.findCurrentTaskDefinition.bind(taskRepository),
|
||||
findTaskDefinitionRevision:
|
||||
taskRepository.findTaskDefinitionRevision.bind(taskRepository),
|
||||
listTaskDefinitions:
|
||||
taskRepository.listTaskDefinitions.bind(taskRepository),
|
||||
}),
|
||||
taskDefinitionAdministration,
|
||||
securityAudit: securityAuthority,
|
||||
activateUserCredentialFence(
|
||||
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
) {
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence);
|
||||
if (activeFence && !sameCredentialFence(activeFence, fence)) {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
}
|
||||
activeFence = Object.freeze({ ...fence });
|
||||
},
|
||||
close() {
|
||||
if (closePromise) return closePromise;
|
||||
closePromise = authority.close();
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isOpen) client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import {
|
||||
InvalidTaskDefinitionError,
|
||||
TaskDefinitionConflictError,
|
||||
TaskDefinitionUnavailableError,
|
||||
assertTaskDefinitionIdentifier,
|
||||
assertTaskDefinitionPageSize,
|
||||
assertTaskDefinitionRevision,
|
||||
createTaskDefinitionRecord,
|
||||
normalizeAppendTaskDefinitionRevisionCommand,
|
||||
normalizeTaskDefinitionCursor,
|
||||
normalizeTaskDefinitionRecord,
|
||||
type AppendTaskDefinitionRevisionCommand,
|
||||
type TaskDefinitionPage,
|
||||
type TaskDefinitionRecord,
|
||||
type TaskDefinitionRepository,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import type { LocalCommandTaskExecutionPlan } from '@qinglong/runtime-core/task-definition-execution-compiler';
|
||||
import {
|
||||
BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
|
||||
TaskSpecSemanticRegistry,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} from '@qinglong/runtime-core/task-spec-semantic';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
LocalSqliteDispatchDefinitionConflictError,
|
||||
LocalSqliteDispatchDefinitionStore,
|
||||
} from './dispatchDefinitionStore';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type TaskDefinitionRow = Record<string, unknown>;
|
||||
type LocalExecutionCompiler = (
|
||||
definition: TaskDefinitionRecord,
|
||||
semanticRegistry: TaskSpecSemanticRegistry,
|
||||
) => LocalCommandTaskExecutionPlan;
|
||||
|
||||
export interface LocalSqliteTaskDefinitionTransactionContext {
|
||||
readonly command: Readonly<AppendTaskDefinitionRevisionCommand>;
|
||||
readonly replay: Readonly<TaskDefinitionRecord> | null;
|
||||
}
|
||||
|
||||
export type LocalSqliteTaskDefinitionTransactionHook = (
|
||||
context: Readonly<LocalSqliteTaskDefinitionTransactionContext>,
|
||||
) => void;
|
||||
|
||||
const SELECT_FIELDS = `
|
||||
head."project_id" AS "projectId",
|
||||
head."task_id" AS "taskId",
|
||||
revision."revision" AS "revision",
|
||||
revision."mutation_id" AS "mutationId",
|
||||
revision."name" AS "name",
|
||||
revision."description" AS "description",
|
||||
revision."kind" AS "kind",
|
||||
revision."spec_json" AS "specJson",
|
||||
revision."labels_json" AS "labelsJson",
|
||||
revision."enabled" AS "enabled",
|
||||
revision."content_digest" AS "contentDigest",
|
||||
head."created_at_ms" AS "createdAtMs",
|
||||
revision."created_at_ms" AS "updatedAtMs"`;
|
||||
|
||||
function text(row: TaskDefinitionRow, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') throw new TaskDefinitionUnavailableError();
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(row: TaskDefinitionRow, key: string): string | undefined {
|
||||
const value = row[key];
|
||||
if (value === null) return undefined;
|
||||
if (typeof value !== 'string') throw new TaskDefinitionUnavailableError();
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: TaskDefinitionRow, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new TaskDefinitionUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function parseJson(row: TaskDefinitionRow, key: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text(row, key));
|
||||
} catch {
|
||||
throw new TaskDefinitionUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function record(row: TaskDefinitionRow): TaskDefinitionRecord {
|
||||
try {
|
||||
return normalizeTaskDefinitionRecord({
|
||||
projectId: text(row, 'projectId'),
|
||||
taskId: text(row, 'taskId'),
|
||||
revision: integer(row, 'revision'),
|
||||
mutationId: text(row, 'mutationId'),
|
||||
name: text(row, 'name'),
|
||||
...(row.description === null
|
||||
? {}
|
||||
: { description: nullableText(row, 'description') as string }),
|
||||
kind: text(row, 'kind') as TaskDefinitionRecord['kind'],
|
||||
spec: parseJson(row, 'specJson') as TaskDefinitionRecord['spec'],
|
||||
labels: parseJson(row, 'labelsJson') as TaskDefinitionRecord['labels'],
|
||||
enabled: integer(row, 'enabled') === 1,
|
||||
contentDigest: text(row, 'contentDigest'),
|
||||
createdAtMs: integer(row, 'createdAtMs'),
|
||||
updatedAtMs: integer(row, 'updatedAtMs'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TaskDefinitionUnavailableError) throw error;
|
||||
throw new TaskDefinitionUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function sameRecord(left: TaskDefinitionRecord, right: TaskDefinitionRecord) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof TaskDefinitionConflictError ||
|
||||
error instanceof TaskDefinitionUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (error instanceof LocalSqliteDispatchDefinitionConflictError) {
|
||||
return new TaskDefinitionConflictError();
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return new TaskDefinitionConflictError();
|
||||
}
|
||||
return new TaskDefinitionUnavailableError();
|
||||
}
|
||||
|
||||
export class LocalSqliteTaskDefinitionRepository
|
||||
implements TaskDefinitionRepository
|
||||
{
|
||||
private readonly authority: LocalSqliteOperationAuthority;
|
||||
private readonly dispatchDefinitions: LocalSqliteDispatchDefinitionStore;
|
||||
private readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
|
||||
|
||||
constructor(
|
||||
authority: LocalSqliteOperationAuthority | DatabaseSync,
|
||||
taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry(),
|
||||
) {
|
||||
this.authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
this.dispatchDefinitions = new LocalSqliteDispatchDefinitionStore(
|
||||
this.authority.client,
|
||||
);
|
||||
this.taskSpecSemanticRegistry = taskSpecSemanticRegistry;
|
||||
}
|
||||
|
||||
private localExecutionPlan(
|
||||
definition: TaskDefinitionRecord,
|
||||
compiler: LocalExecutionCompiler | null,
|
||||
): LocalCommandTaskExecutionPlan | null {
|
||||
if (
|
||||
!definition.enabled ||
|
||||
definition.kind !== 'command' ||
|
||||
definition.spec.schema !== BUILT_IN_COMMAND_TASK_SPEC_SCHEMA
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!compiler) throw new TaskDefinitionUnavailableError();
|
||||
return compiler(definition, this.taskSpecSemanticRegistry);
|
||||
}
|
||||
|
||||
private assertLocalExecutionPlanPublished(
|
||||
plan: LocalCommandTaskExecutionPlan,
|
||||
): void {
|
||||
const recipe = this.dispatchDefinitions.resolveRecipe(
|
||||
plan.contextRecipe.contextRef,
|
||||
);
|
||||
const revision = this.dispatchDefinitions.resolveRevision({
|
||||
projectId: plan.executionRevision.projectId,
|
||||
taskId: plan.executionRevision.taskId,
|
||||
taskRevision: plan.executionRevision.taskRevision,
|
||||
});
|
||||
if (
|
||||
!recipe ||
|
||||
!revision ||
|
||||
recipe.contentDigest !== plan.contextRecipe.contentDigest ||
|
||||
revision.contentDigest !== plan.executionRevision.contentDigest
|
||||
) {
|
||||
throw new TaskDefinitionUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
private enqueue<T>(work: () => T | Promise<T>): Promise<T> {
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
},
|
||||
() => new TaskDefinitionUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
private findCurrent(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
): TaskDefinitionRecord | null {
|
||||
const row = this.authority.client
|
||||
.prepare(
|
||||
`SELECT ${SELECT_FIELDS}
|
||||
FROM "QingLong3TaskDefinitions" AS head
|
||||
JOIN "QingLong3TaskDefinitionRevisions" 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" = ? AND head."task_id" = ?`,
|
||||
)
|
||||
.get(projectId, taskId) as TaskDefinitionRow | undefined;
|
||||
return row ? record(row) : null;
|
||||
}
|
||||
|
||||
private findRevision(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
revision: number,
|
||||
): TaskDefinitionRecord | null {
|
||||
const row = this.authority.client
|
||||
.prepare(
|
||||
`SELECT ${SELECT_FIELDS}
|
||||
FROM "QingLong3TaskDefinitions" AS head
|
||||
JOIN "QingLong3TaskDefinitionRevisions" AS revision
|
||||
ON revision."project_id" = head."project_id"
|
||||
AND revision."task_id" = head."task_id"
|
||||
WHERE head."project_id" = ? AND head."task_id" = ?
|
||||
AND revision."revision" = ?`,
|
||||
)
|
||||
.get(projectId, taskId, revision) as TaskDefinitionRow | undefined;
|
||||
return row ? record(row) : null;
|
||||
}
|
||||
|
||||
private findByMutation(mutationId: string): TaskDefinitionRecord | null {
|
||||
const row = this.authority.client
|
||||
.prepare(
|
||||
`SELECT ${SELECT_FIELDS}
|
||||
FROM "QingLong3TaskDefinitionRevisions" AS revision
|
||||
JOIN "QingLong3TaskDefinitions" AS head
|
||||
ON head."project_id" = revision."project_id"
|
||||
AND head."task_id" = revision."task_id"
|
||||
WHERE revision."mutation_id" = ?`,
|
||||
)
|
||||
.get(mutationId) as TaskDefinitionRow | undefined;
|
||||
return row ? record(row) : null;
|
||||
}
|
||||
|
||||
findCurrentTaskDefinition(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
): Promise<TaskDefinitionRecord | null> {
|
||||
assertTaskDefinitionIdentifier(projectId, 'projectId');
|
||||
assertTaskDefinitionIdentifier(taskId, 'taskId');
|
||||
return this.enqueue(() => this.findCurrent(projectId, taskId));
|
||||
}
|
||||
|
||||
findTaskDefinitionRevision(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
revision: number,
|
||||
): Promise<TaskDefinitionRecord | null> {
|
||||
assertTaskDefinitionIdentifier(projectId, 'projectId');
|
||||
assertTaskDefinitionIdentifier(taskId, 'taskId');
|
||||
assertTaskDefinitionRevision(revision);
|
||||
return this.enqueue(() => this.findRevision(projectId, taskId, revision));
|
||||
}
|
||||
|
||||
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).sort();
|
||||
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;
|
||||
return this.enqueue(() => {
|
||||
const rows = this.authority.client
|
||||
.prepare(
|
||||
`SELECT ${SELECT_FIELDS}
|
||||
FROM "QingLong3TaskDefinitions" AS head
|
||||
JOIN "QingLong3TaskDefinitionRevisions" 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" = ? AND head."task_id" > ?
|
||||
ORDER BY head."task_id"
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(options.projectId, after?.taskId ?? '', options.limit + 1) as
|
||||
| TaskDefinitionRow[]
|
||||
| undefined;
|
||||
if (!Array.isArray(rows)) throw new TaskDefinitionUnavailableError();
|
||||
const truncated = rows.length > options.limit;
|
||||
const definitions = Object.freeze(
|
||||
rows.slice(0, options.limit).map(record),
|
||||
);
|
||||
const last = definitions.at(-1);
|
||||
return Object.freeze({
|
||||
definitions,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? { next: Object.freeze({ taskId: last.taskId }) }
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
appendTaskDefinitionRevision(
|
||||
input: AppendTaskDefinitionRevisionCommand,
|
||||
transactionHook?: LocalSqliteTaskDefinitionTransactionHook,
|
||||
): 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.taskSpecSemanticRegistry.normalize({
|
||||
projectId: normalized.projectId,
|
||||
taskId: normalized.taskId,
|
||||
kind: normalized.kind,
|
||||
spec: normalized.spec,
|
||||
}),
|
||||
});
|
||||
const needsLocalExecutionCompiler =
|
||||
command.enabled &&
|
||||
command.kind === 'command' &&
|
||||
command.spec.schema === BUILT_IN_COMMAND_TASK_SPEC_SCHEMA;
|
||||
const compiler = needsLocalExecutionCompiler
|
||||
? import(
|
||||
'@qinglong/runtime-core/task-definition-execution-compiler'
|
||||
).then(
|
||||
({ compileLocalCommandTaskDefinition }) =>
|
||||
compileLocalCommandTaskDefinition,
|
||||
() => {
|
||||
throw new TaskDefinitionUnavailableError();
|
||||
},
|
||||
)
|
||||
: Promise.resolve(null);
|
||||
return compiler.then((compileLocalExecution) =>
|
||||
this.authority.enqueue(
|
||||
async () => {
|
||||
const client = this.authority.client;
|
||||
let transactionHookError: unknown;
|
||||
try {
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
const ownership = client
|
||||
.prepare(
|
||||
`SELECT package_name AS "packageName"
|
||||
FROM "QingLong3PluginPackageTaskOwnerships"
|
||||
WHERE project_id = ? AND task_id = ?`,
|
||||
)
|
||||
.get(command.projectId, command.taskId);
|
||||
if (ownership) throw new TaskDefinitionConflictError();
|
||||
const replay = this.findByMutation(command.mutationId);
|
||||
if (transactionHook) {
|
||||
let hookResult: void;
|
||||
try {
|
||||
hookResult = transactionHook(
|
||||
Object.freeze({ command, replay }),
|
||||
);
|
||||
} catch (error) {
|
||||
transactionHookError = error;
|
||||
throw error;
|
||||
}
|
||||
if (hookResult !== undefined) {
|
||||
throw new InvalidTaskDefinitionError(
|
||||
'transaction hook must not return a value',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (replay) {
|
||||
const expected = createTaskDefinitionRecord(
|
||||
command,
|
||||
replay.createdAtMs,
|
||||
);
|
||||
if (!sameRecord(replay, expected)) {
|
||||
throw new TaskDefinitionConflictError();
|
||||
}
|
||||
const replayPlan = this.localExecutionPlan(
|
||||
replay,
|
||||
compileLocalExecution,
|
||||
);
|
||||
if (replayPlan)
|
||||
this.assertLocalExecutionPlanPublished(replayPlan);
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
definition: replay,
|
||||
});
|
||||
}
|
||||
|
||||
const project = client
|
||||
.prepare(
|
||||
`SELECT "status" AS "status"
|
||||
FROM "QingLong3Projects" WHERE "id" = ?`,
|
||||
)
|
||||
.get(command.projectId) as { status?: unknown } | undefined;
|
||||
if (project?.status !== 'active') {
|
||||
throw new TaskDefinitionConflictError();
|
||||
}
|
||||
const head = client
|
||||
.prepare(
|
||||
`SELECT "current_revision" AS "currentRevision",
|
||||
"created_at_ms" AS "createdAtMs",
|
||||
"updated_at_ms" AS "updatedAtMs"
|
||||
FROM "QingLong3TaskDefinitions"
|
||||
WHERE "project_id" = ? AND "task_id" = ?`,
|
||||
)
|
||||
.get(command.projectId, command.taskId) as
|
||||
| {
|
||||
currentRevision?: unknown;
|
||||
createdAtMs?: unknown;
|
||||
updatedAtMs?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
const currentRevision = head
|
||||
? integer(head, 'currentRevision')
|
||||
: null;
|
||||
const createdAtMs = head
|
||||
? integer(head, 'createdAtMs')
|
||||
: command.occurredAtMs;
|
||||
const previousUpdatedAtMs = head
|
||||
? integer(head, 'updatedAtMs')
|
||||
: command.occurredAtMs;
|
||||
if (
|
||||
currentRevision !== command.expectedRevision ||
|
||||
command.occurredAtMs < previousUpdatedAtMs
|
||||
) {
|
||||
throw new TaskDefinitionConflictError();
|
||||
}
|
||||
const definition = createTaskDefinitionRecord(command, createdAtMs);
|
||||
const executionPlan = this.localExecutionPlan(
|
||||
definition,
|
||||
compileLocalExecution,
|
||||
);
|
||||
if (!head) {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitions" (
|
||||
"project_id", "task_id", "current_revision",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES (?, ?, 1, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
command.projectId,
|
||||
command.taskId,
|
||||
definition.createdAtMs,
|
||||
definition.updatedAtMs,
|
||||
);
|
||||
}
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitionRevisions" (
|
||||
"project_id", "task_id", "revision", "mutation_id",
|
||||
"name", "description", "kind", "spec_json", "labels_json",
|
||||
"enabled", "content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
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 ? 1 : 0,
|
||||
definition.contentDigest,
|
||||
definition.updatedAtMs,
|
||||
);
|
||||
if (executionPlan) {
|
||||
this.dispatchDefinitions.appendPlan(executionPlan);
|
||||
}
|
||||
if (head) {
|
||||
const update = client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3TaskDefinitions"
|
||||
SET "current_revision" = ?, "updated_at_ms" = ?
|
||||
WHERE "project_id" = ? AND "task_id" = ?
|
||||
AND "current_revision" = ?`,
|
||||
)
|
||||
.run(
|
||||
definition.revision,
|
||||
definition.updatedAtMs,
|
||||
definition.projectId,
|
||||
definition.taskId,
|
||||
command.expectedRevision,
|
||||
);
|
||||
if (update.changes !== 1) {
|
||||
throw new TaskDefinitionConflictError();
|
||||
}
|
||||
}
|
||||
client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: head ? ('updated' as const) : ('created' as const),
|
||||
definition,
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
if (error === transactionHookError && error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
},
|
||||
() => new TaskDefinitionUnavailableError(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user