@seqera/node-red-seqera
Version:
Node-RED nodes for interacting with the Seqera Platform API
349 lines (300 loc) • 14.3 kB
JavaScript
module.exports = function (RED) {
// Add HTTP endpoint for pipeline auto-complete
RED.httpAdmin.get("/admin/seqera/pipelines/:nodeId", async function (req, res) {
try {
const nodeId = req.params.nodeId;
const search = req.query.search || "";
// Try to get the node, but it might not exist yet if this is a new node
let node = RED.nodes.getNode(nodeId);
let seqeraConfigId = null;
let baseUrl = "https://api.cloud.seqera.io";
let workspaceId = null;
if (node && node.seqeraConfig) {
// Node exists and has config
seqeraConfigId = node.seqeraConfig.id;
baseUrl = node.seqeraConfig.baseUrl || baseUrl;
workspaceId = node.seqeraConfig.workspaceId;
// Check for workspace ID override in the node configuration
if (node.workspaceIdProp && node.workspaceIdPropType === "str" && node.workspaceIdProp.trim()) {
workspaceId = node.workspaceIdProp;
}
} else {
// Try to get config ID from request body or query params
seqeraConfigId = req.query.seqeraConfig || req.body?.seqeraConfig;
if (seqeraConfigId) {
const configNode = RED.nodes.getNode(seqeraConfigId);
if (configNode) {
baseUrl = configNode.baseUrl || baseUrl;
workspaceId = configNode.workspaceId;
}
}
}
// Also check for workspace ID override in query parameters (from frontend)
if (req.query.workspaceId && req.query.workspaceId.trim()) {
workspaceId = req.query.workspaceId;
}
if (!workspaceId) {
return res.json([]); // Return empty array instead of error for better UX
}
// Create a temporary node-like object for apiCall if we don't have a real node
const nodeForApi = node || {
seqeraConfig: RED.nodes.getNode(seqeraConfigId),
warn: () => {}, // Dummy warn function
};
if (!nodeForApi.seqeraConfig) {
return res.json([]);
}
const { apiCall } = require("./_utils");
// Build the pipelines API URL
const pipelinesUrl = `${baseUrl.replace(
/\/$/,
"",
)}/pipelines?workspaceId=${workspaceId}&max=50&offset=0&search=${encodeURIComponent(search)}&visibility=all`;
const response = await apiCall(nodeForApi, "get", pipelinesUrl, {
headers: { Accept: "application/json" },
});
const pipelines = response.data?.pipelines || [];
const results = pipelines.map((pipeline) => ({
value: pipeline.name,
label: pipeline.name,
}));
res.json(results);
} catch (error) {
// Return empty array on error for better UX
res.json([]);
}
});
function SeqeraWorkflowLaunchNode(config) {
RED.nodes.createNode(this, config);
const node = this;
// Save typedInput property configs
node.launchpadNameProp = config.launchpadName;
node.launchpadNamePropType = config.launchpadNameType;
node.paramsProp = config.paramsKey;
node.paramsPropType = config.paramsKeyType;
node.paramsArray = config.params || [];
node.runNameProp = config.runName;
node.runNamePropType = config.runNameType;
node.baseUrlProp = config.baseUrl;
node.baseUrlPropType = config.baseUrlType;
node.workspaceIdProp = config.workspaceId;
node.workspaceIdPropType = config.workspaceIdType;
node.sourceWorkspaceIdProp = config.sourceWorkspaceId;
node.sourceWorkspaceIdPropType = config.sourceWorkspaceIdType;
node.resumeWorkflowIdProp = config.resumeWorkflowId;
node.resumeWorkflowIdPropType = config.resumeWorkflowIdType;
node.seqeraConfig = RED.nodes.getNode(config.seqera);
node.defaultBaseUrl = (node.seqeraConfig && node.seqeraConfig.baseUrl) || "https://api.cloud.seqera.io";
node.credentials = RED.nodes.getCredentials(node.id);
const axios = require("axios");
const { apiCall } = require("./_utils");
// Helper to format date as yyyy-mm-dd HH:MM:SS
const formatDateTime = () => {
const d = new Date();
const pad = (n) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${d.toLocaleTimeString()}`;
};
node.on("input", async function (msg, send, done) {
node.status({ fill: "blue", shape: "ring", text: `launching: ${formatDateTime()}` });
// Helper to evaluate properties
const evalProp = async (p, t) => {
if (t === "jsonata") {
const expr = RED.util.prepareJSONataExpression(p, node);
return await new Promise((resolve, reject) => {
RED.util.evaluateJSONataExpression(expr, msg, (err, value) => {
if (err) return reject(err);
resolve(value);
});
});
}
return RED.util.evaluateNodeProperty(p, t, node, msg);
};
const launchpadName = await evalProp(node.launchpadNameProp, node.launchpadNamePropType);
const paramsObj = await evalProp(node.paramsProp, node.paramsPropType);
const runName = await evalProp(node.runNameProp, node.runNamePropType);
const baseUrlOverride = await evalProp(node.baseUrlProp, node.baseUrlPropType);
const workspaceIdOverride = await evalProp(node.workspaceIdProp, node.workspaceIdPropType);
const sourceWorkspaceIdOverride = await evalProp(node.sourceWorkspaceIdProp, node.sourceWorkspaceIdPropType);
const resumeWorkflowId = await evalProp(node.resumeWorkflowIdProp, node.resumeWorkflowIdPropType);
const baseUrl = baseUrlOverride || (node.seqeraConfig && node.seqeraConfig.baseUrl) || node.defaultBaseUrl;
const workspaceId = workspaceIdOverride || (node.seqeraConfig && node.seqeraConfig.workspaceId) || null;
const sourceWorkspaceId = sourceWorkspaceIdOverride || null;
let body = msg.body || msg.payload;
if (launchpadName) {
try {
const pipelinesUrl = `${baseUrl.replace(
/\/$/,
"",
)}/pipelines?workspaceId=${workspaceId}&max=50&offset=0&search=${encodeURIComponent(
launchpadName,
)}&visibility=all`;
const pipelinesResp = await apiCall(node, "get", pipelinesUrl, { headers: { Accept: "application/json" } });
const pipelines = pipelinesResp.data?.pipelines || [];
const match = pipelines.find((p) => p.name === launchpadName) || pipelines[0];
if (!match) throw new Error(`No pipeline found for launchpadName '${launchpadName}'`);
const launchCfgUrl = `${baseUrl.replace(/\/$/, "")}/pipelines/${
match.pipelineId
}/launch?workspaceId=${workspaceId}`;
const launchResp = await apiCall(node, "get", launchCfgUrl, { headers: { Accept: "application/json" } });
if (!launchResp.data || !launchResp.data.launch) throw new Error("Invalid launch config response");
const lp = { ...launchResp.data.launch };
if (lp.computeEnv?.id) {
lp.computeEnvId = lp.computeEnv.id;
delete lp.computeEnv;
}
body = { launch: lp };
} catch (errFetch) {
node.error(`Failed to resolve launchpad name '${launchpadName}': ${errFetch.message}`, msg);
return;
}
}
if (!body) {
done(
new Error(
"No request body supplied (and no launchpadName resolved). Provide JSON payload or configure launchpadName.",
),
);
return;
}
// Evaluate params array
const paramsFromArray = {};
for (const param of node.paramsArray) {
if (param.name && param.name.trim()) {
const evaluatedValue = await evalProp(param.value, param.valueType);
paramsFromArray[param.name] = evaluatedValue;
}
}
// Merge params: start with existing launch.paramsText, then paramsObj (from JSON field), then paramsArray (individual params)
if ((paramsObj && typeof paramsObj === "object") || Object.keys(paramsFromArray).length > 0) {
body.launch = body.launch || {};
let existingParams = {};
if (body.launch.paramsText) {
try {
existingParams = JSON.parse(body.launch.paramsText);
} catch (_) {}
}
// Merge in order: existing -> paramsObj -> paramsFromArray (individual params take precedence)
const mergedParams = { ...existingParams };
if (paramsObj && typeof paramsObj === "object") {
Object.assign(mergedParams, paramsObj);
}
Object.assign(mergedParams, paramsFromArray);
body.launch.paramsText = JSON.stringify(mergedParams);
}
// Set custom run name if provided
if (runName && runName.trim()) {
body.launch = body.launch || {};
body.launch.runName = runName.trim();
}
// Resume from a previous workflow if workflow ID is provided
// This fetches the workflow's launch config and sessionId, then relaunches with resume enabled
if (resumeWorkflowId && resumeWorkflowId.trim && resumeWorkflowId.trim()) {
const wfId = resumeWorkflowId.trim();
try {
// Fetch the workflow details to get the commitId (may be null if workflow was cancelled early)
const workflowUrl = `${baseUrl.replace(/\/$/, "")}/workflow/${wfId}?workspaceId=${workspaceId}`;
const workflowResp = await apiCall(node, "get", workflowUrl, {
headers: { Accept: "application/json" },
});
if (!workflowResp.data || !workflowResp.data.workflow) {
throw new Error("Invalid workflow response");
}
const workflow = workflowResp.data.workflow;
const commitId = workflow.commitId;
// Fetch the workflow launch config (includes sessionId and resumeCommitId)
const workflowLaunchUrl = `${baseUrl.replace(/\/$/, "")}/workflow/${wfId}/launch?workspaceId=${workspaceId}`;
const workflowLaunchResp = await apiCall(node, "get", workflowLaunchUrl, {
headers: { Accept: "application/json" },
});
if (!workflowLaunchResp.data || !workflowLaunchResp.data.launch) {
throw new Error("Invalid workflow launch config response");
}
const wfLaunch = workflowLaunchResp.data.launch;
// Determine resume behavior based on whether workflow actually ran tasks:
// - If workflow ran tasks (has commitId), set resume=true and include revision field
// - If workflow was cancelled before tasks (no commitId), set resume=false and omit revision
// Priority: resumeCommitId > revision from launch config > commitId from workflow
const hasCommitId = wfLaunch.resumeCommitId || wfLaunch.revision || commitId;
const shouldSetResumeFlag = !!hasCommitId;
// Create minimal resume launch config matching what the CLI sends
const resumeLaunch = {
id: wfLaunch.id,
computeEnvId: wfLaunch.computeEnv?.id || wfLaunch.computeEnvId,
pipeline: wfLaunch.pipeline,
workDir: wfLaunch.workDir || wfLaunch.resumeDir,
sessionId: wfLaunch.sessionId,
resume: shouldSetResumeFlag,
pullLatest: wfLaunch.pullLatest || false,
stubRun: wfLaunch.stubRun || false,
dateCreated: new Date().toISOString(),
};
// Include revision field only if we have a valid commit hash
if (hasCommitId) {
resumeLaunch.revision = hasCommitId;
}
// Merge in paramsText if it was set via the params fields
if (body.launch && body.launch.paramsText) {
resumeLaunch.paramsText = body.launch.paramsText;
}
// Include runName if it was set
if (runName && runName.trim()) {
resumeLaunch.runName = runName.trim();
}
body.launch = resumeLaunch;
} catch (errResume) {
node.error(`Failed to fetch workflow launch config for resume: ${errResume.message}`, msg);
node.status({ fill: "red", shape: "ring", text: `error: ${formatDateTime()}` });
return;
}
}
// Build URL with query params
let url = `${baseUrl.replace(/\/$/, "")}/workflow/launch`;
const qs = new URLSearchParams();
if (workspaceId != null) qs.append("workspaceId", workspaceId);
if (sourceWorkspaceId != null) qs.append("sourceWorkspaceId", sourceWorkspaceId);
if (qs.toString()) url += `?${qs.toString()}`;
// Headers
const headers = { "Content-Type": "application/json" };
try {
const response = await apiCall(node, "post", url, { headers, data: body });
const outMsg = {
...msg,
payload: response.data,
workflowId: response.data?.workflowId || response.data?.workflow?.id,
};
node.status({ fill: "green", shape: "dot", text: `launched: ${formatDateTime()}` });
send(outMsg);
if (done) done();
} catch (err) {
node.error(`Seqera API request failed: ${err.message}\nRequest: POST ${url}`, msg);
node.status({ fill: "red", shape: "ring", text: `error: ${formatDateTime()}` });
return;
}
});
}
RED.nodes.registerType("seqera-workflow-launch", SeqeraWorkflowLaunchNode, {
credentials: { token: { type: "password" } },
defaults: {
name: { value: "" },
seqeraConfig: { value: "", type: "seqera-config", required: true },
workflowId: { value: "workflowId" },
workflowIdType: { value: "str" },
workspaceId: { value: "workspaceId" },
workspaceIdType: { value: "str" },
params: { value: "{}" },
paramsType: { value: "json" },
launchpadName: { value: "launchpadName" },
launchpadNameType: { value: "str" },
paramsKey: { value: "params" },
paramsKeyType: { value: "str" },
baseUrl: { value: "baseUrl" },
baseUrlType: { value: "str" },
sourceWorkspaceId: { value: "sourceWorkspaceId" },
sourceWorkspaceIdType: { value: "str" },
resumeWorkflowId: { value: "" },
resumeWorkflowIdType: { value: "str" },
token: { value: "token" },
tokenType: { value: "str" },
},
});
};