-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add datalink create node #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,12 +46,111 @@ async function apiCall(node, method, url, options = {}) { | |
| node.warn({ | ||
| message: `Seqera API ${method.toUpperCase()} call to ${url} failed`, | ||
| error: err, | ||
| response: err.response?.data, // Include API error response | ||
| request: { method: method.toUpperCase(), url, ...finalOpts }, | ||
| }); | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Generic resource resolver for Seqera Platform API. | ||
| * Resolves a resource name to its ID by querying the appropriate endpoint. | ||
| * | ||
| * Supported resource types: | ||
| * - credentials: /credentials → credentials[].id | ||
| * - data-links: /data-links → dataLinks[].id | ||
| * - pipelines: /pipelines → pipelines[].pipelineId | ||
| * - compute-envs: /compute-envs → computeEnvs[].id | ||
| * - datasets: /datasets → datasets[].id | ||
| * | ||
| * @param {object} RED - Node-RED runtime | ||
| * @param {object} node - Node-RED node instance | ||
| * @param {object} msg - Message object (for JSONata context) | ||
| * @param {string} resourceType - Type of resource (e.g., 'credentials', 'data-links', 'pipelines') | ||
| * @param {string} resourceName - Name of the resource to resolve | ||
| * @param {object} options - Options object with baseUrl and workspaceId | ||
| * @returns {Promise<string|null>} The resource ID, or null if resourceName is empty | ||
| */ | ||
| async function resolveResource(RED, node, msg, resourceType, resourceName, { baseUrl, workspaceId = null } = {}) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need |
||
| if (!resourceName || resourceName.trim() === "") { | ||
| return null; // No resource specified | ||
| } | ||
|
|
||
| // Define metadata for each resource type | ||
| const resourceConfig = { | ||
| credentials: { | ||
| endpoint: "/credentials", | ||
| responseKey: "credentials", | ||
| idField: "id", | ||
| nameField: "name", | ||
| queryParams: { pageSize: "100" }, | ||
| }, | ||
| "data-links": { | ||
| endpoint: "/data-links", | ||
| responseKey: "dataLinks", | ||
| idField: "id", | ||
| nameField: "name", | ||
| queryParams: { pageSize: "100", search: resourceName }, | ||
| }, | ||
| pipelines: { | ||
| endpoint: "/pipelines", | ||
| responseKey: "pipelines", | ||
| idField: "pipelineId", | ||
| nameField: "name", | ||
| queryParams: { max: "50", offset: "0", search: resourceName, visibility: "all" }, | ||
| }, | ||
| "compute-envs": { | ||
| endpoint: "/compute-envs", | ||
| responseKey: "computeEnvs", | ||
| idField: "id", | ||
| nameField: "name", | ||
| queryParams: { max: "100" }, | ||
| }, | ||
| datasets: { | ||
| endpoint: "/datasets", | ||
| responseKey: "datasets", | ||
| idField: "id", | ||
| nameField: "name", | ||
| queryParams: { max: "100" }, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems a bit limiting, especially for datasets.. Could the function handle pagination do you think? I implemented pagination for datasets already here. Ideally we would have an API endpoint to do this of course, rather than pulling thousands of dataset details just to do a simple lookup 👀 |
||
| }, | ||
| }; | ||
|
|
||
| const config = resourceConfig[resourceType]; | ||
| if (!config) { | ||
| throw new Error(`Unsupported resource type: ${resourceType}`); | ||
| } | ||
|
|
||
| // Build query string | ||
| const qs = new URLSearchParams(); | ||
| if (workspaceId != null) qs.append("workspaceId", workspaceId); | ||
| for (const [key, value] of Object.entries(config.queryParams)) { | ||
| qs.append(key, value); | ||
| } | ||
|
|
||
| const searchUrl = `${baseUrl.replace(/\/$/, "")}${config.endpoint}?${qs.toString()}`; | ||
|
|
||
| const searchResp = await apiCall(node, "get", searchUrl, { headers: { Accept: "application/json" } }); | ||
| const items = searchResp.data?.[config.responseKey] || []; | ||
|
|
||
| // Find exact match by name | ||
| const matchingItems = items.filter((item) => item[config.nameField] === resourceName); | ||
|
|
||
| if (!matchingItems.length) { | ||
| throw new Error(`Could not find ${resourceType.replace("-", " ")} with name '${resourceName}'`); | ||
| } | ||
| if (matchingItems.length > 1) { | ||
| throw new Error( | ||
| `Found ${matchingItems.length} ${resourceType.replace( | ||
| "-", | ||
| " ", | ||
| )}s with name '${resourceName}'. Please use a unique name.`, | ||
| ); | ||
| } | ||
|
|
||
| return matchingItems[0][config.idField]; | ||
| } | ||
|
|
||
| /** | ||
| * Shared handler for datalink auto-complete HTTP endpoint. | ||
| * Used by both datalink-list and datalink-poll nodes. | ||
|
|
@@ -137,4 +236,4 @@ async function handleDatalinkAutoComplete(RED, req, res) { | |
| } | ||
| } | ||
|
|
||
| module.exports = { buildHeaders, apiCall, handleDatalinkAutoComplete }; | ||
| module.exports = { buildHeaders, apiCall, resolveResource, handleDatalinkAutoComplete }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,230 @@ | ||
| <script type="text/html" data-template-name="seqera-datalink-add"> | ||
| <div class="form-row"> | ||
| <label for="node-input-seqera"><i class="icon-globe"></i> Seqera config</label> | ||
| <input type="text" id="node-input-seqera" data-type="seqera-config" /> | ||
| </div> | ||
| <div class="form-row"> | ||
| <label for="node-input-name"><i class="fa fa-tag"></i> Node Name</label> | ||
| <input type="text" id="node-input-name" /> | ||
| </div> | ||
| <div class="form-row"> | ||
| <label for="node-input-dataLinkName"><i class="fa fa-link"></i> Data link name</label> | ||
| <input type="text" id="node-input-dataLinkName" /> | ||
| </div> | ||
| <div class="form-row"> | ||
| <label for="node-input-description"><i class="fa fa-info-circle"></i> Description</label> | ||
| <input type="text" id="node-input-description" /> | ||
| </div> | ||
| <div class="form-row"> | ||
| <label for="node-input-resourceRef"><i class="fa fa-folder"></i> Resource ref</label> | ||
| <input type="text" id="node-input-resourceRef" placeholder="s3://bucket/path or gs://bucket/path" /> | ||
| </div> | ||
| <div class="form-row"> | ||
| <label for="node-input-provider"><i class="fa fa-cloud"></i> Provider</label> | ||
| <select id="node-input-provider-select" style="width: auto; margin-right: 5px;"> | ||
| <option value="select">Select provider</option> | ||
| <option value="aws">AWS (S3)</option> | ||
| <option value="google">Google Cloud (GCS)</option> | ||
| <option value="azure">Azure Blob Storage</option> | ||
| <option value="custom">Custom / Expression</option> | ||
| </select> | ||
| <input type="text" id="node-input-provider" style="display: none;" /> | ||
| </div> | ||
| <div class="form-row"> | ||
| <label for="node-input-credentialsName"><i class="fa fa-key"></i> Credentials name</label> | ||
| <input type="text" id="node-input-credentialsName" placeholder="Optional" /> | ||
| </div> | ||
| <div class="form-row"> | ||
| <label for="node-input-resourceType"><i class="fa fa-cube"></i> Resource type</label> | ||
| <select id="node-input-resourceType"> | ||
| <option value="bucket">Bucket</option> | ||
| <option value="folder">Folder</option> | ||
| </select> | ||
| </div> | ||
| <div class="form-row"> | ||
| <label for="node-input-publicAccessible"><i class="fa fa-globe"></i> Public accessible</label> | ||
| <input | ||
| type="checkbox" | ||
| id="node-input-publicAccessible" | ||
| style="display: inline-block; width: auto; vertical-align: top;" | ||
| /> | ||
| </div> | ||
| <div class="form-row"> | ||
| <label for="node-input-workspaceId"><i class="icon-tasks"></i> Workspace ID</label> | ||
| <input type="text" id="node-input-workspaceId" /> | ||
| </div> | ||
| </script> | ||
|
|
||
| <!-- prettier-ignore --> | ||
| <script type="text/markdown" data-help-name="seqera-datalink-add"> | ||
| Creates a new Data Link in Seqera Platform. | ||
|
|
||
| ### Inputs | ||
|
|
||
| : dataLinkName (string) : Name of the data link to create (required). | ||
| : resourceRef (string) : Resource reference URL (e.g., `s3://bucket/path`, `gs://bucket/path`, `az://container/path`) (required). | ||
| : provider (string) : Cloud provider type: `aws`, `google`, or `azure` (required). | ||
| : description (string) : Optional description for the data link. | ||
| : credentialsName (string) : Optional credentials name. The node will automatically resolve this to the credentials ID. | ||
| : resourceType (string) : Type of resource - either `bucket` (default) or `folder`. | ||
| : publicAccessible (boolean) : Whether the data link is publicly accessible without credentials (default: false). | ||
| : workspaceId (string) : Override the workspace ID from the config node. | ||
|
|
||
| All inputs support msg.*, flow.*, global.*, env, or JSONata expressions via the **typedInput**. | ||
|
|
||
| ### Outputs | ||
|
|
||
| 1. Standard output | ||
| : payload (object) : The data link creation response from the API. | ||
| : dataLinkId (string) : The ID of the created data link. | ||
| : dataLinkName (string) : The name of the created data link. | ||
|
|
||
| ### Details | ||
|
|
||
| This node creates a new Data Link in Seqera Platform that connects to cloud storage locations. | ||
| Data Links allow you to reference and access data from S3, GCS, or Azure Blob Storage in your workflows. | ||
|
|
||
| The node requires: | ||
| - **Data link name**: A unique name for the data link | ||
| - **Resource ref**: The full path to the cloud storage location (e.g., `s3://my-bucket/data/`) | ||
| - **Provider**: The cloud provider type (`aws`, `google`, or `azure`) | ||
|
|
||
| Optional fields: | ||
| - **Description**: Human-readable description of the data link | ||
| - **Credentials name**: If specified, the node will look up this credentials name in your workspace and use its ID to access the resource | ||
| - **Resource type**: Either `bucket` (entire bucket) or `folder` (specific folder path). Default: `bucket` | ||
| - **Public accessible**: Check this box if the data link should be publicly accessible without credentials. Leave unchecked for private resources | ||
|
|
||
| The node uses the **Seqera config** node for the base URL, workspace ID, and API token. | ||
| Any of these can be overridden using the corresponding input properties. | ||
|
|
||
| ### References | ||
|
|
||
| - [Seqera Platform API docs](https://docs.seqera.io/platform/latest/api) - Data Links endpoints | ||
| - [Seqera Data Links documentation](https://docs.seqera.io/platform/latest/data/data-links) - Information about Data Links | ||
| </script> | ||
|
|
||
| <script type="text/javascript"> | ||
| RED.nodes.registerType("seqera-datalink-add", { | ||
| category: "seqera", | ||
| color: "#A9A1C6", | ||
| inputs: 1, | ||
| outputs: 1, | ||
| icon: "icons/data-explorer.svg", | ||
| align: "left", | ||
| paletteLabel: "Add data link", | ||
| label: function () { | ||
| return this.name || "Add data link"; | ||
| }, | ||
| outputLabels: ["success"], | ||
| defaults: { | ||
| name: { value: "" }, | ||
| seqera: { value: "", type: "seqera-config" }, | ||
| dataLinkName: { value: "", required: true }, | ||
| dataLinkNameType: { value: "str" }, | ||
| description: { value: "" }, | ||
| descriptionType: { value: "str" }, | ||
| resourceRef: { value: "", required: true }, | ||
| resourceRefType: { value: "str" }, | ||
| provider: { value: "aws", required: true }, | ||
| providerType: { value: "str" }, | ||
| credentialsName: { value: "" }, | ||
| credentialsNameType: { value: "str" }, | ||
| resourceType: { value: "bucket" }, | ||
| publicAccessible: { value: false }, | ||
| workspaceId: { value: "" }, | ||
| workspaceIdType: { value: "str" }, | ||
| }, | ||
| oneditprepare: function () { | ||
| const node = this; | ||
|
|
||
| function ti(id, val, type, def = "str") { | ||
| $(id).typedInput({ default: def, types: ["str", "msg", "flow", "global", "env", "jsonata", "json"] }); | ||
| $(id).typedInput("value", val); | ||
| $(id).typedInput("type", type); | ||
| } | ||
|
|
||
| ti("#node-input-dataLinkName", this.dataLinkName || "", this.dataLinkNameType || "str"); | ||
| ti("#node-input-description", this.description || "", this.descriptionType || "str"); | ||
| ti("#node-input-resourceRef", this.resourceRef || "", this.resourceRefType || "str"); | ||
| ti("#node-input-credentialsName", this.credentialsName || "", this.credentialsNameType || "str"); | ||
| ti("#node-input-workspaceId", this.workspaceId || "", this.workspaceIdType || "str"); | ||
|
|
||
| // Set resource type and public accessible | ||
| $("#node-input-resourceType").val(this.resourceType || "bucket"); | ||
| $("#node-input-publicAccessible").prop("checked", this.publicAccessible || false); | ||
|
|
||
| // Handle provider dropdown/typedInput hybrid | ||
| const providerSelect = $("#node-input-provider-select"); | ||
| const providerInput = $("#node-input-provider"); | ||
|
|
||
| // Initialize typedInput for custom provider | ||
| ti("#node-input-provider", this.provider || "aws", this.providerType || "str"); | ||
|
|
||
| // Determine initial state | ||
| const currentProvider = this.provider || "aws"; | ||
| const currentProviderType = this.providerType || "str"; | ||
|
|
||
| if (currentProviderType !== "str" || !["aws", "google", "azure"].includes(currentProvider)) { | ||
| // Custom/expression mode | ||
| providerSelect.val("custom"); | ||
| providerSelect.hide(); | ||
| providerInput.show(); | ||
| } else { | ||
| // Select mode | ||
| providerSelect.val(currentProvider); | ||
| providerSelect.show(); | ||
| providerInput.hide(); | ||
| } | ||
|
|
||
| // Handle provider dropdown changes | ||
| providerSelect.on("change", function () { | ||
| const selectedValue = $(this).val(); | ||
| if (selectedValue === "custom") { | ||
| providerSelect.hide(); | ||
| providerInput.show(); | ||
| } else if (selectedValue !== "select") { | ||
| providerInput.typedInput("value", selectedValue); | ||
| providerInput.typedInput("type", "str"); | ||
| } | ||
| }); | ||
|
|
||
| // Handle switching back from custom to dropdown | ||
| providerInput.on("change", function () { | ||
| const currentType = providerInput.typedInput("type"); | ||
| const currentValue = providerInput.typedInput("value"); | ||
|
|
||
| if (currentType === "str" && ["aws", "google", "azure"].includes(currentValue)) { | ||
| providerSelect.val(currentValue); | ||
| providerSelect.show(); | ||
| providerInput.hide(); | ||
| } | ||
| }); | ||
| }, | ||
| oneditsave: function () { | ||
| function save(id, prop, propType) { | ||
| this[prop] = $(id).typedInput("value"); | ||
| this[propType] = $(id).typedInput("type"); | ||
| } | ||
|
|
||
| save.call(this, "#node-input-dataLinkName", "dataLinkName", "dataLinkNameType"); | ||
| save.call(this, "#node-input-description", "description", "descriptionType"); | ||
| save.call(this, "#node-input-resourceRef", "resourceRef", "resourceRefType"); | ||
| save.call(this, "#node-input-credentialsName", "credentialsName", "credentialsNameType"); | ||
| save.call(this, "#node-input-workspaceId", "workspaceId", "workspaceIdType"); | ||
|
|
||
| // Save resource type and public accessible | ||
| this.resourceType = $("#node-input-resourceType").val(); | ||
| this.publicAccessible = $("#node-input-publicAccessible").prop("checked"); | ||
|
|
||
| // Handle provider save | ||
| const providerSelect = $("#node-input-provider-select"); | ||
| if (providerSelect.is(":visible") && providerSelect.val() !== "custom" && providerSelect.val() !== "select") { | ||
| this.provider = providerSelect.val(); | ||
| this.providerType = "str"; | ||
| } else { | ||
| save.call(this, "#node-input-provider", "provider", "providerType"); | ||
| } | ||
| }, | ||
| }); | ||
| </script> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Super nice! I reckon we could delete a fair bit of code with some refactoring to use this, the lookup is done in quite a few places I think (doesn't have to be done in this PR but it'd be a nice follow-on).