mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add strong local manual run retry
This commit is contained in:
@@ -32,7 +32,7 @@ function runCli(args) {
|
||||
|
||||
test('catalog maps every product subcommand to an existing same-package binary', () => {
|
||||
assert.equal(manifest.bin.ql3, 'dist/product-cli/cli.js');
|
||||
assert.equal(QINGLONG3_PRODUCT_COMMANDS.length, 20);
|
||||
assert.equal(QINGLONG3_PRODUCT_COMMANDS.length, 21);
|
||||
assert.equal(
|
||||
new Set(QINGLONG3_PRODUCT_COMMANDS.map(({ name }) => name)).size,
|
||||
QINGLONG3_PRODUCT_COMMANDS.length,
|
||||
@@ -60,6 +60,7 @@ test('help and version are bounded installation-derived product facts', () => {
|
||||
const help = qingLong3ProductHelp();
|
||||
assert.match(help, /^Usage: ql3 <command> \[arguments\]/);
|
||||
assert.match(help, /\n task\s+manage Task definitions\n/);
|
||||
assert.match(help, /\n run\s+retry terminal Runs/);
|
||||
assert.match(help, /Root service mutation remains isolated/);
|
||||
assert.equal(help.includes('ql3-service-bridge '), false);
|
||||
assert.equal(loadQingLong3ProductVersion(moduleDirectory), manifest.version);
|
||||
@@ -153,6 +154,14 @@ test('product binary exposes help/version and delegates without a shell', () =>
|
||||
);
|
||||
assert.equal(delegatedHelp.stderr, '');
|
||||
|
||||
const delegatedRunHelp = runCli(['run', '--help']);
|
||||
assert.equal(delegatedRunHelp.status, 0);
|
||||
assert.equal(
|
||||
delegatedRunHelp.stdout.trim(),
|
||||
'Usage: ql3-run retry --command-file /absolute/private-command.json',
|
||||
);
|
||||
assert.equal(delegatedRunHelp.stderr, '');
|
||||
|
||||
const delegatedFailure = runCli(['task']);
|
||||
assert.equal(delegatedFailure.status, 64);
|
||||
assert.equal(delegatedFailure.stdout, '');
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
RunManualRetryNotFoundError,
|
||||
} = require('@qinglong/runtime-core/run-manual-retry');
|
||||
const {
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('@qinglong/local-sqlite/runtime');
|
||||
const {
|
||||
runLocalRunRetryCommandFile,
|
||||
} = require('../dist/run-management/runRetryCommand.js');
|
||||
const {
|
||||
auditRows,
|
||||
localManagementFixture,
|
||||
writeCommand,
|
||||
} = require('./localManagementFixture.cjs');
|
||||
|
||||
function uuid(value) {
|
||||
return `019f9200-0000-4000-8000-${value.toString(16).padStart(12, '0')}`;
|
||||
}
|
||||
|
||||
async function createFailedSource(value) {
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath: value.databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
let started;
|
||||
try {
|
||||
const task = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision({
|
||||
projectId: 'default',
|
||||
taskId: 'retry-product-task',
|
||||
expectedRevision: null,
|
||||
mutationId: uuid(1),
|
||||
name: 'Retry product task',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: '/bin/echo',
|
||||
args: ['retry-product'],
|
||||
},
|
||||
},
|
||||
},
|
||||
labels: {},
|
||||
enabled: true,
|
||||
occurredAtMs: value.now,
|
||||
})
|
||||
).definition;
|
||||
started = await (
|
||||
await runtime.taskStartRepository()
|
||||
).startTask({
|
||||
projectId: 'default',
|
||||
taskId: task.taskId,
|
||||
mutationId: uuid(2),
|
||||
expectedRevision: task.revision,
|
||||
expectedContentDigest: task.contentDigest,
|
||||
runId: uuid(3),
|
||||
attemptId: uuid(4),
|
||||
createdEventId: uuid(5),
|
||||
queuedEventId: uuid(6),
|
||||
subject: { type: 'user', id: 'automation-user' },
|
||||
policyFence: { projectVersion: 1, bindingVersion: 1 },
|
||||
});
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
const database = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE "Runs"
|
||||
SET "status" = 'failed', "version" = 3, "event_sequence" = 3,
|
||||
"finished_at_ms" = ?, "error_code" = 'PRODUCT_TEST_FAILURE',
|
||||
"error_summary" = 'failed'
|
||||
WHERE "id" = ?`,
|
||||
)
|
||||
.run(value.now + 10, started.runId);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE "RunAttempts"
|
||||
SET "status" = 'failed', "finished_at_ms" = ?,
|
||||
"error_code" = 'PRODUCT_TEST_FAILURE',
|
||||
"error_summary" = 'failed'
|
||||
WHERE "id" = ?`,
|
||||
)
|
||||
.run(value.now + 10, started.attemptId);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "RunEvents" (
|
||||
"id", "run_id", "sequence", "type", "dedupe_key",
|
||||
"actor_type", "actor_id", "attempt_id", "payload",
|
||||
"created_at_ms"
|
||||
) VALUES (?, ?, 3, 'run.failed', 'product-test-failed', 'executor',
|
||||
'product-test', ?, '{}', ?)`,
|
||||
)
|
||||
.run(uuid(7), started.runId, started.attemptId, value.now + 10);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
return started;
|
||||
}
|
||||
|
||||
function request(value, sourceRunId, overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
sourceRunId,
|
||||
mutationId: uuid(10),
|
||||
expectedRunVersion: 3,
|
||||
expectedRunStatus: 'failed',
|
||||
requestId: 'local-run-retry-product-1',
|
||||
auditEventId: uuid(11),
|
||||
failureAuditEventId: uuid(12),
|
||||
occurredAtMs: value.now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('runs a strongly authenticated manual retry and exactly replays it', async (t) => {
|
||||
const value = await localManagementFixture(t);
|
||||
const source = await createFailedSource(value);
|
||||
const commandFile = writeCommand(
|
||||
value,
|
||||
'run.retry',
|
||||
request(value, source.runId),
|
||||
'run-retry',
|
||||
);
|
||||
|
||||
const accepted = await runLocalRunRetryCommandFile(commandFile);
|
||||
assert.equal(accepted.schemaVersion, 1);
|
||||
assert.equal(accepted.operation, 'run.retry');
|
||||
assert.equal(accepted.retry.status, 'accepted');
|
||||
assert.equal(accepted.retry.retryOfRunId, source.runId);
|
||||
assert.equal(accepted.retry.runStatus, 'queued');
|
||||
|
||||
const replay = await runLocalRunRetryCommandFile(commandFile);
|
||||
assert.equal(replay.retry.status, 'existing');
|
||||
assert.equal(replay.retry.runId, accepted.retry.runId);
|
||||
assert.deepEqual(
|
||||
auditRows(value.databasePath)
|
||||
.filter(({ operationId }) => operationId === 'run.retry')
|
||||
.map((row) => ({ ...row })),
|
||||
[
|
||||
{
|
||||
eventId: uuid(11),
|
||||
operationId: 'run.retry',
|
||||
outcome: 'allowed',
|
||||
reasonsJson: '["role_grant","strong_authentication"]',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('audits a missing source without creating Run state', async (t) => {
|
||||
const value = await localManagementFixture(t);
|
||||
const commandFile = writeCommand(
|
||||
value,
|
||||
'run.retry',
|
||||
request(value, 'missing-run', {
|
||||
mutationId: uuid(20),
|
||||
requestId: 'local-run-retry-missing',
|
||||
auditEventId: uuid(21),
|
||||
failureAuditEventId: uuid(22),
|
||||
}),
|
||||
'run-retry-missing',
|
||||
);
|
||||
await assert.rejects(
|
||||
runLocalRunRetryCommandFile(commandFile),
|
||||
RunManualRetryNotFoundError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
auditRows(value.databasePath)
|
||||
.filter(({ operationId }) => operationId === 'run.retry')
|
||||
.map((row) => ({ ...row })),
|
||||
[
|
||||
{
|
||||
eventId: uuid(22),
|
||||
operationId: 'run.retry',
|
||||
outcome: 'denied',
|
||||
reasonsJson: '["run_not_found"]',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('binary exposes only the private command-file retry interface', () => {
|
||||
const cli = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'dist',
|
||||
'run-management',
|
||||
'runRetryCli.js',
|
||||
);
|
||||
const help = spawnSync(process.execPath, [cli, '--help'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(help.status, 0);
|
||||
assert.equal(
|
||||
help.stdout.trim(),
|
||||
'Usage: ql3-run retry --command-file /absolute/private-command.json',
|
||||
);
|
||||
const invalid = spawnSync(process.execPath, [cli, 'retry'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(invalid.status, 64);
|
||||
assert.equal(
|
||||
JSON.parse(invalid.stderr).code,
|
||||
'LOCAL_RUN_RETRY_CLI_USAGE_INVALID',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user