adfinem 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/CODE_OF_CONDUCT.md +21 -0
- package/CONTRIBUTING.md +29 -0
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/SECURITY.md +13 -0
- package/catalogs/.gitkeep +0 -0
- package/catalogs/api-operations.yaml +21 -0
- package/catalogs/batches.yaml +74 -0
- package/catalogs/queries.yaml +75 -0
- package/config/environments.yaml +13 -0
- package/dist/actions/assert-db.js +3 -0
- package/dist/actions/run-eod.js +3 -0
- package/dist/adapters/api/api-collections.js +296 -0
- package/dist/adapters/api/body-utils.js +9 -0
- package/dist/adapters/api/rest-client.js +557 -0
- package/dist/adapters/api/soap-client.js +5 -0
- package/dist/adapters/db/assertions.js +87 -0
- package/dist/adapters/db/oracle-client.js +115 -0
- package/dist/adapters/db/query-catalog.js +75 -0
- package/dist/adapters/unix/batch-catalog.js +71 -0
- package/dist/adapters/unix/batch-input-files.js +36 -0
- package/dist/adapters/unix/batch-runner.js +382 -0
- package/dist/adapters/unix/ssh-client.js +228 -0
- package/dist/app/server.js +826 -0
- package/dist/cli.js +465 -0
- package/dist/config/environments.js +138 -0
- package/dist/config/registry.js +18 -0
- package/dist/config/secrets.js +123 -0
- package/dist/dsl/parser.js +20 -0
- package/dist/dsl/schema.js +182 -0
- package/dist/dsl/types.js +1 -0
- package/dist/dsl/validator.js +264 -0
- package/dist/engine/captures.js +68 -0
- package/dist/engine/context.js +69 -0
- package/dist/engine/evidence.js +33 -0
- package/dist/engine/known-errors.js +129 -0
- package/dist/engine/retry.js +13 -0
- package/dist/engine/runner.js +710 -0
- package/dist/engine/step-result.js +58 -0
- package/dist/flows/catalog-normalizer.js +72 -0
- package/dist/flows/compiler.js +237 -0
- package/dist/flows/concat.js +130 -0
- package/dist/flows/parser.js +21 -0
- package/dist/flows/schema.js +142 -0
- package/dist/flows/types.js +1 -0
- package/dist/flows/validator.js +470 -0
- package/dist/reports/html-report.js +112 -0
- package/dist/reports/junit-report.js +48 -0
- package/docs/.gitkeep +0 -0
- package/docs/DB_UNIX_OPERATIONS.md +118 -0
- package/docs/FLOW_BUILDER.md +87 -0
- package/flows/account_processing_cycle.flow.yaml +88 -0
- package/flows/new_flow.flow.yaml +22 -0
- package/package.json +92 -7
- package/scenarios/smoke/account-processing-smoke.yaml +44 -0
- package/scenarios/smoke/api-db-batch-check.yaml +40 -0
- package/src/actions/assert-db.ts +6 -0
- package/src/actions/run-eod.ts +6 -0
- package/src/adapters/api/api-collections.ts +375 -0
- package/src/adapters/api/body-utils.ts +10 -0
- package/src/adapters/api/rest-client.ts +587 -0
- package/src/adapters/api/soap-client.ts +7 -0
- package/src/adapters/db/assertions.ts +83 -0
- package/src/adapters/db/oracle-client.ts +133 -0
- package/src/adapters/db/query-catalog.ts +80 -0
- package/src/adapters/unix/batch-catalog.ts +81 -0
- package/src/adapters/unix/batch-input-files.ts +39 -0
- package/src/adapters/unix/batch-runner.ts +456 -0
- package/src/adapters/unix/ssh-client.ts +248 -0
- package/src/app/server.ts +913 -0
- package/src/cli.ts +466 -0
- package/src/config/environments.ts +193 -0
- package/src/config/registry.ts +23 -0
- package/src/config/secrets.ts +128 -0
- package/src/dsl/parser.ts +24 -0
- package/src/dsl/schema.ts +189 -0
- package/src/dsl/types.ts +371 -0
- package/src/dsl/validator.ts +282 -0
- package/src/engine/captures.ts +66 -0
- package/src/engine/context.ts +76 -0
- package/src/engine/evidence.ts +35 -0
- package/src/engine/known-errors.ts +145 -0
- package/src/engine/retry.ts +11 -0
- package/src/engine/runner.ts +746 -0
- package/src/engine/step-result.ts +64 -0
- package/src/flows/catalog-normalizer.ts +86 -0
- package/src/flows/compiler.ts +247 -0
- package/src/flows/concat.ts +149 -0
- package/src/flows/parser.ts +27 -0
- package/src/flows/schema.ts +154 -0
- package/src/flows/types.ts +130 -0
- package/src/flows/validator.ts +468 -0
- package/src/llm/system-prompt.md +9 -0
- package/src/reports/html-report.ts +113 -0
- package/src/reports/junit-report.ts +55 -0
- package/src/types/oracledb.d.ts +1 -0
- package/templates/.gitkeep +0 -0
- package/templates/api/create-test-case.json +5 -0
- package/templates/api/record-test-activity.json +6 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +17 -0
- package/web/index.html +12 -0
- package/web/src/App.tsx +6588 -0
- package/web/src/main.tsx +10 -0
- package/web/src/styles.css +3147 -0
- package/index.js +0 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { assertQueryResult } from "./assertions.js";
|
|
2
|
+
import { normalizeBindParamRecord, validateQueryParams } from "./query-catalog.js";
|
|
3
|
+
export class OracleClient {
|
|
4
|
+
env;
|
|
5
|
+
constructor(env) {
|
|
6
|
+
this.env = env;
|
|
7
|
+
}
|
|
8
|
+
async query(entry, params) {
|
|
9
|
+
const bindParams = normalizeBindParamRecord(params);
|
|
10
|
+
validateQueryParams(entry, bindParams);
|
|
11
|
+
if (!this.env.oracle.user || !this.env.oracle.password || !this.env.oracle.connectString) {
|
|
12
|
+
throw new Error("ADFINEM_DB_USER, ADFINEM_DB_PASSWORD, and ADFINEM_DB_CONNECT_STRING are required for DB execution.");
|
|
13
|
+
}
|
|
14
|
+
const oracledb = await loadOracleDbModule();
|
|
15
|
+
const connection = await oracledb.getConnection(this.env.oracle);
|
|
16
|
+
try {
|
|
17
|
+
const prepared = expandArrayBinds(entry.sql, bindParams);
|
|
18
|
+
const result = await connection.execute(prepared.sql, prepared.binds, { outFormat: oracledb.OUT_FORMAT_OBJECT });
|
|
19
|
+
return limitRows(result.rows ?? [], entry.maxRows);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
await closeQuietly(connection);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async assert(entry, params) {
|
|
26
|
+
const rows = await this.query(entry, params);
|
|
27
|
+
assertQueryResult(entry, rows);
|
|
28
|
+
return rows;
|
|
29
|
+
}
|
|
30
|
+
async execute(entry, params) {
|
|
31
|
+
const bindParams = normalizeBindParamRecord(params);
|
|
32
|
+
validateQueryParams(entry, bindParams);
|
|
33
|
+
if (!this.env.oracle.user || !this.env.oracle.password || !this.env.oracle.connectString) {
|
|
34
|
+
throw new Error("ADFINEM_DB_USER, ADFINEM_DB_PASSWORD, and ADFINEM_DB_CONNECT_STRING are required for DB execution.");
|
|
35
|
+
}
|
|
36
|
+
const oracledb = await loadOracleDbModule();
|
|
37
|
+
const connection = await oracledb.getConnection(this.env.oracle);
|
|
38
|
+
try {
|
|
39
|
+
const prepared = expandArrayBinds(entry.sql, bindParams);
|
|
40
|
+
const result = await connection.execute(prepared.sql, prepared.binds, {
|
|
41
|
+
outFormat: oracledb.OUT_FORMAT_OBJECT,
|
|
42
|
+
autoCommit: true
|
|
43
|
+
});
|
|
44
|
+
const execution = result;
|
|
45
|
+
return {
|
|
46
|
+
status: "passed",
|
|
47
|
+
rowsAffected: execution.rowsAffected,
|
|
48
|
+
outBinds: execution.outBinds
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await closeQuietly(connection);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function closeQuietly(connection) {
|
|
57
|
+
try {
|
|
58
|
+
await connection.close();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
62
|
+
// Connection close failures must not mask the operation error from the surrounding try.
|
|
63
|
+
console.warn(`Oracle connection close failed: ${message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
let oracleDbModule;
|
|
67
|
+
async function loadOracleDbModule() {
|
|
68
|
+
if (oracleDbModule)
|
|
69
|
+
return oracleDbModule;
|
|
70
|
+
try {
|
|
71
|
+
const imported = await import("oracledb");
|
|
72
|
+
oracleDbModule = normalizeOracleDbModule(imported);
|
|
73
|
+
return oracleDbModule;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
77
|
+
throw new Error(`Could not load optional dependency 'oracledb'. Run 'npm install' and ensure Oracle client libraries are available. Original error: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function normalizeOracleDbModule(imported) {
|
|
81
|
+
const moduleValue = imported;
|
|
82
|
+
const candidate = typeof moduleValue.getConnection === "function" ? moduleValue : moduleValue.default;
|
|
83
|
+
if (!candidate || typeof candidate.getConnection !== "function") {
|
|
84
|
+
throw new Error("Loaded 'oracledb', but it did not expose getConnection().");
|
|
85
|
+
}
|
|
86
|
+
return candidate;
|
|
87
|
+
}
|
|
88
|
+
function limitRows(rows, maxRows) {
|
|
89
|
+
if (maxRows === undefined)
|
|
90
|
+
return rows;
|
|
91
|
+
return rows.slice(0, maxRows);
|
|
92
|
+
}
|
|
93
|
+
function expandArrayBinds(sql, params) {
|
|
94
|
+
const binds = {};
|
|
95
|
+
let expandedSql = sql;
|
|
96
|
+
for (const [name, value] of Object.entries(params)) {
|
|
97
|
+
if (!Array.isArray(value)) {
|
|
98
|
+
binds[name] = value;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (value.length === 0) {
|
|
102
|
+
throw new Error(`Query param '${name}' must contain at least one value.`);
|
|
103
|
+
}
|
|
104
|
+
const placeholders = value.map((entry, index) => {
|
|
105
|
+
const bindName = `${name}_${index}`;
|
|
106
|
+
binds[bindName] = entry;
|
|
107
|
+
return `:${bindName}`;
|
|
108
|
+
});
|
|
109
|
+
expandedSql = expandedSql.replace(new RegExp(`:${escapeRegExp(name)}\\b`, "g"), placeholders.join(", "));
|
|
110
|
+
}
|
|
111
|
+
return { sql: expandedSql, binds };
|
|
112
|
+
}
|
|
113
|
+
function escapeRegExp(value) {
|
|
114
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
115
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { isLuhnValid } from "../../config/secrets.js";
|
|
2
|
+
export function validateQueryParams(entry, params) {
|
|
3
|
+
const specs = normalizeBindParamRecord(entry.params ?? {});
|
|
4
|
+
const normalizedParams = normalizeBindParamRecord(params);
|
|
5
|
+
for (const [name, spec] of Object.entries(specs)) {
|
|
6
|
+
const value = normalizedParams[name];
|
|
7
|
+
if (spec.required && (value === undefined || value === null || value === "")) {
|
|
8
|
+
throw new Error(`Missing required query param '${name}'.`);
|
|
9
|
+
}
|
|
10
|
+
validateParamValue(name, value, spec);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function normalizeQueryCatalog(queries) {
|
|
14
|
+
return Object.fromEntries(Object.entries(queries).map(([id, entry]) => [id, normalizeQueryCatalogEntry(entry)]));
|
|
15
|
+
}
|
|
16
|
+
export function normalizeQueryCatalogEntry(entry) {
|
|
17
|
+
return {
|
|
18
|
+
...entry,
|
|
19
|
+
params: entry.params ? normalizeBindParamRecord(entry.params) : undefined
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function normalizeBindParamRecord(value) {
|
|
23
|
+
const normalized = {};
|
|
24
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
25
|
+
const bindName = normalizeBindParamName(name);
|
|
26
|
+
if (bindName)
|
|
27
|
+
normalized[bindName] = entry;
|
|
28
|
+
}
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
31
|
+
export function normalizeBindParamName(name) {
|
|
32
|
+
return String(name ?? "").trim().replace(/^:+/, "");
|
|
33
|
+
}
|
|
34
|
+
function validateParamValue(name, value, spec) {
|
|
35
|
+
if (value === undefined || value === null)
|
|
36
|
+
return;
|
|
37
|
+
if (spec.type?.endsWith("[]")) {
|
|
38
|
+
const expectedItemType = spec.type.slice(0, -"[]".length);
|
|
39
|
+
const values = Array.isArray(value) ? value : [value];
|
|
40
|
+
if (values.length === 0)
|
|
41
|
+
throw new Error(`Query param '${name}' must contain at least one value.`);
|
|
42
|
+
for (const item of values) {
|
|
43
|
+
if (typeof item !== expectedItemType) {
|
|
44
|
+
throw new Error(`Query param '${name}' must be ${spec.type}; got ${typeof item} item.`);
|
|
45
|
+
}
|
|
46
|
+
validatePattern(name, item, spec);
|
|
47
|
+
validateLuhn(name, item, spec);
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (spec.type && typeof value !== spec.type) {
|
|
52
|
+
throw new Error(`Query param '${name}' must be ${spec.type}; got ${typeof value}.`);
|
|
53
|
+
}
|
|
54
|
+
validatePattern(name, value, spec);
|
|
55
|
+
validateLuhn(name, value, spec);
|
|
56
|
+
}
|
|
57
|
+
function validatePattern(name, value, spec) {
|
|
58
|
+
if (spec.pattern && typeof value === "string" && !new RegExp(spec.pattern).test(value)) {
|
|
59
|
+
throw new Error(`Query param '${name}' does not match ${spec.pattern}.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function validateLuhn(name, value, spec) {
|
|
63
|
+
if (!spec.luhn)
|
|
64
|
+
return;
|
|
65
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
66
|
+
throw new Error(`Query param '${name}' must be a string or number for Luhn validation.`);
|
|
67
|
+
}
|
|
68
|
+
const digits = String(value).replace(/\D+/g, "");
|
|
69
|
+
if (digits.length < 12 || digits.length > 19) {
|
|
70
|
+
throw new Error(`Query param '${name}' must be 12-19 digits for Luhn validation.`);
|
|
71
|
+
}
|
|
72
|
+
if (!isLuhnValid(digits)) {
|
|
73
|
+
throw new Error(`Query param '${name}' fails Luhn check.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export function buildBatchCommand(entry, params) {
|
|
2
|
+
return buildBatchCommandDetails(entry, params).command;
|
|
3
|
+
}
|
|
4
|
+
export function buildBatchDisplayCommand(entry, params) {
|
|
5
|
+
return buildBatchCommandDetails(entry, params).displayCommand;
|
|
6
|
+
}
|
|
7
|
+
export function buildBatchCommandDetails(entry, params, extraArgs = []) {
|
|
8
|
+
const fixedArgs = (entry.fixedArgs ?? []).map((value) => resolvePlaceholders(String(value), params));
|
|
9
|
+
const args = (entry.args ?? []).map((arg) => {
|
|
10
|
+
const hasValue = Object.prototype.hasOwnProperty.call(params, arg.name);
|
|
11
|
+
const value = params[arg.name];
|
|
12
|
+
if (!hasValue || value === undefined || value === null) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
if (value === "") {
|
|
16
|
+
if (arg.required === false)
|
|
17
|
+
return undefined;
|
|
18
|
+
throw new Error(`Missing required batch arg '${arg.name}'.`);
|
|
19
|
+
}
|
|
20
|
+
if (arg.pattern && !new RegExp(arg.pattern).test(String(value))) {
|
|
21
|
+
throw new Error(`Batch arg '${arg.name}' does not match ${arg.pattern}.`);
|
|
22
|
+
}
|
|
23
|
+
return resolvePlaceholders(String(value), params);
|
|
24
|
+
}).filter((value) => Boolean(value));
|
|
25
|
+
const appendedArgs = extraArgs.map((value) => resolvePlaceholders(String(value), params));
|
|
26
|
+
const commandTokens = [resolvePlaceholders(entry.command, params), ...fixedArgs, ...args, ...appendedArgs];
|
|
27
|
+
const command = commandTokens.map(shellQuote).join(" ");
|
|
28
|
+
const displayCommand = commandTokens.map(displayToken).join(" ");
|
|
29
|
+
const envEntries = Object.entries(entry.environment ?? {})
|
|
30
|
+
.map(([name, value]) => [name, resolvePlaceholders(String(value), params)]);
|
|
31
|
+
const envPrefix = envEntries
|
|
32
|
+
.map(([name, value]) => `${name}=${shellQuote(value)}`)
|
|
33
|
+
.join(" ");
|
|
34
|
+
const displayEnvPrefix = envEntries
|
|
35
|
+
.map(([name, value]) => `${name}=${displayToken(value)}`)
|
|
36
|
+
.join(" ");
|
|
37
|
+
const commandWithEnv = envPrefix ? `${envPrefix} ${command}` : command;
|
|
38
|
+
const displayCommandWithEnv = displayEnvPrefix ? `${displayEnvPrefix} ${displayCommand}` : displayCommand;
|
|
39
|
+
const workingDirectory = entry.useWorkingDirectory === true
|
|
40
|
+
? blankToUndefined(entry.workingDirectory ? resolvePlaceholders(entry.workingDirectory, params) : undefined)
|
|
41
|
+
: undefined;
|
|
42
|
+
return {
|
|
43
|
+
command: workingDirectory
|
|
44
|
+
? `cd ${shellQuote(workingDirectory)} && ${commandWithEnv}`
|
|
45
|
+
: commandWithEnv,
|
|
46
|
+
displayCommand: workingDirectory
|
|
47
|
+
? `cd ${displayToken(workingDirectory)} && ${displayCommandWithEnv}`
|
|
48
|
+
: displayCommandWithEnv
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function shellQuote(value) {
|
|
52
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
53
|
+
}
|
|
54
|
+
function resolvePlaceholders(value, params) {
|
|
55
|
+
return value.replace(/\$\{([A-Za-z0-9_.-]+)\}/g, (_match, name) => {
|
|
56
|
+
const paramValue = params[name];
|
|
57
|
+
if (paramValue !== undefined && paramValue !== null)
|
|
58
|
+
return String(paramValue);
|
|
59
|
+
return process.env[name] ?? "";
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function displayToken(value) {
|
|
63
|
+
if (value === "")
|
|
64
|
+
return "\"\"";
|
|
65
|
+
if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value))
|
|
66
|
+
return value;
|
|
67
|
+
return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
68
|
+
}
|
|
69
|
+
function blankToUndefined(value) {
|
|
70
|
+
return value && value.trim() ? value : undefined;
|
|
71
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function batchInputFiles(entry) {
|
|
2
|
+
return entry?.inputFiles ?? [];
|
|
3
|
+
}
|
|
4
|
+
export function batchInputFileParamNames(entry) {
|
|
5
|
+
return batchInputFiles(entry).flatMap((file) => {
|
|
6
|
+
const names = [file.name];
|
|
7
|
+
if (file.paramName && file.paramName !== file.name)
|
|
8
|
+
names.push(file.paramName);
|
|
9
|
+
return names;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export function batchFileBackedArgNames(entry) {
|
|
13
|
+
return new Set(batchInputFiles(entry).map((file) => file.paramName || file.name));
|
|
14
|
+
}
|
|
15
|
+
export function isBatchInputFileValue(value) {
|
|
16
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
17
|
+
}
|
|
18
|
+
export function hasBatchInputFilePayload(value) {
|
|
19
|
+
if (typeof value === "string")
|
|
20
|
+
return value.trim().length > 0;
|
|
21
|
+
if (!isBatchInputFileValue(value))
|
|
22
|
+
return false;
|
|
23
|
+
return Boolean(value.localPath || value.contentBase64);
|
|
24
|
+
}
|
|
25
|
+
export function batchArgParamsForValidation(params, entry) {
|
|
26
|
+
const next = { ...params };
|
|
27
|
+
for (const file of batchInputFiles(entry)) {
|
|
28
|
+
const value = params[file.name];
|
|
29
|
+
if (!hasBatchInputFilePayload(value))
|
|
30
|
+
continue;
|
|
31
|
+
next[file.paramName || file.name] = isBatchInputFileValue(value)
|
|
32
|
+
? value.remotePath || file.remotePath || "__uploaded_input_file__"
|
|
33
|
+
: file.remotePath || "__uploaded_input_file__";
|
|
34
|
+
}
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { buildBatchCommandDetails } from "./batch-catalog.js";
|
|
4
|
+
import { hasBatchInputFilePayload, isBatchInputFileValue } from "./batch-input-files.js";
|
|
5
|
+
import { cancellationError, isCancellationError } from "../../engine/step-result.js";
|
|
6
|
+
export class BatchRunner {
|
|
7
|
+
ssh;
|
|
8
|
+
rootDir;
|
|
9
|
+
constructor(ssh, rootDir = process.cwd()) {
|
|
10
|
+
this.ssh = ssh;
|
|
11
|
+
this.rootDir = rootDir;
|
|
12
|
+
}
|
|
13
|
+
async run(entry, params, options = {}) {
|
|
14
|
+
const uploadPlan = await this.uploadInputFiles(entry, params, options);
|
|
15
|
+
const { command, displayCommand } = buildBatchCommandDetails(entry, uploadPlan.params, uploadPlan.appendedArgs);
|
|
16
|
+
const maxAttempts = Math.max(1, options.attempts ?? 1);
|
|
17
|
+
const delayMs = Math.max(0, options.delayMs ?? 0);
|
|
18
|
+
const attempts = [];
|
|
19
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
20
|
+
if (options.signal?.aborted)
|
|
21
|
+
throw cancellationError();
|
|
22
|
+
const startedAt = new Date().toISOString();
|
|
23
|
+
try {
|
|
24
|
+
const result = await this.ssh.execute(entry.hostRef, command, (entry.timeoutSeconds ?? 3600) * 1000, options.signal);
|
|
25
|
+
const endedAt = new Date().toISOString();
|
|
26
|
+
const status = batchSucceeded(entry, result) ? "passed" : "failed";
|
|
27
|
+
const diagnostics = extractBatchDiagnostics(result.stdout, result.stderr);
|
|
28
|
+
const attemptResult = {
|
|
29
|
+
attempt,
|
|
30
|
+
startedAt,
|
|
31
|
+
endedAt,
|
|
32
|
+
command,
|
|
33
|
+
displayCommand,
|
|
34
|
+
stdout: result.stdout,
|
|
35
|
+
stderr: result.stderr,
|
|
36
|
+
exitCode: result.exitCode,
|
|
37
|
+
tracePath: diagnostics.tracePath,
|
|
38
|
+
errno: diagnostics.errno,
|
|
39
|
+
stdoutTruncated: result.stdoutTruncated,
|
|
40
|
+
stderrTruncated: result.stderrTruncated,
|
|
41
|
+
status,
|
|
42
|
+
error: status === "failed" ? batchFailureMessage(entry, result) : undefined
|
|
43
|
+
};
|
|
44
|
+
attempts.push(attemptResult);
|
|
45
|
+
if (status === "passed")
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (isCancellationError(error))
|
|
50
|
+
throw error;
|
|
51
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
52
|
+
attempts.push({
|
|
53
|
+
attempt,
|
|
54
|
+
startedAt,
|
|
55
|
+
endedAt: new Date().toISOString(),
|
|
56
|
+
command,
|
|
57
|
+
displayCommand,
|
|
58
|
+
stdout: "",
|
|
59
|
+
stderr: "",
|
|
60
|
+
status: "failed",
|
|
61
|
+
error: err.message
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (attempt < maxAttempts && delayMs > 0)
|
|
65
|
+
await sleep(delayMs, options.signal);
|
|
66
|
+
}
|
|
67
|
+
const summary = summarizeBatch(command, displayCommand, attempts, uploadPlan.fileUploads);
|
|
68
|
+
return await this.withOutputFiles(entry, uploadPlan.params, summary, options);
|
|
69
|
+
}
|
|
70
|
+
async uploadInputFiles(entry, params, options) {
|
|
71
|
+
const inputFiles = entry.inputFiles ?? [];
|
|
72
|
+
if (inputFiles.length === 0)
|
|
73
|
+
return { params, appendedArgs: [], fileUploads: undefined };
|
|
74
|
+
const commandParams = { ...params };
|
|
75
|
+
const appendedArgs = [];
|
|
76
|
+
const fileUploads = [];
|
|
77
|
+
const timeoutMs = Math.max(30_000, (entry.timeoutSeconds ?? 3600) * 1000);
|
|
78
|
+
for (const spec of inputFiles) {
|
|
79
|
+
const value = params[spec.name];
|
|
80
|
+
if (!hasBatchInputFilePayload(value)) {
|
|
81
|
+
if (spec.required !== false)
|
|
82
|
+
throw new Error(`Batch input file '${spec.name}' is required.`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const file = normalizeInputFileValue(value);
|
|
86
|
+
const fileName = file.fileName || (file.localPath ? basename(file.localPath) : spec.name);
|
|
87
|
+
const remotePath = resolveRemotePath(spec, file, commandParams, fileName);
|
|
88
|
+
const content = await readInputFileContent(this.rootDir, file);
|
|
89
|
+
const upload = {
|
|
90
|
+
name: spec.name,
|
|
91
|
+
fileName,
|
|
92
|
+
localPath: file.localPath,
|
|
93
|
+
remotePath,
|
|
94
|
+
sizeBytes: content.byteLength,
|
|
95
|
+
paramName: spec.paramName || spec.name,
|
|
96
|
+
appendedAsArg: spec.appendAsArg === true || undefined,
|
|
97
|
+
status: "uploaded"
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
await this.ssh.uploadFile(entry.hostRef, remotePath, content, timeoutMs, options.signal);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
104
|
+
fileUploads.push({ ...upload, status: "failed", error: err.message });
|
|
105
|
+
throw new Error(`SFTP upload failed for batch input file '${spec.name}' to '${remotePath}': ${err.message}`);
|
|
106
|
+
}
|
|
107
|
+
fileUploads.push(upload);
|
|
108
|
+
commandParams[spec.paramName || spec.name] = remotePath;
|
|
109
|
+
if (spec.appendAsArg)
|
|
110
|
+
appendedArgs.push(remotePath);
|
|
111
|
+
}
|
|
112
|
+
return { params: commandParams, appendedArgs, fileUploads };
|
|
113
|
+
}
|
|
114
|
+
async withOutputFiles(entry, params, summary, options) {
|
|
115
|
+
const outputFiles = entry.outputFiles ?? [];
|
|
116
|
+
if (outputFiles.length === 0)
|
|
117
|
+
return summary;
|
|
118
|
+
const timeoutMs = Math.max(30_000, (entry.timeoutSeconds ?? 3600) * 1000);
|
|
119
|
+
const last = summary.attempts[summary.attempts.length - 1];
|
|
120
|
+
const fileDownloads = [];
|
|
121
|
+
for (const spec of outputFiles) {
|
|
122
|
+
fileDownloads.push(await this.retrieveOutputFile(entry, spec, params, last, timeoutMs, options));
|
|
123
|
+
}
|
|
124
|
+
const requiredFailure = fileDownloads.find((file) => file.status === "failed" && fileRequired(file.name, outputFiles));
|
|
125
|
+
if (requiredFailure && last) {
|
|
126
|
+
last.status = "failed";
|
|
127
|
+
last.error = requiredFailure.error ?? `Batch output file '${requiredFailure.name}' was not retrieved.`;
|
|
128
|
+
summary.status = "failed";
|
|
129
|
+
}
|
|
130
|
+
return { ...summary, fileDownloads };
|
|
131
|
+
}
|
|
132
|
+
async retrieveOutputFile(entry, spec, params, last, timeoutMs, options) {
|
|
133
|
+
const source = spec.source ?? (spec.remotePath ? "explicit" : "stderr");
|
|
134
|
+
const baseEvidence = { name: spec.name, source, status: "skipped" };
|
|
135
|
+
try {
|
|
136
|
+
const remotePath = resolveOutputRemotePath(spec, params, last);
|
|
137
|
+
if (!remotePath) {
|
|
138
|
+
if (spec.required === false)
|
|
139
|
+
return { ...baseEvidence, status: "skipped", error: "No generated file path was found." };
|
|
140
|
+
return { ...baseEvidence, status: "failed", error: `Batch output file '${spec.name}' path was not found in ${source}.` };
|
|
141
|
+
}
|
|
142
|
+
const templateVars = outputTemplateVars(spec, remotePath, params);
|
|
143
|
+
const decryptCommandTemplate = spec.decrypt?.command?.trim();
|
|
144
|
+
let decryptCommand;
|
|
145
|
+
let downloadRemotePath = remotePath;
|
|
146
|
+
let decryptExitCode;
|
|
147
|
+
let decryptStdout;
|
|
148
|
+
let decryptStderr;
|
|
149
|
+
let decryptedRemotePath;
|
|
150
|
+
if (decryptCommandTemplate) {
|
|
151
|
+
decryptedRemotePath = resolveTemplate(spec.decrypt?.outputRemotePath || "${remotePath}.dec", {
|
|
152
|
+
...templateVars,
|
|
153
|
+
decryptedRemotePath: `${remotePath}.dec`
|
|
154
|
+
});
|
|
155
|
+
decryptCommand = resolveTemplate(decryptCommandTemplate, {
|
|
156
|
+
...templateVars,
|
|
157
|
+
decryptedRemotePath
|
|
158
|
+
});
|
|
159
|
+
const decrypt = await this.ssh.execute(entry.hostRef, decryptCommand, timeoutMs, options.signal);
|
|
160
|
+
decryptExitCode = decrypt.exitCode;
|
|
161
|
+
decryptStdout = decrypt.stdout;
|
|
162
|
+
decryptStderr = decrypt.stderr;
|
|
163
|
+
if (decrypt.exitCode !== 0 && spec.decrypt?.required !== false) {
|
|
164
|
+
return {
|
|
165
|
+
...baseEvidence,
|
|
166
|
+
remotePath,
|
|
167
|
+
decryptCommand,
|
|
168
|
+
decryptedRemotePath,
|
|
169
|
+
decryptExitCode,
|
|
170
|
+
decryptStdout,
|
|
171
|
+
decryptStderr,
|
|
172
|
+
status: "failed",
|
|
173
|
+
error: `Decrypt command failed with exit code ${decrypt.exitCode}.`
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (decrypt.exitCode === 0)
|
|
177
|
+
downloadRemotePath = decryptedRemotePath;
|
|
178
|
+
}
|
|
179
|
+
if (spec.download === false) {
|
|
180
|
+
return {
|
|
181
|
+
...baseEvidence,
|
|
182
|
+
remotePath,
|
|
183
|
+
decryptCommand,
|
|
184
|
+
decryptedRemotePath,
|
|
185
|
+
decryptExitCode,
|
|
186
|
+
decryptStdout,
|
|
187
|
+
decryptStderr,
|
|
188
|
+
status: "skipped"
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (!options.downloadDir) {
|
|
192
|
+
return { ...baseEvidence, remotePath: downloadRemotePath, status: "failed", error: "No local evidence download directory was configured." };
|
|
193
|
+
}
|
|
194
|
+
const content = await this.ssh.downloadFile(entry.hostRef, downloadRemotePath, timeoutMs, options.signal);
|
|
195
|
+
const localPath = await writeDownloadedFile(options.downloadDir, spec.name, downloadRemotePath, content);
|
|
196
|
+
return {
|
|
197
|
+
...baseEvidence,
|
|
198
|
+
remotePath,
|
|
199
|
+
localPath,
|
|
200
|
+
sizeBytes: content.byteLength,
|
|
201
|
+
decryptCommand,
|
|
202
|
+
decryptedRemotePath,
|
|
203
|
+
decryptExitCode,
|
|
204
|
+
decryptStdout,
|
|
205
|
+
decryptStderr,
|
|
206
|
+
status: "downloaded"
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
211
|
+
return { ...baseEvidence, status: "failed", error: err.message };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function batchSucceeded(entry, result) {
|
|
216
|
+
const allowedExitCodes = entry.success?.exitCodes ?? [0];
|
|
217
|
+
if (!allowedExitCodes.includes(result.exitCode))
|
|
218
|
+
return false;
|
|
219
|
+
for (const required of entry.success?.requiredOutput ?? []) {
|
|
220
|
+
if (!result.stdout.includes(required) && !result.stderr.includes(required)) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
function batchFailureMessage(entry, result) {
|
|
227
|
+
const allowedExitCodes = entry.success?.exitCodes ?? [0];
|
|
228
|
+
if (!allowedExitCodes.includes(result.exitCode)) {
|
|
229
|
+
const output = summarizeCommandOutput(result.stderr || result.stdout);
|
|
230
|
+
return output
|
|
231
|
+
? `Batch failed with exit code ${result.exitCode}: ${output}`
|
|
232
|
+
: `Batch failed with exit code ${result.exitCode}.`;
|
|
233
|
+
}
|
|
234
|
+
for (const required of entry.success?.requiredOutput ?? []) {
|
|
235
|
+
if (!result.stdout.includes(required) && !result.stderr.includes(required)) {
|
|
236
|
+
return `Batch output did not contain required text '${required}'.`;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return "Batch did not satisfy success criteria.";
|
|
240
|
+
}
|
|
241
|
+
function summarizeCommandOutput(value) {
|
|
242
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
243
|
+
if (normalized.length <= 300)
|
|
244
|
+
return normalized;
|
|
245
|
+
return `${normalized.slice(0, 297)}...`;
|
|
246
|
+
}
|
|
247
|
+
function summarizeBatch(command, displayCommand, attempts, fileUploads) {
|
|
248
|
+
const last = attempts[attempts.length - 1];
|
|
249
|
+
return {
|
|
250
|
+
command,
|
|
251
|
+
displayCommand,
|
|
252
|
+
status: last?.status ?? "failed",
|
|
253
|
+
fileUploads,
|
|
254
|
+
attempts,
|
|
255
|
+
stdout: last?.stdout ?? "",
|
|
256
|
+
stderr: last?.stderr ?? "",
|
|
257
|
+
exitCode: last?.exitCode,
|
|
258
|
+
tracePath: last?.tracePath,
|
|
259
|
+
errno: last?.errno,
|
|
260
|
+
stdoutTruncated: last?.stdoutTruncated,
|
|
261
|
+
stderrTruncated: last?.stderrTruncated
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function fileRequired(name, specs) {
|
|
265
|
+
return specs.find((spec) => spec.name === name)?.required !== false;
|
|
266
|
+
}
|
|
267
|
+
function normalizeInputFileValue(value) {
|
|
268
|
+
if (typeof value === "string")
|
|
269
|
+
return { localPath: value, fileName: basename(value) };
|
|
270
|
+
if (isBatchInputFileValue(value))
|
|
271
|
+
return value;
|
|
272
|
+
throw new Error("Batch input file value must be a selected file object.");
|
|
273
|
+
}
|
|
274
|
+
function resolveRemotePath(spec, value, params, fileName) {
|
|
275
|
+
const template = value.remotePath || spec.remotePath;
|
|
276
|
+
if (!template?.trim())
|
|
277
|
+
throw new Error(`Batch input file '${spec.name}' needs a remote path.`);
|
|
278
|
+
const baseName = fileName.includes(".") ? fileName.slice(0, fileName.lastIndexOf(".")) : fileName;
|
|
279
|
+
const extension = fileName.includes(".") ? fileName.slice(fileName.lastIndexOf(".") + 1) : "";
|
|
280
|
+
const rendered = template.replace(/\$\{([A-Za-z0-9_.-]+)\}/g, (_match, name) => {
|
|
281
|
+
if (name === "fileName")
|
|
282
|
+
return fileName;
|
|
283
|
+
if (name === "baseName")
|
|
284
|
+
return baseName;
|
|
285
|
+
if (name === "extension")
|
|
286
|
+
return extension;
|
|
287
|
+
if (name === "inputName")
|
|
288
|
+
return spec.name;
|
|
289
|
+
const value = params[name] ?? process.env[name];
|
|
290
|
+
if (value === undefined || value === null)
|
|
291
|
+
throw new Error(`Batch input file '${spec.name}' remote path references unknown value '${name}'.`);
|
|
292
|
+
return String(value);
|
|
293
|
+
});
|
|
294
|
+
return /[\\/]$/.test(rendered) ? `${rendered}${fileName}` : rendered;
|
|
295
|
+
}
|
|
296
|
+
function resolveOutputRemotePath(spec, params, last) {
|
|
297
|
+
if (spec.remotePath?.trim()) {
|
|
298
|
+
return resolveTemplate(spec.remotePath, outputTemplateVars(spec, spec.remotePath, params));
|
|
299
|
+
}
|
|
300
|
+
if (!last)
|
|
301
|
+
return undefined;
|
|
302
|
+
const source = spec.source ?? "stderr";
|
|
303
|
+
const output = source === "stdout"
|
|
304
|
+
? last.stdout
|
|
305
|
+
: source === "both"
|
|
306
|
+
? `${last.stdout}\n${last.stderr}`
|
|
307
|
+
: last.stderr;
|
|
308
|
+
const pattern = spec.pathPattern?.trim() || "(\\/[^\\s'\"<>]+)";
|
|
309
|
+
const match = new RegExp(pattern, "m").exec(output);
|
|
310
|
+
return (match?.[1] ?? match?.[0])?.trim();
|
|
311
|
+
}
|
|
312
|
+
function outputTemplateVars(spec, remotePath, params) {
|
|
313
|
+
const fileName = remoteBaseName(remotePath) || `${spec.name}.out`;
|
|
314
|
+
const dot = fileName.lastIndexOf(".");
|
|
315
|
+
return {
|
|
316
|
+
...params,
|
|
317
|
+
outputName: spec.name,
|
|
318
|
+
remotePath,
|
|
319
|
+
fileName,
|
|
320
|
+
baseName: dot > 0 ? fileName.slice(0, dot) : fileName,
|
|
321
|
+
extension: dot > 0 ? fileName.slice(dot + 1) : ""
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function resolveTemplate(value, params) {
|
|
325
|
+
return value.replace(/\$\{([A-Za-z0-9_.-]+)\}/g, (_match, name) => {
|
|
326
|
+
const next = params[name] ?? process.env[name];
|
|
327
|
+
if (next === undefined || next === null)
|
|
328
|
+
throw new Error(`Batch output file template references unknown value '${name}'.`);
|
|
329
|
+
return String(next);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
async function writeDownloadedFile(downloadDir, outputName, remotePath, content) {
|
|
333
|
+
const safeOutputName = sanitizePathPart(outputName || "output");
|
|
334
|
+
const safeFileName = sanitizePathPart(remoteBaseName(remotePath) || `${safeOutputName}.out`);
|
|
335
|
+
const localPath = join(downloadDir, safeOutputName, safeFileName);
|
|
336
|
+
await mkdir(dirname(localPath), { recursive: true });
|
|
337
|
+
await writeFile(localPath, content);
|
|
338
|
+
return localPath;
|
|
339
|
+
}
|
|
340
|
+
function remoteBaseName(remotePath) {
|
|
341
|
+
return remotePath.split(/[\\/]/).filter(Boolean).pop() ?? "";
|
|
342
|
+
}
|
|
343
|
+
function sanitizePathPart(value) {
|
|
344
|
+
return value.replace(/[^A-Za-z0-9_.-]/g, "_") || "file";
|
|
345
|
+
}
|
|
346
|
+
async function readInputFileContent(rootDir, value) {
|
|
347
|
+
if (value.contentBase64) {
|
|
348
|
+
const base64 = value.contentBase64.includes(",") ? value.contentBase64.slice(value.contentBase64.indexOf(",") + 1) : value.contentBase64;
|
|
349
|
+
return Buffer.from(base64, "base64");
|
|
350
|
+
}
|
|
351
|
+
if (!value.localPath)
|
|
352
|
+
throw new Error("Batch input file has no local file path.");
|
|
353
|
+
const path = isAbsolute(value.localPath) ? resolve(value.localPath) : resolve(rootDir, value.localPath);
|
|
354
|
+
const uploadsRoot = resolve(rootDir, "data", "batch-input-files");
|
|
355
|
+
const rel = relative(uploadsRoot, path);
|
|
356
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
357
|
+
throw new Error("Batch input file path must be under data/batch-input-files.");
|
|
358
|
+
}
|
|
359
|
+
return await readFile(path);
|
|
360
|
+
}
|
|
361
|
+
function extractBatchDiagnostics(stdout, stderr) {
|
|
362
|
+
const output = `${stdout}\n${stderr}`;
|
|
363
|
+
const tracePath = output.match(/FICHIER\s*:\s*(.+)/i)?.[1]?.trim();
|
|
364
|
+
const errno = output.match(/ERRNO\s*:\s*([^\s]+)/i)?.[1]?.trim();
|
|
365
|
+
return { tracePath, errno };
|
|
366
|
+
}
|
|
367
|
+
async function sleep(ms, signal) {
|
|
368
|
+
if (signal?.aborted)
|
|
369
|
+
throw cancellationError();
|
|
370
|
+
await new Promise((resolve, reject) => {
|
|
371
|
+
const timer = setTimeout(done, ms);
|
|
372
|
+
const abort = () => {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
reject(cancellationError());
|
|
375
|
+
};
|
|
376
|
+
function done() {
|
|
377
|
+
signal?.removeEventListener("abort", abort);
|
|
378
|
+
resolve();
|
|
379
|
+
}
|
|
380
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
381
|
+
});
|
|
382
|
+
}
|