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,98 @@
|
||||
import path from 'path';
|
||||
import config from '../../../config';
|
||||
import Logger from '../../../loaders/logger';
|
||||
import {
|
||||
activateManualPrimaryRuntime,
|
||||
type ManualPrimaryActivationAudit,
|
||||
type ManualPrimaryActivationStack,
|
||||
} from '../../application/manualPrimaryRuntimeActivation';
|
||||
import { installManualPrimaryExecutionRouter } from '../../compatibility/manualPrimaryExecutionBridge';
|
||||
import type { RuntimeRolloutPolicy } from '../../domain/runtimeRollout';
|
||||
import { parseDeploymentProfile } from '../../domain/deploymentProfile';
|
||||
import type { RuntimeRolloutLoadResult } from '../../ports/runtimeRolloutLoader';
|
||||
import { loadRuntimeRolloutManifest } from '../fs/runtimeRolloutManifestLoader';
|
||||
import type { DefaultManualPrimaryActivationOptions } from './defaultManualPrimaryActivation';
|
||||
|
||||
export const DEFAULT_RUNTIME_ROLLOUT_MANIFEST_FILE = 'qinglong3-rollout.json';
|
||||
|
||||
interface DefaultManualPrimaryStackModule {
|
||||
createDefaultManualPrimaryActivationStack(
|
||||
rollout: RuntimeRolloutPolicy,
|
||||
options?: DefaultManualPrimaryActivationOptions,
|
||||
): ManualPrimaryActivationStack;
|
||||
}
|
||||
|
||||
export interface BootstrapDefaultManualPrimaryRuntimeOptions
|
||||
extends DefaultManualPrimaryActivationOptions {
|
||||
load?: () => Promise<RuntimeRolloutLoadResult>;
|
||||
loadStack?: () => Promise<DefaultManualPrimaryStackModule>;
|
||||
install?: typeof installManualPrimaryExecutionRouter;
|
||||
audit?: (record: ManualPrimaryActivationAudit) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight HTTP-worker bootstrap. Heavy Runtime adapters are imported only
|
||||
* after an accepted manifest explicitly selects manual Primary ownership.
|
||||
*/
|
||||
export async function bootstrapDefaultManualPrimaryRuntime(
|
||||
options: BootstrapDefaultManualPrimaryRuntimeOptions = {},
|
||||
) {
|
||||
const sourcePath = path.join(
|
||||
config.configPath,
|
||||
DEFAULT_RUNTIME_ROLLOUT_MANIFEST_FILE,
|
||||
);
|
||||
const load = await (
|
||||
options.load ?? (() => loadRuntimeRolloutManifest(sourcePath))
|
||||
)();
|
||||
const selected =
|
||||
load.status === 'accepted' && load.policy.modeFor('manual') === 'primary';
|
||||
const audit =
|
||||
options.audit ??
|
||||
((record: ManualPrimaryActivationAudit) => {
|
||||
Logger.info(`[runtime-activation] ${JSON.stringify(record)}`);
|
||||
});
|
||||
let stackModule: DefaultManualPrimaryStackModule | undefined;
|
||||
if (selected) {
|
||||
try {
|
||||
stackModule = await (
|
||||
options.loadStack ?? (() => import('./defaultManualPrimaryActivation'))
|
||||
)();
|
||||
} catch (error) {
|
||||
try {
|
||||
await audit({ ...load.audit, activation: 'failed' });
|
||||
} catch {
|
||||
// Preserve the module load error without exposing manifest contents.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const activationOptions: DefaultManualPrimaryActivationOptions = {
|
||||
...(options.database === undefined ? {} : { database: options.database }),
|
||||
...(options.owner === undefined ? {} : { owner: options.owner }),
|
||||
...(options.recovery === undefined ? {} : { recovery: options.recovery }),
|
||||
...(options.completion === undefined
|
||||
? {}
|
||||
: { completion: options.completion }),
|
||||
...(options.cancellation === undefined
|
||||
? {}
|
||||
: { cancellation: options.cancellation }),
|
||||
...(options.timeout === undefined ? {} : { timeout: options.timeout }),
|
||||
};
|
||||
|
||||
return activateManualPrimaryRuntime({
|
||||
load: async () => load,
|
||||
create(rollout) {
|
||||
if (!stackModule) {
|
||||
throw new Error('Primary stack was not loaded for the selected policy');
|
||||
}
|
||||
return stackModule.createDefaultManualPrimaryActivationStack(rollout, {
|
||||
...activationOptions,
|
||||
deploymentProfile:
|
||||
options.deploymentProfile ??
|
||||
parseDeploymentProfile(process.env.QL_DEPLOYMENT_PROFILE),
|
||||
});
|
||||
},
|
||||
install: options.install ?? installManualPrimaryExecutionRouter,
|
||||
audit,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { Sequelize } from 'sequelize';
|
||||
import { sequelize } from '../../../data';
|
||||
import Logger from '../../../loaders/logger';
|
||||
import {
|
||||
PrimaryCompletionReceiptLifecycle,
|
||||
type PrimaryCompletionReceiptLifecycleOptions,
|
||||
} from '../../application/primaryCompletionReceiptLifecycle';
|
||||
import { PrimaryCompletionReceiptJournalScanner } from '../../application/primaryCompletionReceiptJournalScanner';
|
||||
import { PrimaryCompletionReceiptSupervisor } from '../../application/primaryCompletionReceiptSupervisor';
|
||||
import { PrimaryCompletionReceiptConsumer } from '../../application/primaryCompletionReceiptConsumer';
|
||||
import { PrimaryRunCompletionService } from '../../application/primaryRunCompletionService';
|
||||
import {
|
||||
PrimaryCancellationLifecycle,
|
||||
type PrimaryCancellationLifecycleOptions,
|
||||
} from '../../application/primaryCancellationLifecycle';
|
||||
import { PrimaryCancellationDispatcher } from '../../application/primaryCancellationDispatcher';
|
||||
import { PrimaryCancellationSupervisor } from '../../application/primaryCancellationSupervisor';
|
||||
import { PrimaryTimeoutRequester } from '../../application/primaryTimeoutRequester';
|
||||
import { PrimaryTimeoutSupervisor } from '../../application/primaryTimeoutSupervisor';
|
||||
import {
|
||||
PrimaryTimeoutLifecycle,
|
||||
type PrimaryTimeoutLifecycleOptions,
|
||||
} from '../../application/primaryTimeoutLifecycle';
|
||||
import { RunCommandService } from '../../application/runCommandService';
|
||||
import { PrimaryRunStartupReconciler } from '../../application/primaryRunStartupReconciler';
|
||||
import {
|
||||
PrimaryRunStartupSupervisor,
|
||||
type PrimaryRunStartupOptions,
|
||||
} from '../../application/primaryRunStartupSupervisor';
|
||||
import type { RuntimeRolloutPolicy } from '../../domain/runtimeRollout';
|
||||
import {
|
||||
localPrimaryResourcePolicy,
|
||||
type DeploymentProfile,
|
||||
} from '../../domain/deploymentProfile';
|
||||
import { LegacySequelizeCancellationDispatchRepository } from '../legacy-sequelize/cancellationDispatchRepository';
|
||||
import { LegacySequelizePrimaryCancellationSource } from '../legacy-sequelize/primaryCancellationSource';
|
||||
import { LegacySequelizePrimaryTimeoutSource } from '../legacy-sequelize/primaryTimeoutSource';
|
||||
import { PrimaryCronProjection } from '../legacy-sequelize/primaryCronProjection';
|
||||
import { LegacySequelizePrimaryRunRecoverySource } from '../legacy-sequelize/primaryRunRecoverySource';
|
||||
import { LegacySequelizeCompletionReceiptJournal } from '../legacy-sequelize/completionReceiptJournal';
|
||||
import { LegacySequelizeProjectedRunRepository } from '../legacy-sequelize/projectedRunRepository';
|
||||
import { LocalProcessPersistedExecutionInspector } from '../local-process/localProcessIdentity';
|
||||
import { LocalProcessPersistedExecutionController } from '../local-process/persistedLocalProcessController';
|
||||
import { LocalProcessExecutor } from '../local-process/localProcessExecutor';
|
||||
import { CompletionReceiptFileStore } from '../fs/completionReceiptFileStore';
|
||||
import {
|
||||
DEFAULT_COMPLETION_RECEIPT_ROOT,
|
||||
DEFAULT_LOCAL_PROCESS_LAUNCHER_PATH,
|
||||
LegacyManualPrimaryLogFiles,
|
||||
} from './defaultManualPrimaryRuntime';
|
||||
import { ManualPrimaryRuntime } from '../../application/manualPrimaryRuntime';
|
||||
|
||||
export interface DefaultManualPrimaryActivationOptions {
|
||||
database?: Sequelize;
|
||||
owner?: string;
|
||||
deploymentProfile?: DeploymentProfile;
|
||||
recovery?: PrimaryRunStartupOptions;
|
||||
completion?: Pick<
|
||||
PrimaryCompletionReceiptLifecycleOptions,
|
||||
'intervalMs' | 'initialDelayMs' | 'stopTimeoutMs' | 'cycle'
|
||||
>;
|
||||
cancellation?: Pick<
|
||||
PrimaryCancellationLifecycleOptions,
|
||||
'intervalMs' | 'initialDelayMs' | 'stopTimeoutMs' | 'cycle'
|
||||
>;
|
||||
timeout?: Pick<
|
||||
PrimaryTimeoutLifecycleOptions,
|
||||
'intervalMs' | 'initialDelayMs' | 'stopTimeoutMs' | 'cycle'
|
||||
>;
|
||||
}
|
||||
|
||||
function boundedOwner(value: string): string {
|
||||
if (!value || value.length > 128) {
|
||||
throw new RangeError(
|
||||
'Primary activation owner must be 1 to 128 characters',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createDefaultManualPrimaryActivationStack(
|
||||
rollout: RuntimeRolloutPolicy,
|
||||
options: DefaultManualPrimaryActivationOptions = {},
|
||||
) {
|
||||
const database = options.database ?? sequelize;
|
||||
const resources = localPrimaryResourcePolicy(
|
||||
options.deploymentProfile ?? 'standalone',
|
||||
);
|
||||
const repository = new LegacySequelizeProjectedRunRepository(database, [
|
||||
new PrimaryCronProjection(database),
|
||||
]);
|
||||
const recoverySource = new LegacySequelizePrimaryRunRecoverySource(database);
|
||||
const completionReceiptJournal = new LegacySequelizeCompletionReceiptJournal(
|
||||
database,
|
||||
);
|
||||
const completionReceiptStore = new CompletionReceiptFileStore(
|
||||
DEFAULT_COMPLETION_RECEIPT_ROOT,
|
||||
);
|
||||
const completionReceipts = new PrimaryCompletionReceiptConsumer(
|
||||
completionReceiptStore,
|
||||
new PrimaryRunCompletionService(repository),
|
||||
{
|
||||
journal: completionReceiptJournal,
|
||||
quarantineRetentionMs: resources.receiptQuarantineRetentionMs,
|
||||
},
|
||||
);
|
||||
const startup = new PrimaryRunStartupSupervisor(
|
||||
new PrimaryRunStartupReconciler(
|
||||
repository,
|
||||
recoverySource,
|
||||
[new LocalProcessPersistedExecutionInspector()],
|
||||
{
|
||||
completionReceipts,
|
||||
completionReceiptJournal,
|
||||
receiptPublishGraceMs: resources.receiptPublishGraceMs,
|
||||
},
|
||||
),
|
||||
);
|
||||
const completion = new PrimaryCompletionReceiptLifecycle(
|
||||
new PrimaryCompletionReceiptSupervisor(
|
||||
new PrimaryCompletionReceiptJournalScanner(
|
||||
completionReceiptJournal,
|
||||
completionReceiptStore,
|
||||
completionReceipts,
|
||||
{
|
||||
terminalMissingRetentionMs:
|
||||
resources.receiptTerminalMissingRetentionMs,
|
||||
},
|
||||
),
|
||||
),
|
||||
{
|
||||
intervalMs:
|
||||
options.completion?.intervalMs ?? resources.completion.intervalMs,
|
||||
initialDelayMs:
|
||||
options.completion?.initialDelayMs ??
|
||||
resources.completion.initialDelayMs,
|
||||
stopTimeoutMs:
|
||||
options.completion?.stopTimeoutMs ?? resources.completion.stopTimeoutMs,
|
||||
cycle: options.completion?.cycle ?? {
|
||||
pageSize: resources.completion.pageSize,
|
||||
maxPages: resources.completion.maxPages,
|
||||
},
|
||||
onCycle(summary) {
|
||||
Logger.info(
|
||||
`[runtime-completion] ${JSON.stringify({
|
||||
profile: resources.profile,
|
||||
pages: summary.pages,
|
||||
scanned: summary.scanned,
|
||||
applied: summary.applied,
|
||||
alreadyTerminal: summary.alreadyTerminal,
|
||||
quarantined: summary.quarantined,
|
||||
purgedQuarantines: summary.purgedQuarantines,
|
||||
expiredMissing: summary.expiredMissing,
|
||||
missing: summary.missing,
|
||||
cleanupPending: summary.cleanupPending,
|
||||
skipped: summary.skipped,
|
||||
ambiguous: summary.ambiguous,
|
||||
failed: summary.failed,
|
||||
stopReason: summary.stopReason,
|
||||
remaining: summary.remaining,
|
||||
})}`,
|
||||
);
|
||||
},
|
||||
onError() {
|
||||
Logger.error('[runtime-completion] cycle failed');
|
||||
},
|
||||
},
|
||||
);
|
||||
const cancellation = new PrimaryCancellationLifecycle(
|
||||
new PrimaryCancellationSupervisor(
|
||||
new PrimaryCancellationDispatcher(
|
||||
new LegacySequelizePrimaryCancellationSource(database),
|
||||
new LegacySequelizeCancellationDispatchRepository(database),
|
||||
[new LocalProcessPersistedExecutionController()],
|
||||
{ owner: boundedOwner(options.owner ?? `http:${process.pid}`) },
|
||||
),
|
||||
),
|
||||
{
|
||||
intervalMs:
|
||||
options.cancellation?.intervalMs ?? resources.cancellation.intervalMs,
|
||||
initialDelayMs:
|
||||
options.cancellation?.initialDelayMs ??
|
||||
resources.cancellation.initialDelayMs,
|
||||
stopTimeoutMs:
|
||||
options.cancellation?.stopTimeoutMs ??
|
||||
resources.cancellation.stopTimeoutMs,
|
||||
cycle: options.cancellation?.cycle ?? {
|
||||
pageSize: resources.cancellation.pageSize,
|
||||
maxPages: resources.cancellation.maxPages,
|
||||
},
|
||||
onCycle(summary) {
|
||||
Logger.info(
|
||||
`[runtime-cancellation] ${JSON.stringify({
|
||||
pages: summary.pages,
|
||||
scanned: summary.scanned,
|
||||
claimed: summary.claimed,
|
||||
pending: summary.pending,
|
||||
failed: summary.failed,
|
||||
stopReason: summary.stopReason,
|
||||
remaining: summary.remaining,
|
||||
})}`,
|
||||
);
|
||||
},
|
||||
onError() {
|
||||
Logger.error('[runtime-cancellation] cycle failed');
|
||||
},
|
||||
},
|
||||
);
|
||||
const timeout = new PrimaryTimeoutLifecycle(
|
||||
new PrimaryTimeoutSupervisor(
|
||||
new PrimaryTimeoutRequester(
|
||||
new LegacySequelizePrimaryTimeoutSource(database),
|
||||
new RunCommandService(repository),
|
||||
),
|
||||
),
|
||||
{
|
||||
intervalMs: options.timeout?.intervalMs ?? resources.timeout.intervalMs,
|
||||
initialDelayMs:
|
||||
options.timeout?.initialDelayMs ?? resources.timeout.initialDelayMs,
|
||||
stopTimeoutMs:
|
||||
options.timeout?.stopTimeoutMs ?? resources.timeout.stopTimeoutMs,
|
||||
cycle: options.timeout?.cycle ?? {
|
||||
pageSize: resources.timeout.pageSize,
|
||||
maxPages: resources.timeout.maxPages,
|
||||
},
|
||||
onCycle(summary) {
|
||||
Logger.info(
|
||||
`[runtime-timeout] ${JSON.stringify({
|
||||
profile: resources.profile,
|
||||
pages: summary.pages,
|
||||
scanned: summary.scanned,
|
||||
accepted: summary.accepted,
|
||||
alreadyRequested: summary.alreadyRequested,
|
||||
alreadyTerminal: summary.alreadyTerminal,
|
||||
failed: summary.failed,
|
||||
stopReason: summary.stopReason,
|
||||
remaining: summary.remaining,
|
||||
})}`,
|
||||
);
|
||||
},
|
||||
onError() {
|
||||
Logger.error('[runtime-timeout] cycle failed');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
router: new ManualPrimaryRuntime(
|
||||
repository,
|
||||
new LocalProcessExecutor({
|
||||
durableLauncherPath: DEFAULT_LOCAL_PROCESS_LAUNCHER_PATH,
|
||||
}),
|
||||
rollout,
|
||||
new LegacyManualPrimaryLogFiles(
|
||||
undefined,
|
||||
undefined,
|
||||
completionReceiptJournal,
|
||||
),
|
||||
{
|
||||
orchestrator: { completionReceiptJournal },
|
||||
},
|
||||
),
|
||||
reconcile: () => startup.run(options.recovery),
|
||||
startCompletion: () => completion.start(),
|
||||
stopCompletion: () => completion.stop(),
|
||||
startTimeout: () => timeout.start(),
|
||||
stopTimeout: () => timeout.stop(),
|
||||
startCancellation: () => cancellation.start(),
|
||||
stopCancellation: () => cancellation.stop(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import dayjs from 'dayjs';
|
||||
import config from '../../../config';
|
||||
import { getUniqPath } from '../../../config/util';
|
||||
import { sequelize } from '../../../data';
|
||||
import { logStreamManager } from '../../../shared/logStreamManager';
|
||||
import {
|
||||
ManualPrimaryRuntime,
|
||||
type ManualPrimaryLogFiles,
|
||||
type PreparedManualPrimaryLog,
|
||||
} from '../../application/manualPrimaryRuntime';
|
||||
import type { ManualPrimaryStartInput } from '../../compatibility/manualPrimaryExecutionBridge';
|
||||
import { createLegacyLogOutputRef } from '../../compatibility/legacyLogOutputRef';
|
||||
import type { RuntimeRolloutPolicy } from '../../domain/runtimeRollout';
|
||||
import { PrimaryCronProjection } from '../legacy-sequelize/primaryCronProjection';
|
||||
import { LegacySequelizeProjectedRunRepository } from '../legacy-sequelize/projectedRunRepository';
|
||||
import { LocalProcessExecutor } from '../local-process/localProcessExecutor';
|
||||
import { enableDurableLocalProcessOutput } from '../local-process/durableLocalProcessOutput';
|
||||
import { CompletionReceiptFileStore } from '../fs/completionReceiptFileStore';
|
||||
import type { CompletionReceiptJournal } from '../../ports/completionReceiptJournal';
|
||||
|
||||
export const DEFAULT_COMPLETION_RECEIPT_ROOT = path.join(
|
||||
config.dataPath,
|
||||
'runtime',
|
||||
'completion-receipts',
|
||||
);
|
||||
export const DEFAULT_LOCAL_PROCESS_LAUNCHER_PATH = path.join(
|
||||
config.rootPath,
|
||||
'shell',
|
||||
'ql3-launcher.sh',
|
||||
);
|
||||
|
||||
function isWithin(root: string, candidate: string): boolean {
|
||||
return candidate === root || candidate.startsWith(root + path.sep);
|
||||
}
|
||||
|
||||
function relativeLogDirectory(root: string, value: string): string {
|
||||
const candidate = path.isAbsolute(value) ? value : path.resolve(root, value);
|
||||
if (!isWithin(root, candidate)) {
|
||||
throw new Error('Manual Primary log directory escapes the configured root');
|
||||
}
|
||||
const relative = path.relative(root, candidate).split(path.sep).join('/');
|
||||
if (!relative || relative === '.') {
|
||||
throw new Error('Manual Primary log directory must be below the log root');
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
export class LegacyManualPrimaryLogFiles implements ManualPrimaryLogFiles {
|
||||
private readonly completionReceipts: CompletionReceiptFileStore;
|
||||
private readonly completionReceiptRoot: string;
|
||||
|
||||
constructor(
|
||||
private readonly logRoot = path.resolve(config.logPath),
|
||||
completionReceiptRoot = DEFAULT_COMPLETION_RECEIPT_ROOT,
|
||||
private readonly completionReceiptJournal?: Pick<
|
||||
CompletionReceiptJournal,
|
||||
'resolve'
|
||||
>,
|
||||
) {
|
||||
this.completionReceiptRoot = path.resolve(completionReceiptRoot);
|
||||
this.completionReceipts = new CompletionReceiptFileStore(
|
||||
this.completionReceiptRoot,
|
||||
);
|
||||
}
|
||||
|
||||
async prepare(
|
||||
input: ManualPrimaryStartInput,
|
||||
): Promise<PreparedManualPrimaryLog> {
|
||||
const configured =
|
||||
!input.cron.logName || input.cron.logName === '/dev/null'
|
||||
? await getUniqPath(input.cron.command, String(input.cron.id))
|
||||
: input.cron.logName;
|
||||
const directory = relativeLogDirectory(this.logRoot, configured);
|
||||
const logPath = path.posix.join(
|
||||
directory,
|
||||
dayjs(input.acceptedAtMs).format('YYYY-MM-DD-HH-mm-ss-SSS') + '.log',
|
||||
);
|
||||
createLegacyLogOutputRef(logPath);
|
||||
const absolutePath = path.resolve(this.logRoot, ...logPath.split('/'));
|
||||
if (!isWithin(this.logRoot, absolutePath)) {
|
||||
throw new Error('Manual Primary log file escapes the configured root');
|
||||
}
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
const output = enableDurableLocalProcessOutput(
|
||||
{
|
||||
async write(output) {
|
||||
await logStreamManager.write(
|
||||
absolutePath,
|
||||
Buffer.from(output.chunk).toString('utf8'),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
outputFilePath: absolutePath,
|
||||
completionReceiptRoot: this.completionReceiptRoot,
|
||||
},
|
||||
);
|
||||
const completionReceipts = this.completionReceipts;
|
||||
const completionReceiptJournal = this.completionReceiptJournal;
|
||||
return {
|
||||
logPath,
|
||||
output,
|
||||
async completionCommitted(attemptId) {
|
||||
await completionReceipts.remove(attemptId);
|
||||
await completionReceiptJournal?.resolve(attemptId);
|
||||
},
|
||||
async close() {
|
||||
await logStreamManager.closeStream(absolutePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy factory retained for focused tests; production activation uses the
|
||||
* shared lifecycle stack in defaultManualPrimaryActivation.ts. */
|
||||
export function createDefaultManualPrimaryRuntime(
|
||||
rollout: RuntimeRolloutPolicy,
|
||||
): ManualPrimaryRuntime {
|
||||
const repository = new LegacySequelizeProjectedRunRepository(sequelize, [
|
||||
new PrimaryCronProjection(sequelize),
|
||||
]);
|
||||
return new ManualPrimaryRuntime(
|
||||
repository,
|
||||
new LocalProcessExecutor({
|
||||
durableLauncherPath: DEFAULT_LOCAL_PROCESS_LAUNCHER_PATH,
|
||||
}),
|
||||
rollout,
|
||||
new LegacyManualPrimaryLogFiles(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
ExecutionResourcePolicy,
|
||||
ExecutionSpec,
|
||||
} from '../../domain/execution';
|
||||
import { InvalidExecutionSpecError } from '../../domain/executorErrors';
|
||||
|
||||
export interface LegacyCronSnapshot {
|
||||
id: number;
|
||||
command: string;
|
||||
taskBefore?: string;
|
||||
taskAfter?: string;
|
||||
workDirectory?: string;
|
||||
logName?: string;
|
||||
}
|
||||
|
||||
export interface LegacyCronExecutionInput {
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
projectId: string;
|
||||
taskRevision: string;
|
||||
cron: LegacyCronSnapshot;
|
||||
realTime: boolean;
|
||||
realLogPath?: string;
|
||||
noDelay?: boolean;
|
||||
timeoutMs?: number;
|
||||
terminationGraceMs?: number;
|
||||
resourcePolicy?: ExecutionResourcePolicy;
|
||||
}
|
||||
|
||||
export const DEFAULT_LEGACY_TERMINATION_GRACE_MS = 10_000;
|
||||
|
||||
function quoteShellValue(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function normalizeHook(value: string): string {
|
||||
return value.replace(/;? *\r?\n/g, ';').trim();
|
||||
}
|
||||
|
||||
function assignment(name: string, value: string | number | boolean): string {
|
||||
return `${name}=${quoteShellValue(String(value))}`;
|
||||
}
|
||||
|
||||
function legacyTaskCommand(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) {
|
||||
throw new InvalidExecutionSpecError(
|
||||
'Legacy Cron command must not be empty',
|
||||
);
|
||||
}
|
||||
if (trimmed.startsWith('task ') || trimmed.startsWith('ql ')) return trimmed;
|
||||
return `task ${trimmed}`;
|
||||
}
|
||||
|
||||
export function buildLegacyCronExecutionSpec(
|
||||
input: LegacyCronExecutionInput,
|
||||
): ExecutionSpec {
|
||||
if (!Number.isSafeInteger(input.cron.id) || input.cron.id < 1) {
|
||||
throw new InvalidExecutionSpecError(
|
||||
'Legacy Cron id must be a positive safe integer',
|
||||
);
|
||||
}
|
||||
|
||||
const variables: string[] = [];
|
||||
if (input.realLogPath) {
|
||||
variables.push(assignment('real_log_path', input.realLogPath));
|
||||
}
|
||||
if (input.noDelay) variables.push(assignment('no_delay', true));
|
||||
variables.push(assignment('real_time', input.realTime));
|
||||
variables.push(assignment('no_tee', true));
|
||||
variables.push(assignment('ID', input.cron.id));
|
||||
if (input.cron.logName) {
|
||||
variables.push(assignment('log_name', input.cron.logName));
|
||||
}
|
||||
if (input.cron.taskBefore) {
|
||||
variables.push(
|
||||
assignment('task_before', normalizeHook(input.cron.taskBefore)),
|
||||
);
|
||||
}
|
||||
if (input.cron.taskAfter) {
|
||||
variables.push(
|
||||
assignment('task_after', normalizeHook(input.cron.taskAfter)),
|
||||
);
|
||||
}
|
||||
if (input.cron.workDirectory) {
|
||||
variables.push(assignment('work_dir', input.cron.workDirectory));
|
||||
}
|
||||
|
||||
return {
|
||||
runId: input.runId,
|
||||
attemptId: input.attemptId,
|
||||
projectId: input.projectId,
|
||||
taskId: `legacy-cron:${input.cron.id}`,
|
||||
taskRevision: input.taskRevision,
|
||||
command: {
|
||||
kind: 'shell',
|
||||
command: `${variables.join(' ')} ${legacyTaskCommand(
|
||||
input.cron.command,
|
||||
)}`,
|
||||
shell: '/bin/bash',
|
||||
},
|
||||
environmentPolicy: 'inherit',
|
||||
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
|
||||
terminationGraceMs:
|
||||
input.terminationGraceMs ?? DEFAULT_LEGACY_TERMINATION_GRACE_MS,
|
||||
...(input.resourcePolicy === undefined
|
||||
? {}
|
||||
: { resourcePolicy: input.resourcePolicy }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user