n8n-nodes-pytenable
Version:
Un nodo de n8n para interactuar con la API de Tenable usando Pytenable en un sandbox de Docker.
188 lines (169 loc) • 5.77 kB
text/typescript
import {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
NodeApiError,
NodeConnectionType,
} from 'n8n-workflow';
import Docker from 'dockerode';
export class Pytenable implements INodeType {
description: INodeTypeDescription = {
displayName: 'Pytenable',
name: 'pytenable',
icon: 'file:pytenable.svg',
group: ['transform'],
version: 1,
description: 'Ejecuta código Python con Pytenable en un entorno Docker seguro',
defaults: {
name: 'Pytenable',
},
inputs: ['main' as unknown as NodeConnectionType],
outputs: ['main' as unknown as NodeConnectionType],
credentials: [
{
name: 'tenableApi',
required: true,
},
],
properties: [
{
displayName: 'Modo de Operación',
name: 'mode',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Ejecutar una vez para todos los items',
value: 'runOnceForAllItems',
description: 'Ejecuta este código una sola vez para todos los datos de entrada.',
},
{
name: 'Ejecutar una vez por cada item',
value: 'runOnceForEachItem',
description: 'Ejecuta este código una vez por cada item de entrada.',
},
],
default: 'runOnceForAllItems',
},
{
displayName: 'Código Python',
name: 'pythonCode',
type: 'string',
typeOptions: {
editor: 'codeNodeEditor',
editorLanguage: 'python',
},
default: '# El resultado debe ser una lista de diccionarios\n# return [{"resultado": "ok"}]',
description: 'El código Python a ejecutar. Usa las variables _items, _item y _credentials.',
noDataExpression: true,
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const node = this.getNode();
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
const imageName = 'pytenable-runner:latest';
const userCode = this.getNodeParameter('pythonCode', 0, '') as string;
const credentials = await this.getCredentials('tenableApi');
const runInContainer = async (inputData: object): Promise<INodeExecutionData[]> => {
const pythonWrapper = `
import sys
import json
import traceback
import asyncio
async def main():
try:
input_data_str = sys.stdin.read()
input_data = json.loads(input_data_str)
global _items, _item, _credentials
_item = input_data.get('item', {})
_items = input_data.get('items', [])
_credentials = input_data.get('credentials', {})
user_code = """
${userCode}
"""
exec_scope = {}
exec(f"async def user_main():\\n" + "\\n".join(f" {line}" for line in user_code.split("\\n")), globals(), exec_scope)
result = await exec_scope['user_main']()
print(json.dumps(result))
except Exception as e:
print(f"Error en el script de Python: {e}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
`;
let outputData = '';
let errorData = '';
const container = await docker.createContainer({
Image: imageName,
Cmd: ['python', '-u', '-c', pythonWrapper],
Tty: false,
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
OpenStdin: true,
StdinOnce: true,
HostConfig: {
AutoRemove: true,
},
});
const stream = await container.attach({ stream: true, stdin: true, stdout: true, stderr: true });
return new Promise<INodeExecutionData[]>((resolve, reject) => {
// Docker multiplexa stdout y stderr en un solo stream.
// El primer byte del chunk indica el canal (1 = stdout, 2 = stderr).
// Los siguientes 3 bytes son padding.
// Los 4 bytes finales son el tamaño del payload.
// Nos saltamos los 8 bytes de la cabecera para leer el mensaje.
stream.on('data', (chunk: Buffer) => {
if (chunk[0] === 1) {
outputData += chunk.slice(8).toString('utf-8');
} else if (chunk[0] === 2) {
errorData += chunk.slice(8).toString('utf-8');
}
});
stream.on('end', () => {
if (errorData) {
return reject(new NodeApiError(node, { message: `Error en el contenedor de Python: ${errorData}` }));
}
try {
const result = JSON.parse(outputData);
const returnItems = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(result),
{ itemData: [] },
);
return resolve(returnItems);
} catch (error) {
const parseErrorMsg = `Error al parsear la salida de Python. Salida recibida: "${outputData}". Error: ${(error as Error).message}`;
return reject(new NodeApiError(node, { message: parseErrorMsg }));
}
});
stream.on('error', (err: Error) => {
return reject(new NodeApiError(node, { message: `Error de stream con Docker: ${err.message}` }));
});
container.start((err: Error) => {
if (err) return reject(new NodeApiError(node, { message: `Error al iniciar el contenedor: ${err.message}` }));
stream.write(JSON.stringify(inputData));
stream.end();
});
});
};
const mode = this.getNodeParameter('mode', 0, 'runOnceForAllItems');
const inputItems = this.getInputData();
if (mode === 'runOnceForAllItems') {
const result = await runInContainer({ items: inputItems.map(item => item.json), credentials });
return [result];
} else { // runOnceForEachItem
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < inputItems.length; i++) {
const resultItems = await runInContainer({ item: inputItems[i].json, credentials });
for (const item of resultItems) {
item.pairedItem = { item: i };
returnData.push(item);
}
}
return [returnData];
}
}
}