diff --git a/modules/nextflow/src/main/groovy/nextflow/script/PlatformMetadata.groovy b/modules/nextflow/src/main/groovy/nextflow/script/PlatformMetadata.groovy new file mode 100644 index 0000000000..72008e2558 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/script/PlatformMetadata.groovy @@ -0,0 +1,113 @@ +/* + * Copyright 2013-2025, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.script + +import groovy.transform.Canonical +import groovy.transform.CompileStatic +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString +import groovy.util.logging.Slf4j + +/** + * Store the workflow platform-related metadata + * + * @author Jorge Ejarque + */ +@Slf4j +@CompileStatic +@ToString(includeNames = true, includePackage = false) +@EqualsAndHashCode +class PlatformMetadata { + + @Canonical + static class User { + String id + String userName + String email + String firstName + String lastName + String organization + + User(Map opts) { + id = opts.id as String + userName = opts.userName as String + email = opts.email as String + firstName = opts.firstName as String + lastName = opts.lastName as String + organization = opts.organization as String + } + } + + @Canonical + static class Workspace { + String id + String name + String fullName + String organization + + Workspace(Map opts) { + id = opts.workspaceId as String + name = opts.workspaceName as String + fullName = opts.workspaceFullName + organization = opts.orgName as String + } + } + + @Canonical + static class ComputeEnv { + String id + String name + String platform + + ComputeEnv(Map opts) { + id = opts.id as String + name = opts.name as String + platform = opts.platform as String + } + } + + @Canonical + static class Pipeline { + String id + String name + String revision + String commitId + + Pipeline(Map opts) { + id = opts.id as String + name = opts.name as String + revision = opts.revision as String + commitId = opts.commitId as String + } + } + + String workflowId + User user + Workspace workspace + ComputeEnv computeEnv + Pipeline pipeline + List labels + + PlatformMetadata(String id, User user, Workspace workspace, ComputeEnv computeEnv, Pipeline pipeline, List labels) { + this.workflowId = id + this.user = user + this.workspace = workspace + this.computeEnv = computeEnv + this.pipeline = pipeline + this.labels = labels + } +} \ No newline at end of file diff --git a/modules/nextflow/src/main/groovy/nextflow/script/WorkflowMetadata.groovy b/modules/nextflow/src/main/groovy/nextflow/script/WorkflowMetadata.groovy index 394d39dbf9..f6c0e9b74b 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/WorkflowMetadata.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/WorkflowMetadata.groovy @@ -218,6 +218,11 @@ class WorkflowMetadata { */ FusionMetadata fusion + /** + * Workflow metadata associated to the Seqera Platform execution. + */ + PlatformMetadata seqeraPlatform + /** * The list of files that concurred to create the config object */ diff --git a/modules/nf-lineage/src/main/nextflow/lineage/LinObserver.groovy b/modules/nf-lineage/src/main/nextflow/lineage/LinObserver.groovy index 2e2b621446..bb0d1b9e7a 100644 --- a/modules/nf-lineage/src/main/nextflow/lineage/LinObserver.groovy +++ b/modules/nf-lineage/src/main/nextflow/lineage/LinObserver.groovy @@ -16,6 +16,8 @@ package nextflow.lineage +import nextflow.NextflowMeta +import nextflow.extension.FilesEx import nextflow.lineage.exception.OutputRelativePathException import static nextflow.lineage.fs.LinPath.* @@ -71,6 +73,9 @@ import nextflow.util.TestOnly @Slf4j @CompileStatic class LinObserver implements TraceObserverV2 { + private static List workflowMetadataPropertiesToRemove = [ + "completed", "duration", "exitStatus", "errorMessage", "errorReport", "stats", "success" // Only existing at the end + ] private static Map, String> taskParamToValue = [ (StdOutParam) : "stdout", (StdInParam) : "stdin", @@ -163,7 +168,8 @@ class LinObserver implements TraceObserverV2 { session.uniqueId.toString(), session.runName, getNormalizedParams(session.params, normalizer), - SecretHelper.hideSecrets(session.config.deepClone()) as Map + SecretHelper.hideSecrets(session.config.deepClone()) as Map, + addOtherMetadata(normalizer) ) final executionHash = CacheHelper.hasher(value).hash().toString() store.save(executionHash, value) @@ -474,4 +480,20 @@ class LinObserver implements TraceObserverV2 { } return paths } + + private Map addOtherMetadata(PathNormalizer normalizer) { + try { + def metadata = session.workflowMetadata.toMap() + .collectEntries { it.value instanceof Path ? [it.key, FilesEx.toUriString(it.value as Path) ] : [it.key, it.value] } + metadata.removeAll {it.key.toString() in workflowMetadataPropertiesToRemove } + if( metadata.containsKey("nextflow") ) + metadata["nextflow"] = (metadata["nextflow"] as NextflowMeta).toJsonMap() + if( metadata.containsKey("configFiles") ) + metadata["configFiles"] = (metadata["configFiles"] as List).collect {normalizer.normalizePath(it)} + return metadata + }catch( Throwable e){ + log.debug("Error creating metadata", e) + return [:] + } + } } diff --git a/modules/nf-lineage/src/main/nextflow/lineage/model/v1beta1/WorkflowRun.groovy b/modules/nf-lineage/src/main/nextflow/lineage/model/v1beta1/WorkflowRun.groovy index 806a479ec1..31db5e7d07 100644 --- a/modules/nf-lineage/src/main/nextflow/lineage/model/v1beta1/WorkflowRun.groovy +++ b/modules/nf-lineage/src/main/nextflow/lineage/model/v1beta1/WorkflowRun.groovy @@ -48,4 +48,9 @@ class WorkflowRun implements LinSerializable { * Resolved Configuration */ Map config + /** + * Raw metadata + */ + Map metadata + } diff --git a/modules/nf-lineage/src/test/nextflow/lineage/LinObserverTest.groovy b/modules/nf-lineage/src/test/nextflow/lineage/LinObserverTest.groovy index 2eb8d52785..60ca53a501 100644 --- a/modules/nf-lineage/src/test/nextflow/lineage/LinObserverTest.groovy +++ b/modules/nf-lineage/src/test/nextflow/lineage/LinObserverTest.groovy @@ -17,6 +17,7 @@ package nextflow.lineage +import nextflow.extension.FilesEx import nextflow.lineage.exception.OutputRelativePathException import java.nio.file.Files @@ -165,13 +166,26 @@ class LinObserverTest extends Specification { def store = new DefaultLinStore(); def uniqueId = UUID.randomUUID() def scriptFile = folder.resolve("main.nf") + def map = [ + repository: "https://nextflow.io/nf-test/", + commitId: "123456", + scriptId: "78910", + scriptFile: scriptFile, + projectDir: folder.resolve("projectDir"), + revision: "main", + projectName: "nextflow.io/nf-test", + workDir: folder.resolve("workDir") + ] def metadata = Mock(WorkflowMetadata){ - getRepository() >> "https://nextflow.io/nf-test/" - getCommitId() >> "123456" - getScriptId() >> "78910" - getScriptFile() >> scriptFile - getProjectDir() >> folder.resolve("projectDir") - getWorkDir() >> folder.resolve("workDir") + getRepository() >> map.repository + getCommitId() >> map.commitId + getScriptId() >> map.scriptId + getScriptFile() >> map.scriptFile + getProjectDir() >> map.projectDir + getRevision() >> map.revision + getProjectName() >> map.projectName + getWorkDir() >> map.workDir + toMap() >> map } def session = Mock(Session) { getConfig() >> config @@ -183,8 +197,8 @@ class LinObserverTest extends Specification { store.open(LineageConfig.create(session)) def observer = new LinObserver(session, store) def mainScript = new DataPath("file://${scriptFile.toString()}", new Checksum("78910", "nextflow", "standard")) - def workflow = new Workflow([mainScript],"https://nextflow.io/nf-test/", "123456" ) - def workflowRun = new WorkflowRun(workflow, uniqueId.toString(), "test_run", [], config) + def workflow = new Workflow([mainScript], map.repository, map.commitId) + def workflowRun = new WorkflowRun(workflow, uniqueId.toString(), "test_run", [], config, map) when: observer.onFlowCreate(session) observer.onFlowBegin() diff --git a/modules/nf-lineage/src/test/nextflow/lineage/serde/LinEncoderTest.groovy b/modules/nf-lineage/src/test/nextflow/lineage/serde/LinEncoderTest.groovy index da9e749b98..7334a1fdd1 100644 --- a/modules/nf-lineage/src/test/nextflow/lineage/serde/LinEncoderTest.groovy +++ b/modules/nf-lineage/src/test/nextflow/lineage/serde/LinEncoderTest.groovy @@ -60,10 +60,40 @@ class LinEncoderTest extends Specification{ given: def encoder = new LinEncoder() and: + def config = [ + process: [ + container : "quay.io/nextflow/bash", + executor : "local", + resourceLabels: ["owner": "xxx"], + scratch : false + ] + ] + def metadata = [ + runName : "big_kare", + start : "2025-11-06T13:35:42.049135334Z", + container : "quay.io/nextflow/bash", + commandLine : "nextflow run 'https://github.com/nextflow-io/hello' -name big_kare -with-tower -r master", + nextflow : [version: "25.10.0", enable: [dsl: 2.0]], + containerEngine: "docker", + wave : [enabled: true], + fusion : [enabled: true, version: "2.4"], + seqeraPlatform : [ + workflowId: "wf1234", + user : [id: "xxx", userName: "john-smith", email: "john.smith@acme.com", firstName: "John", lastName: "Smith", organization: "acme"], + workspace : [id: "1234", name: "test-workspace", fullName: "Test workspace", organization: "acme"], + computeEnv: [id: "ce3456", name: "test-ce", platform: "aws-cloud"], + pipeline : [id: "pipe294", name: "https://github.com/nextflow-io/hello", revision: "master", commitId: null], + labels : [] + ], + failOnIgnore : false + ] def uniqueId = UUID.randomUUID() def mainScript = new DataPath("file://path/to/main.nf", new Checksum("78910", "nextflow", "standard")) def workflow = new Workflow([mainScript], "https://nextflow.io/nf-test/", "123456") - def wfRun = new WorkflowRun(workflow, uniqueId.toString(), "test_run", [new Parameter("String", "param1", "value1"), new Parameter("String", "param2", "value2")]) + def wfRun = new WorkflowRun(workflow, uniqueId.toString(), "test_run", + [new Parameter("String", "param1", "value1"), new Parameter("String", "param2", "value2")], + config, metadata + ) when: def encoded = encoder.encode(wfRun) @@ -82,6 +112,68 @@ class LinEncoderTest extends Specification{ result.name == "test_run" result.params.size() == 2 result.params.get(0).name == "param1" + result.config.process.container == "quay.io/nextflow/bash" + result.config.process.executor == "local" + result.metadata.seqeraPlatform.workflowId == "wf1234" + + } + + def 'should decode WorkflowRuns without metadata'(){ + given: + def encoder = new LinEncoder() + def wfRunStr = ''' +{ + "version": "lineage/v1beta1", + "kind": "WorkflowRun", + "spec": { + "workflow": { + "scriptFiles": [ + { + "path": "https://github.com/nextflow-io/hello/main.nf", + "checksum": { + "value": "78910", + "algorithm": "nextflow", + "mode": "standard" + } + } + ], + "repository": "https://github.com/nextflow-io/hello", + "commitId": "2ce0b0e2943449188092a0e25102f0dadc70cb0a" + }, + "sessionId": "4f02559e-9ebd-41d8-8ee2-a8d1e4f09c67", + "name": "test_run", + "params": [], + "config": { + "process": { + "container": "quay.io/nextflow/bash", + "executor": "local", + "resourceLabels": { + "owner": "xxx" + }, + "scratch": false + } + } + } +} + ''' + when: + def object = encoder.decode(wfRunStr) + + then: + object instanceof WorkflowRun + def result = object as WorkflowRun + result.workflow instanceof Workflow + result.workflow.scriptFiles.first instanceof DataPath + result.workflow.scriptFiles.first.path == "https://github.com/nextflow-io/hello/main.nf" + result.workflow.scriptFiles.first.checksum instanceof Checksum + result.workflow.scriptFiles.first.checksum.value == "78910" + result.workflow.commitId == "2ce0b0e2943449188092a0e25102f0dadc70cb0a" + result.sessionId == "4f02559e-9ebd-41d8-8ee2-a8d1e4f09c67" + result.name == "test_run" + result.params.size() == 0 + result.config.process.container == "quay.io/nextflow/bash" + result.config.process.executor == "local" + result.metadata == null } def 'should encode and decode WorkflowOutputs'(){ diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/BaseCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/BaseCommandImpl.groovy index 8bf1015697..830a144606 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/BaseCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/BaseCommandImpl.groovy @@ -1,3 +1,20 @@ +/* + * Copyright 2013-2025, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + package io.seqera.tower.plugin import groovy.json.JsonSlurper @@ -5,6 +22,7 @@ import groovy.util.logging.Slf4j import io.seqera.http.HxClient import nextflow.Const import nextflow.config.ConfigBuilder +import nextflow.util.TestOnly import java.net.http.HttpRequest import java.net.http.HttpResponse @@ -15,6 +33,15 @@ class BaseCommandImpl { private static final int API_TIMEOUT_MS = 10_000 + /** + * Provides common API operations for Seqera Platform + */ + protected TowerCommonApi commonApi = new TowerCommonApi() + + @TestOnly + void setCommonApi( TowerCommonApi commonApi ){ + this.commonApi = commonApi + } /** * Creates an HxClient instance with optional authentication token. * @@ -42,76 +69,7 @@ class BaseCommandImpl { return builder.buildConfigObject().flatten() } - /** - * Calls the Seqera Platform user-info API to retrieve user information. - * - * @param accessToken Authentication token - * @param apiUrl Seqera Platform API endpoint - * @return Map containing user information (id, userName, email, etc.) - * @throws RuntimeException if the API call fails - */ - protected Map getUserInfo(String accessToken, String apiUrl) { - final client = createHttpClient(accessToken) - final url = "${apiUrl}/user-info" - log.debug "Platform get user info - GET ${url}" - final request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .GET() - .build() - - final response = client.send(request, HttpResponse.BodyHandlers.ofString()) - - if (response.statusCode() != 200) { - final error = response.body() ?: "HTTP ${response.statusCode()}" - throw new RuntimeException("Failed to get user info: ${error}") - } - - final json = new JsonSlurper().parseText(response.body()) as Map - return json.user as Map - } - - protected Map getWorkspaceDetails(String accessToken, String endpoint, String workspaceId) { - try { - final userInfo = getUserInfo(accessToken, endpoint) - final userId = userInfo.id as String - - final client = createHttpClient(accessToken) - final url = "${endpoint}/user/${userId}/workspaces" - log.debug "Platform get workdspace - GET ${url}" - final request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .GET() - .build() - - final response = client.send(request, HttpResponse.BodyHandlers.ofString()) - - if (response.statusCode() != 200) { - return null - } - - final json = new JsonSlurper().parseText(response.body()) as Map - final orgsAndWorkspaces = json.orgsAndWorkspaces as List - - final workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } - if (workspace) { - final ws = workspace as Map - return [ - orgName: ws.orgName, - workspaceName: ws.workspaceName, - workspaceFullName: ws.workspaceFullName, - roles: ws.roles - ] - } - - return null - } catch (Exception e) { - log.debug("Failed to get workspace details for workspace ${workspaceId}: ${e.message}", e) - return null - } - } - - protected List listUserWorkspaces(String accessToken, String endpoint, String userId) { - final client = createHttpClient(accessToken) + protected List listUserWorkspaces(HxClient client, String endpoint, String userId) { final url = "${endpoint}/user/${userId}/workspaces" log.debug "Platform list workspaces - GET ${url}" final request = HttpRequest.newBuilder() @@ -132,8 +90,7 @@ class BaseCommandImpl { return orgsAndWorkspaces.findAll { ((Map) it).workspaceId != null } } - protected List listComputeEnvironments(String accessToken, String endpoint, String workspaceId) { - final client = createHttpClient(accessToken) + protected List listComputeEnvironments(HxClient client, String endpoint, String workspaceId) { final uri = workspaceId ? "${endpoint}/compute-envs?workspaceId=${workspaceId}" : "${endpoint}/compute-envs" @@ -155,8 +112,7 @@ class BaseCommandImpl { return json.computeEnvs as List ?: [] } - protected Map getComputeEnvironment(String accessToken, String endpoint, String computeEnvId, String workspaceId) { - final client = createHttpClient(accessToken) + protected Map getComputeEnvironment(HxClient client, String endpoint, String computeEnvId, String workspaceId) { final uri = workspaceId ? "${endpoint}/compute-envs/${computeEnvId}?workspaceId=${workspaceId}" : "${endpoint}/compute-envs" diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerClient.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerClient.groovy index d070b758c8..4b018934fc 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerClient.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerClient.groovy @@ -42,6 +42,7 @@ import nextflow.exception.AbortOperationException import nextflow.processor.TaskHandler import nextflow.processor.TaskId import nextflow.processor.TaskProcessor +import nextflow.script.PlatformMetadata import nextflow.trace.ResourcesAggregator import nextflow.trace.TraceObserverV2 import nextflow.trace.TraceRecord @@ -60,7 +61,7 @@ import nextflow.util.Threads */ @Slf4j @CompileStatic -class TowerClient implements TraceObserverV2 { +class TowerClient extends TowerCommonApi implements TraceObserverV2 { static final public String DEF_ENDPOINT_URL = 'https://api.cloud.seqera.io' @@ -295,10 +296,63 @@ class TowerClient implements TraceObserverV2 { if( ret.message ) log.warn(ret.message.toString()) + session.workflowMetadata.seqeraPlatform = getPlatformMetadata(workflowId) + // Prepare to collect report paths if tower configuration has a 'reports' section reports.flowCreate(workflowId) } + protected PlatformMetadata getPlatformMetadata(String workflowId) { + try { + final userInfo = getUserInfo(this.httpClient, this.endpoint) + final queryParams = workspaceId ? [workspaceId: workspaceId.toString()] : [:] + final workflowDetails = getWorkflowDetails(this.httpClient, this.endpoint, workflowId, queryParams) + final workspaceDetails = getUserWorkspaceDetails(this.httpClient, userInfo?.id as String, this.endpoint, this.workspaceId) + + if( workspaceDetails?.roles ) { + final roles = workspaceDetails.remove('roles') + userInfo.roles = roles + } + final workflowLaunch = getWorkflowLaunchDetails(workflowId, queryParams) + + return new PlatformMetadata( + workflowId, + userInfo ? new PlatformMetadata.User(userInfo) : null, + workspaceDetails ? new PlatformMetadata.Workspace(workspaceDetails) : null, + workflowLaunch?.computeEnv as PlatformMetadata.ComputeEnv, + workflowLaunch?.pipeline as PlatformMetadata.Pipeline, + getLabels(workflowDetails?.labels as List) + ) + }catch(Throwable e){ + log.debug("Exception getting platform metadata", e) + return null + } + } + + private List getLabels(List labelsMap){ + if( !labelsMap ) + return [] + final labels = [] + for (Map label: labelsMap){ + labels.add( label.key ? "${label.key}=${label.value}" : label.value as String ) + } + return labels + } + + + private Map getWorkflowLaunchDetails(workflowId, Map queryParams) { + final launch = apiGet(this.httpClient, this.endpoint, "/workflow/${workflowId}/launch", queryParams).launch as Map + return [ + computeEnv: launch.computeEnv ? new PlatformMetadata.ComputeEnv(launch.computeEnv as Map) : null, + pipeline : new PlatformMetadata.Pipeline( + id: launch.pipelineId as String, + name: launch.pipeline as String, + revision: launch.revision as String, + commitId: launch.commitId as String + ) + ] + } + protected HxClient newHttpClient() { final builder = HxClient.newBuilder() // auth settings @@ -598,6 +652,10 @@ class TowerClient implements TraceObserverV2 { protected Map makeCompleteReq(Session session) { def workflow = session.getWorkflowMetadata().toMap() + //Remove retrieved seqeraPlatform info + if( workflow.seqeraPlatform ) + workflow.remove('seqeraPlatform') + workflow.params = session.getParams() workflow.id = getWorkflowId() // render as a string diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerCommonApi.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerCommonApi.groovy new file mode 100644 index 0000000000..7c588345cf --- /dev/null +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerCommonApi.groovy @@ -0,0 +1,132 @@ +/* + * Copyright 2013-2025, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package io.seqera.tower.plugin + +import groovy.json.JsonSlurper +import groovy.util.logging.Slf4j +import io.seqera.http.HxClient + +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +/** + * Class with common API calls used in different classes + * + * @author Jorge Ejarque + */ +@Slf4j +class TowerCommonApi { + + /** + * Calls the Seqera Platform user-info API to retrieve user information. + * + * @param client HTTP client to perform the API calls + * @param endpoint Seqera Platform API endpoint + * @return Map containing user information (id, userName, email, etc.) + * @throws RuntimeException if the API call fails + */ + Map getUserInfo(HxClient client, String endpoint) { + final json = apiGet(client, endpoint, "/user-info") + return json.user as Map + } + + /** + * Calls the Seqera Platform to retrieve a the user's workspaces information + * and select the one matching with the workspace Id. + * + * @param client HTTP client to perform the API calls + * @param userId Id of the workspace user + * @param endpoint Seqera Platform API endpoint + * @param workspaceId Id of the workspace + * @return Map containing workspace informatiion + * @throws RuntimeException if the API call fails + */ + Map getUserWorkspaceDetails(HxClient client, String userId, String endpoint, String workspaceId) { + if( !userId || !workspaceId ) { + return null + } + try { + final json = apiGet(client, endpoint, "/user/${userId}/workspaces") + + final orgsAndWorkspaces = json.orgsAndWorkspaces as List + + final workspace = orgsAndWorkspaces.find { ((Map) it).workspaceId?.toString() == workspaceId } + if( workspace ) { + final ws = workspace as Map + return [ + orgName : ws.orgName, + workspaceId : ws.workspaceId, + workspaceName : ws.workspaceName, + workspaceFullName: ws.workspaceFullName, + roles : ws.roles + ] + } + + return null + } catch( Exception e ) { + log.debug("Failed to get workspace details for workspace ${workspaceId}: ${e.message}", e) + return null + } + } + + /** + * Calls the Seqera Platform to retrieve a the workflow information. + * + * @param client HTTP client to perform the API calls + * @param endpoint Seqera Platform API endpoint + * @param workflowId Id of the workflow + * @return Map containing workflow information + * @throws RuntimeException if the API call fails + */ + Map getWorkflowDetails(HxClient client, String endpoint, String workflowId, Map queryParams = [:]) { + final json = apiGet(client, endpoint, "/workflow/${workflowId}", queryParams) + return json.workflow as Map + } + + Map apiGet(HxClient client, String apiEndpoint, String path, Map queryParams = [:]) { + final url = buildUrl(apiEndpoint, path, queryParams) + log.debug "Platform API - GET ${url}" + final request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build() + + final response = client.send(request, HttpResponse.BodyHandlers.ofString()) + + if( response.statusCode() != 200 ) { + final error = response.body() ?: "HTTP ${response.statusCode()}" + throw new RuntimeException("API GET request ${url} failed: ${error}") + } + + return new JsonSlurper().parseText(response.body()) as Map + } + + String buildUrl(String endpoint, String path, Map queryParams) { + def url = new StringBuilder(endpoint) + if( !path.startsWith('/') ) { + url.append('/') + } + url.append(path) + + if( queryParams && !queryParams.isEmpty() ) { + url.append('?') + url.append(queryParams.collect { k, v -> "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" }.join('&')) + } + + return url.toString() + } +} diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/auth/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/auth/AuthCommandImpl.groovy index 1127f0462f..3faecf1c48 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/auth/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/auth/AuthCommandImpl.groovy @@ -1,5 +1,22 @@ +/* + * Copyright 2013-2025, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ package io.seqera.tower.plugin.auth +import io.seqera.http.HxClient import io.seqera.tower.plugin.BaseCommandImpl import nextflow.util.SpinnerUtil @@ -155,7 +172,7 @@ class AuthCommandImpl extends BaseCommandImpl implements CmdAuth.AuthCommand { final accessToken = tokenData['access_token'] as String // Verify login by calling /user-info - final userInfo = getUserInfo(accessToken, apiUrl) + final userInfo = commonApi.getUserInfo( createHttpClient(accessToken), apiUrl) println "\n\n${colorize('✔', 'green', true)} Authentication successful" // Generate PAT @@ -453,7 +470,7 @@ class AuthCommandImpl extends BaseCommandImpl implements CmdAuth.AuthCommand { // Validate token by calling /user-info API try { - final userInfo = getUserInfo(existingToken as String, apiUrl) + final userInfo = commonApi.getUserInfo( createHttpClient(existingToken as String), apiUrl) printColored(" - Token is valid for user: $userInfo.userName", "dim") } catch( Exception e ) { printColored("Failed to validate token: ${e.message}", "red") @@ -590,7 +607,8 @@ class AuthCommandImpl extends BaseCommandImpl implements CmdAuth.AuthCommand { try { // Get user info to validate token and get user ID - final userInfo = getUserInfo(existingToken as String, endpoint as String) + final httpClient = createHttpClient(existingToken as String) + final userInfo = commonApi.getUserInfo(httpClient, endpoint as String) printColored(" - Authenticated as: $userInfo.userName", "dim") println "" @@ -601,7 +619,7 @@ class AuthCommandImpl extends BaseCommandImpl implements CmdAuth.AuthCommand { configChanged |= configureEnabled(config) // Configure workspace - final workspaceResult = configureWorkspace(config, existingToken as String, endpoint as String, userInfo.id as String) + final workspaceResult = configureWorkspace(httpClient, config, endpoint as String, userInfo.id as String) configChanged = configChanged || (workspaceResult.changed as boolean) // Configure compute environment for the workspace (always run after workspace selection) @@ -609,7 +627,7 @@ class AuthCommandImpl extends BaseCommandImpl implements CmdAuth.AuthCommand { def workspaceMetadata = workspaceResult.metadata as Map if( !workspaceMetadata && currentWorkspaceId ) { // Get workspace metadata if not already available (e.g., when user kept existing workspace) - workspaceMetadata = getWorkspaceDetails(existingToken as String, endpoint as String, currentWorkspaceId) + workspaceMetadata = commonApi.getUserWorkspaceDetails(httpClient, userInfo.id as String, endpoint as String, currentWorkspaceId) } final computeEnvResult = configureComputeEnvironment(config as Map, existingToken as String, endpoint as String, currentWorkspaceId, workspaceMetadata) configChanged = configChanged || (computeEnvResult.changed as boolean) @@ -655,13 +673,13 @@ class AuthCommandImpl extends BaseCommandImpl implements CmdAuth.AuthCommand { * the user to select a default workspace. For large numbers of workspaces, * uses a two-stage selection process (organization first, then workspace). * + * @param client * @param config Configuration map to update - * @param accessToken Authentication token for API calls * @param endpoint Seqera Platform API endpoint * @param userId User ID for fetching workspaces * @return Map containing 'changed' (boolean) and 'metadata' (workspace info) */ - private Map configureWorkspace(Map config, String accessToken, String endpoint, String userId) { + private Map configureWorkspace(HxClient client, Map config, String endpoint, String userId) { // Check if TOWER_WORKFLOW_ID environment variable is set final envWorkspaceId = SysEnv.get('TOWER_WORKFLOW_ID') if( envWorkspaceId ) { @@ -671,7 +689,7 @@ class AuthCommandImpl extends BaseCommandImpl implements CmdAuth.AuthCommand { } // Get all workspaces for the user - final workspaces = listUserWorkspaces(accessToken, endpoint, userId) + final workspaces = listUserWorkspaces(client, endpoint, userId) if( !workspaces ) { println "\nNo workspaces found for your account." @@ -832,7 +850,7 @@ class AuthCommandImpl extends BaseCommandImpl implements CmdAuth.AuthCommand { private Map configureComputeEnvironment(Map config, String accessToken, String endpoint, String workspaceId, Map workspaceMetadata) { try { // Get compute environments for the workspace - final computeEnvs = listComputeEnvironments(accessToken, endpoint, workspaceId) + final computeEnvs = listComputeEnvironments(createHttpClient(accessToken), endpoint, workspaceId) // If there are zero compute environments, log a warning and provide a link if( computeEnvs.isEmpty() ) { @@ -985,7 +1003,7 @@ class AuthCommandImpl extends BaseCommandImpl implements CmdAuth.AuthCommand { if( accessToken ) { try { - final userInfo = getUserInfo(accessToken, endpoint) + final userInfo = commonApi.getUserInfo(createHttpClient(accessToken), endpoint) final currentUser = userInfo.userName as String status.table.add(['Authentication', "${colorize('✔ OK', 'green')} (user: $currentUser)".toString(), tokenSource]) } catch( Exception e ) { @@ -1007,7 +1025,9 @@ class AuthCommandImpl extends BaseCommandImpl implements CmdAuth.AuthCommand { // Try to get workspace name and roles from API if we have a token def workspaceDetails = null if( accessToken ) { - workspaceDetails = getWorkspaceDetails(accessToken, endpoint, workspaceId) + final httpClient = createHttpClient(accessToken) + final userInfo = commonApi.getUserInfo(httpClient, endpoint) + workspaceDetails = commonApi.getUserWorkspaceDetails(httpClient, userInfo.id as String, endpoint, workspaceId) } if( workspaceDetails ) { @@ -1030,11 +1050,12 @@ class AuthCommandImpl extends BaseCommandImpl implements CmdAuth.AuthCommand { // Compute environment and work directory def computeEnv = null if( accessToken ) { + final httpClient = createHttpClient(accessToken) try { if( config['tower.computeEnvId'] ) { - computeEnv = getComputeEnvironment(accessToken, endpoint, config['tower.computeEnvId'] as String, workspaceId) + computeEnv = getComputeEnvironment(httpClient, endpoint, config['tower.computeEnvId'] as String, workspaceId) } else { - final computeEnvs = listComputeEnvironments(accessToken, endpoint, workspaceId) + final computeEnvs = listComputeEnvironments(httpClient, endpoint, workspaceId) computeEnv = computeEnvs.find { ((Map) it).primary == true } as Map } } catch( Exception e ) { diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/launch/LaunchCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/launch/LaunchCommandImpl.groovy index 26afa58734..78e7b88d0c 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/launch/LaunchCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/launch/LaunchCommandImpl.groovy @@ -21,6 +21,7 @@ import groovy.json.JsonSlurper import groovy.transform.Canonical import groovy.transform.CompileStatic import groovy.util.logging.Slf4j +import io.seqera.http.HxClient import io.seqera.tower.plugin.BaseCommandImpl import io.seqera.tower.plugin.TowerClient import nextflow.BuildInfo @@ -151,12 +152,14 @@ class LaunchCommandImpl extends BaseCommandImpl implements CmdLaunch.LaunchComma // Resolve workspace final workspaceId = resolveWorkspaceId(config, options.workspace, accessToken, apiEndpoint) - final userName = getUserInfo(accessToken, apiEndpoint).name as String - + final httpClient = createHttpClient(accessToken) + final userInfo = commonApi.getUserInfo(httpClient, apiEndpoint) + final userName = userInfo.name as String + final userId = userInfo.id as String String orgName = null String workspaceName = null if (workspaceId) { - final wsDetails = getWorkspaceDetails(accessToken, apiEndpoint, workspaceId.toString()) + final wsDetails = commonApi.getUserWorkspaceDetails(httpClient, userId, apiEndpoint, workspaceId.toString()) orgName = wsDetails?.orgName as String workspaceName = wsDetails?.workspaceName as String log.debug "Using workspace '${workspaceName}' (ID: ${workspaceId})" @@ -237,7 +240,7 @@ class LaunchCommandImpl extends BaseCommandImpl implements CmdLaunch.LaunchComma log.debug "Fetching workflow details for ID: ${workflowId}" final queryParams = workspaceId ? [workspaceId: workspaceId.toString()] : [:] - return apiGet("/workflow/${workflowId}", queryParams, accessToken, apiEndpoint) + return commonApi.apiGet( createHttpClient(accessToken), apiEndpoint, "/workflow/${workflowId}", queryParams) } /** @@ -285,12 +288,13 @@ class LaunchCommandImpl extends BaseCommandImpl implements CmdLaunch.LaunchComma * Resolve compute environment by flag name config computeEnvId or get primary */ protected Map resolveComputeEnvironment(Map config, String computeEnvName, Long workspaceId, String accessToken, String apiEndpoint) { + final client = createHttpClient(accessToken) Map computeEnvInfo = null if (!computeEnvName && config?.get('tower.computeEnvId')) { - computeEnvInfo = getComputeEnvironment(accessToken, apiEndpoint, config['tower.computeEnvId'] as String, workspaceId?.toString()) + computeEnvInfo = getComputeEnvironment(client, apiEndpoint, config['tower.computeEnvId'] as String, workspaceId?.toString()) } else { log.debug "Looking up compute environment: ${computeEnvName ?: '(primary)'}" - computeEnvInfo = findComputeEnv(computeEnvName, workspaceId, accessToken, apiEndpoint) + computeEnvInfo = findComputeEnv(client, computeEnvName, workspaceId, apiEndpoint) } if (!computeEnvInfo) { if (computeEnvName) { @@ -502,12 +506,12 @@ class LaunchCommandImpl extends BaseCommandImpl implements CmdLaunch.LaunchComma try { spinner.start() - + final client = createHttpClient(accessToken) while (!shouldExit.get() && !Thread.currentThread().isInterrupted()) { try { // Fetch workflow status and logs - final status = fetchWorkflowStatus(workflowId, queryParams, accessToken, apiEndpoint) - final logEntries = fetchWorkflowLogs(workflowId, queryParams, accessToken, apiEndpoint) + final status = fetchWorkflowStatus(client, workflowId, queryParams, apiEndpoint) + final logEntries = fetchWorkflowLogs(client, workflowId, queryParams, apiEndpoint) // Update spinner with status if it changed if (status && status != lastStatus) { @@ -638,9 +642,8 @@ class LaunchCommandImpl extends BaseCommandImpl implements CmdLaunch.LaunchComma /** * Fetch workflow status from API */ - private String fetchWorkflowStatus(String workflowId, Map queryParams, String accessToken, String apiEndpoint) { - final workflowResponse = apiGet("/workflow/${workflowId}", queryParams, accessToken, apiEndpoint) - final workflow = workflowResponse.workflow as Map + private String fetchWorkflowStatus(HxClient client, String workflowId, Map queryParams, String apiEndpoint) { + final workflow = commonApi.getWorkflowDetails(client, apiEndpoint, workflowId, queryParams) final status = workflow?.status as String log.debug "Workflow status: ${status}" return status @@ -649,8 +652,8 @@ class LaunchCommandImpl extends BaseCommandImpl implements CmdLaunch.LaunchComma /** * Fetch workflow logs from API */ - private List fetchWorkflowLogs(String workflowId, Map queryParams, String accessToken, String apiEndpoint) { - final logResponse = apiGet("/workflow/${workflowId}/log", queryParams, accessToken, apiEndpoint) + private List fetchWorkflowLogs(HxClient client, String workflowId, Map queryParams, String apiEndpoint) { + final logResponse = commonApi.apiGet(client, apiEndpoint,"/workflow/${workflowId}/log", queryParams) final logData = logResponse.log as Map return logData?.entries as List ?: [] } @@ -947,37 +950,18 @@ class LaunchCommandImpl extends BaseCommandImpl implements CmdLaunch.LaunchComma // ===== API Helper Methods ===== - protected Map apiGet(String path, Map queryParams = [:], String accessToken, String apiEndpoint) { - final url = buildUrl(apiEndpoint, path, queryParams) - log.debug "Platform API - GET ${url}" - final client = createHttpClient(accessToken) - final request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .GET() - .build() - - final response = client.send(request, HttpResponse.BodyHandlers.ofString()) - - if (response.statusCode() != 200) { - final error = response.body() ?: "HTTP ${response.statusCode()}" - throw new RuntimeException("API request failed: ${error}") - } - - return new JsonSlurper().parseText(response.body()) as Map - } - protected Map apiPost(String path, Map body, Map queryParams = [:], String accessToken, String apiEndpoint) { - final url = buildUrl(apiEndpoint, path, queryParams) + final url = commonApi.buildUrl(apiEndpoint, path, queryParams) log.debug "Platform API - POST ${url}" final requestBody = new JsonBuilder(body).toString() final client = createHttpClient(accessToken) - final request = HttpRequest.newBuilder() + final HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .header('Content-Type', 'application/json') .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build() - final response = client.send(request, HttpResponse.BodyHandlers.ofString()) + final HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()) if (response.statusCode() != 200) { if (response.statusCode() == 403) { @@ -990,21 +974,6 @@ class LaunchCommandImpl extends BaseCommandImpl implements CmdLaunch.LaunchComma return new JsonSlurper().parseText(response.body()) as Map } - private String buildUrl(String endpoint, String path, Map queryParams) { - def url = new StringBuilder(endpoint) - if (!path.startsWith('/')) { - url.append('/') - } - url.append(path) - - if (queryParams && !queryParams.isEmpty()) { - url.append('?') - url.append(queryParams.collect { k, v -> "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" }.join('&')) - } - - return url.toString() - } - // ===== Workspace & User Helper Methods ===== protected Long resolveWorkspaceId(Map config, String workspaceName, String accessToken, String apiEndpoint) { @@ -1016,8 +985,10 @@ class LaunchCommandImpl extends BaseCommandImpl implements CmdLaunch.LaunchComma // If workspace name provided, look it up if (workspaceName) { - final userId = getUserInfo(accessToken, apiEndpoint).id as String - final workspaces = listUserWorkspaces(accessToken, apiEndpoint, userId) + final httpClient = createHttpClient(accessToken) + final userInfo = commonApi.getUserInfo(httpClient, apiEndpoint) as Map + final userId = userInfo.id as String + final workspaces = listUserWorkspaces(httpClient, apiEndpoint, userId) final matchingWorkspace = workspaces.find { workspace -> final ws = workspace as Map @@ -1035,8 +1006,8 @@ class LaunchCommandImpl extends BaseCommandImpl implements CmdLaunch.LaunchComma return null } - protected Map findComputeEnv(String computeEnvName, Long workspaceId, String accessToken, String apiEndpoint) { - final computeEnvs = listComputeEnvironments( accessToken, apiEndpoint, workspaceId ? workspaceId.toString() : null) + protected Map findComputeEnv(HxClient client, String computeEnvName, Long workspaceId, String apiEndpoint) { + final computeEnvs = listComputeEnvironments(client, apiEndpoint, workspaceId ? workspaceId.toString() : null) log.debug "Looking for ${computeEnvName ? "compute environment with name: ${computeEnvName}" : "primary compute environment"} ${workspaceId ? "in workspace ID ${workspaceId}" : "in personal workspace"}" diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerClientTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerClientTest.groovy index 8b69f963dc..c0463fdd36 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerClientTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerClientTest.groovy @@ -25,6 +25,7 @@ import java.time.ZoneId import io.seqera.http.HxClient import nextflow.Session +import nextflow.SysEnv import nextflow.cloud.types.CloudMachineInfo import nextflow.cloud.types.PriceModel import nextflow.container.DockerConfig @@ -394,6 +395,7 @@ class TowerClientTest extends Specification { when: client.onFlowCreate(session) then: + 1 * client.getPlatformMetadata(_) >> null 1 * client.getAccessToken() >> 'secret' 1 * client.makeCreateReq(session) >> [runName: 'foo'] 1 * client.sendHttpMessage('https://api.cloud.seqera.io/trace/create', [runName: 'foo'], 'POST') >> new TowerClient.Response(200, '{"workflowId":"xyz123"}') @@ -534,4 +536,34 @@ class TowerClientTest extends Specification { request.method() == 'POST' request.uri().toString() == 'http://example.com/test' } + + def 'should retreive platform metadata content'() { + + def session = Mock(Session) + def config = new TowerConfig( [accessToken: 'token-1234', workspaceId: '1234'] , SysEnv.get() ) + def towerClient = Spy(new TowerClient(session, config)) { + apiGet(_, 'https://api.cloud.seqera.io', '/user-info', _) >> [ user: [ id: 'u1234', userName: 'user', email: 'john@acme.com', firstName: 'John', lastName: 'Smith', organization: 'ACME Inc.']] + apiGet(_, 'https://api.cloud.seqera.io', '/workflow/wf1234', _) >> [ workflow: [ id: 'wf1234', labels: [ [key: 'key1', value: 'value1'], [value: 'value2'] ] ] ] + apiGet(_, 'https://api.cloud.seqera.io', '/user/u1234/workspaces', _) >> [ orgsAndWorkspaces: [ [ orgId: 123, orgName: "ACME Inc.", workspaceId: 1234, workspaceName: "Workspace-Name", workspaceFullName: "Full Workspace Name", roles: ["member"]], [orgId: 234, orgName: "Name", workspaceId: "5434"]]] + apiGet(_, 'https://api.cloud.seqera.io', '/workflow/wf1234/launch', _) >> [ launch: [ id: 'l1234', computeEnv: [id: 'ce1234', name: 'ce-test', platform: 'aws-batch'], pipeline: 'test-pipeline', pipelineId: 'pipe1234', revision: 'v1.1', commitId: 'abcd12345']] + } + def workflowId = 'wf1234' + + when: + def metadata = towerClient.getPlatformMetadata(workflowId) + + then: + metadata.workflowId == 'wf1234' + metadata.user.id == 'u1234' + metadata.user.userName == 'user' + metadata.user.email == 'john@acme.com' + metadata.workspace.id == '1234' + metadata.workspace.name == "Workspace-Name" + metadata.workspace.organization == "ACME Inc." + metadata.pipeline.id == 'pipe1234' + metadata.pipeline.name == 'test-pipeline' + metadata.pipeline.revision == 'v1.1' + metadata.pipeline.commitId == 'abcd12345' + metadata.labels == ['key1=value1', 'value2'] + } } diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerCommonApiTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerCommonApiTest.groovy new file mode 100644 index 0000000000..227f2eb5fd --- /dev/null +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerCommonApiTest.groovy @@ -0,0 +1,41 @@ +package io.seqera.tower.plugin + +import spock.lang.Specification + +class TowerCommonApiTest extends Specification{ + + + def 'should build URL without query params'() { + given: + def api = new TowerCommonApi() + + when: + def url = api.buildUrl('https://api.cloud.seqera.io', '/workflow/launch', [:]) + + then: + url == 'https://api.cloud.seqera.io/workflow/launch' + } + + def 'should build URL with query params'() { + given: + def api = new TowerCommonApi() + + when: + def url = api.buildUrl('https://api.cloud.seqera.io', '/workflow/launch', [workspaceId: '12345']) + + then: + url.contains('https://api.cloud.seqera.io/workflow/launch?') + url.contains('workspaceId=12345') + } + + def 'should URL encode query params'() { + given: + def api = new TowerCommonApi() + + when: + def url = api.buildUrl('https://api.cloud.seqera.io', '/workflow', [name: 'test workflow']) + + then: + url.contains('name=test+workflow') + } +} diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/auth/AuthCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/auth/AuthCommandImplTest.groovy index 6d08c4c201..9b6a71f32b 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/auth/AuthCommandImplTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/auth/AuthCommandImplTest.groovy @@ -17,6 +17,7 @@ package io.seqera.tower.plugin.auth import io.seqera.http.HxClient +import io.seqera.tower.plugin.TowerCommonApi import nextflow.Const import nextflow.SysEnv import nextflow.util.ColorUtil @@ -682,8 +683,10 @@ param2 = 'value2'""" // Mock API calls cmd.checkApiConnection(_) >> true - cmd.getUserInfo(_, _) >> [userName: 'testuser', id: '123'] - cmd.getWorkspaceDetails(_, _, _) >> null + cmd.commonApi = Mock(TowerCommonApi){ + getUserInfo(_, _) >> [userName: 'testuser', id: '123'] + getWorkflowDetails(_, _, _) >> null + } cmd.listComputeEnvironments(_, _, _) >> [[name: 'ce_test', platform: 'aws', workDir: 's3://test', primary: true]] when: @@ -762,7 +765,9 @@ param2 = 'value2'""" SysEnv.push([:]) // Isolate from actual environment variables cmd.checkApiConnection(_) >> true - cmd.getUserInfo(_, _) >> { throw new RuntimeException('Invalid token') } + cmd.commonApi = Mock(TowerCommonApi){ + getUserInfo(_, _) >> { throw new RuntimeException('Invalid token') } + } when: def status = cmd.collectStatus(config) @@ -818,13 +823,14 @@ param2 = 'value2'""" ] cmd.checkApiConnection(_) >> true - cmd.getUserInfo(_, _) >> [userName: 'testuser', id: '123'] - cmd.getWorkspaceDetails(_, _, _) >> [ - orgName: 'TestOrg', - workspaceName: 'TestWorkspace', - workspaceFullName: 'test-org/test-workspace' - ] - + cmd.commonApi = Mock(TowerCommonApi){ + getUserInfo(_, _) >> [userName: 'testuser', id: '123'] + getUserWorkspaceDetails(_, _, _, _) >> [ + orgName: 'TestOrg', + workspaceName: 'TestWorkspace', + workspaceFullName: 'test-org/test-workspace' + ] + } when: def status = cmd.collectStatus(config) @@ -840,14 +846,16 @@ param2 = 'value2'""" def 'should collect status with workspace ID but no details'() { given: def cmd = Spy(AuthCommandImpl) + cmd.commonApi = Mock(TowerCommonApi){ + getUserInfo(_, _) >> [userName: 'testuser', id: '123'] + getUserWorkspaceDetails(_, _, _, _) >> null + } def config = [ 'tower.accessToken': 'test-token', 'tower.workspaceId': '12345' ] cmd.checkApiConnection(_) >> true - cmd.getUserInfo(_, _) >> [userName: 'testuser', id: '123'] - cmd.getWorkspaceDetails(_, _, _) >> null when: def status = cmd.collectStatus(config) @@ -862,11 +870,13 @@ param2 = 'value2'""" def 'should collect status from environment variables'() { given: def cmd = Spy(AuthCommandImpl) + cmd.commonApi = Mock(TowerCommonApi){ + getUserInfo(_, _) >> [userName: 'envuser', id: '456'] + getUserWorkspaceDetails(_, _, _, _) >> [:] + } def config = [:] cmd.checkApiConnection(_) >> true - cmd.getUserInfo(_, _) >> [userName: 'envuser', id: '456'] - cmd.getWorkspaceDetails(_,_,_) >> [:] cmd.listComputeEnvironments(_,_,_) >> [] SysEnv.push(['TOWER_ACCESS_TOKEN': 'env-token', @@ -925,7 +935,9 @@ param2 = 'value2'""" ] cmd.checkApiConnection(_) >> true - cmd.getUserInfo(_, _) >> [userName: 'mixeduser', id: '789'] + cmd.commonApi = Mock(TowerCommonApi) { + getUserInfo(_, _) >> [userName: 'mixeduser', id: '789'] + } SysEnv.push(['TOWER_WORKSPACE_ID': '99999']) when: diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/launch/LaunchCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/launch/LaunchCommandImplTest.groovy index 4783eb2e03..261592edbc 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/launch/LaunchCommandImplTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/launch/LaunchCommandImplTest.groovy @@ -16,6 +16,8 @@ package io.seqera.tower.plugin.launch +import io.seqera.http.HxClient +import io.seqera.tower.plugin.TowerCommonApi import nextflow.cli.CmdLaunch import nextflow.exception.AbortOperationException import org.junit.Rule @@ -378,10 +380,12 @@ class LaunchCommandImplTest extends Specification { 'tower.accessToken': 'test-token', 'tower.endpoint': 'https://api.cloud.seqera.io' ] + cmd.commonApi = Mock(TowerCommonApi){ + getUserInfo(_, _) >> [name: 'testuser', id: '123'] + getUserWorkspaceDetails(_,_, _, _) >> null + } cmd.readConfig() >> config - cmd.getUserInfo(_, _) >> [name: 'testuser', id: '123'] cmd.resolveWorkspaceId(_, _, _, _) >> null - cmd.getWorkspaceDetails(_, _, _) >> null cmd.resolveComputeEnvironment(_,_, _, _, _) >> [id: 'ce-123', name: 'test-ce', workDir: 's3://bucket/work'] def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq') @@ -402,10 +406,12 @@ class LaunchCommandImplTest extends Specification { given: def cmd = Spy(LaunchCommandImpl) def config = ['tower.accessToken': 'test-token'] + cmd.commonApi = Mock(TowerCommonApi){ + getUserInfo(_, _) >> [name: 'testuser', id: '123'] + getUserWorkspaceDetails(_, _, _, _) >> null + } cmd.readConfig() >> config - cmd.getUserInfo(_, _) >> [name: 'testuser', id: '123'] cmd.resolveWorkspaceId(_, _, _, _) >> null - cmd.getWorkspaceDetails(_, _, _) >> null cmd.resolveComputeEnvironment(_,_, _, _, _) >> [id: 'ce-123', name: 'test-ce', workDir: 's3://bucket/work'] def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq') @@ -421,10 +427,12 @@ class LaunchCommandImplTest extends Specification { given: def cmd = Spy(LaunchCommandImpl) def config = ['tower.accessToken': 'test-token', 'tower.workspaceId': 12345] + cmd.commonApi = Mock(TowerCommonApi){ + getUserInfo(_, _) >> [name: 'testuser', id: '123'] + getUserWorkspaceDetails(_, _, _, _) >> [orgName: 'TestOrg', workspaceName: 'TestWS'] + } cmd.readConfig() >> config - cmd.getUserInfo(_, _) >> [name: 'testuser', id: '123'] cmd.resolveWorkspaceId(_, _, _, _) >> 12345L - cmd.getWorkspaceDetails(_, _, _) >> [orgName: 'TestOrg', workspaceName: 'TestWS'] cmd.resolveComputeEnvironment(_, _, _, _, _) >> [id: 'ce-123', name: 'test-ce', workDir: 's3://bucket/work'] def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq') @@ -450,7 +458,7 @@ class LaunchCommandImplTest extends Specification { cmd.listComputeEnvironments(_, _, _) >> computeEnvs when: - def result = cmd.findComputeEnv('secondary-ce', null, 'token', 'endpoint') + def result = cmd.findComputeEnv(Mock(HxClient), 'secondary-ce', null, 'endpoint') then: result.id == 'ce-2' @@ -467,7 +475,7 @@ class LaunchCommandImplTest extends Specification { cmd.listComputeEnvironments(_, _, _) >> computeEnvs when: - def result = cmd.findComputeEnv(null, null, 'token', 'endpoint') + def result = cmd.findComputeEnv( Mock(HxClient) ,null, null, 'endpoint') then: result.id == 'ce-1' @@ -481,7 +489,7 @@ class LaunchCommandImplTest extends Specification { cmd.listComputeEnvironments(_, _, _) >> [] when: - def result = cmd.findComputeEnv('nonexistent', null, 'token', 'endpoint') + def result = cmd.findComputeEnv(Mock(HxClient), 'nonexistent', null, 'endpoint') then: result == null @@ -491,7 +499,7 @@ class LaunchCommandImplTest extends Specification { given: def cmd = Spy(LaunchCommandImpl) // Mock findComputeEnv to return null (not found) - cmd.findComputeEnv('nonexistent', null, 'token', 'https://api.cloud.seqera.io') >> null + cmd.findComputeEnv(_,'nonexistent', null, 'https://api.cloud.seqera.io') >> null when: cmd.resolveComputeEnvironment(null, 'nonexistent', null, 'token', 'https://api.cloud.seqera.io') @@ -505,7 +513,7 @@ class LaunchCommandImplTest extends Specification { given: def cmd = Spy(LaunchCommandImpl) // Mock findComputeEnv to return null (no primary found) - cmd.findComputeEnv(null, null, 'token', 'https://api.cloud.seqera.io') >> null + cmd.findComputeEnv(_ , null, null, 'https://api.cloud.seqera.io') >> null when: cmd.resolveComputeEnvironment(null, null, null, 'token', 'https://api.cloud.seqera.io') @@ -679,7 +687,9 @@ class LaunchCommandImplTest extends Specification { [workspaceId: 111, workspaceName: 'ws1'], [workspaceId: 222, workspaceName: 'ws2'] ] - cmd.getUserInfo(_, _) >> [id: 'user-123'] + cmd.commonApi = Mock(TowerCommonApi) { + getUserInfo(_, _) >> [id: 'user-123'] + } cmd.listUserWorkspaces(_, _, _) >> workspaces when: @@ -693,7 +703,9 @@ class LaunchCommandImplTest extends Specification { given: def cmd = Spy(LaunchCommandImpl) def config = [:] - cmd.getUserInfo(_, _) >> [id: 'user-123'] + cmd.commonApi = Mock(TowerCommonApi) { + getUserInfo(_, _) >> [id: 'user-123'] + } cmd.listUserWorkspaces(_, _, _) >> [] when: @@ -716,42 +728,6 @@ class LaunchCommandImplTest extends Specification { workspaceId == null } - // ===== URL Building Tests ===== - - def 'should build URL without query params'() { - given: - def cmd = new LaunchCommandImpl() - - when: - def url = cmd.buildUrl('https://api.cloud.seqera.io', '/workflow/launch', [:]) - - then: - url == 'https://api.cloud.seqera.io/workflow/launch' - } - - def 'should build URL with query params'() { - given: - def cmd = new LaunchCommandImpl() - - when: - def url = cmd.buildUrl('https://api.cloud.seqera.io', '/workflow/launch', [workspaceId: '12345']) - - then: - url.contains('https://api.cloud.seqera.io/workflow/launch?') - url.contains('workspaceId=12345') - } - - def 'should URL encode query params'() { - given: - def cmd = new LaunchCommandImpl() - - when: - def url = cmd.buildUrl('https://api.cloud.seqera.io', '/workflow', [name: 'test workflow']) - - then: - url.contains('name=test+workflow') - } - // ===== Launch Result Tests ===== def 'should extract launch result with workflow details'() {