From 0f9904e7c5944bda211e7d86dcbeb6587463384c Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Thu, 4 Sep 2025 08:59:34 +0200 Subject: [PATCH 01/66] WIP: First attempt at auth0 flow for 'nextflow auth login' Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 409 ++++++++++++++++++ .../main/groovy/nextflow/cli/Launcher.groovy | 1 + .../nextflow/trace/ReportObserver.groovy | 2 +- 3 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy new file mode 100644 index 0000000000..b8fedef7de --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -0,0 +1,409 @@ +/* + * 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.cli + +import com.beust.jcommander.Parameter +import com.beust.jcommander.Parameters +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import groovy.yaml.YamlBuilder +import groovy.yaml.YamlSlurper +import nextflow.exception.AbortOperationException + +import java.awt.Desktop +import java.net.ServerSocket +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +/** + * Implements the {@code auth} command + * + * @author Phil Ewels + */ +@Slf4j +@CompileStatic +@Parameters(commandDescription = "Manage Seqera Platform authentication") +class CmdAuth extends CmdBase implements UsageAware { + + interface SubCmd { + String getName() + void apply(List result) + void usage(List result) + } + + static public final String NAME = 'auth' + + private List commands = [] + + String getName() { + return NAME + } + + @Parameter(hidden = true) + List args + + CmdAuth() { + commands.add(new LoginCmd()) + } + + void usage() { + usage(args) + } + + void usage(List args) { + List result = [] + if (!args) { + result << this.getClass().getAnnotation(Parameters).commandDescription() + result << 'Usage: nextflow auth [options]' + result << '' + result << 'Commands:' + commands.collect { it.name }.sort().each { result << " $it".toString() } + result << '' + } else { + def sub = commands.find { it.name == args[0] } + if (sub) + sub.usage(result) + else { + throw new AbortOperationException("Unknown auth sub-command: ${args[0]}") + } + } + println result.join('\n').toString() + } + + @Override + void run() { + if (!args) { + usage() + return + } + + try { + getCmd(args).apply(args.drop(1)) + } catch (Exception e) { + throw new AbortOperationException(e.message) + } + } + + protected SubCmd getCmd(List args) { + def cmd = commands.find { it.name == args[0] } + if (cmd) { + return cmd + } + + def matches = commands.collect { it.name }.closest(args[0]) + def msg = "Unknown auth sub-command: ${args[0]}" + if (matches) + msg += " -- Did you mean one of these?\n" + matches.collect { " $it" }.join('\n') + throw new AbortOperationException(msg) + } + + class LoginCmd implements SubCmd { + + private static final String AUTH0_DOMAIN = "seqera-development.eu.auth0.com" + private static final String AUTH0_CLIENT_ID = "ENTER_CLIENT_ID_HERE_WHEN_WE_HAVE_IT" + private static final String AUTH0_AUDIENCE = "platform" + private static final int CALLBACK_PORT = 8085 + + @Override + String getName() { 'login' } + + @Override + void apply(List args) { + if (args.size() > 0) { + throw new AbortOperationException("Too many arguments for login command") + } + + println "Nextflow authentication with Seqera Platform" + println "" + + // Prompt user for API URL + def apiUrl = promptForApiUrl() + + try { + performAuth0Login(apiUrl) + } catch (Exception e) { + log.debug("Authentication failed", e) + throw new AbortOperationException("Authentication failed: ${e.message}") + } + } + + private String promptForApiUrl() { + System.out.print("Seqera Platform API URL [Default Seqera Cloud, https://api.cloud.seqera.io]: ") + System.out.flush() + + def reader = new BufferedReader(new InputStreamReader(System.in)) + def input = reader.readLine()?.trim() + + return input?.isEmpty() || input == null ? 'https://api.cloud.seqera.io' : input + } + + private void performAuth0Login(String apiUrl) { + println "Opening browser for authentication..." + + // Generate PKCE parameters + def codeVerifier = generateCodeVerifier() + def codeChallenge = generateCodeChallenge(codeVerifier) + def state = generateState() + + // Start local server for callback + def callbackFuture = startCallbackServer(state) + + // Build authorization URL + def authUrl = buildAuthUrl(codeChallenge, state) + + // Open browser + openBrowser(authUrl) + + println "Waiting for authentication to complete..." + + try { + // Wait for callback with timeout + def authCode = callbackFuture.get(5, TimeUnit.MINUTES) + + // Exchange code for token + def tokenData = exchangeCodeForToken(authCode, codeVerifier) + + // Save credentials + saveCredentials(tokenData, apiUrl) + + println "Authentication successful! Credentials saved to ${getCredentialsPath()}" + + } catch (Exception e) { + throw new RuntimeException("Authentication timeout or failed: ${e.message}", e) + } + } + + private String generateCodeVerifier() { + def random = new SecureRandom() + def bytes = new byte[32] + random.nextBytes(bytes) + return Base64.urlEncoder.withoutPadding().encodeToString(bytes) + } + + private String generateCodeChallenge(String codeVerifier) { + def digest = MessageDigest.getInstance("SHA-256") + def hash = digest.digest(codeVerifier.getBytes("UTF-8")) + return Base64.urlEncoder.withoutPadding().encodeToString(hash) + } + + private String generateState() { + def random = new SecureRandom() + def bytes = new byte[16] + random.nextBytes(bytes) + return Base64.urlEncoder.withoutPadding().encodeToString(bytes) + } + + private CompletableFuture startCallbackServer(String expectedState) { + def future = new CompletableFuture() + + Thread.start { + try { + def server = new ServerSocket(CALLBACK_PORT) + server.soTimeout = 300000 // 5 minutes + + def socket = server.accept() + def input = new BufferedReader(new InputStreamReader(socket.inputStream)) + def output = new PrintWriter(socket.outputStream) + + def requestLine = input.readLine() + if (requestLine?.startsWith("GET /callback")) { + def query = requestLine.split("\\?")[1]?.split(" ")[0] + def params = parseQueryParams(query) + + if (params['state'] != expectedState) { + throw new RuntimeException("Invalid state parameter") + } + + if (params['error']) { + throw new RuntimeException("Auth error: ${params['error']} - ${params['error_description']}") + } + + def code = params['code'] + if (!code) { + throw new RuntimeException("No authorization code received") + } + + // Send success response + output.println("HTTP/1.1 200 OK") + output.println("Content-Type: text/html") + output.println() + output.println("

Authentication successful!

You can close this window.

") + output.flush() + + future.complete(code) + } else { + future.completeExceptionally(new RuntimeException("Invalid callback request")) + } + + socket.close() + server.close() + + } catch (Exception e) { + future.completeExceptionally(e) + } + } + + return future + } + + private Map parseQueryParams(String query) { + Map params = [:] + if (query) { + query.split('&').each { param -> + def parts = param.split('=', 2) + if (parts.length == 2) { + params[URLDecoder.decode(parts[0], "UTF-8")] = URLDecoder.decode(parts[1], "UTF-8") + } + } + } + return params + } + + private String buildAuthUrl(String codeChallenge, String state) { + def params = [ + 'response_type': 'code', + 'client_id': AUTH0_CLIENT_ID, + 'redirect_uri': "http://localhost:${CALLBACK_PORT}/callback", + 'scope': 'openid profile email offline_access', + 'audience': AUTH0_AUDIENCE, + 'code_challenge': codeChallenge, + 'code_challenge_method': 'S256', + 'state': state, + 'prompt': 'login' + ] + + def query = params.collect { k, v -> + "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" + }.join('&') + + return "https://${AUTH0_DOMAIN}/authorize?${query}" + } + + private void openBrowser(String url) { + try { + if (Desktop.isDesktopSupported()) { + Desktop.desktop.browse(new URI(url)) + } else { + // Fallback for systems without Desktop support + def os = System.getProperty("os.name").toLowerCase() + if (os.contains("win")) { + Runtime.runtime.exec("rundll32 url.dll,FileProtocolHandler ${url}") + } else if (os.contains("mac")) { + Runtime.runtime.exec("open ${url}") + } else { + Runtime.runtime.exec("xdg-open ${url}") + } + } + } catch (Exception e) { + println "Could not open browser automatically. Please visit: ${url}" + } + } + + private Map exchangeCodeForToken(String authCode, String codeVerifier) { + def tokenUrl = "https://${AUTH0_DOMAIN}/oauth/token" + + def params = [ + 'grant_type': 'authorization_code', + 'client_id': AUTH0_CLIENT_ID, + 'code': authCode, + 'redirect_uri': "http://localhost:${CALLBACK_PORT}/callback", + 'code_verifier': codeVerifier + ] + + def postData = params.collect { k, v -> + "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" + }.join('&') + + def connection = new URL(tokenUrl).openConnection() as HttpURLConnection + connection.requestMethod = 'POST' + connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded') + connection.doOutput = true + + connection.outputStream.withWriter { writer -> + writer.write(postData) + } + + if (connection.responseCode != 200) { + def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + throw new RuntimeException("Token exchange failed: ${error}") + } + + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) + return json as Map + } + + private void saveCredentials(Map tokenData, String apiUrl) { + def xdgConfigHome = System.getenv("XDG_CONFIG_HOME") + def credentialsDir = xdgConfigHome ? + Paths.get(xdgConfigHome, "seqera") : + Paths.get(System.getProperty("user.home"), ".config", "seqera") + Files.createDirectories(credentialsDir) + + def credentialsFile = credentialsDir.resolve("credentials") + + def config = [:] + if (Files.exists(credentialsFile)) { + try { + def yaml = new YamlSlurper() + config = yaml.parse(credentialsFile.toFile()) as Map ?: [:] + } catch (Exception e) { + log.debug("Could not parse existing credentials file", e) + config = [:] + } + } + + config['default'] = [ + 'token': tokenData['access_token'], + 'endpoint': apiUrl + ] + + def yaml = new YamlBuilder() + yaml(config) + + Files.write(credentialsFile, yaml.toString().getBytes("UTF-8")) + + // Set file permissions + try { + def perms = java.nio.file.attribute.PosixFilePermissions.fromString("rw-r--r--") + Files.setPosixFilePermissions(credentialsFile, perms) + } catch (Exception e) { + log.debug("Could not set file permissions", e) + } + } + + private String getCredentialsPath() { + def xdgConfigHome = System.getenv("XDG_CONFIG_HOME") + def credentialsDir = xdgConfigHome ? + Paths.get(xdgConfigHome, "seqera") : + Paths.get(System.getProperty("user.home"), ".config", "seqera") + return credentialsDir.resolve("credentials").toString() + } + + @Override + void usage(List result) { + result << 'Authenticate with Seqera Platform' + result << "Usage: nextflow auth $name".toString() + } + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy index 6afda06942..1b74914cc9 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy @@ -90,6 +90,7 @@ class Launcher { protected void init() { allCommands = (List)[ + new CmdAuth(), new CmdClean(), new CmdClone(), new CmdConsole(), diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/ReportObserver.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/ReportObserver.groovy index 3fa40ed06a..f174753dfe 100644 --- a/modules/nextflow/src/main/groovy/nextflow/trace/ReportObserver.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/trace/ReportObserver.groovy @@ -35,7 +35,7 @@ import nextflow.util.TestOnly * Render pipeline report processes execution. * Based on original TimelineObserver code by Paolo Di Tommaso * - * @author Phil Ewels + * @author Phil Ewels * @author Paolo Di Tommaso */ @Slf4j From b599fe1e0cdd21828442e667d785edf2942bf992 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Thu, 4 Sep 2025 09:13:35 +0200 Subject: [PATCH 02/66] Handle Enterprise installs without auth0 Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 58 +++++++++++++++---- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index b8fedef7de..61d5c79330 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -36,7 +36,7 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit /** - * Implements the {@code auth} command + * Implements the 'nextflow auth' commands * * @author Phil Ewels */ @@ -139,11 +139,17 @@ class CmdAuth extends CmdBase implements UsageAware { // Prompt user for API URL def apiUrl = promptForApiUrl() - try { - performAuth0Login(apiUrl) - } catch (Exception e) { - log.debug("Authentication failed", e) - throw new AbortOperationException("Authentication failed: ${e.message}") + // Check if this is a cloud endpoint or enterprise + if (isCloudEndpoint(apiUrl)) { + try { + performAuth0Login(apiUrl) + } catch (Exception e) { + log.debug("Authentication failed", e) + throw new AbortOperationException("Authentication failed: ${e.message}") + } + } else { + // Enterprise endpoint - use PAT authentication + handleEnterpriseAuth(apiUrl) } } @@ -184,7 +190,7 @@ class CmdAuth extends CmdBase implements UsageAware { def tokenData = exchangeCodeForToken(authCode, codeVerifier) // Save credentials - saveCredentials(tokenData, apiUrl) + saveCredentials('oauth', tokenData['access_token'], apiUrl) println "Authentication successful! Credentials saved to ${getCredentialsPath()}" @@ -353,14 +359,14 @@ class CmdAuth extends CmdBase implements UsageAware { return json as Map } - private void saveCredentials(Map tokenData, String apiUrl) { + private void saveCredentials(String type, String token, String apiUrl) { def xdgConfigHome = System.getenv("XDG_CONFIG_HOME") def credentialsDir = xdgConfigHome ? Paths.get(xdgConfigHome, "seqera") : Paths.get(System.getProperty("user.home"), ".config", "seqera") Files.createDirectories(credentialsDir) - def credentialsFile = credentialsDir.resolve("credentials") + def credentialsFile = credentialsDir.resolve("credentials.yml") def config = [:] if (Files.exists(credentialsFile)) { @@ -374,7 +380,8 @@ class CmdAuth extends CmdBase implements UsageAware { } config['default'] = [ - 'token': tokenData['access_token'], + 'type': type, + 'token': token, 'endpoint': apiUrl ] @@ -392,12 +399,41 @@ class CmdAuth extends CmdBase implements UsageAware { } } + private boolean isCloudEndpoint(String apiUrl) { + return apiUrl == 'https://api.cloud.seqera.io' || + apiUrl == 'https://api.cloud.stage-seqera.io' + } + + private void handleEnterpriseAuth(String apiUrl) { + println "" + println "Please generate a Personal Access Token from your Seqera Platform instance." + println "You can create one at: ${apiUrl.replace('/api', '').replace('api.', '')}/tokens" + println "" + + System.out.print("Enter your Personal Access Token: ") + System.out.flush() + + def console = System.console() + def pat = console ? + new String(console.readPassword()) : + new BufferedReader(new InputStreamReader(System.in)).readLine() + + if (!pat || pat.trim().isEmpty()) { + throw new AbortOperationException("Personal Access Token is required for Seqera Platform Enterprise authentication") + } + + // Save PAT credentials + saveCredentials('pat', pat.trim(), apiUrl) + println "Authentication successful! Credentials saved to ${getCredentialsPath()}" + } + + private String getCredentialsPath() { def xdgConfigHome = System.getenv("XDG_CONFIG_HOME") def credentialsDir = xdgConfigHome ? Paths.get(xdgConfigHome, "seqera") : Paths.get(System.getProperty("user.home"), ".config", "seqera") - return credentialsDir.resolve("credentials").toString() + return credentialsDir.resolve("credentials.yml").toString() } @Override From 5fa52fcd70b9d288ffe4c827912aa200bfc6d75d Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Thu, 4 Sep 2025 09:44:06 +0200 Subject: [PATCH 03/66] Add auth0 client ID Signed-off-by: Phil Ewels --- modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 61d5c79330..4651c52403 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -120,7 +120,7 @@ class CmdAuth extends CmdBase implements UsageAware { class LoginCmd implements SubCmd { private static final String AUTH0_DOMAIN = "seqera-development.eu.auth0.com" - private static final String AUTH0_CLIENT_ID = "ENTER_CLIENT_ID_HERE_WHEN_WE_HAVE_IT" + private static final String AUTH0_CLIENT_ID = "Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ" private static final String AUTH0_AUDIENCE = "platform" private static final int CALLBACK_PORT = 8085 From f03162242063234e896358257a1c41a610b67b7d Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Thu, 4 Sep 2025 14:54:43 +0200 Subject: [PATCH 04/66] Hardcode 'platform' as audience as shouldn't ever need to edit this Signed-off-by: Phil Ewels --- modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 4651c52403..2424d26b88 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -121,7 +121,6 @@ class CmdAuth extends CmdBase implements UsageAware { private static final String AUTH0_DOMAIN = "seqera-development.eu.auth0.com" private static final String AUTH0_CLIENT_ID = "Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ" - private static final String AUTH0_AUDIENCE = "platform" private static final int CALLBACK_PORT = 8085 @Override @@ -291,7 +290,7 @@ class CmdAuth extends CmdBase implements UsageAware { 'client_id': AUTH0_CLIENT_ID, 'redirect_uri': "http://localhost:${CALLBACK_PORT}/callback", 'scope': 'openid profile email offline_access', - 'audience': AUTH0_AUDIENCE, + 'audience': 'platform', 'code_challenge': codeChallenge, 'code_challenge_method': 'S256', 'state': state, From a0d800a860fb74792729711c19e5ea2660a042e8 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Fri, 5 Sep 2025 10:45:52 +0200 Subject: [PATCH 05/66] Add additional allowed API URLs for auth0 flow Signed-off-by: Phil Ewels --- .../nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 2424d26b88..993929cafd 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -400,7 +400,12 @@ class CmdAuth extends CmdBase implements UsageAware { private boolean isCloudEndpoint(String apiUrl) { return apiUrl == 'https://api.cloud.seqera.io' || - apiUrl == 'https://api.cloud.stage-seqera.io' + apiUrl == 'https://api.cloud.stage-seqera.io' || + apiUrl == 'https://api.cloud.dev-seqera.io' || + apiUrl == 'https://cloud.seqera.io/api' || + apiUrl == 'https://cloud.stage-seqera.io/api' || + apiUrl == 'https://cloud.dev-seqera.io/api' + } private void handleEnterpriseAuth(String apiUrl) { From ec86e14dc81a818560dd1b3ca4b0b196729f4bdb Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Fri, 5 Sep 2025 11:15:44 +0200 Subject: [PATCH 06/66] Use auth0 to generate a Platform PAT, add to bashrc Set TOWER_ACCESS_TOKEN with new PAT. Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 227 +++++++++++++----- 1 file changed, 170 insertions(+), 57 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 993929cafd..e0ae81ae86 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -20,8 +20,6 @@ import com.beust.jcommander.Parameter import com.beust.jcommander.Parameters import groovy.transform.CompileStatic import groovy.util.logging.Slf4j -import groovy.yaml.YamlBuilder -import groovy.yaml.YamlSlurper import nextflow.exception.AbortOperationException import java.awt.Desktop @@ -132,16 +130,48 @@ class CmdAuth extends CmdBase implements UsageAware { throw new AbortOperationException("Too many arguments for login command") } + // Check if TOWER_ACCESS_TOKEN is already set + def existingToken = System.getenv("TOWER_ACCESS_TOKEN") + if (existingToken) { + println "TOWER_ACCESS_TOKEN environment variable is already set." + + // Try to find the token in shell config files + def configFile = findTokenInShellConfig(existingToken) + if (configFile) { + println "Token found in: ${configFile}" + } + + println "Run `nextflow auth status` to view details." + return + } + println "Nextflow authentication with Seqera Platform" println "" + // Detect shell and config file + def shellConfigInfo = detectShellConfig() + + if (shellConfigInfo.shell == "unknown") { + println "Unrecognized shell detected. After authentication, you will need to manually add the TOWER_ACCESS_TOKEN export to your shell configuration file." + println "" + } else { + // Check if config file exists before proceeding + def configFile = Paths.get(shellConfigInfo.configFile as String) + if (!Files.exists(configFile)) { + throw new AbortOperationException("Shell ${shellConfigInfo.shell} was detected but config file ${shellConfigInfo.configFile} was not found. Please create this file first.") + } + + println "A Personal Access Token will be generated and saved to: ${shellConfigInfo.configFile}" + println "" + } + // Prompt user for API URL def apiUrl = promptForApiUrl() // Check if this is a cloud endpoint or enterprise if (isCloudEndpoint(apiUrl)) { try { - performAuth0Login(apiUrl) + performAuth0Login(apiUrl, shellConfigInfo) } catch (Exception e) { log.debug("Authentication failed", e) throw new AbortOperationException("Authentication failed: ${e.message}") @@ -162,8 +192,8 @@ class CmdAuth extends CmdBase implements UsageAware { return input?.isEmpty() || input == null ? 'https://api.cloud.seqera.io' : input } - private void performAuth0Login(String apiUrl) { - println "Opening browser for authentication..." + private void performAuth0Login(String apiUrl, Map shellConfigInfo) { + println "- Opening browser for authentication..." // Generate PKCE parameters def codeVerifier = generateCodeVerifier() @@ -179,7 +209,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Open browser openBrowser(authUrl) - println "Waiting for authentication to complete..." + println "- Waiting for authentication to complete..." try { // Wait for callback with timeout @@ -187,11 +217,23 @@ class CmdAuth extends CmdBase implements UsageAware { // Exchange code for token def tokenData = exchangeCodeForToken(authCode, codeVerifier) + def accessToken = tokenData['access_token'] as String - // Save credentials - saveCredentials('oauth', tokenData['access_token'], apiUrl) + // Verify login by calling /user-info + def userInfo = callUserInfoApi(accessToken, apiUrl) + println "Authentication successful! Logged in as: ${userInfo.userName}" - println "Authentication successful! Credentials saved to ${getCredentialsPath()}" + // Generate PAT + def pat = generatePAT(accessToken, apiUrl) + + // Add to shell config or display for manual addition + if (shellConfigInfo.shell == "unknown") { + displayTokenForManualSetup(pat) + } else { + addTokenToShellConfig(pat, shellConfigInfo) + println "- Personal Access Token generated and added to ${shellConfigInfo.configFile}" + println "Please restart your terminal or run: source ${shellConfigInfo.configFile}" + } } catch (Exception e) { throw new RuntimeException("Authentication timeout or failed: ${e.message}", e) @@ -358,45 +400,6 @@ class CmdAuth extends CmdBase implements UsageAware { return json as Map } - private void saveCredentials(String type, String token, String apiUrl) { - def xdgConfigHome = System.getenv("XDG_CONFIG_HOME") - def credentialsDir = xdgConfigHome ? - Paths.get(xdgConfigHome, "seqera") : - Paths.get(System.getProperty("user.home"), ".config", "seqera") - Files.createDirectories(credentialsDir) - - def credentialsFile = credentialsDir.resolve("credentials.yml") - - def config = [:] - if (Files.exists(credentialsFile)) { - try { - def yaml = new YamlSlurper() - config = yaml.parse(credentialsFile.toFile()) as Map ?: [:] - } catch (Exception e) { - log.debug("Could not parse existing credentials file", e) - config = [:] - } - } - - config['default'] = [ - 'type': type, - 'token': token, - 'endpoint': apiUrl - ] - - def yaml = new YamlBuilder() - yaml(config) - - Files.write(credentialsFile, yaml.toString().getBytes("UTF-8")) - - // Set file permissions - try { - def perms = java.nio.file.attribute.PosixFilePermissions.fromString("rw-r--r--") - Files.setPosixFilePermissions(credentialsFile, perms) - } catch (Exception e) { - log.debug("Could not set file permissions", e) - } - } private boolean isCloudEndpoint(String apiUrl) { return apiUrl == 'https://api.cloud.seqera.io' || @@ -426,18 +429,128 @@ class CmdAuth extends CmdBase implements UsageAware { throw new AbortOperationException("Personal Access Token is required for Seqera Platform Enterprise authentication") } - // Save PAT credentials - saveCredentials('pat', pat.trim(), apiUrl) - println "Authentication successful! Credentials saved to ${getCredentialsPath()}" + // Add PAT to shell config or display for manual addition + def shellConfigInfo = detectShellConfig() + if (shellConfigInfo.shell == "unknown") { + displayTokenForManualSetup(pat.trim()) + } else { + addTokenToShellConfig(pat.trim(), shellConfigInfo) + println "Personal Access Token added to ${shellConfigInfo.configFile}" + println "Please restart your terminal or run: source ${shellConfigInfo.configFile}" + } } - private String getCredentialsPath() { - def xdgConfigHome = System.getenv("XDG_CONFIG_HOME") - def credentialsDir = xdgConfigHome ? - Paths.get(xdgConfigHome, "seqera") : - Paths.get(System.getProperty("user.home"), ".config", "seqera") - return credentialsDir.resolve("credentials.yml").toString() + private Map callUserInfoApi(String accessToken, String apiUrl) { + def userInfoUrl = "${apiUrl}/user-info" + def connection = new URL(userInfoUrl).openConnection() as HttpURLConnection + connection.requestMethod = 'GET' + connection.setRequestProperty('Authorization', "Bearer ${accessToken}") + + if (connection.responseCode != 200) { + def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + throw new RuntimeException("Failed to get user info: ${error}") + } + + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) as Map + return json.user as Map + } + + private String generatePAT(String accessToken, String apiUrl) { + def tokensUrl = "${apiUrl}/tokens" + def username = System.getProperty("user.name") + def timestamp = new Date().format("yyyy-MM-dd-HH-mm") + def tokenName = "nextflow-${username}-${timestamp}" + + def requestBody = new groovy.json.JsonBuilder([name: tokenName]).toString() + + def connection = new URL(tokensUrl).openConnection() as HttpURLConnection + connection.requestMethod = 'POST' + connection.setRequestProperty('Authorization', "Bearer ${accessToken}") + connection.setRequestProperty('Content-Type', 'application/json') + connection.doOutput = true + + connection.outputStream.withWriter { writer -> + writer.write(requestBody) + } + + if (connection.responseCode != 200) { + def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + throw new RuntimeException("Failed to generate PAT: ${error}") + } + + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) as Map + return json.accessKey as String + } + + private void displayTokenForManualSetup(String pat) { + println "" + println "=".repeat(60) + println "MANUAL SETUP REQUIRED" + println "=".repeat(60) + println "" + println "Please add the following line to your shell configuration file:" + println "" + println "export TOWER_ACCESS_TOKEN=${pat}" + println "" + println "Common shell config files:" + println " ~/.bashrc (bash)" + println " ~/.zshrc (zsh)" + println " ~/.config/fish/config.fish (fish)" + println "" + println "After adding the export, restart your terminal or run 'source '" + println "=".repeat(60) + } + + private void addTokenToShellConfig(String pat, Map shellConfigInfo) { + def configFile = Paths.get(shellConfigInfo.configFile as String) + def commentLine = "# Seqera Platform access token" + def exportLine = "export TOWER_ACCESS_TOKEN=\"${pat}\"" + + // Append to existing file (we already checked it exists at command start) + Files.writeString(configFile, "\n${commentLine}\n${exportLine}\n", java.nio.file.StandardOpenOption.APPEND) + } + + private String findTokenInShellConfig(String token) { + def shellConfigInfo = detectShellConfig() + + if (shellConfigInfo.configFile) { + def content = Files.readString(Paths.get(shellConfigInfo.configFile as String)) + if (content.contains("export TOWER_ACCESS_TOKEN=${token}")) { + return shellConfigInfo.configFile + } + } + + return null + } + + private Map detectShellConfig() { + def shell = System.getenv("SHELL") + def homeDir = System.getProperty("user.home") + def configFile + def shellName + + if (shell?.contains("zsh")) { + shellName = "zsh" + configFile = "${homeDir}/.zshrc" + } else if (shell?.contains("fish")) { + shellName = "fish" + configFile = "${homeDir}/.config/fish/config.fish" + } else if (shell?.contains("bash")) { + shellName = "bash" + // Check for .bash_profile first, then .bashrc + def bashProfile = "${homeDir}/.bash_profile" + def bashrc = "${homeDir}/.bashrc" + configFile = Files.exists(Paths.get(bashProfile)) ? bashProfile : bashrc + } else { + // Unrecognized shell + shellName = "unknown" + configFile = null + } + + return [shell: shellName, configFile: configFile] } @Override From 356e8f5ef09d8125ff986034d47ae58a945496fe Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Fri, 5 Sep 2025 15:32:58 +0200 Subject: [PATCH 07/66] WIP: Started work on 'nextflow auth logout' Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 237 ++++++++++++++---- 1 file changed, 190 insertions(+), 47 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index e0ae81ae86..4edeb940c4 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -62,6 +62,7 @@ class CmdAuth extends CmdBase implements UsageAware { CmdAuth() { commands.add(new LoginCmd()) + commands.add(new LogoutCmd()) } void usage() { @@ -115,6 +116,63 @@ class CmdAuth extends CmdBase implements UsageAware { throw new AbortOperationException(msg) } + // Shared helper methods for both login and logout + private Map callUserInfoApi(String accessToken, String apiUrl) { + def userInfoUrl = "${apiUrl}/user-info" + def connection = new URL(userInfoUrl).openConnection() as HttpURLConnection + connection.requestMethod = 'GET' + connection.setRequestProperty('Authorization', "Bearer ${accessToken}") + + if (connection.responseCode != 200) { + def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + throw new RuntimeException("Failed to get user info: ${error}") + } + + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) as Map + return json.user as Map + } + + private String findTokenInShellConfig(String token) { + def shellConfigInfo = detectShellConfig() + + if (shellConfigInfo.configFile) { + def content = Files.readString(Paths.get(shellConfigInfo.configFile as String)) + if (content.contains("export TOWER_ACCESS_TOKEN=${token}") || content.contains("export TOWER_ACCESS_TOKEN=\"${token}\"")) { + return shellConfigInfo.configFile + } + } + + return null + } + + private Map detectShellConfig() { + def shell = System.getenv("SHELL") + def homeDir = System.getProperty("user.home") + def configFile + def shellName + + if (shell?.contains("zsh")) { + shellName = "zsh" + configFile = "${homeDir}/.zshrc" + } else if (shell?.contains("fish")) { + shellName = "fish" + configFile = "${homeDir}/.config/fish/config.fish" + } else if (shell?.contains("bash")) { + shellName = "bash" + // Check for .bash_profile first, then .bashrc + def bashProfile = "${homeDir}/.bash_profile" + def bashrc = "${homeDir}/.bashrc" + configFile = Files.exists(Paths.get(bashProfile)) ? bashProfile : bashrc + } else { + // Unrecognized shell + shellName = "unknown" + configFile = null + } + + return [shell: shellName, configFile: configFile] + } + class LoginCmd implements SubCmd { private static final String AUTH0_DOMAIN = "seqera-development.eu.auth0.com" @@ -441,22 +499,6 @@ class CmdAuth extends CmdBase implements UsageAware { } - private Map callUserInfoApi(String accessToken, String apiUrl) { - def userInfoUrl = "${apiUrl}/user-info" - def connection = new URL(userInfoUrl).openConnection() as HttpURLConnection - connection.requestMethod = 'GET' - connection.setRequestProperty('Authorization', "Bearer ${accessToken}") - - if (connection.responseCode != 200) { - def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" - throw new RuntimeException("Failed to get user info: ${error}") - } - - def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) as Map - return json.user as Map - } - private String generatePAT(String accessToken, String apiUrl) { def tokensUrl = "${apiUrl}/tokens" def username = System.getProperty("user.name") @@ -513,49 +555,150 @@ class CmdAuth extends CmdBase implements UsageAware { Files.writeString(configFile, "\n${commentLine}\n${exportLine}\n", java.nio.file.StandardOpenOption.APPEND) } - private String findTokenInShellConfig(String token) { - def shellConfigInfo = detectShellConfig() + @Override + void usage(List result) { + result << 'Authenticate with Seqera Platform' + result << "Usage: nextflow auth $name".toString() + } + } + + class LogoutCmd implements SubCmd { + + @Override + String getName() { 'logout' } + + @Override + void apply(List args) { + if (args.size() > 0) { + throw new AbortOperationException("Too many arguments for logout command") + } + + println "Nextflow authentication logout" + println "" + + // Check if TOWER_ACCESS_TOKEN is set + def existingToken = System.getenv("TOWER_ACCESS_TOKEN") + if (!existingToken) { + println "No TOWER_ACCESS_TOKEN environment variable found. Already logged out." + return + } - if (shellConfigInfo.configFile) { - def content = Files.readString(Paths.get(shellConfigInfo.configFile as String)) - if (content.contains("export TOWER_ACCESS_TOKEN=${token}")) { - return shellConfigInfo.configFile + // Find token in shell config + def configFilePath = findTokenInShellConfig(existingToken) + if (!configFilePath) { + println "Token found in environment but not in shell config files." + println "You may need to manually remove: export TOWER_ACCESS_TOKEN=${existingToken}" + return + } + + println "Found token in: ${configFilePath}" + + // Prompt user for API URL + def apiUrl = promptForApiUrl() + + // Validate token by calling /user-info API + try { + def userInfo = callUserInfoApi(existingToken, apiUrl) + println "Token is valid for user: ${userInfo.userName}" + + // Decode token to get token ID + def tokenId = decodeTokenId(existingToken) + println "Token ID: ${tokenId}" + + // Delete token via API + deleteTokenViaApi(existingToken, apiUrl, tokenId) + + // Remove from shell config + removeTokenFromShellConfig(existingToken, configFilePath) + + println "Successfully logged out and removed token." + + } catch (Exception e) { + println "Failed to validate or delete token: ${e.message}" + println "You may need to manually remove the export line from ${configFilePath}" + } + } + + private String promptForApiUrl() { + System.out.print("Seqera Platform API URL [Default Seqera Cloud, https://api.cloud.seqera.io]: ") + System.out.flush() + + def reader = new BufferedReader(new InputStreamReader(System.in)) + def input = reader.readLine()?.trim() + + return input?.isEmpty() || input == null ? 'https://api.cloud.seqera.io' : input + } + + private String decodeTokenId(String token) { + try { + // Decode base64 token + def decoded = new String(Base64.decoder.decode(token), "UTF-8") + + // Parse JSON to extract token ID + def json = new groovy.json.JsonSlurper().parseText(decoded) as Map + def tokenId = json.tid + + if (!tokenId) { + throw new RuntimeException("No token ID found in decoded token") } + + return tokenId.toString() + } catch (Exception e) { + throw new RuntimeException("Failed to decode token ID: ${e.message}") } + } - return null + private void deleteTokenViaApi(String token, String apiUrl, String tokenId) { + def deleteUrl = "${apiUrl}/tokens/${tokenId}" + def connection = new URL(deleteUrl).openConnection() as HttpURLConnection + connection.requestMethod = 'DELETE' + connection.setRequestProperty('Authorization', "Bearer ${token}") + + if (connection.responseCode != 200 && connection.responseCode != 204) { + def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + throw new RuntimeException("Failed to delete token: ${error}") + } + + println "Token successfully deleted from Seqera Platform." } - private Map detectShellConfig() { - def shell = System.getenv("SHELL") - def homeDir = System.getProperty("user.home") - def configFile - def shellName - - if (shell?.contains("zsh")) { - shellName = "zsh" - configFile = "${homeDir}/.zshrc" - } else if (shell?.contains("fish")) { - shellName = "fish" - configFile = "${homeDir}/.config/fish/config.fish" - } else if (shell?.contains("bash")) { - shellName = "bash" - // Check for .bash_profile first, then .bashrc - def bashProfile = "${homeDir}/.bash_profile" - def bashrc = "${homeDir}/.bashrc" - configFile = Files.exists(Paths.get(bashProfile)) ? bashProfile : bashrc - } else { - // Unrecognized shell - shellName = "unknown" - configFile = null + private void removeTokenFromShellConfig(String token, String configFilePath) { + def configFile = Paths.get(configFilePath) + def content = Files.readString(configFile) + + // Look for the export line and optional comment above it + def exportLine = "export TOWER_ACCESS_TOKEN=\"${token}\"" + def commentLine = "# Seqera Platform access token" + + def lines = content.split('\n').toList() + def newLines = [] + def i = 0 + + while (i < lines.size()) { + def currentLine = lines[i] + + // Check if this is the export line we want to remove + if (currentLine.trim() == exportLine.trim()) { + // Check if previous line is our comment + if (i > 0 && lines[i-1].trim() == commentLine.trim()) { + // Remove the comment line too (it was just added) + newLines.removeLast() + } + // Skip the export line (don't add it to newLines) + } else { + newLines.add(currentLine) + } + i++ } - return [shell: shellName, configFile: configFile] + // Write back the modified content + Files.writeString(configFile, newLines.join('\n')) + println "Removed token export from ${configFilePath}" } @Override void usage(List result) { - result << 'Authenticate with Seqera Platform' + result << 'Log out and remove Seqera Platform authentication' result << "Usage: nextflow auth $name".toString() } } From b6b6127cb6862cdc1f84753d9c1d13e101463b53 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Fri, 5 Sep 2025 21:36:03 +0200 Subject: [PATCH 08/66] Rewrite: Save to ~/.nextflow/config instead of using env vars Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 434 +++++++++--------- 1 file changed, 224 insertions(+), 210 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 4edeb940c4..40e7874648 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -20,6 +20,7 @@ import com.beust.jcommander.Parameter import com.beust.jcommander.Parameters import groovy.transform.CompileStatic import groovy.util.logging.Slf4j +import nextflow.Const import nextflow.exception.AbortOperationException import java.awt.Desktop @@ -27,7 +28,7 @@ import java.net.ServerSocket import java.net.URI import java.nio.file.Files import java.nio.file.Path -import java.nio.file.Paths +import java.nio.file.StandardOpenOption import java.security.MessageDigest import java.security.SecureRandom import java.util.concurrent.CompletableFuture @@ -116,7 +117,27 @@ class CmdAuth extends CmdBase implements UsageAware { throw new AbortOperationException(msg) } - // Shared helper methods for both login and logout + // Shared methods + private String promptForApiUrl() { + System.out.print("Seqera Platform API endpoint [Default https://api.cloud.seqera.io]: ") + System.out.flush() + + def reader = new BufferedReader(new InputStreamReader(System.in)) + def input = reader.readLine()?.trim() + + return input?.isEmpty() || input == null ? 'https://api.cloud.seqera.io' : input + } + + private boolean isCloudEndpoint(String apiUrl) { + return apiUrl == 'https://api.cloud.seqera.io' || + apiUrl == 'https://api.cloud.stage-seqera.io' || + apiUrl == 'https://api.cloud.dev-seqera.io' || + apiUrl == 'https://cloud.seqera.io/api' || + apiUrl == 'https://cloud.stage-seqera.io/api' || + apiUrl == 'https://cloud.dev-seqera.io/api' + } + + // Get user info from Seqera Platform private Map callUserInfoApi(String accessToken, String apiUrl) { def userInfoUrl = "${apiUrl}/user-info" def connection = new URL(userInfoUrl).openConnection() as HttpURLConnection @@ -133,46 +154,75 @@ class CmdAuth extends CmdBase implements UsageAware { return json.user as Map } - private String findTokenInShellConfig(String token) { - def shellConfigInfo = detectShellConfig() + private Path getConfigFile() { + return Const.APP_HOME_DIR.resolve('config') + } - if (shellConfigInfo.configFile) { - def content = Files.readString(Paths.get(shellConfigInfo.configFile as String)) - if (content.contains("export TOWER_ACCESS_TOKEN=${token}") || content.contains("export TOWER_ACCESS_TOKEN=\"${token}\"")) { - return shellConfigInfo.configFile - } + private Map readConfig() { + def configFile = getConfigFile() + if (!Files.exists(configFile)) { + return [:] } - return null + try { + def configText = Files.readString(configFile) + def config = new ConfigSlurper().parse(configText) + return config.flatten() + } catch (Exception e) { + throw new RuntimeException("Failed to read config file ${configFile}: ${e.message}") + } } - private Map detectShellConfig() { - def shell = System.getenv("SHELL") - def homeDir = System.getProperty("user.home") - def configFile - def shellName - - if (shell?.contains("zsh")) { - shellName = "zsh" - configFile = "${homeDir}/.zshrc" - } else if (shell?.contains("fish")) { - shellName = "fish" - configFile = "${homeDir}/.config/fish/config.fish" - } else if (shell?.contains("bash")) { - shellName = "bash" - // Check for .bash_profile first, then .bashrc - def bashProfile = "${homeDir}/.bash_profile" - def bashrc = "${homeDir}/.bashrc" - configFile = Files.exists(Paths.get(bashProfile)) ? bashProfile : bashrc - } else { - // Unrecognized shell - shellName = "unknown" - configFile = null + private String cleanTowerConfig(String content) { + // Remove tower scoped blocks: tower { ... } + content = content.replaceAll(/(?ms)tower\s*\{.*?\}/, '') + // Remove individual tower.* lines + content = content.replaceAll(/(?m)^tower\..*$\n?/, '') + // Remove Seqera Platform configuration comment + content = content.replaceAll(/\/\/\s*Seqera Platform configuration\s*/, '') + // Clean up extra whitespace + return content.replaceAll(/\n\n+/, '\n\n').trim() + "\n\n" + } + + private void writeConfig(Map config) { + def configFile = getConfigFile() + + // Create directory if it doesn't exist + if (!Files.exists(configFile.parent)) { + Files.createDirectories(configFile.parent) + } + + // Read existing config and clean out old tower blocks + def configText = new StringBuilder() + if (Files.exists(configFile)) { + def existingContent = Files.readString(configFile) + def cleanedContent = cleanTowerConfig(existingContent) + configText.append(cleanedContent) + } + + // Write tower config block + def towerConfig = config.findAll { key, value -> + key.toString().startsWith('tower.') } - return [shell: shellName, configFile: configFile] + configText.append("// Seqera Platform configuration\n") + configText.append("tower {\n") + towerConfig.each { key, value -> + def configKey = key.toString().substring(6) // Remove "tower." prefix + if (value instanceof String) { + configText.append(" ${configKey} = '${value}'\n") + } else { + configText.append(" ${configKey} = ${value}\n") + } + } + configText.append("}\n") + + Files.writeString(configFile, configText.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) } + // + // nextflow auth login + // class LoginCmd implements SubCmd { private static final String AUTH0_DOMAIN = "seqera-development.eu.auth0.com" @@ -188,48 +238,27 @@ class CmdAuth extends CmdBase implements UsageAware { throw new AbortOperationException("Too many arguments for login command") } - // Check if TOWER_ACCESS_TOKEN is already set - def existingToken = System.getenv("TOWER_ACCESS_TOKEN") + // Check if tower.accessToken is already set + def config = readConfig() + def existingToken = config['tower.accessToken'] if (existingToken) { - println "TOWER_ACCESS_TOKEN environment variable is already set." - - // Try to find the token in shell config files - def configFile = findTokenInShellConfig(existingToken) - if (configFile) { - println "Token found in: ${configFile}" - } - - println "Run `nextflow auth status` to view details." + println "Authentication token is already configured in Nextflow config." + println "Config file: ${getConfigFile()}" + println "Run 'nextflow auth logout' to remove the current authentication." return } println "Nextflow authentication with Seqera Platform" + println " - Authentication will be saved to: ${getConfigFile()}" println "" - // Detect shell and config file - def shellConfigInfo = detectShellConfig() - - if (shellConfigInfo.shell == "unknown") { - println "Unrecognized shell detected. After authentication, you will need to manually add the TOWER_ACCESS_TOKEN export to your shell configuration file." - println "" - } else { - // Check if config file exists before proceeding - def configFile = Paths.get(shellConfigInfo.configFile as String) - if (!Files.exists(configFile)) { - throw new AbortOperationException("Shell ${shellConfigInfo.shell} was detected but config file ${shellConfigInfo.configFile} was not found. Please create this file first.") - } - - println "A Personal Access Token will be generated and saved to: ${shellConfigInfo.configFile}" - println "" - } - // Prompt user for API URL def apiUrl = promptForApiUrl() // Check if this is a cloud endpoint or enterprise if (isCloudEndpoint(apiUrl)) { try { - performAuth0Login(apiUrl, shellConfigInfo) + performAuth0Login(apiUrl) } catch (Exception e) { log.debug("Authentication failed", e) throw new AbortOperationException("Authentication failed: ${e.message}") @@ -240,18 +269,8 @@ class CmdAuth extends CmdBase implements UsageAware { } } - private String promptForApiUrl() { - System.out.print("Seqera Platform API URL [Default Seqera Cloud, https://api.cloud.seqera.io]: ") - System.out.flush() - - def reader = new BufferedReader(new InputStreamReader(System.in)) - def input = reader.readLine()?.trim() - - return input?.isEmpty() || input == null ? 'https://api.cloud.seqera.io' : input - } - - private void performAuth0Login(String apiUrl, Map shellConfigInfo) { - println "- Opening browser for authentication..." + private void performAuth0Login(String apiUrl) { + println " - Opening browser for authentication..." // Generate PKCE parameters def codeVerifier = generateCodeVerifier() @@ -267,8 +286,6 @@ class CmdAuth extends CmdBase implements UsageAware { // Open browser openBrowser(authUrl) - println "- Waiting for authentication to complete..." - try { // Wait for callback with timeout def authCode = callbackFuture.get(5, TimeUnit.MINUTES) @@ -279,19 +296,14 @@ class CmdAuth extends CmdBase implements UsageAware { // Verify login by calling /user-info def userInfo = callUserInfoApi(accessToken, apiUrl) - println "Authentication successful! Logged in as: ${userInfo.userName}" + println " - Authentication successful! Logged in as: ${userInfo.userName}" // Generate PAT def pat = generatePAT(accessToken, apiUrl) - // Add to shell config or display for manual addition - if (shellConfigInfo.shell == "unknown") { - displayTokenForManualSetup(pat) - } else { - addTokenToShellConfig(pat, shellConfigInfo) - println "- Personal Access Token generated and added to ${shellConfigInfo.configFile}" - println "Please restart your terminal or run: source ${shellConfigInfo.configFile}" - } + // Save to config + saveAuthToConfig(pat, apiUrl) + println " - Seqera Platform configuration saved to ${getConfigFile()}" } catch (Exception e) { throw new RuntimeException("Authentication timeout or failed: ${e.message}", e) @@ -352,7 +364,36 @@ class CmdAuth extends CmdBase implements UsageAware { output.println("HTTP/1.1 200 OK") output.println("Content-Type: text/html") output.println() - output.println("

Authentication successful!

You can close this window.

") + output.println(""" + + + + +
+

Nextflow authentication successful!

+

You can now close this window.

+
+ +""") output.flush() future.complete(code) @@ -407,20 +448,32 @@ class CmdAuth extends CmdBase implements UsageAware { private void openBrowser(String url) { try { if (Desktop.isDesktopSupported()) { - Desktop.desktop.browse(new URI(url)) - } else { - // Fallback for systems without Desktop support - def os = System.getProperty("os.name").toLowerCase() - if (os.contains("win")) { - Runtime.runtime.exec("rundll32 url.dll,FileProtocolHandler ${url}") - } else if (os.contains("mac")) { - Runtime.runtime.exec("open ${url}") - } else { - Runtime.runtime.exec("xdg-open ${url}") + def desktop = Desktop.desktop + if (desktop.isSupported(Desktop.Action.BROWSE)) { + desktop.browse(new URI(url)) + return } } - } catch (Exception e) { + + // Fallback: try OS-specific commands + def os = System.getProperty("os.name").toLowerCase() + if (os.contains("mac")) { + Runtime.runtime.exec(["open", url] as String[]) + return + } else if (os.contains("linux")) { + Runtime.runtime.exec(["xdg-open", url] as String[]) + return + } else if (os.contains("windows")) { + Runtime.runtime.exec(["rundll32", "url.dll,FileProtocolHandler", url] as String[]) + return + } + + // If all else fails, show URL println "Could not open browser automatically. Please visit: ${url}" + + } catch (Exception e) { + log.debug("Failed to open browser", e) + println "Failed to open browser automatically. Please visit: ${url}" } } @@ -458,21 +511,10 @@ class CmdAuth extends CmdBase implements UsageAware { return json as Map } - - private boolean isCloudEndpoint(String apiUrl) { - return apiUrl == 'https://api.cloud.seqera.io' || - apiUrl == 'https://api.cloud.stage-seqera.io' || - apiUrl == 'https://api.cloud.dev-seqera.io' || - apiUrl == 'https://cloud.seqera.io/api' || - apiUrl == 'https://cloud.stage-seqera.io/api' || - apiUrl == 'https://cloud.dev-seqera.io/api' - - } - private void handleEnterpriseAuth(String apiUrl) { println "" println "Please generate a Personal Access Token from your Seqera Platform instance." - println "You can create one at: ${apiUrl.replace('/api', '').replace('api.', '')}/tokens" + println "You can create one at: ${apiUrl.replace('/api', '').replace('://api.', '')}/tokens" println "" System.out.print("Enter your Personal Access Token: ") @@ -487,18 +529,12 @@ class CmdAuth extends CmdBase implements UsageAware { throw new AbortOperationException("Personal Access Token is required for Seqera Platform Enterprise authentication") } - // Add PAT to shell config or display for manual addition - def shellConfigInfo = detectShellConfig() - if (shellConfigInfo.shell == "unknown") { - displayTokenForManualSetup(pat.trim()) - } else { - addTokenToShellConfig(pat.trim(), shellConfigInfo) - println "Personal Access Token added to ${shellConfigInfo.configFile}" - println "Please restart your terminal or run: source ${shellConfigInfo.configFile}" - } + // Save to config + saveAuthToConfig(pat.trim(), apiUrl) + println "Personal Access Token saved to Nextflow config" + println "Config file: ${getConfigFile()}" } - private String generatePAT(String accessToken, String apiUrl) { def tokensUrl = "${apiUrl}/tokens" def username = System.getProperty("user.name") @@ -527,38 +563,38 @@ class CmdAuth extends CmdBase implements UsageAware { return json.accessKey as String } - private void displayTokenForManualSetup(String pat) { - println "" - println "=".repeat(60) - println "MANUAL SETUP REQUIRED" - println "=".repeat(60) - println "" - println "Please add the following line to your shell configuration file:" - println "" - println "export TOWER_ACCESS_TOKEN=${pat}" - println "" - println "Common shell config files:" - println " ~/.bashrc (bash)" - println " ~/.zshrc (zsh)" - println " ~/.config/fish/config.fish (fish)" - println "" - println "After adding the export, restart your terminal or run 'source '" - println "=".repeat(60) - } + private void saveAuthToConfig(String accessToken, String apiUrl) { + def config = readConfig() + config['tower.accessToken'] = accessToken + config['tower.endpoint'] = apiUrl + + // Ask user if they want to enable workflow monitoring by default + System.out.print("Enable workflow monitoring for all runs? (Y/n): ") + System.out.flush() - private void addTokenToShellConfig(String pat, Map shellConfigInfo) { - def configFile = Paths.get(shellConfigInfo.configFile as String) - def commentLine = "# Seqera Platform access token" - def exportLine = "export TOWER_ACCESS_TOKEN=\"${pat}\"" + def reader = new BufferedReader(new InputStreamReader(System.in)) + def input = reader.readLine()?.trim()?.toLowerCase() + + if (input == 'n' || input == 'no') { + println "Workflow monitoring not enabled by default. You can enable it per-run with -with-tower." + } else { + config['tower.enabled'] = true + } - // Append to existing file (we already checked it exists at command start) - Files.writeString(configFile, "\n${commentLine}\n${exportLine}\n", java.nio.file.StandardOpenOption.APPEND) + writeConfig(config) } @Override void usage(List result) { result << 'Authenticate with Seqera Platform' result << "Usage: nextflow auth $name".toString() + result << '' + result << 'This command will:' + result << ' 1. Prompt for Seqera Platform API endpoint' + result << ' 2. Open browser for OAuth2 authentication (Cloud) or prompt for PAT (Enterprise)' + result << ' 3. Generate and save access token to home-directory Nextflow config' + result << ' 4. Configure tower.accessToken, tower.endpoint, and tower.enabled settings' + result << '' } } @@ -576,57 +612,50 @@ class CmdAuth extends CmdBase implements UsageAware { println "Nextflow authentication logout" println "" - // Check if TOWER_ACCESS_TOKEN is set - def existingToken = System.getenv("TOWER_ACCESS_TOKEN") - if (!existingToken) { - println "No TOWER_ACCESS_TOKEN environment variable found. Already logged out." - return - } + // Check if tower.accessToken is set + def config = readConfig() + def existingToken = config['tower.accessToken'] + def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' - // Find token in shell config - def configFilePath = findTokenInShellConfig(existingToken) - if (!configFilePath) { - println "Token found in environment but not in shell config files." - println "You may need to manually remove: export TOWER_ACCESS_TOKEN=${existingToken}" + if (!existingToken) { + println "No authentication token found in Nextflow config. Already logged out." + println "Config file: ${getConfigFile()}" return } - println "Found token in: ${configFilePath}" + println " - Found authentication token in config file: ${getConfigFile()}" - // Prompt user for API URL - def apiUrl = promptForApiUrl() + // Prompt user for API URL if not already configured + def apiUrl = endpoint as String + if (!apiUrl || apiUrl.isEmpty()) { + apiUrl = promptForApiUrl() + } else { + println " - Using Seqera Platform endpoint: ${apiUrl}" + } // Validate token by calling /user-info API try { - def userInfo = callUserInfoApi(existingToken, apiUrl) - println "Token is valid for user: ${userInfo.userName}" - - // Decode token to get token ID - def tokenId = decodeTokenId(existingToken) - println "Token ID: ${tokenId}" + def userInfo = callUserInfoApi(existingToken as String, apiUrl) + println " - Token is valid for user: ${userInfo.userName}" - // Delete token via API - deleteTokenViaApi(existingToken, apiUrl, tokenId) - - // Remove from shell config - removeTokenFromShellConfig(existingToken, configFilePath) + // Only delete PAT from platform if this is a cloud endpoint + if (isCloudEndpoint(apiUrl)) { + def tokenId = decodeTokenId(existingToken as String) + deleteTokenViaApi(existingToken as String, apiUrl, tokenId) + } else { + println " - Enterprise installation detected - PAT will not be deleted from platform." + } - println "Successfully logged out and removed token." + removeAuthFromConfig() } catch (Exception e) { println "Failed to validate or delete token: ${e.message}" - println "You may need to manually remove the export line from ${configFilePath}" - } - } - - private String promptForApiUrl() { - System.out.print("Seqera Platform API URL [Default Seqera Cloud, https://api.cloud.seqera.io]: ") - System.out.flush() - - def reader = new BufferedReader(new InputStreamReader(System.in)) - def input = reader.readLine()?.trim() + println "Removing token from config anyway..." - return input?.isEmpty() || input == null ? 'https://api.cloud.seqera.io' : input + // Remove from config even if API calls fail + removeAuthFromConfig() + println "Token removed from Nextflow config." + } } private String decodeTokenId(String token) { @@ -659,47 +688,32 @@ class CmdAuth extends CmdBase implements UsageAware { throw new RuntimeException("Failed to delete token: ${error}") } - println "Token successfully deleted from Seqera Platform." + println " - Token successfully deleted from Seqera Platform." } - private void removeTokenFromShellConfig(String token, String configFilePath) { - def configFile = Paths.get(configFilePath) - def content = Files.readString(configFile) - - // Look for the export line and optional comment above it - def exportLine = "export TOWER_ACCESS_TOKEN=\"${token}\"" - def commentLine = "# Seqera Platform access token" - - def lines = content.split('\n').toList() - def newLines = [] - def i = 0 + private void removeAuthFromConfig() { + def configFile = getConfigFile() - while (i < lines.size()) { - def currentLine = lines[i] - - // Check if this is the export line we want to remove - if (currentLine.trim() == exportLine.trim()) { - // Check if previous line is our comment - if (i > 0 && lines[i-1].trim() == commentLine.trim()) { - // Remove the comment line too (it was just added) - newLines.removeLast() - } - // Skip the export line (don't add it to newLines) - } else { - newLines.add(currentLine) - } - i++ + if (Files.exists(configFile)) { + def existingContent = Files.readString(configFile) + def cleanedContent = cleanTowerConfig(existingContent) + Files.writeString(configFile, cleanedContent, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) } - // Write back the modified content - Files.writeString(configFile, newLines.join('\n')) - println "Removed token export from ${configFilePath}" + println " - Authentication removed from Nextflow config." } @Override void usage(List result) { result << 'Log out and remove Seqera Platform authentication' result << "Usage: nextflow auth $name".toString() + result << '' + result << 'This command will:' + result << ' 1. Check if tower.accessToken is configured' + result << ' 2. Validate the token with Seqera Platform' + result << ' 3. Delete the PAT from Platform (only if Seqera Platform Cloud)' + result << ' 4. Remove the authentication from Nextflow config' + result << '' } } } From ec03361dfa991ce36ff147cece2057c2462205b5 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Fri, 5 Sep 2025 23:14:22 +0200 Subject: [PATCH 09/66] Always set tower.enabled true when logging in Signed-off-by: Phil Ewels --- .../src/main/groovy/nextflow/cli/CmdAuth.groovy | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 40e7874648..138506b41d 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -567,19 +567,7 @@ class CmdAuth extends CmdBase implements UsageAware { def config = readConfig() config['tower.accessToken'] = accessToken config['tower.endpoint'] = apiUrl - - // Ask user if they want to enable workflow monitoring by default - System.out.print("Enable workflow monitoring for all runs? (Y/n): ") - System.out.flush() - - def reader = new BufferedReader(new InputStreamReader(System.in)) - def input = reader.readLine()?.trim()?.toLowerCase() - - if (input == 'n' || input == 'no') { - println "Workflow monitoring not enabled by default. You can enable it per-run with -with-tower." - } else { - config['tower.enabled'] = true - } + config['tower.enabled'] = true writeConfig(config) } From cf344f992ac7c0d434024fc861fefc6579ff91ed Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Sat, 6 Sep 2025 00:39:08 +0200 Subject: [PATCH 10/66] nextflow auth config Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 138506b41d..5530d68ef8 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -64,6 +64,7 @@ class CmdAuth extends CmdBase implements UsageAware { CmdAuth() { commands.add(new LoginCmd()) commands.add(new LogoutCmd()) + commands.add(new ConfigCmd()) } void usage() { @@ -704,4 +705,296 @@ class CmdAuth extends CmdBase implements UsageAware { result << '' } } + + // + // nextflow auth config + // + class ConfigCmd implements SubCmd { + + @Override + String getName() { 'config' } + + @Override + void apply(List args) { + if (args.size() > 0) { + throw new AbortOperationException("Too many arguments for config command") + } + + // Check if user is authenticated + def config = readConfig() + def existingToken = config['tower.accessToken'] ?: System.getenv('TOWER_ACCESS_TOKEN') + def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' + + if (!existingToken) { + println "No authentication found. Please run 'nextflow auth login' first." + return + } + + println "Nextflow Seqera Platform configuration" + println " - Config file: ${getConfigFile()}" + + try { + // Get user info to validate token and get user ID + def userInfo = callUserInfoApi(existingToken as String, endpoint as String) + println " - Authenticated as: ${userInfo.userName}" + println "" + + // Track if any changes are made + def configChanged = false + + // Configure tower.enabled + configChanged |= configureEnabled(config) + + // Configure workspace + configChanged |= configureWorkspace(config, existingToken as String, endpoint as String, userInfo.id as String) + + // Save updated config only if changes were made + if (configChanged) { + writeConfig(config) + println " - Configuration saved to ${getConfigFile()}" + } + + } catch (Exception e) { + throw new AbortOperationException("Failed to configure settings: ${e.message}") + } + } + + private boolean configureEnabled(Map config) { + def currentEnabled = config.get('tower.enabled', false) + + println "Workflow monitoring settings:" + println " Current: ${currentEnabled ? 'enabled' : 'disabled'}" + println " When enabled, all workflow runs are automatically monitored by Seqera Platform" + println " When disabled, you can enable per-run with the -with-tower flag" + println "" + + System.out.print("Enable workflow monitoring for all runs? (${currentEnabled ? 'Y/n' : 'y/N'}): ") + System.out.flush() + + def reader = new BufferedReader(new InputStreamReader(System.in)) + def input = reader.readLine()?.trim()?.toLowerCase() + + if (input.isEmpty()) { + // Keep current setting if user just presses enter + return false + } else if (input == 'y' || input == 'yes') { + if (!currentEnabled) { + config['tower.enabled'] = true + return true + } else { + return false + } + } else if (input == 'n' || input == 'no') { + if (currentEnabled) { + config.remove('tower.enabled') // Don't set it to false, just remove it + return true + } else { + return false + } + } else { + println "Invalid input." + return false + } + } + + private boolean configureWorkspace(Map config, String accessToken, String endpoint, String userId) { + // Get all workspaces for the user + def workspaces = getUserWorkspaces(accessToken, endpoint, userId) + + if (!workspaces) { + println "\nNo workspaces found for your account." + return false + } + + // Show current workspace (check both config and env var) + def currentWorkspaceId = config.get('tower.workspaceId') + def envWorkspaceId = System.getenv('TOWER_WORKFLOW_ID') + def effectiveWorkspaceId = currentWorkspaceId ?: envWorkspaceId + def currentWorkspace = workspaces.find { ((Map)it).workspaceId.toString() == effectiveWorkspaceId?.toString() } + + println "Default workspace settings:" + if (currentWorkspace) { + def workspace = currentWorkspace as Map + def source = currentWorkspaceId ? "config" : (envWorkspaceId ? "TOWER_WORKFLOW_ID env var" : "config") + println " Current: ${workspace.orgName} / ${workspace.workspaceFullName} (from ${source})" + } else if (envWorkspaceId) { + println " Current: TOWER_WORKFLOW_ID=${envWorkspaceId} (workspace not found in available workspaces)" + } else { + println " Current: Personal workspace (default)" + } + println "" + + // Group by organization + def orgWorkspaces = workspaces.groupBy { ((Map)it).orgName ?: 'Personal' } + + // If 8 or fewer total options, show all at once + if (workspaces.size() <= 8) { + return selectWorkspaceFromAll(config, workspaces, currentWorkspaceId, envWorkspaceId) + } else { + // Two-stage selection: org first, then workspace + return selectWorkspaceByOrg(config, orgWorkspaces, currentWorkspaceId, envWorkspaceId) + } + } + + private boolean selectWorkspaceFromAll(Map config, List workspaces, def currentWorkspaceId, def envWorkspaceId) { + println "Select default workspace:" + println " 0. Personal workspace (no organization)" + + workspaces.eachWithIndex { workspace, index -> + def ws = workspace as Map + def prefix = ws.orgName ? "${ws.orgName} / " : "" + println " ${index + 1}. ${prefix}${ws.workspaceFullName}" + } + + System.out.print("Select workspace (0-${workspaces.size()}, Enter to keep current): ") + System.out.flush() + + def reader = new BufferedReader(new InputStreamReader(System.in)) + def input = reader.readLine()?.trim() + + if (input.isEmpty()) { + return false + } + + try { + def selection = Integer.parseInt(input) + if (selection == 0) { + if (envWorkspaceId) { + return false + } else { + def hadWorkspaceId = config.containsKey('tower.workspaceId') + config.remove('tower.workspaceId') + return hadWorkspaceId + } + } else if (selection > 0 && selection <= workspaces.size()) { + def selectedWorkspace = workspaces[selection - 1] as Map + def selectedId = selectedWorkspace.workspaceId.toString() + if (envWorkspaceId && selectedId == envWorkspaceId) { + return false + } else { + def currentId = config.get('tower.workspaceId') + config['tower.workspaceId'] = selectedId + return currentId != selectedId + } + } else { + println "Invalid selection." + return false + } + } catch (NumberFormatException e) { + println "Invalid input." + return false + } + } + + private boolean selectWorkspaceByOrg(Map config, Map orgWorkspaces, def currentWorkspaceId, def envWorkspaceId) { + // First, select organization + def orgs = orgWorkspaces.keySet().toList() + + println "Select organization:" + orgs.eachWithIndex { orgName, index -> + println " ${index + 1}. ${orgName}" + } + + System.out.print("Select organization (1-${orgs.size()}, Enter to keep current): ") + System.out.flush() + + def reader = new BufferedReader(new InputStreamReader(System.in)) + def orgInput = reader.readLine()?.trim() + + if (orgInput.isEmpty()) { + return false + } + + try { + def orgSelection = Integer.parseInt(orgInput) + if (orgSelection < 1 || orgSelection > orgs.size()) { + println "Invalid selection." + return false + } + + def selectedOrgName = orgs[orgSelection - 1] + def orgWorkspaceList = orgWorkspaces[selectedOrgName] as List + + println "" + println "Select workspace in ${selectedOrgName}:" + + if (selectedOrgName == 'Personal') { + println " 0. Personal workspace (default)" + } + + orgWorkspaceList.eachWithIndex { workspace, index -> + def ws = workspace as Map + println " ${index + 1}. ${ws.workspaceFullName}" + } + + def maxSelection = orgWorkspaceList.size() + System.out.print("Select workspace (${selectedOrgName == 'Personal' ? '0-' : '1-'}${maxSelection}, Enter to keep current): ") + System.out.flush() + + def wsInput = reader.readLine()?.trim() + if (wsInput.isEmpty()) { + return false + } + + def wsSelection = Integer.parseInt(wsInput) + if (selectedOrgName == 'Personal' && wsSelection == 0) { + if (envWorkspaceId) { + return false + } else { + def hadWorkspaceId = config.containsKey('tower.workspaceId') + config.remove('tower.workspaceId') + return hadWorkspaceId + } + } else if (wsSelection > 0 && wsSelection <= orgWorkspaceList.size()) { + def selectedWorkspace = orgWorkspaceList[wsSelection - 1] as Map + def selectedId = selectedWorkspace.workspaceId.toString() + if (envWorkspaceId && selectedId == envWorkspaceId) { + return false + } else { + def currentId = config.get('tower.workspaceId') + config['tower.workspaceId'] = selectedId + return currentId != selectedId + } + } else { + println "Invalid selection." + return false + } + + } catch (NumberFormatException e) { + println "Invalid input." + return false + } + } + + private List getUserWorkspaces(String accessToken, String endpoint, String userId) { + def workspacesUrl = "${endpoint}/user/${userId}/workspaces" + def connection = new URL(workspacesUrl).openConnection() as HttpURLConnection + connection.requestMethod = 'GET' + connection.setRequestProperty('Authorization', "Bearer ${accessToken}") + + if (connection.responseCode != 200) { + def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + throw new RuntimeException("Failed to get workspaces: ${error}") + } + + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) as Map + def orgsAndWorkspaces = json.orgsAndWorkspaces as List + + // Filter to only include actual workspaces (where workspaceId is not null) + return orgsAndWorkspaces.findAll { ((Map)it).workspaceId != null } + } + + @Override + void usage(List result) { + result << 'Configure Seqera Platform settings' + result << "Usage: nextflow auth $name".toString() + result << '' + result << 'This command will:' + result << ' 1. Check authentication status' + result << ' 2. Configure tower.enabled setting for workflow monitoring' + result << ' 3. Configure default workspace (tower.workspaceId)' + result << '' + } + } } From f6f3694a9afa0a25acbe9ccdc4368b0be61087cb Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Sat, 6 Sep 2025 00:39:27 +0200 Subject: [PATCH 11/66] Add code comment next to workspace ID saying what the org / ws is Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 5530d68ef8..add3a52c31 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -33,6 +33,7 @@ import java.security.MessageDigest import java.security.SecureRandom import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit +import java.util.regex.Pattern /** * Implements the 'nextflow auth' commands @@ -210,6 +211,11 @@ class CmdAuth extends CmdBase implements UsageAware { configText.append("tower {\n") towerConfig.each { key, value -> def configKey = key.toString().substring(6) // Remove "tower." prefix + if (configKey.endsWith('.comment')) { + // Skip comment keys - they're handled below + return + } + if (value instanceof String) { configText.append(" ${configKey} = '${value}'\n") } else { @@ -218,7 +224,19 @@ class CmdAuth extends CmdBase implements UsageAware { } configText.append("}\n") - Files.writeString(configFile, configText.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + def finalConfig = configText.toString() + + // Add workspace comment if available + if (config.containsKey('tower.workspaceId.comment')) { + def workspaceId = config['tower.workspaceId'] + def comment = config['tower.workspaceId.comment'] + finalConfig = finalConfig.replaceAll( + /workspaceId = '${Pattern.quote(workspaceId.toString())}'/, + "workspaceId = '${workspaceId}' // ${comment}" + ) + } + + Files.writeString(configFile, finalConfig, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) } // @@ -864,6 +882,7 @@ class CmdAuth extends CmdBase implements UsageAware { } else { def hadWorkspaceId = config.containsKey('tower.workspaceId') config.remove('tower.workspaceId') + config.remove('tower.workspaceId.comment') return hadWorkspaceId } } else if (selection > 0 && selection <= workspaces.size()) { @@ -874,6 +893,7 @@ class CmdAuth extends CmdBase implements UsageAware { } else { def currentId = config.get('tower.workspaceId') config['tower.workspaceId'] = selectedId + config['tower.workspaceId.comment'] = "${selectedWorkspace.orgName} / ${selectedWorkspace.workspaceFullName}" return currentId != selectedId } } else { @@ -943,6 +963,7 @@ class CmdAuth extends CmdBase implements UsageAware { } else { def hadWorkspaceId = config.containsKey('tower.workspaceId') config.remove('tower.workspaceId') + config.remove('tower.workspaceId.comment') return hadWorkspaceId } } else if (wsSelection > 0 && wsSelection <= orgWorkspaceList.size()) { @@ -953,6 +974,7 @@ class CmdAuth extends CmdBase implements UsageAware { } else { def currentId = config.get('tower.workspaceId') config['tower.workspaceId'] = selectedId + config['tower.workspaceId.comment'] = "${selectedWorkspace.orgName} / ${selectedWorkspace.workspaceFullName}" return currentId != selectedId } } else { From ba279b1cf80d3e3cb1d709d0f3bcc3a911a470d4 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Sat, 6 Sep 2025 21:01:46 +0200 Subject: [PATCH 12/66] New nextflow auth status command Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 182 +++++++++++++++++- 1 file changed, 176 insertions(+), 6 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index add3a52c31..c2efdc3814 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -66,6 +66,7 @@ class CmdAuth extends CmdBase implements UsageAware { commands.add(new LoginCmd()) commands.add(new LogoutCmd()) commands.add(new ConfigCmd()) + commands.add(new StatusCmd()) } void usage() { @@ -387,7 +388,7 @@ class CmdAuth extends CmdBase implements UsageAware { @@ -412,7 +416,8 @@ class CmdAuth extends CmdBase implements UsageAware {

You can now close this window.

-""") + +""") output.flush() future.complete(code) @@ -1019,4 +1024,169 @@ class CmdAuth extends CmdBase implements UsageAware { result << '' } } + + class StatusCmd implements SubCmd { + + @Override + String getName() { 'status' } + + @Override + void apply(List args) { + if (args.size() > 0) { + throw new AbortOperationException("Too many arguments for status command") + } + + def config = readConfig() + + println "Nextflow Seqera Platform authentication status" + println "" + + // API endpoint + def endpointInfo = getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://api.cloud.seqera.io') + println "API endpoint: ${endpointInfo.value} (${endpointInfo.source})" + + // API connection check + def apiConnectionOk = checkApiConnection(endpointInfo.value as String) + println "API connection check: ${apiConnectionOk ? 'OK' : 'ERROR'}" + + // Access token status + def tokenInfo = getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') + if (tokenInfo.value) { + println "Access token: Configured (${tokenInfo.source})" + } else { + println "Access token: Not configured" + } + + // Authentication check + if (tokenInfo.value) { + try { + def userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) + def currentUser = userInfo.userName + println "Authentication: Success (${currentUser})" + } catch (Exception e) { + println "Authentication: Error (${e})" + } + } else { + println "Authentication: ERROR (no token)" + } + + // Monitoring enabled + def enabledInfo = getConfigValue(config, 'tower.enabled', null, 'false') + def enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' + println "Local workflow monitoring enabled: ${enabledValue} (${enabledInfo.source ?: 'default'})" + + // Default workspace + def workspaceInfo = getConfigValue(config, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') + if (workspaceInfo.value) { + // Try to get workspace name from API if we have a token + def workspaceName = null + if (tokenInfo.value) { + workspaceName = getWorkspaceNameFromApi(tokenInfo.value as String, endpointInfo.value as String, workspaceInfo.value as String) + } + + if (workspaceName) { + println "Default workspace: '${workspaceName}' [${workspaceInfo.value}] (${workspaceInfo.source})" + } else { + println "Default workspace: ${workspaceInfo.value} (${workspaceInfo.source})" + } + } else { + println "Default workspace: Personal workspace (default)" + } + } + + private String shortenPath(String path) { + def userHome = System.getProperty('user.home') + if (path.startsWith(userHome)) { + return '~' + path.substring(userHome.length()) + } + return path + } + + private Map getConfigValue(Map config, String configKey, String envVarName, String defaultValue = null) { + def configValue = config[configKey] + def envValue = envVarName ? System.getenv(envVarName) : null + def effectiveValue = configValue ?: envValue ?: defaultValue + + def source = null + if (configValue) { + source = shortenPath(getConfigFile().toString()) + } else if (envValue) { + source = "env var \$${envVarName}" + } else if (defaultValue) { + source = "default" + } + + return [ + value: effectiveValue, + source: source, + fromConfig: configValue != null, + fromEnv: envValue != null, + isDefault: !configValue && !envValue + ] + } + + private String getWorkspaceNameFromApi(String accessToken, String endpoint, String workspaceId) { + try { + // Get user info to get user ID + def userInfo = callUserInfoApi(accessToken, endpoint) + def userId = userInfo.id as String + + // Get workspaces for the user + def workspacesUrl = "${endpoint}/user/${userId}/workspaces" + def connection = new URL(workspacesUrl).openConnection() as HttpURLConnection + connection.requestMethod = 'GET' + connection.connectTimeout = 10000 // 10 second timeout + connection.readTimeout = 10000 + connection.setRequestProperty('Authorization', "Bearer ${accessToken}") + + if (connection.responseCode != 200) { + return null + } + + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) as Map + def orgsAndWorkspaces = json.orgsAndWorkspaces as List + + // Find the workspace with matching ID + def workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } + if (workspace) { + def ws = workspace as Map + return "${ws.orgName} / ${ws.workspaceFullName}" + } + + return null + } catch (Exception e) { + return null + } + } + + private boolean checkApiConnection(String endpoint) { + try { + def serviceInfoUrl = "${endpoint}/service-info" + def connection = new URL(serviceInfoUrl).openConnection() as HttpURLConnection + connection.requestMethod = 'GET' + connection.connectTimeout = 10000 // 10 second timeout + connection.readTimeout = 10000 + + return connection.responseCode == 200 + } catch (Exception e) { + return false + } + } + + + @Override + void usage(List result) { + result << 'Show authentication status and configuration' + result << "Usage: nextflow auth $name".toString() + result << '' + result << 'This command shows:' + result << ' - Authentication status (yes/no) and source' + result << ' - API endpoint and source' + result << ' - Monitoring enabled status and source' + result << ' - Default workspace and source' + result << ' - System health status (API connection and authentication)' + result << '' + } + } } From 0b0123464c60a8830a9fc44052703484eb88770f Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 10 Sep 2025 10:41:41 +0200 Subject: [PATCH 13/66] Don't prompt for API URL, just have it as a CLI option Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index c2efdc3814..fa4157f4f4 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -62,6 +62,9 @@ class CmdAuth extends CmdBase implements UsageAware { @Parameter(hidden = true) List args + @Parameter(names = ['-u', '-url'], description = 'Seqera Platform API endpoint') + String apiUrl + CmdAuth() { commands.add(new LoginCmd()) commands.add(new LogoutCmd()) @@ -101,7 +104,11 @@ class CmdAuth extends CmdBase implements UsageAware { } try { - getCmd(args).apply(args.drop(1)) + def cmd = getCmd(args) + if (cmd instanceof LoginCmd && apiUrl) { + cmd.apiUrl = apiUrl + } + cmd.apply(args.drop(1)) } catch (Exception e) { throw new AbortOperationException(e.message) } @@ -249,6 +256,8 @@ class CmdAuth extends CmdBase implements UsageAware { private static final String AUTH0_CLIENT_ID = "Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ" private static final int CALLBACK_PORT = 8085 + String apiUrl + @Override String getName() { 'login' } @@ -270,10 +279,17 @@ class CmdAuth extends CmdBase implements UsageAware { println "Nextflow authentication with Seqera Platform" println " - Authentication will be saved to: ${getConfigFile()}" - println "" - // Prompt user for API URL - def apiUrl = promptForApiUrl() + // Use provided URL or default + if (!apiUrl) { + apiUrl = 'https://api.cloud.seqera.io' + } else if (!apiUrl.startsWith('http://') && !apiUrl.startsWith('https://')) { + apiUrl = 'https://' + apiUrl + } + + println " - Seqera Platform API endpoint: ${apiUrl}" + println " (can be customised with -u option)" + Thread.sleep(2000) // Check if this is a cloud endpoint or enterprise if (isCloudEndpoint(apiUrl)) { @@ -599,13 +615,15 @@ class CmdAuth extends CmdBase implements UsageAware { @Override void usage(List result) { result << 'Authenticate with Seqera Platform' - result << "Usage: nextflow auth $name".toString() + result << "Usage: nextflow auth $name [-u ]".toString() + result << '' + result << 'Options:' + result << ' -u, -url Seqera Platform API endpoint (default: https://api.cloud.seqera.io)' result << '' result << 'This command will:' - result << ' 1. Prompt for Seqera Platform API endpoint' - result << ' 2. Open browser for OAuth2 authentication (Cloud) or prompt for PAT (Enterprise)' - result << ' 3. Generate and save access token to home-directory Nextflow config' - result << ' 4. Configure tower.accessToken, tower.endpoint, and tower.enabled settings' + result << ' 1. Open browser for OAuth2 authentication (Cloud) or prompt for PAT (Enterprise)' + result << ' 2. Generate and save access token to home-directory Nextflow config' + result << ' 3. Configure tower.accessToken, tower.endpoint, and tower.enabled settings' result << '' } } @@ -1038,9 +1056,6 @@ class CmdAuth extends CmdBase implements UsageAware { def config = readConfig() - println "Nextflow Seqera Platform authentication status" - println "" - // API endpoint def endpointInfo = getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://api.cloud.seqera.io') println "API endpoint: ${endpointInfo.value} (${endpointInfo.source})" From 1909da0ed2a74b6fa16086d8af1d8a475af6ef11 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 10 Sep 2025 11:08:41 +0200 Subject: [PATCH 14/66] Add warnings to login and logout if TOWER_ACCESS_TOKEN is set Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index fa4157f4f4..65d1b7520f 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -267,6 +267,15 @@ class CmdAuth extends CmdBase implements UsageAware { throw new AbortOperationException("Too many arguments for login command") } + // Check if TOWER_ACCESS_TOKEN environment variable is set + def envToken = System.getenv('TOWER_ACCESS_TOKEN') + if (envToken) { + println "WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable." + println "nextflow auth login' sets credentials using Nextflow config files, which take precedence over the environment variable." + println "however, caution is advised to avoid confusing behaviour." + println "" + } + // Check if tower.accessToken is already set def config = readConfig() def existingToken = config['tower.accessToken'] @@ -642,13 +651,22 @@ class CmdAuth extends CmdBase implements UsageAware { println "Nextflow authentication logout" println "" + // Check if TOWER_ACCESS_TOKEN environment variable is set + def envToken = System.getenv('TOWER_ACCESS_TOKEN') + if (envToken) { + println "WARNING: TOWER_ACCESS_TOKEN environment variable is set." + println "'nextflow auth logout' only removes credentials from Nextflow config files." + println "The environment variable will remain unaffected." + println "" + } + // Check if tower.accessToken is set def config = readConfig() def existingToken = config['tower.accessToken'] def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' if (!existingToken) { - println "No authentication token found in Nextflow config. Already logged out." + println "No authentication token found in Nextflow config." println "Config file: ${getConfigFile()}" return } From 0b3ffabcfc06256e4fe30bbbb49996475a49f84f Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 10 Sep 2025 11:22:36 +0200 Subject: [PATCH 15/66] Bugfix: change how workspace name and fullName are used Signed-off-by: Phil Ewels --- .../src/main/groovy/nextflow/cli/CmdAuth.groovy | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 65d1b7520f..3ef382f33a 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -875,7 +875,7 @@ class CmdAuth extends CmdBase implements UsageAware { if (currentWorkspace) { def workspace = currentWorkspace as Map def source = currentWorkspaceId ? "config" : (envWorkspaceId ? "TOWER_WORKFLOW_ID env var" : "config") - println " Current: ${workspace.orgName} / ${workspace.workspaceFullName} (from ${source})" + println " Current: ${workspace.orgName} / ${workspace.workspaceName} [${workspace.workspaceFullName}] (from ${source})" } else if (envWorkspaceId) { println " Current: TOWER_WORKFLOW_ID=${envWorkspaceId} (workspace not found in available workspaces)" } else { @@ -902,7 +902,7 @@ class CmdAuth extends CmdBase implements UsageAware { workspaces.eachWithIndex { workspace, index -> def ws = workspace as Map def prefix = ws.orgName ? "${ws.orgName} / " : "" - println " ${index + 1}. ${prefix}${ws.workspaceFullName}" + println " ${index + 1}. ${prefix}${ws.workspaceName} [${ws.workspaceFullName}]" } System.out.print("Select workspace (0-${workspaces.size()}, Enter to keep current): ") @@ -934,7 +934,7 @@ class CmdAuth extends CmdBase implements UsageAware { } else { def currentId = config.get('tower.workspaceId') config['tower.workspaceId'] = selectedId - config['tower.workspaceId.comment'] = "${selectedWorkspace.orgName} / ${selectedWorkspace.workspaceFullName}" + config['tower.workspaceId.comment'] = "${selectedWorkspace.orgName} / ${selectedWorkspace.workspaceName} [${selectedWorkspace.workspaceFullName}]" return currentId != selectedId } } else { @@ -985,7 +985,7 @@ class CmdAuth extends CmdBase implements UsageAware { orgWorkspaceList.eachWithIndex { workspace, index -> def ws = workspace as Map - println " ${index + 1}. ${ws.workspaceFullName}" + println " ${index + 1}. ${ws.workspaceName} [${ws.workspaceFullName}]" } def maxSelection = orgWorkspaceList.size() @@ -1015,7 +1015,7 @@ class CmdAuth extends CmdBase implements UsageAware { } else { def currentId = config.get('tower.workspaceId') config['tower.workspaceId'] = selectedId - config['tower.workspaceId.comment'] = "${selectedWorkspace.orgName} / ${selectedWorkspace.workspaceFullName}" + config['tower.workspaceId.comment'] = "${selectedWorkspace.orgName} / ${selectedWorkspace.workspaceName} [${selectedWorkspace.workspaceFullName}]" return currentId != selectedId } } else { @@ -1118,7 +1118,7 @@ class CmdAuth extends CmdBase implements UsageAware { } if (workspaceName) { - println "Default workspace: '${workspaceName}' [${workspaceInfo.value}] (${workspaceInfo.source})" + println "Default workspace: ${workspaceInfo.value} - ${workspaceName} (${workspaceInfo.source})" } else { println "Default workspace: ${workspaceInfo.value} (${workspaceInfo.source})" } @@ -1184,7 +1184,7 @@ class CmdAuth extends CmdBase implements UsageAware { def workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } if (workspace) { def ws = workspace as Map - return "${ws.orgName} / ${ws.workspaceFullName}" + return "${ws.orgName} / ${ws.workspaceName} [${ws.workspaceFullName}]" } return null From b31b1c7842d84c2f6c2a653ffe5c8691eb5b43b8 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 10 Sep 2025 11:27:10 +0200 Subject: [PATCH 16/66] Tell the user we're using their env var token in 'auth config' Signed-off-by: Phil Ewels --- modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 3ef382f33a..ce4dec2780 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -792,6 +792,11 @@ class CmdAuth extends CmdBase implements UsageAware { println "Nextflow Seqera Platform configuration" println " - Config file: ${getConfigFile()}" + // Check if token is from environment variable + if (!config['tower.accessToken'] && System.getenv('TOWER_ACCESS_TOKEN')) { + println " - Using access token from TOWER_ACCESS_TOKEN environment variable" + } + try { // Get user info to validate token and get user ID def userInfo = callUserInfoApi(existingToken as String, endpoint as String) From b9547ff6727a6fbf22c8fb9c6b5b09bad5bb7b65 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 10 Sep 2025 15:41:21 +0200 Subject: [PATCH 17/66] Add colour Signed-off-by: Phil Ewels --- .../groovy/nextflow/cli/AuthColorUtil.groovy | 95 +++++++++ .../main/groovy/nextflow/cli/CmdAuth.groovy | 192 +++++++++++++----- 2 files changed, 233 insertions(+), 54 deletions(-) create mode 100644 modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy new file mode 100644 index 0000000000..0e84973664 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy @@ -0,0 +1,95 @@ +/* + * 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.cli + +import groovy.transform.CompileStatic +import nextflow.SysEnv +import org.fusesource.jansi.Ansi +import static org.fusesource.jansi.Ansi.* + +/** + * Utility class for ANSI color formatting in auth commands + * + * @author Phil Ewels + */ +@CompileStatic +class AuthColorUtil { + + /** + * Check if ANSI colors should be enabled based on Nextflow conventions + */ + static boolean isAnsiEnabled() { + // Check NO_COLOR environment variable (https://no-color.org/) + if (SysEnv.get('NO_COLOR')) { + return false + } + + // Check NXF_ANSI_LOG environment variable + final env = SysEnv.get('NXF_ANSI_LOG') + if (env) { + try { + return Boolean.parseBoolean(env) + } catch (Exception e) { + // Invalid value, fall through to default + } + } + + // Default to enabled if terminal supports it + return Ansi.isEnabled() + } + + /** + * Print colored text if ANSI is enabled, otherwise plain text + */ + static void printColored(Object text, Color color = null, boolean bold = false, boolean dim = false) { + def textStr = text?.toString() ?: '' + if (!isAnsiEnabled() || (!color && !bold && !dim)) { + println textStr + return + } + + def fmt = ansi() + if (color) fmt = fmt.fg(color) + if (bold) fmt = fmt.bold() + if (dim) fmt = fmt.a(Attribute.INTENSITY_FAINT) + fmt = fmt.a(textStr) + if (dim) fmt = fmt.a(Attribute.RESET) + if (bold) fmt = fmt.boldOff() + if (color) fmt = fmt.fg(Color.DEFAULT) + println fmt + } + + /** + * Format text with color inline + */ + static String colorize(Object text, Color color = null, boolean bold = false, boolean dim = false) { + def textStr = text?.toString() ?: '' + if (!isAnsiEnabled() || (!color && !bold && !dim)) { + return textStr + } + + def fmt = ansi() + if (color) fmt = fmt.fg(color) + if (bold) fmt = fmt.bold() + if (dim) fmt = fmt.a(Attribute.INTENSITY_FAINT) + fmt = fmt.a(textStr) + if (dim) fmt = fmt.a(Attribute.RESET) + if (bold) fmt = fmt.boldOff() + if (color) fmt = fmt.fg(Color.DEFAULT) + return fmt.toString() + } +} \ No newline at end of file diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index ce4dec2780..7ced7b40cc 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -21,7 +21,10 @@ import com.beust.jcommander.Parameters import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import nextflow.Const +import nextflow.SysEnv import nextflow.exception.AbortOperationException +import org.fusesource.jansi.Ansi +import static org.fusesource.jansi.Ansi.* import java.awt.Desktop import java.net.ServerSocket @@ -127,7 +130,7 @@ class CmdAuth extends CmdBase implements UsageAware { throw new AbortOperationException(msg) } - // Shared methods + private String promptForApiUrl() { System.out.print("Seqera Platform API endpoint [Default https://api.cloud.seqera.io]: ") System.out.flush() @@ -270,8 +273,8 @@ class CmdAuth extends CmdBase implements UsageAware { // Check if TOWER_ACCESS_TOKEN environment variable is set def envToken = System.getenv('TOWER_ACCESS_TOKEN') if (envToken) { - println "WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable." - println "nextflow auth login' sets credentials using Nextflow config files, which take precedence over the environment variable." + AuthColorUtil.printColored("WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable.", Color.YELLOW, true) + println "${AuthColorUtil.colorize('nextflow auth login', Color.CYAN)} sets credentials using Nextflow config files, which take precedence over the environment variable." println "however, caution is advised to avoid confusing behaviour." println "" } @@ -281,13 +284,13 @@ class CmdAuth extends CmdBase implements UsageAware { def existingToken = config['tower.accessToken'] if (existingToken) { println "Authentication token is already configured in Nextflow config." - println "Config file: ${getConfigFile()}" - println "Run 'nextflow auth logout' to remove the current authentication." + println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}" + println "Run ${AuthColorUtil.colorize('nextflow auth logout', Color.CYAN)} to remove the current authentication." return } - println "Nextflow authentication with Seqera Platform" - println " - Authentication will be saved to: ${getConfigFile()}" + println "Nextflow authentication with ${AuthColorUtil.colorize('Seqera Platform', Color.CYAN, true)}" + AuthColorUtil.printColored(" - Authentication will be saved to: ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}", null, false, true) // Use provided URL or default if (!apiUrl) { @@ -296,8 +299,8 @@ class CmdAuth extends CmdBase implements UsageAware { apiUrl = 'https://' + apiUrl } - println " - Seqera Platform API endpoint: ${apiUrl}" - println " (can be customised with -u option)" + AuthColorUtil.printColored(" - Seqera Platform API endpoint: ${AuthColorUtil.colorize(apiUrl, Color.MAGENTA)}", null, false, true) + AuthColorUtil.printColored(" (can be customised with ${AuthColorUtil.colorize('-u', Color.CYAN)} option)", null, false, true) Thread.sleep(2000) // Check if this is a cloud endpoint or enterprise @@ -315,7 +318,7 @@ class CmdAuth extends CmdBase implements UsageAware { } private void performAuth0Login(String apiUrl) { - println " - Opening browser for authentication..." + AuthColorUtil.printColored(" - Opening browser for authentication...", null, false, true) // Generate PKCE parameters def codeVerifier = generateCodeVerifier() @@ -341,14 +344,14 @@ class CmdAuth extends CmdBase implements UsageAware { // Verify login by calling /user-info def userInfo = callUserInfoApi(accessToken, apiUrl) - println " - Authentication successful! Logged in as: ${userInfo.userName}" + AuthColorUtil.printColored("\nAuthentication successful! Logged in as: ${AuthColorUtil.colorize(userInfo.userName, Color.CYAN, true)}", Color.GREEN) // Generate PAT def pat = generatePAT(accessToken, apiUrl) // Save to config saveAuthToConfig(pat, apiUrl) - println " - Seqera Platform configuration saved to ${getConfigFile()}" + AuthColorUtil.printColored("Seqera Platform configuration saved to ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}", Color.GREEN) } catch (Exception e) { throw new RuntimeException("Authentication timeout or failed: ${e.message}", e) @@ -562,8 +565,8 @@ class CmdAuth extends CmdBase implements UsageAware { private void handleEnterpriseAuth(String apiUrl) { println "" - println "Please generate a Personal Access Token from your Seqera Platform instance." - println "You can create one at: ${apiUrl.replace('/api', '').replace('://api.', '')}/tokens" + println "Please generate a Personal Access Token from your ${AuthColorUtil.colorize('Seqera Platform', Color.CYAN, true)} instance." + println "You can create one at: ${AuthColorUtil.colorize(apiUrl.replace('/api', '').replace('://api.', '') + '/tokens', Color.MAGENTA)}" println "" System.out.print("Enter your Personal Access Token: ") @@ -580,8 +583,8 @@ class CmdAuth extends CmdBase implements UsageAware { // Save to config saveAuthToConfig(pat.trim(), apiUrl) - println "Personal Access Token saved to Nextflow config" - println "Config file: ${getConfigFile()}" + AuthColorUtil.printColored("Personal Access Token saved to Nextflow config", Color.GREEN) + println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}" } private String generatePAT(String accessToken, String apiUrl) { @@ -648,14 +651,11 @@ class CmdAuth extends CmdBase implements UsageAware { throw new AbortOperationException("Too many arguments for logout command") } - println "Nextflow authentication logout" - println "" - // Check if TOWER_ACCESS_TOKEN environment variable is set def envToken = System.getenv('TOWER_ACCESS_TOKEN') if (envToken) { - println "WARNING: TOWER_ACCESS_TOKEN environment variable is set." - println "'nextflow auth logout' only removes credentials from Nextflow config files." + AuthColorUtil.printColored("WARNING: TOWER_ACCESS_TOKEN environment variable is set.", Color.YELLOW, true) + println "${AuthColorUtil.colorize('nextflow auth logout', Color.CYAN)} only removes credentials from Nextflow config files." println "The environment variable will remain unaffected." println "" } @@ -667,11 +667,11 @@ class CmdAuth extends CmdBase implements UsageAware { if (!existingToken) { println "No authentication token found in Nextflow config." - println "Config file: ${getConfigFile()}" + println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}" return } - println " - Found authentication token in config file: ${getConfigFile()}" + AuthColorUtil.printColored(" - Found authentication token in config file: ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}", null, false, true) // Prompt user for API URL if not already configured def apiUrl = endpoint as String @@ -684,7 +684,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Validate token by calling /user-info API try { def userInfo = callUserInfoApi(existingToken as String, apiUrl) - println " - Token is valid for user: ${userInfo.userName}" + AuthColorUtil.printColored(" - Token is valid for user: ${AuthColorUtil.colorize(userInfo.userName, Color.CYAN, true)}", null, false, true) // Only delete PAT from platform if this is a cloud endpoint if (isCloudEndpoint(apiUrl)) { @@ -702,7 +702,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Remove from config even if API calls fail removeAuthFromConfig() - println "Token removed from Nextflow config." + AuthColorUtil.printColored("Token removed from Nextflow config.", Color.GREEN) } } @@ -736,7 +736,7 @@ class CmdAuth extends CmdBase implements UsageAware { throw new RuntimeException("Failed to delete token: ${error}") } - println " - Token successfully deleted from Seqera Platform." + AuthColorUtil.printColored("\nToken successfully deleted from Seqera Platform.", Color.GREEN) } private void removeAuthFromConfig() { @@ -748,7 +748,7 @@ class CmdAuth extends CmdBase implements UsageAware { Files.writeString(configFile, cleanedContent, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) } - println " - Authentication removed from Nextflow config." + AuthColorUtil.printColored("Authentication removed from Nextflow config.", Color.GREEN) } @Override @@ -785,22 +785,22 @@ class CmdAuth extends CmdBase implements UsageAware { def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' if (!existingToken) { - println "No authentication found. Please run 'nextflow auth login' first." + println "No authentication found. Please run ${AuthColorUtil.colorize('nextflow auth login', Color.CYAN)} first." return } - println "Nextflow Seqera Platform configuration" - println " - Config file: ${getConfigFile()}" + println "Nextflow ${AuthColorUtil.colorize('Seqera Platform', Color.CYAN, true)} configuration" + AuthColorUtil.printColored(" - Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}", null, false, true) // Check if token is from environment variable if (!config['tower.accessToken'] && System.getenv('TOWER_ACCESS_TOKEN')) { - println " - Using access token from TOWER_ACCESS_TOKEN environment variable" + AuthColorUtil.printColored(" - Using access token from TOWER_ACCESS_TOKEN environment variable", null, false, true) } try { // Get user info to validate token and get user ID def userInfo = callUserInfoApi(existingToken as String, endpoint as String) - println " - Authenticated as: ${userInfo.userName}" + AuthColorUtil.printColored(" - Authenticated as: ${AuthColorUtil.colorize(userInfo.userName, Color.CYAN, true)}", null, false, true) println "" // Track if any changes are made @@ -815,7 +815,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Save updated config only if changes were made if (configChanged) { writeConfig(config) - println " - Configuration saved to ${getConfigFile()}" + AuthColorUtil.printColored(" - Configuration saved to ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}", Color.GREEN) } } catch (Exception e) { @@ -1079,59 +1079,104 @@ class CmdAuth extends CmdBase implements UsageAware { def config = readConfig() + // Collect all status information + List> statusRows = [] + // API endpoint def endpointInfo = getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://api.cloud.seqera.io') - println "API endpoint: ${endpointInfo.value} (${endpointInfo.source})" + statusRows.add(['API endpoint', AuthColorUtil.colorize(endpointInfo.value, Color.MAGENTA), endpointInfo.source as String]) // API connection check def apiConnectionOk = checkApiConnection(endpointInfo.value as String) - println "API connection check: ${apiConnectionOk ? 'OK' : 'ERROR'}" - - // Access token status - def tokenInfo = getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') - if (tokenInfo.value) { - println "Access token: Configured (${tokenInfo.source})" - } else { - println "Access token: Not configured" - } + def connectionColor = apiConnectionOk ? Color.GREEN : Color.RED + statusRows.add(['API connection', AuthColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) // Authentication check + def tokenInfo = getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') if (tokenInfo.value) { try { def userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) - def currentUser = userInfo.userName - println "Authentication: Success (${currentUser})" + def currentUser = userInfo.userName as String + statusRows.add(['Authentication', "${AuthColorUtil.colorize('OK', Color.GREEN)} ${AuthColorUtil.colorize('(user: ' + currentUser + ')', Color.CYAN)}".toString(), tokenInfo.source as String]) } catch (Exception e) { - println "Authentication: Error (${e})" + statusRows.add(['Authentication', AuthColorUtil.colorize('ERROR', Color.RED), 'failed']) } } else { - println "Authentication: ERROR (no token)" + statusRows.add(['Authentication', "${AuthColorUtil.colorize('ERROR', Color.RED)} ${AuthColorUtil.colorize('(no token)', null, false, true)}".toString(), 'not set']) } // Monitoring enabled def enabledInfo = getConfigValue(config, 'tower.enabled', null, 'false') def enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' - println "Local workflow monitoring enabled: ${enabledValue} (${enabledInfo.source ?: 'default'})" + def enabledColor = enabledValue == 'Yes' ? Color.GREEN : Color.YELLOW + statusRows.add(['Workflow monitoring', AuthColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) // Default workspace def workspaceInfo = getConfigValue(config, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') if (workspaceInfo.value) { // Try to get workspace name from API if we have a token - def workspaceName = null + def workspaceDetails = null if (tokenInfo.value) { - workspaceName = getWorkspaceNameFromApi(tokenInfo.value as String, endpointInfo.value as String, workspaceInfo.value as String) + workspaceDetails = getWorkspaceDetailsFromApi(tokenInfo.value as String, endpointInfo.value as String, workspaceInfo.value as String) } - if (workspaceName) { - println "Default workspace: ${workspaceInfo.value} - ${workspaceName} (${workspaceInfo.source})" + if (workspaceDetails) { + // Add workspace ID row + statusRows.add(['Default workspace ID', AuthColorUtil.colorize(workspaceInfo.value, Color.BLUE), workspaceInfo.source as String]) + // Add org/name row + statusRows.add([' - workspace name', "${AuthColorUtil.colorize(workspaceDetails.orgName, Color.CYAN, true)} / ${AuthColorUtil.colorize(workspaceDetails.workspaceName, Color.CYAN)}".toString(), '']) + // Add full name row (truncate if too long) + def fullName = workspaceDetails.workspaceFullName as String + def truncatedFullName = fullName.length() > 50 ? fullName.substring(0, 47) + '...' : fullName + statusRows.add([' - workspace full name', AuthColorUtil.colorize(truncatedFullName, Color.CYAN, false, true), '']) } else { - println "Default workspace: ${workspaceInfo.value} (${workspaceInfo.source})" + statusRows.add(['Default workspace', AuthColorUtil.colorize(workspaceInfo.value, Color.BLUE), workspaceInfo.source as String]) } } else { - println "Default workspace: Personal workspace (default)" + statusRows.add(['Default workspace', AuthColorUtil.colorize('Personal workspace', Color.CYAN), 'default']) + } + + // Print table + println "" + printStatusTable(statusRows) + } + + private void printStatusTable(List> rows) { + if (!rows) return + + // Calculate column widths (accounting for ANSI codes) + def col1Width = rows.collect { stripAnsiCodes(it[0]).length() }.max() + def col2Width = rows.collect { stripAnsiCodes(it[1]).length() }.max() + def col3Width = rows.collect { stripAnsiCodes(it[2]).length() }.max() + + // Add some padding + col1Width = Math.max(col1Width, 15) + 2 + col2Width = Math.max(col2Width, 15) + 2 + col3Width = Math.max(col3Width, 10) + 2 + + // Print table header + println "${AuthColorUtil.colorize('Setting'.padRight(col1Width), Color.CYAN, true)} ${AuthColorUtil.colorize('Value'.padRight(col2Width), Color.CYAN, true)} ${AuthColorUtil.colorize('Source', Color.CYAN, true)}" + println "${'-' * col1Width} ${'-' * col2Width} ${'-' * col3Width}" + + // Print rows + rows.each { row -> + def paddedCol1 = padStringWithAnsi(row[0], col1Width) + def paddedCol2 = padStringWithAnsi(row[1], col2Width) + def paddedCol3 = AuthColorUtil.colorize(row[2], null, false, true) + println "${paddedCol1} ${paddedCol2} ${paddedCol3}" } } + private String stripAnsiCodes(String text) { + return text?.replaceAll(/\u001B\[[0-9;]*m/, '') ?: '' + } + + private String padStringWithAnsi(String text, int width) { + def plainText = stripAnsiCodes(text) + def padding = width - plainText.length() + return padding > 0 ? text + (' ' * padding) : text + } + private String shortenPath(String path) { def userHome = System.getProperty('user.home') if (path.startsWith(userHome)) { @@ -1198,6 +1243,45 @@ class CmdAuth extends CmdBase implements UsageAware { } } + private Map getWorkspaceDetailsFromApi(String accessToken, String endpoint, String workspaceId) { + try { + // Get user info to get user ID + def userInfo = callUserInfoApi(accessToken, endpoint) + def userId = userInfo.id as String + + // Get workspaces for the user + def workspacesUrl = "${endpoint}/user/${userId}/workspaces" + def connection = new URL(workspacesUrl).openConnection() as HttpURLConnection + connection.requestMethod = 'GET' + connection.connectTimeout = 10000 // 10 second timeout + connection.readTimeout = 10000 + connection.setRequestProperty('Authorization', "Bearer ${accessToken}") + + if (connection.responseCode != 200) { + return null + } + + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) as Map + def orgsAndWorkspaces = json.orgsAndWorkspaces as List + + // Find the workspace with matching ID + def workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } + if (workspace) { + def ws = workspace as Map + return [ + orgName: ws.orgName, + workspaceName: ws.workspaceName, + workspaceFullName: ws.workspaceFullName + ] + } + + return null + } catch (Exception e) { + return null + } + } + private boolean checkApiConnection(String endpoint) { try { def serviceInfoUrl = "${endpoint}/service-info" From fa056b5ad39fd019735ee17f7438b96dc39581e2 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 10 Sep 2025 23:40:51 +0200 Subject: [PATCH 18/66] Refactor colour helpers to take string argument Signed-off-by: Phil Ewels --- .../groovy/nextflow/cli/AuthColorUtil.groovy | 118 +++++++++++++----- .../main/groovy/nextflow/cli/CmdAuth.groovy | 84 ++++++------- 2 files changed, 127 insertions(+), 75 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy index 0e84973664..a3a1296367 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy @@ -37,7 +37,7 @@ class AuthColorUtil { if (SysEnv.get('NO_COLOR')) { return false } - + // Check NXF_ANSI_LOG environment variable final env = SysEnv.get('NXF_ANSI_LOG') if (env) { @@ -47,49 +47,101 @@ class AuthColorUtil { // Invalid value, fall through to default } } - + // Default to enabled if terminal supports it return Ansi.isEnabled() } - + /** - * Print colored text if ANSI is enabled, otherwise plain text + * Print colored text using format keywords + * Format: colorize(text, format) - e.g. colorize('Hello', 'red bold'), colorize('World', 'cyan dim') */ - static void printColored(Object text, Color color = null, boolean bold = false, boolean dim = false) { - def textStr = text?.toString() ?: '' - if (!isAnsiEnabled() || (!color && !bold && !dim)) { - println textStr - return - } - - def fmt = ansi() - if (color) fmt = fmt.fg(color) - if (bold) fmt = fmt.bold() - if (dim) fmt = fmt.a(Attribute.INTENSITY_FAINT) - fmt = fmt.a(textStr) - if (dim) fmt = fmt.a(Attribute.RESET) - if (bold) fmt = fmt.boldOff() - if (color) fmt = fmt.fg(Color.DEFAULT) - println fmt + static void printColored(String text, String format = '') { + println colorize(text, format) } - + /** - * Format text with color inline + * Format text with color using format keywords + * Format: colorize(text, format) - e.g. colorize('Hello', 'red bold'), colorize('World', 'cyan on yellow') + * Supported colors: black, red, green, yellow, blue, magenta, cyan, white, default + * Supported background: on black, on red, on green, on yellow, on blue, on magenta, on cyan, on white, on default + * Supported formatting: bold, dim */ - static String colorize(Object text, Color color = null, boolean bold = false, boolean dim = false) { - def textStr = text?.toString() ?: '' - if (!isAnsiEnabled() || (!color && !bold && !dim)) { - return textStr + static String colorize(String text, String format = '') { + if (!isAnsiEnabled() || !text) { + return text ?: '' + } + + if (!format) { + return text + } + + def parts = format.split(' ') + def foregroundColor = null + def backgroundColor = null + def bold = false + def dim = false + def onNext = false + + // Parse format keywords + parts.each { part -> + def partLower = part.toLowerCase() + + if (onNext) { + // Previous word was 'on', so this is a background color + backgroundColor = parseColor(partLower) + onNext = false + } else if (partLower == 'on') { + onNext = true + } else { + switch (partLower) { + case 'black': + case 'red': + case 'green': + case 'yellow': + case 'blue': + case 'magenta': + case 'cyan': + case 'white': + case 'default': + foregroundColor = parseColor(partLower) + break + case 'bold': + bold = true + break + case 'dim': + dim = true + break + } + } } - + def fmt = ansi() - if (color) fmt = fmt.fg(color) + if (foregroundColor) fmt = fmt.fg(foregroundColor) + if (backgroundColor) fmt = fmt.bg(backgroundColor) if (bold) fmt = fmt.bold() if (dim) fmt = fmt.a(Attribute.INTENSITY_FAINT) - fmt = fmt.a(textStr) - if (dim) fmt = fmt.a(Attribute.RESET) - if (bold) fmt = fmt.boldOff() - if (color) fmt = fmt.fg(Color.DEFAULT) + fmt = fmt.a(text) + fmt = fmt.a(Attribute.RESET) return fmt.toString() } -} \ No newline at end of file + + /** + * Parse color name to Jansi Color enum + */ + private static Color parseColor(String colorName) { + switch (colorName) { + case 'black': return Color.BLACK + case 'red': return Color.RED + case 'green': return Color.GREEN + case 'yellow': return Color.YELLOW + case 'blue': return Color.BLUE + case 'magenta': return Color.MAGENTA + case 'cyan': return Color.CYAN + case 'white': return Color.WHITE + case 'default': return Color.DEFAULT + default: return null + } + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 7ced7b40cc..01c15caae8 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -273,8 +273,8 @@ class CmdAuth extends CmdBase implements UsageAware { // Check if TOWER_ACCESS_TOKEN environment variable is set def envToken = System.getenv('TOWER_ACCESS_TOKEN') if (envToken) { - AuthColorUtil.printColored("WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable.", Color.YELLOW, true) - println "${AuthColorUtil.colorize('nextflow auth login', Color.CYAN)} sets credentials using Nextflow config files, which take precedence over the environment variable." + AuthColorUtil.printColored("WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable.", "yellow bold") + println "${AuthColorUtil.colorize('nextflow auth login', 'cyan')} sets credentials using Nextflow config files, which take precedence over the environment variable." println "however, caution is advised to avoid confusing behaviour." println "" } @@ -284,13 +284,13 @@ class CmdAuth extends CmdBase implements UsageAware { def existingToken = config['tower.accessToken'] if (existingToken) { println "Authentication token is already configured in Nextflow config." - println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}" - println "Run ${AuthColorUtil.colorize('nextflow auth logout', Color.CYAN)} to remove the current authentication." + println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}" + println "Run ${AuthColorUtil.colorize('nextflow auth logout', 'cyan')} to remove the current authentication." return } - println "Nextflow authentication with ${AuthColorUtil.colorize('Seqera Platform', Color.CYAN, true)}" - AuthColorUtil.printColored(" - Authentication will be saved to: ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}", null, false, true) + println "Nextflow authentication with ${AuthColorUtil.colorize('Seqera Platform', 'cyan bold')}" + AuthColorUtil.printColored(" - Authentication will be saved to: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") // Use provided URL or default if (!apiUrl) { @@ -299,8 +299,8 @@ class CmdAuth extends CmdBase implements UsageAware { apiUrl = 'https://' + apiUrl } - AuthColorUtil.printColored(" - Seqera Platform API endpoint: ${AuthColorUtil.colorize(apiUrl, Color.MAGENTA)}", null, false, true) - AuthColorUtil.printColored(" (can be customised with ${AuthColorUtil.colorize('-u', Color.CYAN)} option)", null, false, true) + AuthColorUtil.printColored(" - Seqera Platform API endpoint: ${AuthColorUtil.colorize(apiUrl, 'magenta')}", "dim") + AuthColorUtil.printColored(" (can be customised with ${AuthColorUtil.colorize('-u', 'cyan')} option)", "dim") Thread.sleep(2000) // Check if this is a cloud endpoint or enterprise @@ -318,7 +318,7 @@ class CmdAuth extends CmdBase implements UsageAware { } private void performAuth0Login(String apiUrl) { - AuthColorUtil.printColored(" - Opening browser for authentication...", null, false, true) + AuthColorUtil.printColored(" - Opening browser for authentication...", "dim") // Generate PKCE parameters def codeVerifier = generateCodeVerifier() @@ -344,14 +344,14 @@ class CmdAuth extends CmdBase implements UsageAware { // Verify login by calling /user-info def userInfo = callUserInfoApi(accessToken, apiUrl) - AuthColorUtil.printColored("\nAuthentication successful! Logged in as: ${AuthColorUtil.colorize(userInfo.userName, Color.CYAN, true)}", Color.GREEN) + AuthColorUtil.printColored("\nAuthentication successful! Logged in as: ${AuthColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "green") // Generate PAT def pat = generatePAT(accessToken, apiUrl) // Save to config saveAuthToConfig(pat, apiUrl) - AuthColorUtil.printColored("Seqera Platform configuration saved to ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}", Color.GREEN) + AuthColorUtil.printColored("Seqera Platform configuration saved to ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "green") } catch (Exception e) { throw new RuntimeException("Authentication timeout or failed: ${e.message}", e) @@ -565,8 +565,8 @@ class CmdAuth extends CmdBase implements UsageAware { private void handleEnterpriseAuth(String apiUrl) { println "" - println "Please generate a Personal Access Token from your ${AuthColorUtil.colorize('Seqera Platform', Color.CYAN, true)} instance." - println "You can create one at: ${AuthColorUtil.colorize(apiUrl.replace('/api', '').replace('://api.', '') + '/tokens', Color.MAGENTA)}" + println "Please generate a Personal Access Token from your ${AuthColorUtil.colorize('Seqera Platform', 'cyan bold')} instance." + println "You can create one at: ${AuthColorUtil.colorize(apiUrl.replace('/api', '').replace('://api.', '') + '/tokens', 'magenta')}" println "" System.out.print("Enter your Personal Access Token: ") @@ -583,8 +583,8 @@ class CmdAuth extends CmdBase implements UsageAware { // Save to config saveAuthToConfig(pat.trim(), apiUrl) - AuthColorUtil.printColored("Personal Access Token saved to Nextflow config", Color.GREEN) - println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}" + AuthColorUtil.printColored("Personal Access Token saved to Nextflow config", "green") + println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}" } private String generatePAT(String accessToken, String apiUrl) { @@ -654,8 +654,8 @@ class CmdAuth extends CmdBase implements UsageAware { // Check if TOWER_ACCESS_TOKEN environment variable is set def envToken = System.getenv('TOWER_ACCESS_TOKEN') if (envToken) { - AuthColorUtil.printColored("WARNING: TOWER_ACCESS_TOKEN environment variable is set.", Color.YELLOW, true) - println "${AuthColorUtil.colorize('nextflow auth logout', Color.CYAN)} only removes credentials from Nextflow config files." + AuthColorUtil.printColored("WARNING: TOWER_ACCESS_TOKEN environment variable is set.", "yellow bold") + println "${AuthColorUtil.colorize('nextflow auth logout', 'cyan')} only removes credentials from Nextflow config files." println "The environment variable will remain unaffected." println "" } @@ -667,11 +667,11 @@ class CmdAuth extends CmdBase implements UsageAware { if (!existingToken) { println "No authentication token found in Nextflow config." - println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}" + println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}" return } - AuthColorUtil.printColored(" - Found authentication token in config file: ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}", null, false, true) + AuthColorUtil.printColored(" - Found authentication token in config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") // Prompt user for API URL if not already configured def apiUrl = endpoint as String @@ -684,7 +684,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Validate token by calling /user-info API try { def userInfo = callUserInfoApi(existingToken as String, apiUrl) - AuthColorUtil.printColored(" - Token is valid for user: ${AuthColorUtil.colorize(userInfo.userName, Color.CYAN, true)}", null, false, true) + AuthColorUtil.printColored(" - Token is valid for user: ${AuthColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") // Only delete PAT from platform if this is a cloud endpoint if (isCloudEndpoint(apiUrl)) { @@ -702,7 +702,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Remove from config even if API calls fail removeAuthFromConfig() - AuthColorUtil.printColored("Token removed from Nextflow config.", Color.GREEN) + AuthColorUtil.printColored("Token removed from Nextflow config.", "green") } } @@ -736,7 +736,7 @@ class CmdAuth extends CmdBase implements UsageAware { throw new RuntimeException("Failed to delete token: ${error}") } - AuthColorUtil.printColored("\nToken successfully deleted from Seqera Platform.", Color.GREEN) + AuthColorUtil.printColored("\nToken successfully deleted from Seqera Platform.", "green") } private void removeAuthFromConfig() { @@ -748,7 +748,7 @@ class CmdAuth extends CmdBase implements UsageAware { Files.writeString(configFile, cleanedContent, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) } - AuthColorUtil.printColored("Authentication removed from Nextflow config.", Color.GREEN) + AuthColorUtil.printColored("Authentication removed from Nextflow config.", "green") } @Override @@ -785,22 +785,22 @@ class CmdAuth extends CmdBase implements UsageAware { def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' if (!existingToken) { - println "No authentication found. Please run ${AuthColorUtil.colorize('nextflow auth login', Color.CYAN)} first." + println "No authentication found. Please run ${AuthColorUtil.colorize('nextflow auth login', 'cyan')} first." return } - println "Nextflow ${AuthColorUtil.colorize('Seqera Platform', Color.CYAN, true)} configuration" - AuthColorUtil.printColored(" - Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}", null, false, true) + println "Nextflow ${AuthColorUtil.colorize('Seqera Platform', 'cyan bold')} configuration" + AuthColorUtil.printColored(" - Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") // Check if token is from environment variable if (!config['tower.accessToken'] && System.getenv('TOWER_ACCESS_TOKEN')) { - AuthColorUtil.printColored(" - Using access token from TOWER_ACCESS_TOKEN environment variable", null, false, true) + AuthColorUtil.printColored(" - Using access token from TOWER_ACCESS_TOKEN environment variable", "dim") } try { // Get user info to validate token and get user ID def userInfo = callUserInfoApi(existingToken as String, endpoint as String) - AuthColorUtil.printColored(" - Authenticated as: ${AuthColorUtil.colorize(userInfo.userName, Color.CYAN, true)}", null, false, true) + AuthColorUtil.printColored(" - Authenticated as: ${AuthColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") println "" // Track if any changes are made @@ -815,7 +815,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Save updated config only if changes were made if (configChanged) { writeConfig(config) - AuthColorUtil.printColored(" - Configuration saved to ${AuthColorUtil.colorize(getConfigFile().toString(), Color.MAGENTA)}", Color.GREEN) + AuthColorUtil.printColored(" - Configuration saved to ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "green") } } catch (Exception e) { @@ -1084,11 +1084,11 @@ class CmdAuth extends CmdBase implements UsageAware { // API endpoint def endpointInfo = getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://api.cloud.seqera.io') - statusRows.add(['API endpoint', AuthColorUtil.colorize(endpointInfo.value, Color.MAGENTA), endpointInfo.source as String]) + statusRows.add(['API endpoint', AuthColorUtil.colorize(endpointInfo.value as String, 'magenta'), endpointInfo.source as String]) // API connection check def apiConnectionOk = checkApiConnection(endpointInfo.value as String) - def connectionColor = apiConnectionOk ? Color.GREEN : Color.RED + def connectionColor = apiConnectionOk ? 'green' : 'red' statusRows.add(['API connection', AuthColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) // Authentication check @@ -1097,18 +1097,18 @@ class CmdAuth extends CmdBase implements UsageAware { try { def userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) def currentUser = userInfo.userName as String - statusRows.add(['Authentication', "${AuthColorUtil.colorize('OK', Color.GREEN)} ${AuthColorUtil.colorize('(user: ' + currentUser + ')', Color.CYAN)}".toString(), tokenInfo.source as String]) + statusRows.add(['Authentication', "${AuthColorUtil.colorize('OK', 'green')} ${AuthColorUtil.colorize('(user: ' + currentUser + ')', 'cyan')}".toString(), tokenInfo.source as String]) } catch (Exception e) { - statusRows.add(['Authentication', AuthColorUtil.colorize('ERROR', Color.RED), 'failed']) + statusRows.add(['Authentication', AuthColorUtil.colorize('ERROR', 'red'), 'failed']) } } else { - statusRows.add(['Authentication', "${AuthColorUtil.colorize('ERROR', Color.RED)} ${AuthColorUtil.colorize('(no token)', null, false, true)}".toString(), 'not set']) + statusRows.add(['Authentication', "${AuthColorUtil.colorize('ERROR', 'red')} ${AuthColorUtil.colorize('(no token)', 'dim')}".toString(), 'not set']) } // Monitoring enabled def enabledInfo = getConfigValue(config, 'tower.enabled', null, 'false') def enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' - def enabledColor = enabledValue == 'Yes' ? Color.GREEN : Color.YELLOW + def enabledColor = enabledValue == 'Yes' ? 'green' : 'yellow' statusRows.add(['Workflow monitoring', AuthColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) // Default workspace @@ -1122,18 +1122,18 @@ class CmdAuth extends CmdBase implements UsageAware { if (workspaceDetails) { // Add workspace ID row - statusRows.add(['Default workspace ID', AuthColorUtil.colorize(workspaceInfo.value, Color.BLUE), workspaceInfo.source as String]) + statusRows.add(['Default workspace ID', AuthColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) // Add org/name row - statusRows.add([' - workspace name', "${AuthColorUtil.colorize(workspaceDetails.orgName, Color.CYAN, true)} / ${AuthColorUtil.colorize(workspaceDetails.workspaceName, Color.CYAN)}".toString(), '']) + statusRows.add([' - workspace name', "${AuthColorUtil.colorize(workspaceDetails.orgName as String, 'cyan bold')} / ${AuthColorUtil.colorize(workspaceDetails.workspaceName as String, 'cyan')}".toString(), '']) // Add full name row (truncate if too long) def fullName = workspaceDetails.workspaceFullName as String def truncatedFullName = fullName.length() > 50 ? fullName.substring(0, 47) + '...' : fullName - statusRows.add([' - workspace full name', AuthColorUtil.colorize(truncatedFullName, Color.CYAN, false, true), '']) + statusRows.add([' - workspace full name', AuthColorUtil.colorize(truncatedFullName, 'cyan dim'), '']) } else { - statusRows.add(['Default workspace', AuthColorUtil.colorize(workspaceInfo.value, Color.BLUE), workspaceInfo.source as String]) + statusRows.add(['Default workspace', AuthColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) } } else { - statusRows.add(['Default workspace', AuthColorUtil.colorize('Personal workspace', Color.CYAN), 'default']) + statusRows.add(['Default workspace', AuthColorUtil.colorize('Personal workspace', 'cyan'), 'default']) } // Print table @@ -1155,14 +1155,14 @@ class CmdAuth extends CmdBase implements UsageAware { col3Width = Math.max(col3Width, 10) + 2 // Print table header - println "${AuthColorUtil.colorize('Setting'.padRight(col1Width), Color.CYAN, true)} ${AuthColorUtil.colorize('Value'.padRight(col2Width), Color.CYAN, true)} ${AuthColorUtil.colorize('Source', Color.CYAN, true)}" + println "${AuthColorUtil.colorize('Setting'.padRight(col1Width), 'cyan bold')} ${AuthColorUtil.colorize('Value'.padRight(col2Width), 'cyan bold')} ${AuthColorUtil.colorize('Source', 'cyan bold')}" println "${'-' * col1Width} ${'-' * col2Width} ${'-' * col3Width}" // Print rows rows.each { row -> def paddedCol1 = padStringWithAnsi(row[0], col1Width) def paddedCol2 = padStringWithAnsi(row[1], col2Width) - def paddedCol3 = AuthColorUtil.colorize(row[2], null, false, true) + def paddedCol3 = AuthColorUtil.colorize(row[2], 'dim') println "${paddedCol1} ${paddedCol2} ${paddedCol3}" } } From 320bee4cf78db2b917141438dfebe5764b3e91f5 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 10 Sep 2025 23:46:46 +0200 Subject: [PATCH 19/66] Minor tweak Signed-off-by: Phil Ewels --- .../nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 01c15caae8..953a01fc4e 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -654,9 +654,10 @@ class CmdAuth extends CmdBase implements UsageAware { // Check if TOWER_ACCESS_TOKEN environment variable is set def envToken = System.getenv('TOWER_ACCESS_TOKEN') if (envToken) { + println "" AuthColorUtil.printColored("WARNING: TOWER_ACCESS_TOKEN environment variable is set.", "yellow bold") - println "${AuthColorUtil.colorize('nextflow auth logout', 'cyan')} only removes credentials from Nextflow config files." - println "The environment variable will remain unaffected." + println " ${AuthColorUtil.colorize('nextflow auth logout', 'dim cyan')}${AuthColorUtil.colorize(' only removes credentials from Nextflow config files.', 'dim')}" + AuthColorUtil.printColored(" The environment variable will remain unaffected.", "dim") println "" } @@ -666,7 +667,7 @@ class CmdAuth extends CmdBase implements UsageAware { def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' if (!existingToken) { - println "No authentication token found in Nextflow config." + AuthColorUtil.printColored("Error: No authentication token found in Nextflow config.", "red") println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}" return } From 39d3c6462b52d3ba83573b8d2fb27a2337efab47 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Thu, 11 Sep 2025 09:36:38 +0200 Subject: [PATCH 20/66] Switch from PKCE to device-flow auth, with code confirmation Signed-off-by: Phil Ewels --- .../groovy/nextflow/cli/AuthColorUtil.groovy | 22 +- .../main/groovy/nextflow/cli/CmdAuth.groovy | 340 ++++++------------ 2 files changed, 136 insertions(+), 226 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy index a3a1296367..9aa2824571 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy @@ -53,21 +53,22 @@ class AuthColorUtil { } /** - * Print colored text using format keywords + * Print colored text using format keywords and reset all styles when done * Format: colorize(text, format) - e.g. colorize('Hello', 'red bold'), colorize('World', 'cyan dim') */ static void printColored(String text, String format = '') { - println colorize(text, format) + println colorize(text, format, true) // Always do full reset for printColored } /** * Format text with color using format keywords - * Format: colorize(text, format) - e.g. colorize('Hello', 'red bold'), colorize('World', 'cyan on yellow') + * Format: colorize(text, format, fullReset) - e.g. colorize('Hello', 'red bold'), colorize('World', 'cyan on yellow') * Supported colors: black, red, green, yellow, blue, magenta, cyan, white, default * Supported background: on black, on red, on green, on yellow, on blue, on magenta, on cyan, on white, on default * Supported formatting: bold, dim + * @param fullReset if true, resets all styles; if false, only resets styles that were applied */ - static String colorize(String text, String format = '') { + static String colorize(String text, String format = '', boolean fullReset = false) { if (!isAnsiEnabled() || !text) { return text ?: '' } @@ -122,7 +123,18 @@ class AuthColorUtil { if (bold) fmt = fmt.bold() if (dim) fmt = fmt.a(Attribute.INTENSITY_FAINT) fmt = fmt.a(text) - fmt = fmt.a(Attribute.RESET) + + if (fullReset) { + // Reset all styles + fmt = fmt.a(Attribute.RESET) + } else { + // Reset only what was set + if (bold) fmt = fmt.boldOff() + if (dim) fmt = fmt.a(Attribute.INTENSITY_BOLD) // Reset dim + if (foregroundColor) fmt = fmt.fg(Color.DEFAULT) + if (backgroundColor) fmt = fmt.bg(Color.DEFAULT) + } + return fmt.toString() } diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 953a01fc4e..95ba8f7918 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -26,14 +26,9 @@ import nextflow.exception.AbortOperationException import org.fusesource.jansi.Ansi import static org.fusesource.jansi.Ansi.* -import java.awt.Desktop -import java.net.ServerSocket -import java.net.URI import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardOpenOption -import java.security.MessageDigest -import java.security.SecureRandom import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import java.util.regex.Pattern @@ -257,7 +252,24 @@ class CmdAuth extends CmdBase implements UsageAware { private static final String AUTH0_DOMAIN = "seqera-development.eu.auth0.com" private static final String AUTH0_CLIENT_ID = "Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ" - private static final int CALLBACK_PORT = 8085 + // Generate using: qrencode -t UTF8i "url" -m 2 + private static final String AUTH0_QRCODE = """ + █▀▀▀▀▀█ ▄ █ ▀▄ ▄▀▀▀▀▀ █▀▀▀▀▀█ + █ ███ █ ▄▄▄ █▄█ ▀▄ ▄ █ ███ █ + █ ▀▀▀ █ ▄█ ▄▄█▄▄ ▀▀█ █ ▀▀▀ █ + ▀▀▀▀▀▀▀ ▀▄█▄▀ █▄▀ ▀▄█ ▀▀▀▀▀▀▀ + █▀▀██ ▀▀█▀▄▀██▀▀▀█▄█▄▀▄█▄▀ ▀▄ + ▀██ ▀ ▄ ▀ ██ ▀▄ ▄█▄██▄ ▄ + ▀███▀▀▀▄█▀▄▀█ ▄▀ █▄▄▄▄▄▀▀ ▄ + █▀█ ▀▄▀ █ ▀▀██▄ ▄ █▀ ▀█▀▀▄ + ▄▀ █▄▄▀ ▀ █▄ ▀▀▀███▄▄█▄█▄▀█ ▄ + █ ▀▄ ▀▀▄▄▀▄ ▄ ▄██▀▄▄▄ ▀██ ▀▄ + ▀ ▀ ▀▀ █▄█▄▀▀▀▀█▀▄▄█▀▀▀█▄███ + █▀▀▀▀▀█ ▀ █▄▀▀▄█▀ ██ ▀ █▀▀▄ + █ ███ █ ███▄ ▀█▀▄▀ ▄▀▀▀▀▀▄█▀▄ + █ ▀▀▀ █ █ ▀ ▄ ▀▀▄ ▀▄▀▀█▀█▀█ + ▀▀▀▀▀▀▀ ▀ ▀▀▀▀▀ ▀▀▀ ▀ ▀▀▀ +""" String apiUrl @@ -273,9 +285,10 @@ class CmdAuth extends CmdBase implements UsageAware { // Check if TOWER_ACCESS_TOKEN environment variable is set def envToken = System.getenv('TOWER_ACCESS_TOKEN') if (envToken) { + println "" AuthColorUtil.printColored("WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable.", "yellow bold") - println "${AuthColorUtil.colorize('nextflow auth login', 'cyan')} sets credentials using Nextflow config files, which take precedence over the environment variable." - println "however, caution is advised to avoid confusing behaviour." + AuthColorUtil.printColored("${AuthColorUtil.colorize('nextflow auth login', 'cyan')} sets credentials using Nextflow config files, which take precedence over the environment variable.", "dim") + AuthColorUtil.printColored(" however, caution is advised to avoid confusing behaviour.", "dim") println "" } @@ -283,9 +296,9 @@ class CmdAuth extends CmdBase implements UsageAware { def config = readConfig() def existingToken = config['tower.accessToken'] if (existingToken) { - println "Authentication token is already configured in Nextflow config." - println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}" - println "Run ${AuthColorUtil.colorize('nextflow auth logout', 'cyan')} to remove the current authentication." + AuthColorUtil.printColored("Error: Authentication token is already configured in Nextflow config.", "red") + AuthColorUtil.printColored("Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") + println " Run ${AuthColorUtil.colorize('nextflow auth logout', 'cyan')} to remove the current authentication." return } @@ -299,9 +312,7 @@ class CmdAuth extends CmdBase implements UsageAware { apiUrl = 'https://' + apiUrl } - AuthColorUtil.printColored(" - Seqera Platform API endpoint: ${AuthColorUtil.colorize(apiUrl, 'magenta')}", "dim") - AuthColorUtil.printColored(" (can be customised with ${AuthColorUtil.colorize('-u', 'cyan')} option)", "dim") - Thread.sleep(2000) + AuthColorUtil.printColored(" - Seqera Platform API endpoint: ${AuthColorUtil.colorize(apiUrl, 'magenta')} (can be customised with ${AuthColorUtil.colorize('-url', 'cyan')})", "dim") // Check if this is a cloud endpoint or enterprise if (isCloudEndpoint(apiUrl)) { @@ -309,7 +320,8 @@ class CmdAuth extends CmdBase implements UsageAware { performAuth0Login(apiUrl) } catch (Exception e) { log.debug("Authentication failed", e) - throw new AbortOperationException("Authentication failed: ${e.message}") + println "" + throw new AbortOperationException("${e.message}") } } else { // Enterprise endpoint - use PAT authentication @@ -318,33 +330,26 @@ class CmdAuth extends CmdBase implements UsageAware { } private void performAuth0Login(String apiUrl) { - AuthColorUtil.printColored(" - Opening browser for authentication...", "dim") - - // Generate PKCE parameters - def codeVerifier = generateCodeVerifier() - def codeChallenge = generateCodeChallenge(codeVerifier) - def state = generateState() - - // Start local server for callback - def callbackFuture = startCallbackServer(state) - - // Build authorization URL - def authUrl = buildAuthUrl(codeChallenge, state) + // Start device authorization flow + def deviceAuth = requestDeviceAuthorization() - // Open browser - openBrowser(authUrl) + println "" + AuthColorUtil.printColored("Please visit the following URL in your web browser:", "cyan bold") + println " ${AuthColorUtil.colorize(deviceAuth.verification_uri as String, 'magenta')}" + println AUTH0_QRCODE + AuthColorUtil.printColored("Enter the following code when prompted:", "cyan bold") + println " ${AuthColorUtil.colorize(deviceAuth.user_code as String, 'yellow bold')}" + println "" + AuthColorUtil.printColored("Waiting for authentication...", "dim") try { - // Wait for callback with timeout - def authCode = callbackFuture.get(5, TimeUnit.MINUTES) - - // Exchange code for token - def tokenData = exchangeCodeForToken(authCode, codeVerifier) + // Poll for device token + def tokenData = pollForDeviceToken(deviceAuth.device_code as String, deviceAuth.interval as Integer ?: 5) def accessToken = tokenData['access_token'] as String // Verify login by calling /user-info def userInfo = callUserInfoApi(accessToken, apiUrl) - AuthColorUtil.printColored("\nAuthentication successful! Logged in as: ${AuthColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "green") + AuthColorUtil.printColored("Authentication successful! Logged in as: ${AuthColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "green") // Generate PAT def pat = generatePAT(accessToken, apiUrl) @@ -354,197 +359,24 @@ class CmdAuth extends CmdBase implements UsageAware { AuthColorUtil.printColored("Seqera Platform configuration saved to ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "green") } catch (Exception e) { - throw new RuntimeException("Authentication timeout or failed: ${e.message}", e) + throw new RuntimeException("Authentication failed: ${e.message}", e) } } - private String generateCodeVerifier() { - def random = new SecureRandom() - def bytes = new byte[32] - random.nextBytes(bytes) - return Base64.urlEncoder.withoutPadding().encodeToString(bytes) - } - - private String generateCodeChallenge(String codeVerifier) { - def digest = MessageDigest.getInstance("SHA-256") - def hash = digest.digest(codeVerifier.getBytes("UTF-8")) - return Base64.urlEncoder.withoutPadding().encodeToString(hash) - } - - private String generateState() { - def random = new SecureRandom() - def bytes = new byte[16] - random.nextBytes(bytes) - return Base64.urlEncoder.withoutPadding().encodeToString(bytes) - } - - private CompletableFuture startCallbackServer(String expectedState) { - def future = new CompletableFuture() - - Thread.start { - try { - def server = new ServerSocket(CALLBACK_PORT) - server.soTimeout = 300000 // 5 minutes - - def socket = server.accept() - def input = new BufferedReader(new InputStreamReader(socket.inputStream)) - def output = new PrintWriter(socket.outputStream) - - def requestLine = input.readLine() - if (requestLine?.startsWith("GET /callback")) { - def query = requestLine.split("\\?")[1]?.split(" ")[0] - def params = parseQueryParams(query) - - if (params['state'] != expectedState) { - throw new RuntimeException("Invalid state parameter") - } - - if (params['error']) { - throw new RuntimeException("Auth error: ${params['error']} - ${params['error_description']}") - } - - def code = params['code'] - if (!code) { - throw new RuntimeException("No authorization code received") - } - - // Send success response - output.println("HTTP/1.1 200 OK") - output.println("Content-Type: text/html") - output.println() - output.println(""" - - - - -
-

Nextflow authentication successful!

-

You can now close this window.

-
- - -""") - output.flush() - - future.complete(code) - } else { - future.completeExceptionally(new RuntimeException("Invalid callback request")) - } - - socket.close() - server.close() - - } catch (Exception e) { - future.completeExceptionally(e) - } - } - - return future - } - - private Map parseQueryParams(String query) { - Map params = [:] - if (query) { - query.split('&').each { param -> - def parts = param.split('=', 2) - if (parts.length == 2) { - params[URLDecoder.decode(parts[0], "UTF-8")] = URLDecoder.decode(parts[1], "UTF-8") - } - } - } - return params - } + private Map requestDeviceAuthorization() { + def deviceAuthUrl = "https://${AUTH0_DOMAIN}/oauth/device/code" - private String buildAuthUrl(String codeChallenge, String state) { def params = [ - 'response_type': 'code', 'client_id': AUTH0_CLIENT_ID, - 'redirect_uri': "http://localhost:${CALLBACK_PORT}/callback", 'scope': 'openid profile email offline_access', - 'audience': 'platform', - 'code_challenge': codeChallenge, - 'code_challenge_method': 'S256', - 'state': state, - 'prompt': 'login' - ] - - def query = params.collect { k, v -> - "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" - }.join('&') - - return "https://${AUTH0_DOMAIN}/authorize?${query}" - } - - private void openBrowser(String url) { - try { - if (Desktop.isDesktopSupported()) { - def desktop = Desktop.desktop - if (desktop.isSupported(Desktop.Action.BROWSE)) { - desktop.browse(new URI(url)) - return - } - } - - // Fallback: try OS-specific commands - def os = System.getProperty("os.name").toLowerCase() - if (os.contains("mac")) { - Runtime.runtime.exec(["open", url] as String[]) - return - } else if (os.contains("linux")) { - Runtime.runtime.exec(["xdg-open", url] as String[]) - return - } else if (os.contains("windows")) { - Runtime.runtime.exec(["rundll32", "url.dll,FileProtocolHandler", url] as String[]) - return - } - - // If all else fails, show URL - println "Could not open browser automatically. Please visit: ${url}" - - } catch (Exception e) { - log.debug("Failed to open browser", e) - println "Failed to open browser automatically. Please visit: ${url}" - } - } - - private Map exchangeCodeForToken(String authCode, String codeVerifier) { - def tokenUrl = "https://${AUTH0_DOMAIN}/oauth/token" - - def params = [ - 'grant_type': 'authorization_code', - 'client_id': AUTH0_CLIENT_ID, - 'code': authCode, - 'redirect_uri': "http://localhost:${CALLBACK_PORT}/callback", - 'code_verifier': codeVerifier + 'audience': 'platform' ] def postData = params.collect { k, v -> "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" }.join('&') - def connection = new URL(tokenUrl).openConnection() as HttpURLConnection + def connection = new URL(deviceAuthUrl).openConnection() as HttpURLConnection connection.requestMethod = 'POST' connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded') connection.doOutput = true @@ -555,7 +387,7 @@ class CmdAuth extends CmdBase implements UsageAware { if (connection.responseCode != 200) { def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" - throw new RuntimeException("Token exchange failed: ${error}") + throw new RuntimeException("Device authorization request failed: ${error}") } def response = connection.inputStream.text @@ -563,6 +395,71 @@ class CmdAuth extends CmdBase implements UsageAware { return json as Map } + private Map pollForDeviceToken(String deviceCode, int intervalSeconds) { + def tokenUrl = "https://${AUTH0_DOMAIN}/oauth/token" + def maxRetries = 60 // 5 minutes with 5-second intervals + def retryCount = 0 + + while (retryCount < maxRetries) { + def params = [ + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': deviceCode, + 'client_id': AUTH0_CLIENT_ID + ] + + def postData = params.collect { k, v -> + "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" + }.join('&') + + def connection = new URL(tokenUrl).openConnection() as HttpURLConnection + connection.requestMethod = 'POST' + connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded') + connection.doOutput = true + + connection.outputStream.withWriter { writer -> + writer.write(postData) + } + + if (connection.responseCode == 200) { + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) + return json as Map + } else { + def errorResponse = connection.errorStream?.text + if (errorResponse) { + def errorJson = new groovy.json.JsonSlurper().parseText(errorResponse) as Map + def error = errorJson.error + + if (error == 'authorization_pending') { + // User hasn't completed authorization yet, continue polling + print "." + System.out.flush() + } else if (error == 'slow_down') { + // Increase polling interval + intervalSeconds += 5 + print "." + System.out.flush() + } else if (error == 'expired_token') { + throw new RuntimeException("The device code has expired. Please try again.") + } else if (error == 'access_denied') { + throw new RuntimeException("Access denied by user") + } else { + throw new RuntimeException("Token request failed: ${error} - ${errorJson.error_description ?: ''}") + } + } else { + throw new RuntimeException("Token request failed: HTTP ${connection.responseCode}") + } + } + + // Wait before next poll + Thread.sleep(intervalSeconds * 1000) + retryCount++ + } + + throw new RuntimeException("Authentication timed out. Please try again.") + } + + private void handleEnterpriseAuth(String apiUrl) { println "" println "Please generate a Personal Access Token from your ${AuthColorUtil.colorize('Seqera Platform', 'cyan bold')} instance." @@ -633,9 +530,10 @@ class CmdAuth extends CmdBase implements UsageAware { result << ' -u, -url Seqera Platform API endpoint (default: https://api.cloud.seqera.io)' result << '' result << 'This command will:' - result << ' 1. Open browser for OAuth2 authentication (Cloud) or prompt for PAT (Enterprise)' - result << ' 2. Generate and save access token to home-directory Nextflow config' - result << ' 3. Configure tower.accessToken, tower.endpoint, and tower.enabled settings' + result << ' 1. Display a URL and device code for OAuth2 authentication (Cloud) or prompt for PAT (Enterprise)' + result << ' 2. Wait for user to complete authentication in web browser' + result << ' 3. Generate and save access token to home-directory Nextflow config' + result << ' 4. Configure tower.accessToken, tower.endpoint, and tower.enabled settings' result << '' } } @@ -656,8 +554,8 @@ class CmdAuth extends CmdBase implements UsageAware { if (envToken) { println "" AuthColorUtil.printColored("WARNING: TOWER_ACCESS_TOKEN environment variable is set.", "yellow bold") - println " ${AuthColorUtil.colorize('nextflow auth logout', 'dim cyan')}${AuthColorUtil.colorize(' only removes credentials from Nextflow config files.', 'dim')}" - AuthColorUtil.printColored(" The environment variable will remain unaffected.", "dim") + println " ${AuthColorUtil.colorize('nextflow auth logout', 'dim cyan')}${AuthColorUtil.colorize(' only removes credentials from Nextflow config files.', 'dim')}" + AuthColorUtil.printColored(" The environment variable will remain unaffected.", "dim") println "" } @@ -679,7 +577,7 @@ class CmdAuth extends CmdBase implements UsageAware { if (!apiUrl || apiUrl.isEmpty()) { apiUrl = promptForApiUrl() } else { - println " - Using Seqera Platform endpoint: ${apiUrl}" + AuthColorUtil.printColored(" - Using Seqera Platform endpoint: ${AuthColorUtil.colorize(apiUrl, 'magenta')}", "dim") } // Validate token by calling /user-info API @@ -1156,7 +1054,7 @@ class CmdAuth extends CmdBase implements UsageAware { col3Width = Math.max(col3Width, 10) + 2 // Print table header - println "${AuthColorUtil.colorize('Setting'.padRight(col1Width), 'cyan bold')} ${AuthColorUtil.colorize('Value'.padRight(col2Width), 'cyan bold')} ${AuthColorUtil.colorize('Source', 'cyan bold')}" + AuthColorUtil.printColored("${'Setting'.padRight(col1Width)} ${'Value'.padRight(col2Width)} Source", "cyan bold") println "${'-' * col1Width} ${'-' * col2Width} ${'-' * col3Width}" // Print rows From 261e1e60dfab14f8856f1a3bd701ed52a4f3f8fe Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Thu, 11 Sep 2025 10:36:24 +0200 Subject: [PATCH 21/66] Add support for Platform auth on stage and prod Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 116 ++++++++++++++---- 1 file changed, 93 insertions(+), 23 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 95ba8f7918..ab1dfb72c0 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -136,13 +136,25 @@ class CmdAuth extends CmdBase implements UsageAware { return input?.isEmpty() || input == null ? 'https://api.cloud.seqera.io' : input } + private Map getCloudEndpointInfo(String apiUrl) { + // Check production endpoints + if (apiUrl == 'https://api.cloud.seqera.io' || apiUrl == 'https://cloud.seqera.io/api') { + return [isCloud: true, environment: 'prod'] + } + // Check staging endpoints + if (apiUrl == 'https://api.cloud.stage-seqera.io' || apiUrl == 'https://cloud.stage-seqera.io/api') { + return [isCloud: true, environment: 'stage'] + } + // Check development endpoints + if (apiUrl == 'https://api.cloud.dev-seqera.io' || apiUrl == 'https://cloud.dev-seqera.io/api') { + return [isCloud: true, environment: 'dev'] + } + // Enterprise/other endpoints + return [isCloud: false, environment: null] + } + private boolean isCloudEndpoint(String apiUrl) { - return apiUrl == 'https://api.cloud.seqera.io' || - apiUrl == 'https://api.cloud.stage-seqera.io' || - apiUrl == 'https://api.cloud.dev-seqera.io' || - apiUrl == 'https://cloud.seqera.io/api' || - apiUrl == 'https://cloud.stage-seqera.io/api' || - apiUrl == 'https://cloud.dev-seqera.io/api' + return getCloudEndpointInfo(apiUrl).isCloud } // Get user info from Seqera Platform @@ -250,10 +262,12 @@ class CmdAuth extends CmdBase implements UsageAware { // class LoginCmd implements SubCmd { - private static final String AUTH0_DOMAIN = "seqera-development.eu.auth0.com" - private static final String AUTH0_CLIENT_ID = "Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ" - // Generate using: qrencode -t UTF8i "url" -m 2 - private static final String AUTH0_QRCODE = """ + // Auth0 configuration per environment + private static final Map AUTH0_CONFIG = [ + 'dev': [ + domain: 'seqera-development.eu.auth0.com', + clientId: 'Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ', + qrCode: ''' █▀▀▀▀▀█ ▄ █ ▀▄ ▄▀▀▀▀▀ █▀▀▀▀▀█ █ ███ █ ▄▄▄ █▄█ ▀▄ ▄ █ ███ █ █ ▀▀▀ █ ▄█ ▄▄█▄▄ ▀▀█ █ ▀▀▀ █ @@ -269,10 +283,62 @@ class CmdAuth extends CmdBase implements UsageAware { █ ███ █ ███▄ ▀█▀▄▀ ▄▀▀▀▀▀▄█▀▄ █ ▀▀▀ █ █ ▀ ▄ ▀▀▄ ▀▄▀▀█▀█▀█ ▀▀▀▀▀▀▀ ▀ ▀▀▀▀▀ ▀▀▀ ▀ ▀▀▀ -""" +''' + ], + 'stage': [ + domain: 'seqera-stage.eu.auth0.com', + clientId: '60cPDjI6YhoTPjyMTIBjGtxatSUwWswB', + qrCode: ''' + █▀▀▀▀▀█ ▄ █ ▀ █▄█ ▀█ █▀▀▀▀▀█ + █ ███ █ ▄▄▄ █▄▄▄ ▄▄ ▀ █ ███ █ + █ ▀▀▀ █ ▄█ ▄▄▀ ▄██▀▀▀ █ ▀▀▀ █ + ▀▀▀▀▀▀▀ ▀▄█▄▀ █▄▀ █▄█ ▀▀▀▀▀▀▀ + ▀▀██▀▄▀█▀▀▄▀▄▀█▀▄▀▄█ ▀▄█▄▀ ▀▄ + ▄ █▄▄▀▀▀ ▀ ▄▀▀▀█ ▀ ██▄██▄ ▄ + ▄▀ ▀ █▀▀ ▀▀▀ ▀ ▄▄▄▄▄▄▀▀ ▄ + █▀▀ ▄▄▀██ ██▀ ▄██ ▄█▀ ▀█▀▀▄ + ▄█▄▄ ▀▀▀█ ▀▄▀█ ▄▀ ▄▄█▄█▄▀█ ▄ + █ ▀ ▄▄▀ ▄ █ ▄ ▀▀█ ▄ ▀ ▀██ ▀▄ + ▀ ▀▀▀ ▀ ██▄ ▀▀▀ ▄█▄ █▀▀▀█▄███ + █▀▀▀▀▀█ ▀▄ █▀ █▀▀ ███ ▀ █▀▀▄▄ + █ ███ █ █▀▀▀▄▄ ▀▄▀▀ ▀▀▀▀▀▄███ + █ ▀▀▀ █ █▀ ▄█▀▀▄ ██▄▄▀█▀█▀█ + ▀▀▀▀▀▀▀ ▀▀ ▀ ▀ ▀▀▀ ▀ ▀▀▀ +''' + ], + 'prod': [ + domain: 'seqera.eu.auth0.com', + clientId: 'FxCM8EJ76nNeHUDidSHkZfT8VtsrhHeL', + qrCode: ''' + █▀▀▀▀▀█ ▀▄▄█▀█▄█▄ █▀ █▀▀▀▀▀█ + █ ███ █ ▀██▄▀██▄ ▀ ▀ █ ███ █ + █ ▀▀▀ █ ▄▀ ▄▀█ ▀██ █ ▀▀▀ █ + ▀▀▀▀▀▀▀ █▄█ █▄█▄█▄▀▄█ ▀▀▀▀▀▀▀ + █▀▀ ▀█▀█▀ ██ █ ▄█ ▄▀█ ▄▀ ▄ + ▄▀▄█▀▀ ▀▀ ▄ ▀ ▄ ██▄▄▄█▀▀ ▀█▀ + ▀▄▄▄▄█▀█ ▄█▄ █▀ ▄██ █ ▀█ + █▀▀▀█▀▄██ ▀█▄█ ██▄▄ ▀█▀█ █▀ + ▄ ▄▀█▀▀▀▄▀█ █ ▄▀ █▄█▀ █▄▀█ + ▀▄▄ ▄▀ █▄▄ ▀ ▄ ▄▄ ██▄▀▀▄ █▀ + ▀ ▀ ▀ █▄▀▄█▄ ▀█▀▀██▀▀▀█ ▄▄▄ + █▀▀▀▀▀█ █▄▀▀█▄█▄██▄██ ▀ ██ █▀ + █ ███ █ ▀▄▄█ █ ▄█ ▀▀▀██ ▄██ + █ ▀▀▀ █ █▀▄▄ ▀ ▄█▀█ ▀█▀ ▄▀ + ▀▀▀▀▀▀▀ ▀ ▀ ▀▀ ▀▀▀▀ ▀▀ ▀▀ +''' + ] + ] String apiUrl + private Map getAuth0Config(String environment) { + def config = AUTH0_CONFIG[environment] as Map + if (!config) { + throw new RuntimeException("Unknown environment: ${environment}") + } + return config + } + @Override String getName() { 'login' } @@ -315,9 +381,10 @@ class CmdAuth extends CmdBase implements UsageAware { AuthColorUtil.printColored(" - Seqera Platform API endpoint: ${AuthColorUtil.colorize(apiUrl, 'magenta')} (can be customised with ${AuthColorUtil.colorize('-url', 'cyan')})", "dim") // Check if this is a cloud endpoint or enterprise - if (isCloudEndpoint(apiUrl)) { + def endpointInfo = getCloudEndpointInfo(apiUrl) + if (endpointInfo.isCloud) { try { - performAuth0Login(apiUrl) + performAuth0Login(apiUrl, endpointInfo.environment as String) } catch (Exception e) { log.debug("Authentication failed", e) println "" @@ -329,14 +396,17 @@ class CmdAuth extends CmdBase implements UsageAware { } } - private void performAuth0Login(String apiUrl) { + private void performAuth0Login(String apiUrl, String environment) { + // Get Auth0 configuration for this environment + def auth0Config = getAuth0Config(environment) + // Start device authorization flow - def deviceAuth = requestDeviceAuthorization() + def deviceAuth = requestDeviceAuthorization(auth0Config) println "" AuthColorUtil.printColored("Please visit the following URL in your web browser:", "cyan bold") println " ${AuthColorUtil.colorize(deviceAuth.verification_uri as String, 'magenta')}" - println AUTH0_QRCODE + println auth0Config.qrCode AuthColorUtil.printColored("Enter the following code when prompted:", "cyan bold") println " ${AuthColorUtil.colorize(deviceAuth.user_code as String, 'yellow bold')}" println "" @@ -344,7 +414,7 @@ class CmdAuth extends CmdBase implements UsageAware { try { // Poll for device token - def tokenData = pollForDeviceToken(deviceAuth.device_code as String, deviceAuth.interval as Integer ?: 5) + def tokenData = pollForDeviceToken(deviceAuth.device_code as String, deviceAuth.interval as Integer ?: 5, auth0Config) def accessToken = tokenData['access_token'] as String // Verify login by calling /user-info @@ -363,11 +433,11 @@ class CmdAuth extends CmdBase implements UsageAware { } } - private Map requestDeviceAuthorization() { - def deviceAuthUrl = "https://${AUTH0_DOMAIN}/oauth/device/code" + private Map requestDeviceAuthorization(Map auth0Config) { + def deviceAuthUrl = "https://${auth0Config.domain}/oauth/device/code" def params = [ - 'client_id': AUTH0_CLIENT_ID, + 'client_id': auth0Config.clientId, 'scope': 'openid profile email offline_access', 'audience': 'platform' ] @@ -395,8 +465,8 @@ class CmdAuth extends CmdBase implements UsageAware { return json as Map } - private Map pollForDeviceToken(String deviceCode, int intervalSeconds) { - def tokenUrl = "https://${AUTH0_DOMAIN}/oauth/token" + private Map pollForDeviceToken(String deviceCode, int intervalSeconds, Map auth0Config) { + def tokenUrl = "https://${auth0Config.domain}/oauth/token" def maxRetries = 60 // 5 minutes with 5-second intervals def retryCount = 0 @@ -404,7 +474,7 @@ class CmdAuth extends CmdBase implements UsageAware { def params = [ 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', 'device_code': deviceCode, - 'client_id': AUTH0_CLIENT_ID + 'client_id': auth0Config.clientId ] def postData = params.collect { k, v -> From 8a2b8475aac8ed413de18f233e211c2c05d638a5 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Thu, 11 Sep 2025 20:17:14 +0200 Subject: [PATCH 22/66] Tweak formatting Signed-off-by: Phil Ewels --- modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index ab1dfb72c0..0efdb22f65 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -419,14 +419,15 @@ class CmdAuth extends CmdBase implements UsageAware { // Verify login by calling /user-info def userInfo = callUserInfoApi(accessToken, apiUrl) - AuthColorUtil.printColored("Authentication successful! Logged in as: ${AuthColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "green") + AuthColorUtil.printColored("\nAuthentication successful!", "green") + println "Logged in to ${AuthColorUtil.colorize(apiUrl.replace('api.', '').replace('/api', ''), 'magenta')} as: ${AuthColorUtil.colorize(userInfo.userName as String, 'cyan bold')}" // Generate PAT def pat = generatePAT(accessToken, apiUrl) // Save to config saveAuthToConfig(pat, apiUrl) - AuthColorUtil.printColored("Seqera Platform configuration saved to ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "green") + println "Seqera Platform configuration saved to ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}" } catch (Exception e) { throw new RuntimeException("Authentication failed: ${e.message}", e) From a340b66e71989fb931a3d9a3d9a4350e5b3976d2 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Sat, 20 Sep 2025 17:04:10 +0200 Subject: [PATCH 23/66] Auth flow with code, appended to URL Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 112 +++++++++--------- 1 file changed, 53 insertions(+), 59 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 0efdb22f65..d2f919aed1 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -266,66 +266,15 @@ class CmdAuth extends CmdBase implements UsageAware { private static final Map AUTH0_CONFIG = [ 'dev': [ domain: 'seqera-development.eu.auth0.com', - clientId: 'Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ', - qrCode: ''' - █▀▀▀▀▀█ ▄ █ ▀▄ ▄▀▀▀▀▀ █▀▀▀▀▀█ - █ ███ █ ▄▄▄ █▄█ ▀▄ ▄ █ ███ █ - █ ▀▀▀ █ ▄█ ▄▄█▄▄ ▀▀█ █ ▀▀▀ █ - ▀▀▀▀▀▀▀ ▀▄█▄▀ █▄▀ ▀▄█ ▀▀▀▀▀▀▀ - █▀▀██ ▀▀█▀▄▀██▀▀▀█▄█▄▀▄█▄▀ ▀▄ - ▀██ ▀ ▄ ▀ ██ ▀▄ ▄█▄██▄ ▄ - ▀███▀▀▀▄█▀▄▀█ ▄▀ █▄▄▄▄▄▀▀ ▄ - █▀█ ▀▄▀ █ ▀▀██▄ ▄ █▀ ▀█▀▀▄ - ▄▀ █▄▄▀ ▀ █▄ ▀▀▀███▄▄█▄█▄▀█ ▄ - █ ▀▄ ▀▀▄▄▀▄ ▄ ▄██▀▄▄▄ ▀██ ▀▄ - ▀ ▀ ▀▀ █▄█▄▀▀▀▀█▀▄▄█▀▀▀█▄███ - █▀▀▀▀▀█ ▀ █▄▀▀▄█▀ ██ ▀ █▀▀▄ - █ ███ █ ███▄ ▀█▀▄▀ ▄▀▀▀▀▀▄█▀▄ - █ ▀▀▀ █ █ ▀ ▄ ▀▀▄ ▀▄▀▀█▀█▀█ - ▀▀▀▀▀▀▀ ▀ ▀▀▀▀▀ ▀▀▀ ▀ ▀▀▀ -''' + clientId: 'Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ' ], 'stage': [ domain: 'seqera-stage.eu.auth0.com', - clientId: '60cPDjI6YhoTPjyMTIBjGtxatSUwWswB', - qrCode: ''' - █▀▀▀▀▀█ ▄ █ ▀ █▄█ ▀█ █▀▀▀▀▀█ - █ ███ █ ▄▄▄ █▄▄▄ ▄▄ ▀ █ ███ █ - █ ▀▀▀ █ ▄█ ▄▄▀ ▄██▀▀▀ █ ▀▀▀ █ - ▀▀▀▀▀▀▀ ▀▄█▄▀ █▄▀ █▄█ ▀▀▀▀▀▀▀ - ▀▀██▀▄▀█▀▀▄▀▄▀█▀▄▀▄█ ▀▄█▄▀ ▀▄ - ▄ █▄▄▀▀▀ ▀ ▄▀▀▀█ ▀ ██▄██▄ ▄ - ▄▀ ▀ █▀▀ ▀▀▀ ▀ ▄▄▄▄▄▄▀▀ ▄ - █▀▀ ▄▄▀██ ██▀ ▄██ ▄█▀ ▀█▀▀▄ - ▄█▄▄ ▀▀▀█ ▀▄▀█ ▄▀ ▄▄█▄█▄▀█ ▄ - █ ▀ ▄▄▀ ▄ █ ▄ ▀▀█ ▄ ▀ ▀██ ▀▄ - ▀ ▀▀▀ ▀ ██▄ ▀▀▀ ▄█▄ █▀▀▀█▄███ - █▀▀▀▀▀█ ▀▄ █▀ █▀▀ ███ ▀ █▀▀▄▄ - █ ███ █ █▀▀▀▄▄ ▀▄▀▀ ▀▀▀▀▀▄███ - █ ▀▀▀ █ █▀ ▄█▀▀▄ ██▄▄▀█▀█▀█ - ▀▀▀▀▀▀▀ ▀▀ ▀ ▀ ▀▀▀ ▀ ▀▀▀ -''' + clientId: '60cPDjI6YhoTPjyMTIBjGtxatSUwWswB' ], 'prod': [ domain: 'seqera.eu.auth0.com', - clientId: 'FxCM8EJ76nNeHUDidSHkZfT8VtsrhHeL', - qrCode: ''' - █▀▀▀▀▀█ ▀▄▄█▀█▄█▄ █▀ █▀▀▀▀▀█ - █ ███ █ ▀██▄▀██▄ ▀ ▀ █ ███ █ - █ ▀▀▀ █ ▄▀ ▄▀█ ▀██ █ ▀▀▀ █ - ▀▀▀▀▀▀▀ █▄█ █▄█▄█▄▀▄█ ▀▀▀▀▀▀▀ - █▀▀ ▀█▀█▀ ██ █ ▄█ ▄▀█ ▄▀ ▄ - ▄▀▄█▀▀ ▀▀ ▄ ▀ ▄ ██▄▄▄█▀▀ ▀█▀ - ▀▄▄▄▄█▀█ ▄█▄ █▀ ▄██ █ ▀█ - █▀▀▀█▀▄██ ▀█▄█ ██▄▄ ▀█▀█ █▀ - ▄ ▄▀█▀▀▀▄▀█ █ ▄▀ █▄█▀ █▄▀█ - ▀▄▄ ▄▀ █▄▄ ▀ ▄ ▄▄ ██▄▀▀▄ █▀ - ▀ ▀ ▀ █▄▀▄█▄ ▀█▀▀██▀▀▀█ ▄▄▄ - █▀▀▀▀▀█ █▄▀▀█▄█▄██▄██ ▀ ██ █▀ - █ ███ █ ▀▄▄█ █ ▄█ ▀▀▀██ ▄██ - █ ▀▀▀ █ █▀▄▄ ▀ ▄█▀█ ▀█▀ ▄▀ - ▀▀▀▀▀▀▀ ▀ ▀ ▀▀ ▀▀▀▀ ▀▀ ▀▀ -''' + clientId: 'FxCM8EJ76nNeHUDidSHkZfT8VtsrhHeL' ] ] @@ -339,6 +288,7 @@ class CmdAuth extends CmdBase implements UsageAware { return config } + @Override String getName() { 'login' } @@ -404,11 +354,55 @@ class CmdAuth extends CmdBase implements UsageAware { def deviceAuth = requestDeviceAuthorization(auth0Config) println "" - AuthColorUtil.printColored("Please visit the following URL in your web browser:", "cyan bold") - println " ${AuthColorUtil.colorize(deviceAuth.verification_uri as String, 'magenta')}" - println auth0Config.qrCode - AuthColorUtil.printColored("Enter the following code when prompted:", "cyan bold") - println " ${AuthColorUtil.colorize(deviceAuth.user_code as String, 'yellow bold')}" + AuthColorUtil.printColored("Opening authentication URL in web browser:", "cyan bold") + def urlWithCode = "${deviceAuth.verification_uri}?user_code=${deviceAuth.user_code}" + println " ${AuthColorUtil.colorize(urlWithCode, 'magenta')}" + + // Try to open browser automatically (fail silently if not possible) + try { + def opened = false + + // Method 1: Java Desktop API + if (!opened && java.awt.Desktop.isDesktopSupported()) { + def desktop = java.awt.Desktop.getDesktop() + if (desktop.isSupported(java.awt.Desktop.Action.BROWSE)) { + desktop.browse(new URI(urlWithCode)) + opened = true + } + } + + // Method 2: Platform-specific commands + if (!opened) { + def os = System.getProperty("os.name").toLowerCase() + def command = [] + + if (os.contains("mac") || os.contains("darwin")) { + command = ["open", urlWithCode] + } else if (os.contains("win")) { + command = ["cmd", "/c", "start", urlWithCode] + } else { + // Linux and other Unix-like systems + def browsers = ["xdg-open", "firefox", "google-chrome", "chromium", "safari"] + for (browser in browsers) { + try { + new ProcessBuilder(browser, urlWithCode).start() + opened = true + break + } catch (Exception ignored) { + // Try next browser + } + } + } + + if (!opened && command) { + new ProcessBuilder(command as String[]).start() + } + } + } catch (Exception ignored) { + // Silently ignore any errors - user can open URL manually + } + + AuthColorUtil.printColored("Confirm that the following code is shown: ${AuthColorUtil.colorize(deviceAuth.user_code as String, 'yellow')}", "cyan bold") println "" AuthColorUtil.printColored("Waiting for authentication...", "dim") From 4c7cf8354079f59f40f27fed7ec9b97b12c6d28b Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Sat, 20 Sep 2025 17:45:42 +0200 Subject: [PATCH 24/66] Prompt to open browser, don't do it without warning Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index d2f919aed1..c039de4775 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -354,25 +354,26 @@ class CmdAuth extends CmdBase implements UsageAware { def deviceAuth = requestDeviceAuthorization(auth0Config) println "" - AuthColorUtil.printColored("Opening authentication URL in web browser:", "cyan bold") + AuthColorUtil.printColored("Confirmation code: ${AuthColorUtil.colorize(deviceAuth.user_code as String, 'yellow')}", "cyan bold") def urlWithCode = "${deviceAuth.verification_uri}?user_code=${deviceAuth.user_code}" - println " ${AuthColorUtil.colorize(urlWithCode, 'magenta')}" + println "${AuthColorUtil.colorize('Authentication URL:', 'cyan bold')} ${AuthColorUtil.colorize(urlWithCode, 'magenta')}" + AuthColorUtil.printColored("\n[ Press Enter to open in browser ]", "cyan bold") + System.in.read() // Wait for Enter key - // Try to open browser automatically (fail silently if not possible) + // Try to open browser automatically + def browserOpened = false try { - def opened = false - // Method 1: Java Desktop API - if (!opened && java.awt.Desktop.isDesktopSupported()) { + if (java.awt.Desktop.isDesktopSupported()) { def desktop = java.awt.Desktop.getDesktop() if (desktop.isSupported(java.awt.Desktop.Action.BROWSE)) { desktop.browse(new URI(urlWithCode)) - opened = true + browserOpened = true } } // Method 2: Platform-specific commands - if (!opened) { + if (!browserOpened) { def os = System.getProperty("os.name").toLowerCase() def command = [] @@ -386,7 +387,7 @@ class CmdAuth extends CmdBase implements UsageAware { for (browser in browsers) { try { new ProcessBuilder(browser, urlWithCode).start() - opened = true + browserOpened = true break } catch (Exception ignored) { // Try next browser @@ -394,17 +395,19 @@ class CmdAuth extends CmdBase implements UsageAware { } } - if (!opened && command) { + if (!browserOpened && command) { new ProcessBuilder(command as String[]).start() + browserOpened = true } } } catch (Exception ignored) { - // Silently ignore any errors - user can open URL manually + // Will handle below } - AuthColorUtil.printColored("Confirm that the following code is shown: ${AuthColorUtil.colorize(deviceAuth.user_code as String, 'yellow')}", "cyan bold") - println "" - AuthColorUtil.printColored("Waiting for authentication...", "dim") + if (!browserOpened) { + AuthColorUtil.printColored("Could not open browser automatically. Please copy the URL above and open it manually in your browser.", "yellow") + } + print("${AuthColorUtil.colorize('Waiting for authentication...', 'dim', true)}") try { // Poll for device token @@ -413,7 +416,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Verify login by calling /user-info def userInfo = callUserInfoApi(accessToken, apiUrl) - AuthColorUtil.printColored("\nAuthentication successful!", "green") + AuthColorUtil.printColored("\n\nAuthentication successful!", "green") println "Logged in to ${AuthColorUtil.colorize(apiUrl.replace('api.', '').replace('/api', ''), 'magenta')} as: ${AuthColorUtil.colorize(userInfo.userName as String, 'cyan bold')}" // Generate PAT @@ -497,12 +500,12 @@ class CmdAuth extends CmdBase implements UsageAware { if (error == 'authorization_pending') { // User hasn't completed authorization yet, continue polling - print "." + print "${AuthColorUtil.colorize('.', 'dim', true)}" System.out.flush() } else if (error == 'slow_down') { // Increase polling interval intervalSeconds += 5 - print "." + print "${AuthColorUtil.colorize('.', 'dim', true)}" System.out.flush() } else if (error == 'expired_token') { throw new RuntimeException("The device code has expired. Please try again.") From 564890bdab6c9a6550a30e2ff7a65ec14a8b2aaf Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Sat, 20 Sep 2025 17:59:44 +0200 Subject: [PATCH 25/66] Rename AuthColorUtil to ColorUtil, expect to use it elsewhere Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 106 +++++++++--------- ...{AuthColorUtil.groovy => ColorUtil.groovy} | 6 +- 2 files changed, 56 insertions(+), 56 deletions(-) rename modules/nextflow/src/main/groovy/nextflow/cli/{AuthColorUtil.groovy => ColorUtil.groovy} (98%) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index c039de4775..1056b00f90 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -302,9 +302,9 @@ class CmdAuth extends CmdBase implements UsageAware { def envToken = System.getenv('TOWER_ACCESS_TOKEN') if (envToken) { println "" - AuthColorUtil.printColored("WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable.", "yellow bold") - AuthColorUtil.printColored("${AuthColorUtil.colorize('nextflow auth login', 'cyan')} sets credentials using Nextflow config files, which take precedence over the environment variable.", "dim") - AuthColorUtil.printColored(" however, caution is advised to avoid confusing behaviour.", "dim") + ColorUtil.printColored("WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable.", "yellow bold") + ColorUtil.printColored("${ColorUtil.colorize('nextflow auth login', 'cyan')} sets credentials using Nextflow config files, which take precedence over the environment variable.", "dim") + ColorUtil.printColored(" however, caution is advised to avoid confusing behaviour.", "dim") println "" } @@ -312,14 +312,14 @@ class CmdAuth extends CmdBase implements UsageAware { def config = readConfig() def existingToken = config['tower.accessToken'] if (existingToken) { - AuthColorUtil.printColored("Error: Authentication token is already configured in Nextflow config.", "red") - AuthColorUtil.printColored("Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") - println " Run ${AuthColorUtil.colorize('nextflow auth logout', 'cyan')} to remove the current authentication." + ColorUtil.printColored("Error: Authentication token is already configured in Nextflow config.", "red") + ColorUtil.printColored("Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") + println " Run ${ColorUtil.colorize('nextflow auth logout', 'cyan')} to remove the current authentication." return } - println "Nextflow authentication with ${AuthColorUtil.colorize('Seqera Platform', 'cyan bold')}" - AuthColorUtil.printColored(" - Authentication will be saved to: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") + println "Nextflow authentication with ${ColorUtil.colorize('Seqera Platform', 'cyan bold')}" + ColorUtil.printColored(" - Authentication will be saved to: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") // Use provided URL or default if (!apiUrl) { @@ -328,7 +328,7 @@ class CmdAuth extends CmdBase implements UsageAware { apiUrl = 'https://' + apiUrl } - AuthColorUtil.printColored(" - Seqera Platform API endpoint: ${AuthColorUtil.colorize(apiUrl, 'magenta')} (can be customised with ${AuthColorUtil.colorize('-url', 'cyan')})", "dim") + ColorUtil.printColored(" - Seqera Platform API endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')} (can be customised with ${ColorUtil.colorize('-url', 'cyan')})", "dim") // Check if this is a cloud endpoint or enterprise def endpointInfo = getCloudEndpointInfo(apiUrl) @@ -354,10 +354,10 @@ class CmdAuth extends CmdBase implements UsageAware { def deviceAuth = requestDeviceAuthorization(auth0Config) println "" - AuthColorUtil.printColored("Confirmation code: ${AuthColorUtil.colorize(deviceAuth.user_code as String, 'yellow')}", "cyan bold") + ColorUtil.printColored("Confirmation code: ${ColorUtil.colorize(deviceAuth.user_code as String, 'yellow')}", "cyan bold") def urlWithCode = "${deviceAuth.verification_uri}?user_code=${deviceAuth.user_code}" - println "${AuthColorUtil.colorize('Authentication URL:', 'cyan bold')} ${AuthColorUtil.colorize(urlWithCode, 'magenta')}" - AuthColorUtil.printColored("\n[ Press Enter to open in browser ]", "cyan bold") + println "${ColorUtil.colorize('Authentication URL:', 'cyan bold')} ${ColorUtil.colorize(urlWithCode, 'magenta')}" + ColorUtil.printColored("\n[ Press Enter to open in browser ]", "cyan bold") System.in.read() // Wait for Enter key // Try to open browser automatically @@ -405,9 +405,9 @@ class CmdAuth extends CmdBase implements UsageAware { } if (!browserOpened) { - AuthColorUtil.printColored("Could not open browser automatically. Please copy the URL above and open it manually in your browser.", "yellow") + ColorUtil.printColored("Could not open browser automatically. Please copy the URL above and open it manually in your browser.", "yellow") } - print("${AuthColorUtil.colorize('Waiting for authentication...', 'dim', true)}") + print("${ColorUtil.colorize('Waiting for authentication...', 'dim', true)}") try { // Poll for device token @@ -416,15 +416,15 @@ class CmdAuth extends CmdBase implements UsageAware { // Verify login by calling /user-info def userInfo = callUserInfoApi(accessToken, apiUrl) - AuthColorUtil.printColored("\n\nAuthentication successful!", "green") - println "Logged in to ${AuthColorUtil.colorize(apiUrl.replace('api.', '').replace('/api', ''), 'magenta')} as: ${AuthColorUtil.colorize(userInfo.userName as String, 'cyan bold')}" + ColorUtil.printColored("\n\nAuthentication successful!", "green") + println "Logged in to ${ColorUtil.colorize(apiUrl.replace('api.', '').replace('/api', ''), 'magenta')} as: ${ColorUtil.colorize(userInfo.userName as String, 'cyan bold')}" // Generate PAT def pat = generatePAT(accessToken, apiUrl) // Save to config saveAuthToConfig(pat, apiUrl) - println "Seqera Platform configuration saved to ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}" + println "Seqera Platform configuration saved to ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}" } catch (Exception e) { throw new RuntimeException("Authentication failed: ${e.message}", e) @@ -500,12 +500,12 @@ class CmdAuth extends CmdBase implements UsageAware { if (error == 'authorization_pending') { // User hasn't completed authorization yet, continue polling - print "${AuthColorUtil.colorize('.', 'dim', true)}" + print "${ColorUtil.colorize('.', 'dim', true)}" System.out.flush() } else if (error == 'slow_down') { // Increase polling interval intervalSeconds += 5 - print "${AuthColorUtil.colorize('.', 'dim', true)}" + print "${ColorUtil.colorize('.', 'dim', true)}" System.out.flush() } else if (error == 'expired_token') { throw new RuntimeException("The device code has expired. Please try again.") @@ -530,8 +530,8 @@ class CmdAuth extends CmdBase implements UsageAware { private void handleEnterpriseAuth(String apiUrl) { println "" - println "Please generate a Personal Access Token from your ${AuthColorUtil.colorize('Seqera Platform', 'cyan bold')} instance." - println "You can create one at: ${AuthColorUtil.colorize(apiUrl.replace('/api', '').replace('://api.', '') + '/tokens', 'magenta')}" + println "Please generate a Personal Access Token from your ${ColorUtil.colorize('Seqera Platform', 'cyan bold')} instance." + println "You can create one at: ${ColorUtil.colorize(apiUrl.replace('/api', '').replace('://api.', '') + '/tokens', 'magenta')}" println "" System.out.print("Enter your Personal Access Token: ") @@ -548,8 +548,8 @@ class CmdAuth extends CmdBase implements UsageAware { // Save to config saveAuthToConfig(pat.trim(), apiUrl) - AuthColorUtil.printColored("Personal Access Token saved to Nextflow config", "green") - println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}" + ColorUtil.printColored("Personal Access Token saved to Nextflow config", "green") + println "Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}" } private String generatePAT(String accessToken, String apiUrl) { @@ -621,9 +621,9 @@ class CmdAuth extends CmdBase implements UsageAware { def envToken = System.getenv('TOWER_ACCESS_TOKEN') if (envToken) { println "" - AuthColorUtil.printColored("WARNING: TOWER_ACCESS_TOKEN environment variable is set.", "yellow bold") - println " ${AuthColorUtil.colorize('nextflow auth logout', 'dim cyan')}${AuthColorUtil.colorize(' only removes credentials from Nextflow config files.', 'dim')}" - AuthColorUtil.printColored(" The environment variable will remain unaffected.", "dim") + ColorUtil.printColored("WARNING: TOWER_ACCESS_TOKEN environment variable is set.", "yellow bold") + println " ${ColorUtil.colorize('nextflow auth logout', 'dim cyan')}${ColorUtil.colorize(' only removes credentials from Nextflow config files.', 'dim')}" + ColorUtil.printColored(" The environment variable will remain unaffected.", "dim") println "" } @@ -633,25 +633,25 @@ class CmdAuth extends CmdBase implements UsageAware { def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' if (!existingToken) { - AuthColorUtil.printColored("Error: No authentication token found in Nextflow config.", "red") - println "Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}" + ColorUtil.printColored("Error: No authentication token found in Nextflow config.", "red") + println "Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}" return } - AuthColorUtil.printColored(" - Found authentication token in config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") + ColorUtil.printColored(" - Found authentication token in config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") // Prompt user for API URL if not already configured def apiUrl = endpoint as String if (!apiUrl || apiUrl.isEmpty()) { apiUrl = promptForApiUrl() } else { - AuthColorUtil.printColored(" - Using Seqera Platform endpoint: ${AuthColorUtil.colorize(apiUrl, 'magenta')}", "dim") + ColorUtil.printColored(" - Using Seqera Platform endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')}", "dim") } // Validate token by calling /user-info API try { def userInfo = callUserInfoApi(existingToken as String, apiUrl) - AuthColorUtil.printColored(" - Token is valid for user: ${AuthColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") + ColorUtil.printColored(" - Token is valid for user: ${ColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") // Only delete PAT from platform if this is a cloud endpoint if (isCloudEndpoint(apiUrl)) { @@ -669,7 +669,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Remove from config even if API calls fail removeAuthFromConfig() - AuthColorUtil.printColored("Token removed from Nextflow config.", "green") + ColorUtil.printColored("Token removed from Nextflow config.", "green") } } @@ -703,7 +703,7 @@ class CmdAuth extends CmdBase implements UsageAware { throw new RuntimeException("Failed to delete token: ${error}") } - AuthColorUtil.printColored("\nToken successfully deleted from Seqera Platform.", "green") + ColorUtil.printColored("\nToken successfully deleted from Seqera Platform.", "green") } private void removeAuthFromConfig() { @@ -715,7 +715,7 @@ class CmdAuth extends CmdBase implements UsageAware { Files.writeString(configFile, cleanedContent, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) } - AuthColorUtil.printColored("Authentication removed from Nextflow config.", "green") + ColorUtil.printColored("Authentication removed from Nextflow config.", "green") } @Override @@ -752,22 +752,22 @@ class CmdAuth extends CmdBase implements UsageAware { def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' if (!existingToken) { - println "No authentication found. Please run ${AuthColorUtil.colorize('nextflow auth login', 'cyan')} first." + println "No authentication found. Please run ${ColorUtil.colorize('nextflow auth login', 'cyan')} first." return } - println "Nextflow ${AuthColorUtil.colorize('Seqera Platform', 'cyan bold')} configuration" - AuthColorUtil.printColored(" - Config file: ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") + println "Nextflow ${ColorUtil.colorize('Seqera Platform', 'cyan bold')} configuration" + ColorUtil.printColored(" - Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") // Check if token is from environment variable if (!config['tower.accessToken'] && System.getenv('TOWER_ACCESS_TOKEN')) { - AuthColorUtil.printColored(" - Using access token from TOWER_ACCESS_TOKEN environment variable", "dim") + ColorUtil.printColored(" - Using access token from TOWER_ACCESS_TOKEN environment variable", "dim") } try { // Get user info to validate token and get user ID def userInfo = callUserInfoApi(existingToken as String, endpoint as String) - AuthColorUtil.printColored(" - Authenticated as: ${AuthColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") + ColorUtil.printColored(" - Authenticated as: ${ColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") println "" // Track if any changes are made @@ -782,7 +782,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Save updated config only if changes were made if (configChanged) { writeConfig(config) - AuthColorUtil.printColored(" - Configuration saved to ${AuthColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "green") + ColorUtil.printColored(" - Configuration saved to ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "green") } } catch (Exception e) { @@ -1051,12 +1051,12 @@ class CmdAuth extends CmdBase implements UsageAware { // API endpoint def endpointInfo = getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://api.cloud.seqera.io') - statusRows.add(['API endpoint', AuthColorUtil.colorize(endpointInfo.value as String, 'magenta'), endpointInfo.source as String]) + statusRows.add(['API endpoint', ColorUtil.colorize(endpointInfo.value as String, 'magenta'), endpointInfo.source as String]) // API connection check def apiConnectionOk = checkApiConnection(endpointInfo.value as String) def connectionColor = apiConnectionOk ? 'green' : 'red' - statusRows.add(['API connection', AuthColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) + statusRows.add(['API connection', ColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) // Authentication check def tokenInfo = getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') @@ -1064,19 +1064,19 @@ class CmdAuth extends CmdBase implements UsageAware { try { def userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) def currentUser = userInfo.userName as String - statusRows.add(['Authentication', "${AuthColorUtil.colorize('OK', 'green')} ${AuthColorUtil.colorize('(user: ' + currentUser + ')', 'cyan')}".toString(), tokenInfo.source as String]) + statusRows.add(['Authentication', "${ColorUtil.colorize('OK', 'green')} ${ColorUtil.colorize('(user: ' + currentUser + ')', 'cyan')}".toString(), tokenInfo.source as String]) } catch (Exception e) { - statusRows.add(['Authentication', AuthColorUtil.colorize('ERROR', 'red'), 'failed']) + statusRows.add(['Authentication', ColorUtil.colorize('ERROR', 'red'), 'failed']) } } else { - statusRows.add(['Authentication', "${AuthColorUtil.colorize('ERROR', 'red')} ${AuthColorUtil.colorize('(no token)', 'dim')}".toString(), 'not set']) + statusRows.add(['Authentication', "${ColorUtil.colorize('ERROR', 'red')} ${ColorUtil.colorize('(no token)', 'dim')}".toString(), 'not set']) } // Monitoring enabled def enabledInfo = getConfigValue(config, 'tower.enabled', null, 'false') def enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' def enabledColor = enabledValue == 'Yes' ? 'green' : 'yellow' - statusRows.add(['Workflow monitoring', AuthColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) + statusRows.add(['Workflow monitoring', ColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) // Default workspace def workspaceInfo = getConfigValue(config, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') @@ -1089,18 +1089,18 @@ class CmdAuth extends CmdBase implements UsageAware { if (workspaceDetails) { // Add workspace ID row - statusRows.add(['Default workspace ID', AuthColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) + statusRows.add(['Default workspace ID', ColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) // Add org/name row - statusRows.add([' - workspace name', "${AuthColorUtil.colorize(workspaceDetails.orgName as String, 'cyan bold')} / ${AuthColorUtil.colorize(workspaceDetails.workspaceName as String, 'cyan')}".toString(), '']) + statusRows.add([' - workspace name', "${ColorUtil.colorize(workspaceDetails.orgName as String, 'cyan bold')} / ${ColorUtil.colorize(workspaceDetails.workspaceName as String, 'cyan')}".toString(), '']) // Add full name row (truncate if too long) def fullName = workspaceDetails.workspaceFullName as String def truncatedFullName = fullName.length() > 50 ? fullName.substring(0, 47) + '...' : fullName - statusRows.add([' - workspace full name', AuthColorUtil.colorize(truncatedFullName, 'cyan dim'), '']) + statusRows.add([' - workspace full name', ColorUtil.colorize(truncatedFullName, 'cyan dim'), '']) } else { - statusRows.add(['Default workspace', AuthColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) + statusRows.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) } } else { - statusRows.add(['Default workspace', AuthColorUtil.colorize('Personal workspace', 'cyan'), 'default']) + statusRows.add(['Default workspace', ColorUtil.colorize('Personal workspace', 'cyan'), 'default']) } // Print table @@ -1122,14 +1122,14 @@ class CmdAuth extends CmdBase implements UsageAware { col3Width = Math.max(col3Width, 10) + 2 // Print table header - AuthColorUtil.printColored("${'Setting'.padRight(col1Width)} ${'Value'.padRight(col2Width)} Source", "cyan bold") + ColorUtil.printColored("${'Setting'.padRight(col1Width)} ${'Value'.padRight(col2Width)} Source", "cyan bold") println "${'-' * col1Width} ${'-' * col2Width} ${'-' * col3Width}" // Print rows rows.each { row -> def paddedCol1 = padStringWithAnsi(row[0], col1Width) def paddedCol2 = padStringWithAnsi(row[1], col2Width) - def paddedCol3 = AuthColorUtil.colorize(row[2], 'dim') + def paddedCol3 = ColorUtil.colorize(row[2], 'dim') println "${paddedCol1} ${paddedCol2} ${paddedCol3}" } } diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/ColorUtil.groovy similarity index 98% rename from modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy rename to modules/nextflow/src/main/groovy/nextflow/cli/ColorUtil.groovy index 9aa2824571..f375ddad77 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/AuthColorUtil.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/ColorUtil.groovy @@ -22,12 +22,12 @@ import org.fusesource.jansi.Ansi import static org.fusesource.jansi.Ansi.* /** - * Utility class for ANSI color formatting in auth commands + * Utility class for ANSI color formatting * * @author Phil Ewels */ @CompileStatic -class AuthColorUtil { +class ColorUtil { /** * Check if ANSI colors should be enabled based on Nextflow conventions @@ -156,4 +156,4 @@ class AuthColorUtil { } } -} +} \ No newline at end of file From 401518679249bf4bdf9611b5f1560569a19811f9 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Sat, 20 Sep 2025 18:40:18 +0200 Subject: [PATCH 26/66] Improve styling and flow for status / auth Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 118 ++++++++++++------ 1 file changed, 83 insertions(+), 35 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 1056b00f90..efc82bb5d4 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -782,7 +782,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Save updated config only if changes were made if (configChanged) { writeConfig(config) - ColorUtil.printColored(" - Configuration saved to ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "green") + ColorUtil.printColored("\nConfiguration saved to ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "green") } } catch (Exception e) { @@ -793,13 +793,12 @@ class CmdAuth extends CmdBase implements UsageAware { private boolean configureEnabled(Map config) { def currentEnabled = config.get('tower.enabled', false) - println "Workflow monitoring settings:" - println " Current: ${currentEnabled ? 'enabled' : 'disabled'}" - println " When enabled, all workflow runs are automatically monitored by Seqera Platform" - println " When disabled, you can enable per-run with the -with-tower flag" + println "Workflow monitoring settings. Current setting: ${currentEnabled ? ColorUtil.colorize('enabled', 'green') : ColorUtil.colorize('disabled', 'red')}" + ColorUtil.printColored(" When enabled, all workflow runs are automatically monitored by Seqera Platform", "dim") + ColorUtil.printColored(" When disabled, you can enable per-run with the ${ColorUtil.colorize('-with-tower', 'cyan')} flag", "dim") println "" - System.out.print("Enable workflow monitoring for all runs? (${currentEnabled ? 'Y/n' : 'y/N'}): ") + System.out.print("${ColorUtil.colorize('Enable workflow monitoring for all runs?', 'cyan bold', true)} (${currentEnabled ? ColorUtil.colorize('Y', 'green') + '/n' : 'y/' + ColorUtil.colorize('N', 'red')}): ") System.out.flush() def reader = new BufferedReader(new InputStreamReader(System.in)) @@ -843,17 +842,6 @@ class CmdAuth extends CmdBase implements UsageAware { def effectiveWorkspaceId = currentWorkspaceId ?: envWorkspaceId def currentWorkspace = workspaces.find { ((Map)it).workspaceId.toString() == effectiveWorkspaceId?.toString() } - println "Default workspace settings:" - if (currentWorkspace) { - def workspace = currentWorkspace as Map - def source = currentWorkspaceId ? "config" : (envWorkspaceId ? "TOWER_WORKFLOW_ID env var" : "config") - println " Current: ${workspace.orgName} / ${workspace.workspaceName} [${workspace.workspaceFullName}] (from ${source})" - } else if (envWorkspaceId) { - println " Current: TOWER_WORKFLOW_ID=${envWorkspaceId} (workspace not found in available workspaces)" - } else { - println " Current: Personal workspace (default)" - } - println "" // Group by organization def orgWorkspaces = workspaces.groupBy { ((Map)it).orgName ?: 'Personal' } @@ -868,16 +856,29 @@ class CmdAuth extends CmdBase implements UsageAware { } private boolean selectWorkspaceFromAll(Map config, List workspaces, def currentWorkspaceId, def envWorkspaceId) { - println "Select default workspace:" - println " 0. Personal workspace (no organization)" + println "\nAvailable workspaces:" + println " 0. ${ColorUtil.colorize('Personal workspace', 'cyan', true)} ${ColorUtil.colorize('[no organization]', 'dim', true)}" workspaces.eachWithIndex { workspace, index -> def ws = workspace as Map - def prefix = ws.orgName ? "${ws.orgName} / " : "" - println " ${index + 1}. ${prefix}${ws.workspaceName} [${ws.workspaceFullName}]" + def prefix = ws.orgName ? "${ColorUtil.colorize(ws.orgName as String, 'cyan', true)} / " : "" + println " ${index + 1}. ${prefix}${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}" } - System.out.print("Select workspace (0-${workspaces.size()}, Enter to keep current): ") + // Show current workspace and prepare prompt + def currentWorkspace = workspaces.find { ((Map)it).workspaceId.toString() == (currentWorkspaceId ?: System.getenv('TOWER_WORKFLOW_ID'))?.toString() } + def currentWorkspaceName + if (currentWorkspace) { + def workspace = currentWorkspace as Map + def source = currentWorkspaceId ? "config" : "TOWER_WORKFLOW_ID env var" + currentWorkspaceName = "${workspace.orgName} / ${workspace.workspaceName}" + } else if (System.getenv('TOWER_WORKFLOW_ID')) { + currentWorkspaceName = "TOWER_WORKFLOW_ID=${System.getenv('TOWER_WORKFLOW_ID')}" + } else { + currentWorkspaceName = "Personal workspace" + } + + ColorUtil.printColored("\nSelect workspace (0-${workspaces.size()}, press Enter to keep as '${currentWorkspaceName}'): ", "bold cyan") System.out.flush() def reader = new BufferedReader(new InputStreamReader(System.in)) @@ -920,15 +921,45 @@ class CmdAuth extends CmdBase implements UsageAware { } private boolean selectWorkspaceByOrg(Map config, Map orgWorkspaces, def currentWorkspaceId, def envWorkspaceId) { + // Get current workspace info for prompts + def allWorkspaces = [] + orgWorkspaces.values().each { workspaceList -> + allWorkspaces.addAll(workspaceList as List) + } + def currentWorkspace = allWorkspaces.find { ((Map)it).workspaceId.toString() == (currentWorkspaceId ?: envWorkspaceId)?.toString() } + def currentOrgName + def currentWorkspaceName + if (currentWorkspace) { + def workspace = currentWorkspace as Map + currentOrgName = workspace.orgName as String + currentWorkspaceName = workspace.workspaceName as String + } else if (envWorkspaceId) { + currentOrgName = "TOWER_WORKFLOW_ID=${envWorkspaceId}" + currentWorkspaceName = null + } else { + currentOrgName = "Personal" + currentWorkspaceName = null + } + // First, select organization def orgs = orgWorkspaces.keySet().toList() - println "Select organization:" - orgs.eachWithIndex { orgName, index -> - println " ${index + 1}. ${orgName}" + println "\nAvailable organizations:" + // Add Personal workspace option if not already in the list + def hasPersonal = orgs.contains('Personal') + if (!hasPersonal) { + println " 1. ${ColorUtil.colorize('Personal', 'cyan', true)} ${ColorUtil.colorize('[Personal workspace - no organization]', 'dim', true)}" + orgs.eachWithIndex { orgName, index -> + println " ${index + 2}. ${ColorUtil.colorize(orgName as String, 'cyan', true)}" + } + System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size() + 1}, Enter to keep as '${currentOrgName}'): ", 'dim', true)}") + } else { + orgs.eachWithIndex { orgName, index -> + def displayName = orgName == 'Personal' ? 'Personal [Personal workspace - no organization]' : orgName + println " ${index + 1}. ${ColorUtil.colorize(displayName as String, 'cyan', true)}" + } + System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size()}, Enter to keep as '${currentOrgName}'): ", 'dim', true)}") } - - System.out.print("Select organization (1-${orgs.size()}, Enter to keep current): ") System.out.flush() def reader = new BufferedReader(new InputStreamReader(System.in)) @@ -940,12 +971,28 @@ class CmdAuth extends CmdBase implements UsageAware { try { def orgSelection = Integer.parseInt(orgInput) - if (orgSelection < 1 || orgSelection > orgs.size()) { + def maxOrgSelection = hasPersonal ? orgs.size() : orgs.size() + 1 + if (orgSelection < 1 || orgSelection > maxOrgSelection) { println "Invalid selection." return false } - def selectedOrgName = orgs[orgSelection - 1] + def selectedOrgName + if (!hasPersonal && orgSelection == 1) { + // Personal workspace selected + if (envWorkspaceId) { + return false + } else { + def hadWorkspaceId = config.containsKey('tower.workspaceId') + config.remove('tower.workspaceId') + config.remove('tower.workspaceId.comment') + return hadWorkspaceId + } + } else { + def orgIndex = hasPersonal ? orgSelection - 1 : orgSelection - 2 + selectedOrgName = orgs[orgIndex] + } + def orgWorkspaceList = orgWorkspaces[selectedOrgName] as List println "" @@ -957,11 +1004,12 @@ class CmdAuth extends CmdBase implements UsageAware { orgWorkspaceList.eachWithIndex { workspace, index -> def ws = workspace as Map - println " ${index + 1}. ${ws.workspaceName} [${ws.workspaceFullName}]" + println " ${index + 1}. ${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}" } def maxSelection = orgWorkspaceList.size() - System.out.print("Select workspace (${selectedOrgName == 'Personal' ? '0-' : '1-'}${maxSelection}, Enter to keep current): ") + def keepAsText = currentWorkspaceName ? "'${currentWorkspaceName}'" : "'Personal workspace'" + System.out.print("${ColorUtil.colorize("Select workspace (${selectedOrgName == 'Personal' ? '0-' : '1-'}${maxSelection}, Enter to keep as ${keepAsText}): ", 'dim', true)}") System.out.flush() def wsInput = reader.readLine()?.trim() @@ -1064,7 +1112,7 @@ class CmdAuth extends CmdBase implements UsageAware { try { def userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) def currentUser = userInfo.userName as String - statusRows.add(['Authentication', "${ColorUtil.colorize('OK', 'green')} ${ColorUtil.colorize('(user: ' + currentUser + ')', 'cyan')}".toString(), tokenInfo.source as String]) + statusRows.add(['Authentication', "${ColorUtil.colorize('OK', 'green')} (user: ${ColorUtil.colorize(currentUser, 'cyan')})".toString(), tokenInfo.source as String]) } catch (Exception e) { statusRows.add(['Authentication', ColorUtil.colorize('ERROR', 'red'), 'failed']) } @@ -1097,10 +1145,10 @@ class CmdAuth extends CmdBase implements UsageAware { def truncatedFullName = fullName.length() > 50 ? fullName.substring(0, 47) + '...' : fullName statusRows.add([' - workspace full name', ColorUtil.colorize(truncatedFullName, 'cyan dim'), '']) } else { - statusRows.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) + statusRows.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue', true), workspaceInfo.source as String]) } } else { - statusRows.add(['Default workspace', ColorUtil.colorize('Personal workspace', 'cyan'), 'default']) + statusRows.add(['Default workspace', ColorUtil.colorize('Personal workspace', 'cyan', true), 'default']) } // Print table @@ -1129,7 +1177,7 @@ class CmdAuth extends CmdBase implements UsageAware { rows.each { row -> def paddedCol1 = padStringWithAnsi(row[0], col1Width) def paddedCol2 = padStringWithAnsi(row[1], col2Width) - def paddedCol3 = ColorUtil.colorize(row[2], 'dim') + def paddedCol3 = ColorUtil.colorize(row[2], 'dim', true) println "${paddedCol1} ${paddedCol2} ${paddedCol3}" } } From 95908ffe40ba36c116c56a92ed7b11ecaa18cb09 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Sun, 21 Sep 2025 00:31:43 +0200 Subject: [PATCH 27/66] lots of testing and tweaking CLI Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 369 ++++++++++-------- 1 file changed, 214 insertions(+), 155 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index efc82bb5d4..e0ad99357e 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -318,7 +318,7 @@ class CmdAuth extends CmdBase implements UsageAware { return } - println "Nextflow authentication with ${ColorUtil.colorize('Seqera Platform', 'cyan bold')}" + ColorUtil.printColored("Nextflow authentication with Seqera Platform", "cyan bold") ColorUtil.printColored(" - Authentication will be saved to: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") // Use provided URL or default @@ -417,20 +417,32 @@ class CmdAuth extends CmdBase implements UsageAware { // Verify login by calling /user-info def userInfo = callUserInfoApi(accessToken, apiUrl) ColorUtil.printColored("\n\nAuthentication successful!", "green") - println "Logged in to ${ColorUtil.colorize(apiUrl.replace('api.', '').replace('/api', ''), 'magenta')} as: ${ColorUtil.colorize(userInfo.userName as String, 'cyan bold')}" // Generate PAT def pat = generatePAT(accessToken, apiUrl) // Save to config saveAuthToConfig(pat, apiUrl) - println "Seqera Platform configuration saved to ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}" + + // Automatically run configuration + runConfiguration() } catch (Exception e) { throw new RuntimeException("Authentication failed: ${e.message}", e) } } + private void runConfiguration() { + try { + println "" + // Just run the existing config command + new ConfigCmd().apply([]) + } catch (Exception e) { + ColorUtil.printColored("Configuration setup failed: ${e.message}", "red") + ColorUtil.printColored("You can run 'nextflow auth config' later to set up your configuration.", "dim") + } + } + private Map requestDeviceAuthorization(Map auth0Config) { def deviceAuthUrl = "https://${auth0Config.domain}/oauth/device/code" @@ -530,7 +542,7 @@ class CmdAuth extends CmdBase implements UsageAware { private void handleEnterpriseAuth(String apiUrl) { println "" - println "Please generate a Personal Access Token from your ${ColorUtil.colorize('Seqera Platform', 'cyan bold')} instance." + ColorUtil.printColored("Please generate a Personal Access Token from your Seqera Platform instance.", "cyan bold") println "You can create one at: ${ColorUtil.colorize(apiUrl.replace('/api', '').replace('://api.', '') + '/tokens', 'magenta')}" println "" @@ -549,7 +561,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Save to config saveAuthToConfig(pat.trim(), apiUrl) ColorUtil.printColored("Personal Access Token saved to Nextflow config", "green") - println "Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}" + ColorUtil.printColored("Config file: ${getConfigFile().toString()}", "magenta") } private String generatePAT(String accessToken, String apiUrl) { @@ -756,7 +768,7 @@ class CmdAuth extends CmdBase implements UsageAware { return } - println "Nextflow ${ColorUtil.colorize('Seqera Platform', 'cyan bold')} configuration" + ColorUtil.printColored("Nextflow Seqera Platform configuration", "cyan bold") ColorUtil.printColored(" - Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") // Check if token is from environment variable @@ -782,7 +794,14 @@ class CmdAuth extends CmdBase implements UsageAware { // Save updated config only if changes were made if (configChanged) { writeConfig(config) + + // Show the new configuration + println "\nNew configuration:" + showCurrentConfig(config, existingToken as String, endpoint as String) + ColorUtil.printColored("\nConfiguration saved to ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "green") + } else { + ColorUtil.printColored("\nNo configuration changes were made.", "dim") } } catch (Exception e) { @@ -790,6 +809,31 @@ class CmdAuth extends CmdBase implements UsageAware { } } + private void showCurrentConfig(Map config, String accessToken, String endpoint) { + // Show workflow monitoring status + def monitoringEnabled = config.get('tower.enabled', false) + println " ${ColorUtil.colorize('Workflow monitoring:', 'cyan')} ${monitoringEnabled ? ColorUtil.colorize('enabled', 'green') : ColorUtil.colorize('disabled', 'red')}" + + // Show workspace setting + def workspaceId = config.get('tower.workspaceId') + if (workspaceId) { + def workspaceComment = config.get('tower.workspaceId.comment') as String + if (workspaceComment) { + // Extract just the org/workspace part (before the square brackets) + def orgWorkspace = workspaceComment.split(' \\[')[0] + println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')} ${ColorUtil.colorize('[' + orgWorkspace + ']', 'dim', true)}" + } else { + println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')}" + } + } else { + println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize('None (Personal workspace)', 'magenta')}" + } + + // Show API endpoint + def apiEndpoint = config.get('tower.endpoint') ?: endpoint + println " ${ColorUtil.colorize('API endpoint:', 'cyan', true)} $apiEndpoint" + } + private boolean configureEnabled(Map config) { def currentEnabled = config.get('tower.enabled', false) @@ -798,36 +842,46 @@ class CmdAuth extends CmdBase implements UsageAware { ColorUtil.printColored(" When disabled, you can enable per-run with the ${ColorUtil.colorize('-with-tower', 'cyan')} flag", "dim") println "" - System.out.print("${ColorUtil.colorize('Enable workflow monitoring for all runs?', 'cyan bold', true)} (${currentEnabled ? ColorUtil.colorize('Y', 'green') + '/n' : 'y/' + ColorUtil.colorize('N', 'red')}): ") - System.out.flush() - def reader = new BufferedReader(new InputStreamReader(System.in)) - def input = reader.readLine()?.trim()?.toLowerCase() + def input + while (true) { + System.out.print("${ColorUtil.colorize('Enable workflow monitoring for all runs?', 'cyan bold', true)} (${currentEnabled ? ColorUtil.colorize('Y', 'green') + '/n' : 'y/' + ColorUtil.colorize('N', 'red')}): ") + System.out.flush() - if (input.isEmpty()) { - // Keep current setting if user just presses enter - return false - } else if (input == 'y' || input == 'yes') { - if (!currentEnabled) { - config['tower.enabled'] = true - return true - } else { + input = reader.readLine()?.trim()?.toLowerCase() + + if (input.isEmpty()) { + // Keep current setting if user just presses enter return false - } - } else if (input == 'n' || input == 'no') { - if (currentEnabled) { - config.remove('tower.enabled') // Don't set it to false, just remove it - return true + } else if (input == 'y' || input == 'yes') { + if (!currentEnabled) { + config['tower.enabled'] = true + return true + } else { + return false + } + } else if (input == 'n' || input == 'no') { + if (currentEnabled) { + config.remove('tower.enabled') // Don't set it to false, just remove it + return true + } else { + return false + } } else { - return false + ColorUtil.printColored("Invalid input. Please enter 'y', 'n', or press Enter to keep current setting.", "red") } - } else { - println "Invalid input." - return false } } private boolean configureWorkspace(Map config, String accessToken, String endpoint, String userId) { + // Check if TOWER_WORKFLOW_ID environment variable is set + def envWorkspaceId = System.getenv('TOWER_WORKFLOW_ID') + if (envWorkspaceId) { + println "\nDefault workspace: ${ColorUtil.colorize('TOWER_WORKFLOW_ID environment variable is set', 'yellow')}" + ColorUtil.printColored(" Not prompting for default workspace configuration as environment variable takes precedence", "dim") + return false + } + // Get all workspaces for the user def workspaces = getUserWorkspaces(accessToken, endpoint, userId) @@ -836,26 +890,33 @@ class CmdAuth extends CmdBase implements UsageAware { return false } - // Show current workspace (check both config and env var) + // Show current workspace setting def currentWorkspaceId = config.get('tower.workspaceId') - def envWorkspaceId = System.getenv('TOWER_WORKFLOW_ID') - def effectiveWorkspaceId = currentWorkspaceId ?: envWorkspaceId - def currentWorkspace = workspaces.find { ((Map)it).workspaceId.toString() == effectiveWorkspaceId?.toString() } + def currentWorkspace = workspaces.find { ((Map)it).workspaceId.toString() == currentWorkspaceId?.toString() } + def currentSetting + if (currentWorkspace) { + def workspace = currentWorkspace as Map + currentSetting = "${workspace.orgName} / ${workspace.workspaceName}" + } else { + currentSetting = "Personal workspace" + } + println "\nDefault workspace. Current setting: ${ColorUtil.colorize(currentSetting as String, 'cyan', true)}" + ColorUtil.printColored(" Workflow runs use this workspace by default", "dim") // Group by organization def orgWorkspaces = workspaces.groupBy { ((Map)it).orgName ?: 'Personal' } // If 8 or fewer total options, show all at once if (workspaces.size() <= 8) { - return selectWorkspaceFromAll(config, workspaces, currentWorkspaceId, envWorkspaceId) + return selectWorkspaceFromAll(config, workspaces, currentWorkspaceId) } else { // Two-stage selection: org first, then workspace - return selectWorkspaceByOrg(config, orgWorkspaces, currentWorkspaceId, envWorkspaceId) + return selectWorkspaceByOrg(config, orgWorkspaces, currentWorkspaceId) } } - private boolean selectWorkspaceFromAll(Map config, List workspaces, def currentWorkspaceId, def envWorkspaceId) { + private boolean selectWorkspaceFromAll(Map config, List workspaces, def currentWorkspaceId) { println "\nAvailable workspaces:" println " 0. ${ColorUtil.colorize('Personal workspace', 'cyan', true)} ${ColorUtil.colorize('[no organization]', 'dim', true)}" @@ -866,79 +927,77 @@ class CmdAuth extends CmdBase implements UsageAware { } // Show current workspace and prepare prompt - def currentWorkspace = workspaces.find { ((Map)it).workspaceId.toString() == (currentWorkspaceId ?: System.getenv('TOWER_WORKFLOW_ID'))?.toString() } + def currentWorkspace = workspaces.find { ((Map)it).workspaceId.toString() == currentWorkspaceId?.toString() } def currentWorkspaceName if (currentWorkspace) { def workspace = currentWorkspace as Map - def source = currentWorkspaceId ? "config" : "TOWER_WORKFLOW_ID env var" currentWorkspaceName = "${workspace.orgName} / ${workspace.workspaceName}" - } else if (System.getenv('TOWER_WORKFLOW_ID')) { - currentWorkspaceName = "TOWER_WORKFLOW_ID=${System.getenv('TOWER_WORKFLOW_ID')}" } else { currentWorkspaceName = "Personal workspace" } - ColorUtil.printColored("\nSelect workspace (0-${workspaces.size()}, press Enter to keep as '${currentWorkspaceName}'): ", "bold cyan") - System.out.flush() - def reader = new BufferedReader(new InputStreamReader(System.in)) - def input = reader.readLine()?.trim() - - if (input.isEmpty()) { - return false - } - - try { - def selection = Integer.parseInt(input) - if (selection == 0) { - if (envWorkspaceId) { - return false - } else { - def hadWorkspaceId = config.containsKey('tower.workspaceId') - config.remove('tower.workspaceId') - config.remove('tower.workspaceId.comment') - return hadWorkspaceId - } - } else if (selection > 0 && selection <= workspaces.size()) { - def selectedWorkspace = workspaces[selection - 1] as Map - def selectedId = selectedWorkspace.workspaceId.toString() - if (envWorkspaceId && selectedId == envWorkspaceId) { - return false - } else { - def currentId = config.get('tower.workspaceId') - config['tower.workspaceId'] = selectedId - config['tower.workspaceId.comment'] = "${selectedWorkspace.orgName} / ${selectedWorkspace.workspaceName} [${selectedWorkspace.workspaceFullName}]" - return currentId != selectedId + def selection + while (true) { + ColorUtil.printColored("\nSelect workspace (0-${workspaces.size()}, press Enter to keep as '${currentWorkspaceName}'): ", "bold cyan") + System.out.flush() + def input = reader.readLine()?.trim() + + if (input.isEmpty()) { + // Ensure comment is preserved if workspace is already set + if (currentWorkspaceId && currentWorkspace) { + def workspace = currentWorkspace as Map + def existingComment = config.get('tower.workspaceId.comment') + if (!existingComment) { + config['tower.workspaceId.comment'] = "${workspace.orgName} / ${workspace.workspaceName} [${workspace.workspaceFullName}]" + return true // Return true because we added the comment + } } - } else { - println "Invalid selection." return false } - } catch (NumberFormatException e) { - println "Invalid input." - return false + + try { + selection = Integer.parseInt(input) + if (selection >= 0 && selection <= workspaces.size()) { + break + } + } catch (NumberFormatException e) { + // Fall through to error message + } + ColorUtil.printColored("Invalid input. Please enter a number between 0 and ${workspaces.size()}.", "red") + } + + if (selection == 0) { + def hadWorkspaceId = config.containsKey('tower.workspaceId') + config.remove('tower.workspaceId') + config.remove('tower.workspaceId.comment') + return hadWorkspaceId + } else { + def selectedWorkspace = workspaces[selection - 1] as Map + def selectedId = selectedWorkspace.workspaceId.toString() + def currentId = config.get('tower.workspaceId') + config['tower.workspaceId'] = selectedId + config['tower.workspaceId.comment'] = "${selectedWorkspace.orgName} / ${selectedWorkspace.workspaceName} [${selectedWorkspace.workspaceFullName}]" + return currentId != selectedId } } - private boolean selectWorkspaceByOrg(Map config, Map orgWorkspaces, def currentWorkspaceId, def envWorkspaceId) { + private boolean selectWorkspaceByOrg(Map config, Map orgWorkspaces, def currentWorkspaceId) { // Get current workspace info for prompts def allWorkspaces = [] orgWorkspaces.values().each { workspaceList -> allWorkspaces.addAll(workspaceList as List) } - def currentWorkspace = allWorkspaces.find { ((Map)it).workspaceId.toString() == (currentWorkspaceId ?: envWorkspaceId)?.toString() } - def currentOrgName + def currentWorkspace = allWorkspaces.find { ((Map)it).workspaceId.toString() == currentWorkspaceId?.toString() } def currentWorkspaceName + def currentWorkspaceDisplay if (currentWorkspace) { def workspace = currentWorkspace as Map - currentOrgName = workspace.orgName as String currentWorkspaceName = workspace.workspaceName as String - } else if (envWorkspaceId) { - currentOrgName = "TOWER_WORKFLOW_ID=${envWorkspaceId}" - currentWorkspaceName = null + currentWorkspaceDisplay = "${workspace.orgName} / ${workspace.workspaceName}" } else { - currentOrgName = "Personal" currentWorkspaceName = null + currentWorkspaceDisplay = "Personal workspace" } // First, select organization @@ -952,101 +1011,99 @@ class CmdAuth extends CmdBase implements UsageAware { orgs.eachWithIndex { orgName, index -> println " ${index + 2}. ${ColorUtil.colorize(orgName as String, 'cyan', true)}" } - System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size() + 1}, Enter to keep as '${currentOrgName}'): ", 'dim', true)}") + System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size() + 1}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") } else { orgs.eachWithIndex { orgName, index -> def displayName = orgName == 'Personal' ? 'Personal [Personal workspace - no organization]' : orgName println " ${index + 1}. ${ColorUtil.colorize(displayName as String, 'cyan', true)}" } - System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size()}, Enter to keep as '${currentOrgName}'): ", 'dim', true)}") + System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size()}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") } System.out.flush() def reader = new BufferedReader(new InputStreamReader(System.in)) - def orgInput = reader.readLine()?.trim() - - if (orgInput.isEmpty()) { - return false - } - - try { - def orgSelection = Integer.parseInt(orgInput) - def maxOrgSelection = hasPersonal ? orgs.size() : orgs.size() + 1 - if (orgSelection < 1 || orgSelection > maxOrgSelection) { - println "Invalid selection." + def maxOrgSelection = hasPersonal ? orgs.size() : orgs.size() + 1 + def orgSelection + while (true) { + def orgInput = reader.readLine()?.trim() + + if (orgInput.isEmpty()) { + // Ensure comment is preserved if workspace is already set + if (currentWorkspaceId && currentWorkspace) { + def workspace = currentWorkspace as Map + def existingComment = config.get('tower.workspaceId.comment') + if (!existingComment) { + config['tower.workspaceId.comment'] = "${workspace.orgName} / ${workspace.workspaceName} [${workspace.workspaceFullName}]" + return true // Return true because we added the comment + } + } return false } - def selectedOrgName - if (!hasPersonal && orgSelection == 1) { - // Personal workspace selected - if (envWorkspaceId) { - return false - } else { - def hadWorkspaceId = config.containsKey('tower.workspaceId') - config.remove('tower.workspaceId') - config.remove('tower.workspaceId.comment') - return hadWorkspaceId + try { + orgSelection = Integer.parseInt(orgInput) + if (orgSelection >= 1 && orgSelection <= maxOrgSelection) { + break } - } else { - def orgIndex = hasPersonal ? orgSelection - 1 : orgSelection - 2 - selectedOrgName = orgs[orgIndex] + } catch (NumberFormatException e) { + // Fall through to error message } + ColorUtil.printColored("Invalid input. Please enter a number between 1 and ${maxOrgSelection}.", "red") + System.out.print("${ColorUtil.colorize("Select organization (1-${maxOrgSelection}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") + System.out.flush() + } - def orgWorkspaceList = orgWorkspaces[selectedOrgName] as List + def selectedOrgName + if (!hasPersonal && orgSelection == 1) { + // Personal workspace selected + def hadWorkspaceId = config.containsKey('tower.workspaceId') + config.remove('tower.workspaceId') + config.remove('tower.workspaceId.comment') + return hadWorkspaceId + } else { + def orgIndex = hasPersonal ? orgSelection - 1 : orgSelection - 2 + selectedOrgName = orgs[orgIndex] + } - println "" - println "Select workspace in ${selectedOrgName}:" + def orgWorkspaceList = orgWorkspaces[selectedOrgName] as List - if (selectedOrgName == 'Personal') { - println " 0. Personal workspace (default)" - } + println "" + println "Select workspace in ${selectedOrgName}:" - orgWorkspaceList.eachWithIndex { workspace, index -> - def ws = workspace as Map - println " ${index + 1}. ${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}" - } + orgWorkspaceList.eachWithIndex { workspace, index -> + def ws = workspace as Map + println " ${index + 1}. ${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}" + } - def maxSelection = orgWorkspaceList.size() - def keepAsText = currentWorkspaceName ? "'${currentWorkspaceName}'" : "'Personal workspace'" - System.out.print("${ColorUtil.colorize("Select workspace (${selectedOrgName == 'Personal' ? '0-' : '1-'}${maxSelection}, Enter to keep as ${keepAsText}): ", 'dim', true)}") + def maxSelection = orgWorkspaceList.size() + def wsSelection + while (true) { + System.out.print("${ColorUtil.colorize("Select workspace (1-${maxSelection}): ", 'dim', true)}") System.out.flush() def wsInput = reader.readLine()?.trim() if (wsInput.isEmpty()) { - return false + ColorUtil.printColored("Please enter a selection.", "red") + continue } - def wsSelection = Integer.parseInt(wsInput) - if (selectedOrgName == 'Personal' && wsSelection == 0) { - if (envWorkspaceId) { - return false - } else { - def hadWorkspaceId = config.containsKey('tower.workspaceId') - config.remove('tower.workspaceId') - config.remove('tower.workspaceId.comment') - return hadWorkspaceId - } - } else if (wsSelection > 0 && wsSelection <= orgWorkspaceList.size()) { - def selectedWorkspace = orgWorkspaceList[wsSelection - 1] as Map - def selectedId = selectedWorkspace.workspaceId.toString() - if (envWorkspaceId && selectedId == envWorkspaceId) { - return false - } else { - def currentId = config.get('tower.workspaceId') - config['tower.workspaceId'] = selectedId - config['tower.workspaceId.comment'] = "${selectedWorkspace.orgName} / ${selectedWorkspace.workspaceName} [${selectedWorkspace.workspaceFullName}]" - return currentId != selectedId + try { + wsSelection = Integer.parseInt(wsInput) + if (wsSelection >= 1 && wsSelection <= maxSelection) { + break } - } else { - println "Invalid selection." - return false + } catch (NumberFormatException e) { + // Fall through to error message } - - } catch (NumberFormatException e) { - println "Invalid input." - return false + ColorUtil.printColored("Invalid input. Please enter a number between 1 and ${maxSelection}.", "red") } + + def selectedWorkspace = orgWorkspaceList[wsSelection - 1] as Map + def selectedId = selectedWorkspace.workspaceId.toString() + def currentId = config.get('tower.workspaceId') + config['tower.workspaceId'] = selectedId + config['tower.workspaceId.comment'] = "${selectedWorkspace.orgName} / ${selectedWorkspace.workspaceName} [${selectedWorkspace.workspaceFullName}]" + return currentId != selectedId } private List getUserWorkspaces(String accessToken, String endpoint, String userId) { @@ -1096,6 +1153,7 @@ class CmdAuth extends CmdBase implements UsageAware { // Collect all status information List> statusRows = [] + def workspaceDisplayInfo = null // API endpoint def endpointInfo = getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://api.cloud.seqera.io') @@ -1137,13 +1195,9 @@ class CmdAuth extends CmdBase implements UsageAware { if (workspaceDetails) { // Add workspace ID row - statusRows.add(['Default workspace ID', ColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) - // Add org/name row - statusRows.add([' - workspace name', "${ColorUtil.colorize(workspaceDetails.orgName as String, 'cyan bold')} / ${ColorUtil.colorize(workspaceDetails.workspaceName as String, 'cyan')}".toString(), '']) - // Add full name row (truncate if too long) - def fullName = workspaceDetails.workspaceFullName as String - def truncatedFullName = fullName.length() > 50 ? fullName.substring(0, 47) + '...' : fullName - statusRows.add([' - workspace full name', ColorUtil.colorize(truncatedFullName, 'cyan dim'), '']) + statusRows.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) + // Store workspace details for display after the table + workspaceDisplayInfo = workspaceDetails } else { statusRows.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue', true), workspaceInfo.source as String]) } @@ -1154,6 +1208,11 @@ class CmdAuth extends CmdBase implements UsageAware { // Print table println "" printStatusTable(statusRows) + + // Print workspace details if available + if (workspaceDisplayInfo) { + println "${' '*22}${ColorUtil.colorize(workspaceDisplayInfo.orgName as String, 'cyan')} / ${ColorUtil.colorize(workspaceDisplayInfo.workspaceName as String, 'cyan')} ${ColorUtil.colorize('[' + (workspaceDisplayInfo.workspaceFullName as String) + ']', 'dim')}" + } } private void printStatusTable(List> rows) { From 3a67b90db62a0740032dd644e0d7683bc3959391 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Sun, 21 Sep 2025 01:11:01 +0200 Subject: [PATCH 28/66] Code cleanup Signed-off-by: Phil Ewels --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 586 ++++++++---------- 1 file changed, 261 insertions(+), 325 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index e0ad99357e..5ea5527a01 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -50,6 +50,15 @@ class CmdAuth extends CmdBase implements UsageAware { } static public final String NAME = 'auth' + static final Map SEQERA_ENDPOINTS = [ + 'prod': 'https://api.cloud.seqera.io', + 'stage': 'https://api.cloud.stage-seqera.io', + 'dev': 'https://api.cloud.dev-seqera.io' + ] + static final int API_TIMEOUT_MS = 10000 + static final int AUTH_POLL_TIMEOUT_RETRIES = 60 + static final int AUTH_POLL_INTERVAL_SECONDS = 5 + static final int WORKSPACE_SELECTION_THRESHOLD = 8 // Max workspaces to show in single list; above this uses org-first selection private List commands = [] @@ -127,29 +136,74 @@ class CmdAuth extends CmdBase implements UsageAware { private String promptForApiUrl() { - System.out.print("Seqera Platform API endpoint [Default https://api.cloud.seqera.io]: ") + System.out.print("Seqera Platform API endpoint [Default ${SEQERA_ENDPOINTS.prod}]: ") System.out.flush() + def input = readUserInput() + return input?.isEmpty() ? SEQERA_ENDPOINTS.prod : input + } + + private String readUserInput() { def reader = new BufferedReader(new InputStreamReader(System.in)) - def input = reader.readLine()?.trim() + return reader.readLine()?.trim() + } - return input?.isEmpty() || input == null ? 'https://api.cloud.seqera.io' : input + private HttpURLConnection createHttpConnection(String url, String method, String authToken = null) { + def connection = new URL(url).openConnection() as HttpURLConnection + connection.requestMethod = method + connection.connectTimeout = API_TIMEOUT_MS + connection.readTimeout = API_TIMEOUT_MS + if (authToken) { + connection.setRequestProperty('Authorization', "Bearer ${authToken}") + } + return connection } - private Map getCloudEndpointInfo(String apiUrl) { - // Check production endpoints - if (apiUrl == 'https://api.cloud.seqera.io' || apiUrl == 'https://cloud.seqera.io/api') { - return [isCloud: true, environment: 'prod'] + private void validateArgumentCount(List args, String commandName) { + if (args.size() > 0) { + throw new AbortOperationException("Too many arguments for ${commandName} command") } - // Check staging endpoints - if (apiUrl == 'https://api.cloud.stage-seqera.io' || apiUrl == 'https://cloud.stage-seqera.io/api') { - return [isCloud: true, environment: 'stage'] + } + + private Map getWorkspaceDetailsFromApi(String accessToken, String endpoint, String workspaceId) { + try { + def userInfo = callUserInfoApi(accessToken, endpoint) + def userId = userInfo.id as String + + def connection = createHttpConnection("${endpoint}/user/${userId}/workspaces", 'GET', accessToken) + + if (connection.responseCode != 200) { + return null + } + + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) as Map + def orgsAndWorkspaces = json.orgsAndWorkspaces as List + + def workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } + if (workspace) { + def ws = workspace as Map + return [ + orgName: ws.orgName, + workspaceName: ws.workspaceName, + workspaceFullName: ws.workspaceFullName + ] + } + + return null + } catch (Exception e) { + return null } - // Check development endpoints - if (apiUrl == 'https://api.cloud.dev-seqera.io' || apiUrl == 'https://cloud.dev-seqera.io/api') { - return [isCloud: true, environment: 'dev'] + } + + private Map getCloudEndpointInfo(String apiUrl) { + for (env in SEQERA_ENDPOINTS) { + def standardUrl = env.value + def legacyUrl = standardUrl.replace('://api.', '://') + '/api' + if (apiUrl == standardUrl || apiUrl == legacyUrl) { + return [isCloud: true, environment: env.key] + } } - // Enterprise/other endpoints return [isCloud: false, environment: null] } @@ -157,12 +211,8 @@ class CmdAuth extends CmdBase implements UsageAware { return getCloudEndpointInfo(apiUrl).isCloud } - // Get user info from Seqera Platform private Map callUserInfoApi(String accessToken, String apiUrl) { - def userInfoUrl = "${apiUrl}/user-info" - def connection = new URL(userInfoUrl).openConnection() as HttpURLConnection - connection.requestMethod = 'GET' - connection.setRequestProperty('Authorization', "Bearer ${accessToken}") + def connection = createHttpConnection("${apiUrl}/user-info", 'GET', accessToken) if (connection.responseCode != 200) { def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" @@ -204,7 +254,7 @@ class CmdAuth extends CmdBase implements UsageAware { return content.replaceAll(/\n\n+/, '\n\n').trim() + "\n\n" } - private void writeConfig(Map config) { + private void writeConfig(Map config, Map workspaceMetadata = null) { def configFile = getConfigFile() // Create directory if it doesn't exist @@ -222,39 +272,28 @@ class CmdAuth extends CmdBase implements UsageAware { // Write tower config block def towerConfig = config.findAll { key, value -> - key.toString().startsWith('tower.') + key.toString().startsWith('tower.') && !key.toString().endsWith('.comment') } configText.append("// Seqera Platform configuration\n") configText.append("tower {\n") towerConfig.each { key, value -> def configKey = key.toString().substring(6) // Remove "tower." prefix - if (configKey.endsWith('.comment')) { - // Skip comment keys - they're handled below - return - } if (value instanceof String) { - configText.append(" ${configKey} = '${value}'\n") + def line = " ${configKey} = '${value}'" + // Add workspace comment if this is workspaceId and we have metadata + if (configKey == 'workspaceId' && workspaceMetadata) { + line += " // ${workspaceMetadata.orgName} / ${workspaceMetadata.workspaceName} [${workspaceMetadata.workspaceFullName}]" + } + configText.append("${line}\n") } else { configText.append(" ${configKey} = ${value}\n") } } configText.append("}\n") - def finalConfig = configText.toString() - - // Add workspace comment if available - if (config.containsKey('tower.workspaceId.comment')) { - def workspaceId = config['tower.workspaceId'] - def comment = config['tower.workspaceId.comment'] - finalConfig = finalConfig.replaceAll( - /workspaceId = '${Pattern.quote(workspaceId.toString())}'/, - "workspaceId = '${workspaceId}' // ${comment}" - ) - } - - Files.writeString(configFile, finalConfig, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + Files.writeString(configFile, configText.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) } // @@ -294,9 +333,7 @@ class CmdAuth extends CmdBase implements UsageAware { @Override void apply(List args) { - if (args.size() > 0) { - throw new AbortOperationException("Too many arguments for login command") - } + validateArgumentCount(args, name) // Check if TOWER_ACCESS_TOKEN environment variable is set def envToken = System.getenv('TOWER_ACCESS_TOKEN') @@ -321,13 +358,7 @@ class CmdAuth extends CmdBase implements UsageAware { ColorUtil.printColored("Nextflow authentication with Seqera Platform", "cyan bold") ColorUtil.printColored(" - Authentication will be saved to: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") - // Use provided URL or default - if (!apiUrl) { - apiUrl = 'https://api.cloud.seqera.io' - } else if (!apiUrl.startsWith('http://') && !apiUrl.startsWith('https://')) { - apiUrl = 'https://' + apiUrl - } - + apiUrl = normalizeApiUrl(apiUrl) ColorUtil.printColored(" - Seqera Platform API endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')} (can be customised with ${ColorUtil.colorize('-url', 'cyan')})", "dim") // Check if this is a cloud endpoint or enterprise @@ -444,94 +475,46 @@ class CmdAuth extends CmdBase implements UsageAware { } private Map requestDeviceAuthorization(Map auth0Config) { - def deviceAuthUrl = "https://${auth0Config.domain}/oauth/device/code" - def params = [ 'client_id': auth0Config.clientId, 'scope': 'openid profile email offline_access', 'audience': 'platform' ] - - def postData = params.collect { k, v -> - "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" - }.join('&') - - def connection = new URL(deviceAuthUrl).openConnection() as HttpURLConnection - connection.requestMethod = 'POST' - connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded') - connection.doOutput = true - - connection.outputStream.withWriter { writer -> - writer.write(postData) - } - - if (connection.responseCode != 200) { - def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" - throw new RuntimeException("Device authorization request failed: ${error}") - } - - def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) - return json as Map + return performAuth0Request("https://${auth0Config.domain}/oauth/device/code", params) } private Map pollForDeviceToken(String deviceCode, int intervalSeconds, Map auth0Config) { def tokenUrl = "https://${auth0Config.domain}/oauth/token" - def maxRetries = 60 // 5 minutes with 5-second intervals def retryCount = 0 - while (retryCount < maxRetries) { + while (retryCount < AUTH_POLL_TIMEOUT_RETRIES) { def params = [ 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', 'device_code': deviceCode, 'client_id': auth0Config.clientId ] - def postData = params.collect { k, v -> - "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" - }.join('&') - - def connection = new URL(tokenUrl).openConnection() as HttpURLConnection - connection.requestMethod = 'POST' - connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded') - connection.doOutput = true - - connection.outputStream.withWriter { writer -> - writer.write(postData) - } - - if (connection.responseCode == 200) { - def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) - return json as Map - } else { - def errorResponse = connection.errorStream?.text - if (errorResponse) { - def errorJson = new groovy.json.JsonSlurper().parseText(errorResponse) as Map - def error = errorJson.error - - if (error == 'authorization_pending') { - // User hasn't completed authorization yet, continue polling - print "${ColorUtil.colorize('.', 'dim', true)}" - System.out.flush() - } else if (error == 'slow_down') { - // Increase polling interval - intervalSeconds += 5 - print "${ColorUtil.colorize('.', 'dim', true)}" - System.out.flush() - } else if (error == 'expired_token') { - throw new RuntimeException("The device code has expired. Please try again.") - } else if (error == 'access_denied') { - throw new RuntimeException("Access denied by user") - } else { - throw new RuntimeException("Token request failed: ${error} - ${errorJson.error_description ?: ''}") - } + try { + def result = performAuth0Request(tokenUrl, params) + return result + } catch (RuntimeException e) { + def message = e.message + if (message.contains('authorization_pending')) { + print "${ColorUtil.colorize('.', 'dim', true)}" + System.out.flush() + } else if (message.contains('slow_down')) { + intervalSeconds += 5 + print "${ColorUtil.colorize('.', 'dim', true)}" + System.out.flush() + } else if (message.contains('expired_token')) { + throw new RuntimeException("The device code has expired. Please try again.") + } else if (message.contains('access_denied')) { + throw new RuntimeException("Access denied by user") } else { - throw new RuntimeException("Token request failed: HTTP ${connection.responseCode}") + throw e } } - // Wait before next poll Thread.sleep(intervalSeconds * 1000) retryCount++ } @@ -572,9 +555,7 @@ class CmdAuth extends CmdBase implements UsageAware { def requestBody = new groovy.json.JsonBuilder([name: tokenName]).toString() - def connection = new URL(tokensUrl).openConnection() as HttpURLConnection - connection.requestMethod = 'POST' - connection.setRequestProperty('Authorization', "Bearer ${accessToken}") + def connection = createHttpConnection(tokensUrl, 'POST', accessToken) connection.setRequestProperty('Content-Type', 'application/json') connection.doOutput = true @@ -592,13 +573,55 @@ class CmdAuth extends CmdBase implements UsageAware { return json.accessKey as String } + private String normalizeApiUrl(String url) { + if (!url) { + return SEQERA_ENDPOINTS.prod + } + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return 'https://' + url + } + return url + } + + private Map performAuth0Request(String url, Map params) { + def postData = params.collect { k, v -> + "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" + }.join('&') + + def connection = new URL(url).openConnection() as HttpURLConnection + connection.requestMethod = 'POST' + connection.connectTimeout = API_TIMEOUT_MS + connection.readTimeout = API_TIMEOUT_MS + connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded') + connection.doOutput = true + + connection.outputStream.withWriter { writer -> + writer.write(postData) + } + + if (connection.responseCode == 200) { + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) + return json as Map + } else { + def errorResponse = connection.errorStream?.text + if (errorResponse) { + def errorJson = new groovy.json.JsonSlurper().parseText(errorResponse) as Map + def error = errorJson.error + throw new RuntimeException("${error}: ${errorJson.error_description ?: ''}") + } else { + throw new RuntimeException("Request failed: HTTP ${connection.responseCode}") + } + } + } + private void saveAuthToConfig(String accessToken, String apiUrl) { - def config = readConfig() + def config = CmdAuth.this.readConfig() config['tower.accessToken'] = accessToken config['tower.endpoint'] = apiUrl config['tower.enabled'] = true - writeConfig(config) + CmdAuth.this.writeConfig(config, null) } @Override @@ -625,9 +648,7 @@ class CmdAuth extends CmdBase implements UsageAware { @Override void apply(List args) { - if (args.size() > 0) { - throw new AbortOperationException("Too many arguments for logout command") - } + validateArgumentCount(args, name) // Check if TOWER_ACCESS_TOKEN environment variable is set def envToken = System.getenv('TOWER_ACCESS_TOKEN') @@ -705,10 +726,7 @@ class CmdAuth extends CmdBase implements UsageAware { } private void deleteTokenViaApi(String token, String apiUrl, String tokenId) { - def deleteUrl = "${apiUrl}/tokens/${tokenId}" - def connection = new URL(deleteUrl).openConnection() as HttpURLConnection - connection.requestMethod = 'DELETE' - connection.setRequestProperty('Authorization', "Bearer ${token}") + def connection = createHttpConnection("${apiUrl}/tokens/${tokenId}", 'DELETE', token) if (connection.responseCode != 200 && connection.responseCode != 204) { def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" @@ -754,9 +772,7 @@ class CmdAuth extends CmdBase implements UsageAware { @Override void apply(List args) { - if (args.size() > 0) { - throw new AbortOperationException("Too many arguments for config command") - } + validateArgumentCount(args, name) // Check if user is authenticated def config = readConfig() @@ -789,11 +805,12 @@ class CmdAuth extends CmdBase implements UsageAware { configChanged |= configureEnabled(config) // Configure workspace - configChanged |= configureWorkspace(config, existingToken as String, endpoint as String, userInfo.id as String) + def workspaceResult = configureWorkspace(config, existingToken as String, endpoint as String, userInfo.id as String) + configChanged = configChanged || (workspaceResult.changed as boolean) // Save updated config only if changes were made if (configChanged) { - writeConfig(config) + writeConfig(config, workspaceResult.metadata as Map) // Show the new configuration println "\nNew configuration:" @@ -817,11 +834,11 @@ class CmdAuth extends CmdBase implements UsageAware { // Show workspace setting def workspaceId = config.get('tower.workspaceId') if (workspaceId) { - def workspaceComment = config.get('tower.workspaceId.comment') as String - if (workspaceComment) { - // Extract just the org/workspace part (before the square brackets) - def orgWorkspace = workspaceComment.split(' \\[')[0] - println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')} ${ColorUtil.colorize('[' + orgWorkspace + ']', 'dim', true)}" + // Try to get workspace details from API for display + def workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpoint, workspaceId as String) + if (workspaceDetails) { + def details = workspaceDetails as Map + println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')} ${ColorUtil.colorize('[' + details.orgName + ' / ' + details.workspaceName + ']', 'dim', true)}" } else { println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')}" } @@ -842,44 +859,28 @@ class CmdAuth extends CmdBase implements UsageAware { ColorUtil.printColored(" When disabled, you can enable per-run with the ${ColorUtil.colorize('-with-tower', 'cyan')} flag", "dim") println "" - def reader = new BufferedReader(new InputStreamReader(System.in)) - def input - while (true) { - System.out.print("${ColorUtil.colorize('Enable workflow monitoring for all runs?', 'cyan bold', true)} (${currentEnabled ? ColorUtil.colorize('Y', 'green') + '/n' : 'y/' + ColorUtil.colorize('N', 'red')}): ") - System.out.flush() - - input = reader.readLine()?.trim()?.toLowerCase() - - if (input.isEmpty()) { - // Keep current setting if user just presses enter - return false - } else if (input == 'y' || input == 'yes') { - if (!currentEnabled) { - config['tower.enabled'] = true - return true - } else { - return false - } - } else if (input == 'n' || input == 'no') { - if (currentEnabled) { - config.remove('tower.enabled') // Don't set it to false, just remove it - return true - } else { - return false - } - } else { - ColorUtil.printColored("Invalid input. Please enter 'y', 'n', or press Enter to keep current setting.", "red") - } + def promptText = "${ColorUtil.colorize('Enable workflow monitoring for all runs?', 'cyan bold', true)} (${currentEnabled ? ColorUtil.colorize('Y', 'green') + '/n' : 'y/' + ColorUtil.colorize('N', 'red')}): " + def input = promptForYesNo(promptText, currentEnabled) + + if (input == null) { + return false // No change + } else if (input && !currentEnabled) { + config['tower.enabled'] = true + return true + } else if (!input && currentEnabled) { + config.remove('tower.enabled') + return true } + return false } - private boolean configureWorkspace(Map config, String accessToken, String endpoint, String userId) { + private Map configureWorkspace(Map config, String accessToken, String endpoint, String userId) { // Check if TOWER_WORKFLOW_ID environment variable is set def envWorkspaceId = System.getenv('TOWER_WORKFLOW_ID') if (envWorkspaceId) { println "\nDefault workspace: ${ColorUtil.colorize('TOWER_WORKFLOW_ID environment variable is set', 'yellow')}" ColorUtil.printColored(" Not prompting for default workspace configuration as environment variable takes precedence", "dim") - return false + return [changed: false, metadata: null] } // Get all workspaces for the user @@ -887,7 +888,7 @@ class CmdAuth extends CmdBase implements UsageAware { if (!workspaces) { println "\nNo workspaces found for your account." - return false + return [changed: false, metadata: null] } // Show current workspace setting @@ -899,7 +900,7 @@ class CmdAuth extends CmdBase implements UsageAware { def workspace = currentWorkspace as Map currentSetting = "${workspace.orgName} / ${workspace.workspaceName}" } else { - currentSetting = "Personal workspace" + currentSetting = "None (Personal workspace)" } println "\nDefault workspace. Current setting: ${ColorUtil.colorize(currentSetting as String, 'cyan', true)}" @@ -907,8 +908,8 @@ class CmdAuth extends CmdBase implements UsageAware { // Group by organization def orgWorkspaces = workspaces.groupBy { ((Map)it).orgName ?: 'Personal' } - // If 8 or fewer total options, show all at once - if (workspaces.size() <= 8) { + // If threshold or fewer total options, show all at once + if (workspaces.size() <= WORKSPACE_SELECTION_THRESHOLD) { return selectWorkspaceFromAll(config, workspaces, currentWorkspaceId) } else { // Two-stage selection: org first, then workspace @@ -916,9 +917,9 @@ class CmdAuth extends CmdBase implements UsageAware { } } - private boolean selectWorkspaceFromAll(Map config, List workspaces, def currentWorkspaceId) { + private Map selectWorkspaceFromAll(Map config, List workspaces, def currentWorkspaceId) { println "\nAvailable workspaces:" - println " 0. ${ColorUtil.colorize('Personal workspace', 'cyan', true)} ${ColorUtil.colorize('[no organization]', 'dim', true)}" + println " 0. ${ColorUtil.colorize('None (Personal workspace)', 'cyan', true)} ${ColorUtil.colorize('[no organization]', 'dim', true)}" workspaces.eachWithIndex { workspace, index -> def ws = workspace as Map @@ -933,56 +934,35 @@ class CmdAuth extends CmdBase implements UsageAware { def workspace = currentWorkspace as Map currentWorkspaceName = "${workspace.orgName} / ${workspace.workspaceName}" } else { - currentWorkspaceName = "Personal workspace" + currentWorkspaceName = "None (Personal workspace)" } - def reader = new BufferedReader(new InputStreamReader(System.in)) - def selection - while (true) { - ColorUtil.printColored("\nSelect workspace (0-${workspaces.size()}, press Enter to keep as '${currentWorkspaceName}'): ", "bold cyan") - System.out.flush() - def input = reader.readLine()?.trim() - - if (input.isEmpty()) { - // Ensure comment is preserved if workspace is already set - if (currentWorkspaceId && currentWorkspace) { - def workspace = currentWorkspace as Map - def existingComment = config.get('tower.workspaceId.comment') - if (!existingComment) { - config['tower.workspaceId.comment'] = "${workspace.orgName} / ${workspace.workspaceName} [${workspace.workspaceFullName}]" - return true // Return true because we added the comment - } - } - return false - } + def prompt = "\nSelect workspace (0-${workspaces.size()}, press Enter to keep as '${currentWorkspaceName}'): " + def selection = promptForNumber(prompt, 0, workspaces.size(), true) - try { - selection = Integer.parseInt(input) - if (selection >= 0 && selection <= workspaces.size()) { - break - } - } catch (NumberFormatException e) { - // Fall through to error message - } - ColorUtil.printColored("Invalid input. Please enter a number between 0 and ${workspaces.size()}.", "red") + if (selection == null) { + return [changed: false, metadata: null] } if (selection == 0) { def hadWorkspaceId = config.containsKey('tower.workspaceId') config.remove('tower.workspaceId') - config.remove('tower.workspaceId.comment') - return hadWorkspaceId + return [changed: hadWorkspaceId, metadata: null] } else { def selectedWorkspace = workspaces[selection - 1] as Map def selectedId = selectedWorkspace.workspaceId.toString() def currentId = config.get('tower.workspaceId') config['tower.workspaceId'] = selectedId - config['tower.workspaceId.comment'] = "${selectedWorkspace.orgName} / ${selectedWorkspace.workspaceName} [${selectedWorkspace.workspaceFullName}]" - return currentId != selectedId + def metadata = [ + orgName: selectedWorkspace.orgName, + workspaceName: selectedWorkspace.workspaceName, + workspaceFullName: selectedWorkspace.workspaceFullName + ] + return [changed: currentId != selectedId, metadata: metadata] } } - private boolean selectWorkspaceByOrg(Map config, Map orgWorkspaces, def currentWorkspaceId) { + private Map selectWorkspaceByOrg(Map config, Map orgWorkspaces, def currentWorkspaceId) { // Get current workspace info for prompts def allWorkspaces = [] orgWorkspaces.values().each { workspaceList -> @@ -997,72 +977,52 @@ class CmdAuth extends CmdBase implements UsageAware { currentWorkspaceDisplay = "${workspace.orgName} / ${workspace.workspaceName}" } else { currentWorkspaceName = null - currentWorkspaceDisplay = "Personal workspace" + currentWorkspaceDisplay = "None" } // First, select organization def orgs = orgWorkspaces.keySet().toList() + // Always add Personal as first option (it's never returned by the API but should always be available) + orgs.add(0, 'Personal') + println "\nAvailable organizations:" - // Add Personal workspace option if not already in the list - def hasPersonal = orgs.contains('Personal') - if (!hasPersonal) { - println " 1. ${ColorUtil.colorize('Personal', 'cyan', true)} ${ColorUtil.colorize('[Personal workspace - no organization]', 'dim', true)}" - orgs.eachWithIndex { orgName, index -> - println " ${index + 2}. ${ColorUtil.colorize(orgName as String, 'cyan', true)}" - } - System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size() + 1}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") - } else { - orgs.eachWithIndex { orgName, index -> - def displayName = orgName == 'Personal' ? 'Personal [Personal workspace - no organization]' : orgName - println " ${index + 1}. ${ColorUtil.colorize(displayName as String, 'cyan', true)}" - } - System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size()}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") + orgs.eachWithIndex { orgName, index -> + def displayName = orgName == 'Personal' ? 'None [Personal workspace]' : orgName + println " ${index + 1}. ${ColorUtil.colorize(displayName as String, 'cyan', true)}" } + System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size()}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") System.out.flush() def reader = new BufferedReader(new InputStreamReader(System.in)) - def maxOrgSelection = hasPersonal ? orgs.size() : orgs.size() + 1 def orgSelection while (true) { def orgInput = reader.readLine()?.trim() if (orgInput.isEmpty()) { - // Ensure comment is preserved if workspace is already set - if (currentWorkspaceId && currentWorkspace) { - def workspace = currentWorkspace as Map - def existingComment = config.get('tower.workspaceId.comment') - if (!existingComment) { - config['tower.workspaceId.comment'] = "${workspace.orgName} / ${workspace.workspaceName} [${workspace.workspaceFullName}]" - return true // Return true because we added the comment - } - } - return false + return [changed: false, metadata: null] } try { orgSelection = Integer.parseInt(orgInput) - if (orgSelection >= 1 && orgSelection <= maxOrgSelection) { + if (orgSelection >= 1 && orgSelection <= orgs.size()) { break } } catch (NumberFormatException e) { // Fall through to error message } - ColorUtil.printColored("Invalid input. Please enter a number between 1 and ${maxOrgSelection}.", "red") - System.out.print("${ColorUtil.colorize("Select organization (1-${maxOrgSelection}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") + ColorUtil.printColored("Invalid input. Please enter a number between 1 and ${orgs.size()}.", "red") + System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size()}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") System.out.flush() } - def selectedOrgName - if (!hasPersonal && orgSelection == 1) { - // Personal workspace selected + def selectedOrgName = orgs[orgSelection - 1] + + // If Personal was selected, remove workspace ID (use personal workspace) + if (selectedOrgName == 'Personal') { def hadWorkspaceId = config.containsKey('tower.workspaceId') config.remove('tower.workspaceId') - config.remove('tower.workspaceId.comment') - return hadWorkspaceId - } else { - def orgIndex = hasPersonal ? orgSelection - 1 : orgSelection - 2 - selectedOrgName = orgs[orgIndex] + return [changed: hadWorkspaceId, metadata: null] } def orgWorkspaceList = orgWorkspaces[selectedOrgName] as List @@ -1102,15 +1062,16 @@ class CmdAuth extends CmdBase implements UsageAware { def selectedId = selectedWorkspace.workspaceId.toString() def currentId = config.get('tower.workspaceId') config['tower.workspaceId'] = selectedId - config['tower.workspaceId.comment'] = "${selectedWorkspace.orgName} / ${selectedWorkspace.workspaceName} [${selectedWorkspace.workspaceFullName}]" - return currentId != selectedId + def metadata = [ + orgName: selectedWorkspace.orgName, + workspaceName: selectedWorkspace.workspaceName, + workspaceFullName: selectedWorkspace.workspaceFullName + ] + return [changed: currentId != selectedId, metadata: metadata] } private List getUserWorkspaces(String accessToken, String endpoint, String userId) { - def workspacesUrl = "${endpoint}/user/${userId}/workspaces" - def connection = new URL(workspacesUrl).openConnection() as HttpURLConnection - connection.requestMethod = 'GET' - connection.setRequestProperty('Authorization', "Bearer ${accessToken}") + def connection = createHttpConnection("${endpoint}/user/${userId}/workspaces", 'GET', accessToken) if (connection.responseCode != 200) { def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" @@ -1121,10 +1082,50 @@ class CmdAuth extends CmdBase implements UsageAware { def json = new groovy.json.JsonSlurper().parseText(response) as Map def orgsAndWorkspaces = json.orgsAndWorkspaces as List - // Filter to only include actual workspaces (where workspaceId is not null) return orgsAndWorkspaces.findAll { ((Map)it).workspaceId != null } } + private Boolean promptForYesNo(String prompt, Boolean defaultValue) { + while (true) { + System.out.print(prompt) + System.out.flush() + def input = readUserInput()?.toLowerCase() + + if (input?.isEmpty()) { + return null // Keep current setting + } else if (input in ['y', 'yes']) { + return true + } else if (input in ['n', 'no']) { + return false + } else { + ColorUtil.printColored("Invalid input. Please enter 'y', 'n', or press Enter to keep current setting.", "red") + } + } + } + + private Integer promptForNumber(String prompt, int min, int max, boolean allowEmpty = false) { + while (true) { + ColorUtil.printColored(prompt, "bold cyan") + System.out.flush() + def input = readUserInput() + + if (input?.isEmpty() && allowEmpty) { + return null + } + + try { + def number = Integer.parseInt(input) + if (number >= min && number <= max) { + return number + } + } catch (NumberFormatException e) { + // Fall through to error message + } + ColorUtil.printColored("Invalid input. Please enter a number between ${min} and ${max}.", "red") + } + } + + @Override void usage(List result) { result << 'Configure Seqera Platform settings' @@ -1145,9 +1146,7 @@ class CmdAuth extends CmdBase implements UsageAware { @Override void apply(List args) { - if (args.size() > 0) { - throw new AbortOperationException("Too many arguments for status command") - } + validateArgumentCount(args, name) def config = readConfig() @@ -1202,7 +1201,7 @@ class CmdAuth extends CmdBase implements UsageAware { statusRows.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue', true), workspaceInfo.source as String]) } } else { - statusRows.add(['Default workspace', ColorUtil.colorize('Personal workspace', 'cyan', true), 'default']) + statusRows.add(['Default workspace', ColorUtil.colorize('None (Personal workspace)', 'cyan', true), 'default']) } // Print table @@ -1284,86 +1283,23 @@ class CmdAuth extends CmdBase implements UsageAware { private String getWorkspaceNameFromApi(String accessToken, String endpoint, String workspaceId) { try { - // Get user info to get user ID - def userInfo = callUserInfoApi(accessToken, endpoint) - def userId = userInfo.id as String - - // Get workspaces for the user - def workspacesUrl = "${endpoint}/user/${userId}/workspaces" - def connection = new URL(workspacesUrl).openConnection() as HttpURLConnection - connection.requestMethod = 'GET' - connection.connectTimeout = 10000 // 10 second timeout - connection.readTimeout = 10000 - connection.setRequestProperty('Authorization', "Bearer ${accessToken}") - - if (connection.responseCode != 200) { - return null - } - - def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) as Map - def orgsAndWorkspaces = json.orgsAndWorkspaces as List - - // Find the workspace with matching ID - def workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } - if (workspace) { - def ws = workspace as Map - return "${ws.orgName} / ${ws.workspaceName} [${ws.workspaceFullName}]" + def workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpoint, workspaceId) + if (workspaceDetails) { + return "${workspaceDetails.orgName} / ${workspaceDetails.workspaceName} [${workspaceDetails.workspaceFullName}]" } - return null } catch (Exception e) { return null } } - private Map getWorkspaceDetailsFromApi(String accessToken, String endpoint, String workspaceId) { - try { - // Get user info to get user ID - def userInfo = callUserInfoApi(accessToken, endpoint) - def userId = userInfo.id as String - - // Get workspaces for the user - def workspacesUrl = "${endpoint}/user/${userId}/workspaces" - def connection = new URL(workspacesUrl).openConnection() as HttpURLConnection - connection.requestMethod = 'GET' - connection.connectTimeout = 10000 // 10 second timeout - connection.readTimeout = 10000 - connection.setRequestProperty('Authorization', "Bearer ${accessToken}") - - if (connection.responseCode != 200) { - return null - } - - def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) as Map - def orgsAndWorkspaces = json.orgsAndWorkspaces as List - - // Find the workspace with matching ID - def workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } - if (workspace) { - def ws = workspace as Map - return [ - orgName: ws.orgName, - workspaceName: ws.workspaceName, - workspaceFullName: ws.workspaceFullName - ] - } - - return null - } catch (Exception e) { - return null - } - } private boolean checkApiConnection(String endpoint) { try { - def serviceInfoUrl = "${endpoint}/service-info" - def connection = new URL(serviceInfoUrl).openConnection() as HttpURLConnection + def connection = new URL("${endpoint}/service-info").openConnection() as HttpURLConnection connection.requestMethod = 'GET' - connection.connectTimeout = 10000 // 10 second timeout - connection.readTimeout = 10000 - + connection.connectTimeout = API_TIMEOUT_MS + connection.readTimeout = API_TIMEOUT_MS return connection.responseCode == 200 } catch (Exception e) { return false From 248fedc594a0c1669ebd4e8a06574be3cbd8d901 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Sun, 21 Sep 2025 01:21:55 +0200 Subject: [PATCH 29/66] Add some tests Signed-off-by: Phil Ewels --- .../groovy/nextflow/cli/CmdAuthTest.groovy | 325 ++++++++++++++++++ .../groovy/nextflow/cli/ColorUtilTest.groovy | 241 +++++++++++++ 2 files changed, 566 insertions(+) create mode 100644 modules/nextflow/src/test/groovy/nextflow/cli/CmdAuthTest.groovy create mode 100644 modules/nextflow/src/test/groovy/nextflow/cli/ColorUtilTest.groovy diff --git a/modules/nextflow/src/test/groovy/nextflow/cli/CmdAuthTest.groovy b/modules/nextflow/src/test/groovy/nextflow/cli/CmdAuthTest.groovy new file mode 100644 index 0000000000..0e9ed6e159 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/cli/CmdAuthTest.groovy @@ -0,0 +1,325 @@ +/* + * 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.cli + +import nextflow.exception.AbortOperationException +import org.junit.Rule +import spock.lang.Specification +import spock.lang.TempDir +import test.OutputCapture + +import java.nio.file.Files +import java.nio.file.Path + +/** + * Test CmdAuth functionality + * + * @author Phil Ewels + */ +class CmdAuthTest extends Specification { + + @Rule + OutputCapture capture = new OutputCapture() + + @TempDir + Path tempDir + + def 'should have correct name'() { + given: + def cmd = new CmdAuth() + + expect: + cmd.getName() == 'auth' + } + + def 'should define correct constants'() { + expect: + CmdAuth.SEQERA_ENDPOINTS.prod == 'https://api.cloud.seqera.io' + CmdAuth.SEQERA_ENDPOINTS.stage == 'https://api.cloud.stage-seqera.io' + CmdAuth.SEQERA_ENDPOINTS.dev == 'https://api.cloud.dev-seqera.io' + CmdAuth.API_TIMEOUT_MS == 10000 + CmdAuth.AUTH_POLL_TIMEOUT_RETRIES == 60 + CmdAuth.AUTH_POLL_INTERVAL_SECONDS == 5 + CmdAuth.WORKSPACE_SELECTION_THRESHOLD == 8 + } + + def 'should show usage when no args provided'() { + given: + def cmd = new CmdAuth() + + when: + cmd.run() + + then: + def output = capture.toString() + output.contains('Manage Seqera Platform authentication') + output.contains('Usage: nextflow auth [options]') + output.contains('Commands:') + output.contains('login') + output.contains('logout') + output.contains('config') + output.contains('status') + } + + def 'should show specific command usage'() { + given: + def cmd = new CmdAuth() + cmd.args = ['login'] + + when: + cmd.usage() + + then: + def output = capture.toString() + output.contains('Authenticate with Seqera Platform') + output.contains('Usage: nextflow auth login') + output.contains('-u, -url ') + } + + def 'should throw error for unknown command'() { + given: + def cmd = new CmdAuth() + cmd.args = ['unknown'] + + when: + cmd.run() + + then: + def ex = thrown(AbortOperationException) + ex.message.contains('Unknown auth sub-command: unknown') + } + + def 'should suggest closest command for typos'() { + given: + def cmd = new CmdAuth() + + when: + cmd.getCmd(['loginn']) + + then: + def ex = thrown(AbortOperationException) + ex.message.contains('Unknown auth sub-command: loginn') + ex.message.contains('Did you mean one of these?') + ex.message.contains('login') + } + + def 'should identify cloud endpoints correctly'() { + given: + def cmd = new CmdAuth() + + expect: + cmd.getCloudEndpointInfo('https://api.cloud.seqera.io').isCloud == true + cmd.getCloudEndpointInfo('https://api.cloud.seqera.io').environment == 'prod' + cmd.getCloudEndpointInfo('https://api.cloud.stage-seqera.io').isCloud == true + cmd.getCloudEndpointInfo('https://api.cloud.stage-seqera.io').environment == 'stage' + cmd.getCloudEndpointInfo('https://api.cloud.dev-seqera.io').isCloud == true + cmd.getCloudEndpointInfo('https://api.cloud.dev-seqera.io').environment == 'dev' + cmd.getCloudEndpointInfo('https://cloud.seqera.io/api').isCloud == true + cmd.getCloudEndpointInfo('https://cloud.seqera.io/api').environment == 'prod' + cmd.getCloudEndpointInfo('https://enterprise.example.com').isCloud == false + cmd.getCloudEndpointInfo('https://enterprise.example.com').environment == null + } + + def 'should identify cloud endpoint from URL'() { + given: + def cmd = new CmdAuth() + + expect: + cmd.isCloudEndpoint('https://api.cloud.seqera.io') == true + cmd.isCloudEndpoint('https://api.cloud.stage-seqera.io') == true + cmd.isCloudEndpoint('https://enterprise.example.com') == false + } + + def 'should validate argument count correctly'() { + given: + def cmd = new CmdAuth() + + when: + cmd.validateArgumentCount(['extra'], 'test') + + then: + def ex = thrown(AbortOperationException) + ex.message == 'Too many arguments for test command' + + when: + cmd.validateArgumentCount([], 'test') + + then: + noExceptionThrown() + } + + def 'should read config correctly'() { + given: + def cmd = new CmdAuth() + + expect: + // readConfig method should return a Map + cmd.readConfig() instanceof Map + } + + def 'should clean tower config from existing content'() { + given: + def cmd = new CmdAuth() + def content = ''' +// Some other config +process { + executor = 'local' +} + +// Seqera Platform configuration +tower { + accessToken = 'old-token' + enabled = true +} + +tower.endpoint = 'old-endpoint' + +// More config +params.test = true +''' + + when: + def cleaned = cmd.cleanTowerConfig(content) + + then: + !cleaned.contains('tower {') + !cleaned.contains('accessToken = \'old-token\'') + !cleaned.contains('tower.endpoint') + !cleaned.contains('Seqera Platform configuration') + cleaned.contains('process {') + cleaned.contains('params.test = true') + } + + def 'should handle config writing'() { + given: + def cmd = new CmdAuth() + def config = [ + 'tower.accessToken': 'test-token', + 'tower.enabled': true + ] + + when: + cmd.writeConfig(config, null) + + then: + noExceptionThrown() + } + + def 'login command should validate too many arguments'() { + given: + def cmd = new CmdAuth() + cmd.args = ['login', 'extra'] + + when: + cmd.run() + + then: + def ex = thrown(AbortOperationException) + ex.message.contains('Too many arguments for login command') + } + + def 'logout command should validate too many arguments'() { + given: + def cmd = new CmdAuth() + cmd.args = ['logout', 'extra'] + + when: + cmd.run() + + then: + def ex = thrown(AbortOperationException) + ex.message.contains('Too many arguments for logout command') + } + + def 'config command should validate too many arguments'() { + given: + def cmd = new CmdAuth() + cmd.args = ['config', 'extra'] + + when: + cmd.run() + + then: + def ex = thrown(AbortOperationException) + ex.message.contains('Too many arguments for config command') + } + + def 'status command should validate too many arguments'() { + given: + def cmd = new CmdAuth() + cmd.args = ['status', 'extra'] + + when: + cmd.run() + + then: + def ex = thrown(AbortOperationException) + ex.message.contains('Too many arguments for status command') + } + + def 'login command should use provided API URL'() { + given: + def cmd = new CmdAuth() + cmd.args = ['login'] + cmd.apiUrl = 'https://api.example.com' + + when: + def loginCmd = cmd.getCmd(cmd.args) + + then: + loginCmd instanceof CmdAuth.LoginCmd + + when: + cmd.run() + + then: + loginCmd.apiUrl == 'https://api.example.com' + } + + def 'should have all required subcommands'() { + given: + def cmd = new CmdAuth() + + expect: + cmd.commands.size() == 4 + cmd.commands.find { it.name == 'login' } != null + cmd.commands.find { it.name == 'logout' } != null + cmd.commands.find { it.name == 'config' } != null + cmd.commands.find { it.name == 'status' } != null + } + + def 'should create HTTP connection with correct properties'() { + given: + def cmd = new CmdAuth() + + when: + def connection = cmd.createHttpConnection('https://example.com', 'GET', 'test-token') + + then: + connection.requestMethod == 'GET' + connection.connectTimeout == CmdAuth.API_TIMEOUT_MS + connection.readTimeout == CmdAuth.API_TIMEOUT_MS + + when: + def connectionNoAuth = cmd.createHttpConnection('https://example.com', 'POST') + + then: + connectionNoAuth.requestMethod == 'POST' + connectionNoAuth.connectTimeout == CmdAuth.API_TIMEOUT_MS + connectionNoAuth.readTimeout == CmdAuth.API_TIMEOUT_MS + } +} \ No newline at end of file diff --git a/modules/nextflow/src/test/groovy/nextflow/cli/ColorUtilTest.groovy b/modules/nextflow/src/test/groovy/nextflow/cli/ColorUtilTest.groovy new file mode 100644 index 0000000000..13fb1b85c1 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/cli/ColorUtilTest.groovy @@ -0,0 +1,241 @@ +/* + * 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.cli + +import nextflow.SysEnv +import org.fusesource.jansi.Ansi +import org.junit.Rule +import spock.lang.Specification +import test.OutputCapture + +/** + * Test ColorUtil functionality + * + * @author Phil Ewels + */ +class ColorUtilTest extends Specification { + + @Rule + OutputCapture capture = new OutputCapture() + + def setup() { + // No special setup needed + } + + def 'should disable ANSI when NO_COLOR is set'() { + given: + SysEnv.push([NO_COLOR: '1']) + + when: + def result = ColorUtil.isAnsiEnabled() + + then: + !result + + cleanup: + SysEnv.pop() + } + + def 'should disable ANSI when NXF_ANSI_LOG is false'() { + given: + SysEnv.push([NXF_ANSI_LOG: 'false']) + + when: + def result = ColorUtil.isAnsiEnabled() + + then: + !result + + cleanup: + SysEnv.pop() + } + + def 'should enable ANSI when NXF_ANSI_LOG is true'() { + given: + SysEnv.push([NXF_ANSI_LOG: 'true']) + + when: + def result = ColorUtil.isAnsiEnabled() + + then: + result + + cleanup: + SysEnv.pop() + } + + def 'should handle invalid NXF_ANSI_LOG value'() { + given: + SysEnv.push([NXF_ANSI_LOG: 'invalid']) + + when: + def result = ColorUtil.isAnsiEnabled() + + then: + // Should not crash and return a boolean + result instanceof Boolean + + cleanup: + SysEnv.pop() + } + + def 'should colorize text with basic colors'() { + when: + def result = ColorUtil.colorize('test', 'red') + + then: + result.contains('test') + if (ColorUtil.isAnsiEnabled()) { + result.contains('\u001B[31m') // Red color code + } + } + + def 'should colorize text with background colors'() { + when: + def result = ColorUtil.colorize('test', 'red on yellow') + + then: + result.contains('test') + if (ColorUtil.isAnsiEnabled()) { + result.contains('\u001B[31m') // Red foreground + result.contains('\u001B[43m') // Yellow background + } + } + + def 'should colorize text with formatting'() { + when: + def result = ColorUtil.colorize('test', 'bold dim cyan') + + then: + result.contains('test') + if (ColorUtil.isAnsiEnabled()) { + result.contains('\u001B[36m') // Cyan color + result.contains('\u001B[1m') // Bold + result.contains('\u001B[2m') // Dim + } + } + + def 'should handle empty or null text'() { + expect: + ColorUtil.colorize(null, 'red') == '' + ColorUtil.colorize('', 'red') == '' + } + + def 'should handle empty format'() { + when: + def result = ColorUtil.colorize('test', '') + + then: + result == 'test' + } + + def 'should handle null format'() { + when: + def result = ColorUtil.colorize('test', null) + + then: + result == 'test' + } + + def 'should reset styles with fullReset=true'() { + when: + def result = ColorUtil.colorize('test', 'red bold', true) + + then: + if (ColorUtil.isAnsiEnabled()) { + result.endsWith('\u001B[0m') // Full reset code + } + } + + def 'should reset only applied styles with fullReset=false'() { + when: + def result = ColorUtil.colorize('test', 'red bold', false) + + then: + if (ColorUtil.isAnsiEnabled()) { + !result.endsWith('\u001B[0m') // Should not end with full reset + result.contains('\u001B[22m') // Bold off + result.contains('\u001B[39m') // Default foreground + } + } + + def 'should print colored text'() { + when: + ColorUtil.printColored('test message', 'green') + + then: + def output = capture.toString() + output.contains('test message') + if (ColorUtil.isAnsiEnabled()) { + output.contains('\u001B[32m') // Green color + output.contains('\u001B[0m') // Reset at end + } + } + + def 'should handle all supported colors'() { + given: + def colors = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'default'] + + expect: + colors.each { color -> + def result = ColorUtil.colorize('test', color) + result.contains('test') + } + } + + def 'should handle complex format combinations'() { + when: + def result = ColorUtil.colorize('complex', 'bold red on blue dim') + + then: + result.contains('complex') + if (ColorUtil.isAnsiEnabled()) { + result.contains('\u001B[31m') // Red foreground + result.contains('\u001B[44m') // Blue background + result.contains('\u001B[1m') // Bold + result.contains('\u001B[2m') // Dim + } + } + + def 'should be case insensitive'() { + when: + def result1 = ColorUtil.colorize('test', 'RED BOLD') + def result2 = ColorUtil.colorize('test', 'red bold') + + then: + if (ColorUtil.isAnsiEnabled()) { + result1 == result2 + } else { + result1 == 'test' + result2 == 'test' + } + } + + def 'should return plain text when ANSI disabled'() { + given: + SysEnv.push([NO_COLOR: '1']) + + when: + def result = ColorUtil.colorize('plain text', 'red bold on yellow') + + then: + result == 'plain text' + + cleanup: + SysEnv.pop() + } +} \ No newline at end of file From 5c7a1928a9104fac6a08dcc2f53a77a950cec714 Mon Sep 17 00:00:00 2001 From: jorgee Date: Fri, 26 Sep 2025 18:30:43 +0200 Subject: [PATCH 30/66] move authcommand implementation to nf-tower Signed-off-by: jorgee --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 1135 +---------------- .../tower/plugin/cli/AuthCommandImpl.groovy | 1108 ++++++++++++++++ 2 files changed, 1146 insertions(+), 1097 deletions(-) create mode 100644 plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 5ea5527a01..831c13bbe4 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -22,8 +22,14 @@ import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import nextflow.Const import nextflow.SysEnv +import nextflow.config.ConfigBuilder import nextflow.exception.AbortOperationException +import nextflow.plugin.Plugins import org.fusesource.jansi.Ansi +import org.pf4j.ExtensionPoint + +import java.nio.file.Paths + import static org.fusesource.jansi.Ansi.* import java.nio.file.Files @@ -40,7 +46,7 @@ import java.util.regex.Pattern */ @Slf4j @CompileStatic -@Parameters(commandDescription = "Manage Seqera Platform authentication") +@Parameters(commandDescription = "Manage authentication") class CmdAuth extends CmdBase implements UsageAware { interface SubCmd { @@ -49,19 +55,19 @@ class CmdAuth extends CmdBase implements UsageAware { void usage(List result) } + interface AuthCommand extends ExtensionPoint { + void login(String url) + void logout() + void config() + void status() + } + static public final String NAME = 'auth' - static final Map SEQERA_ENDPOINTS = [ - 'prod': 'https://api.cloud.seqera.io', - 'stage': 'https://api.cloud.stage-seqera.io', - 'dev': 'https://api.cloud.dev-seqera.io' - ] - static final int API_TIMEOUT_MS = 10000 - static final int AUTH_POLL_TIMEOUT_RETRIES = 60 - static final int AUTH_POLL_INTERVAL_SECONDS = 5 - static final int WORKSPACE_SELECTION_THRESHOLD = 8 // Max workspaces to show in single list; above this uses org-first selection private List commands = [] + private AuthCommand operation + String getName() { return NAME } @@ -109,16 +115,16 @@ class CmdAuth extends CmdBase implements UsageAware { usage() return } - - try { - def cmd = getCmd(args) - if (cmd instanceof LoginCmd && apiUrl) { - cmd.apiUrl = apiUrl - } - cmd.apply(args.drop(1)) - } catch (Exception e) { - throw new AbortOperationException(e.message) - } + // setup the plugins system and load the secrets provider + Plugins.init() + // load the config + Plugins.start('nf-tower') + // load the command operations + this.operation = Plugins.getExtension(AuthCommand) + if( !operation ) + throw new IllegalStateException("Unable to load lineage extensions.") + // consume the first argument + getCmd(args).apply(args.drop(1)) } protected SubCmd getCmd(List args) { @@ -134,495 +140,23 @@ class CmdAuth extends CmdBase implements UsageAware { throw new AbortOperationException(msg) } - - private String promptForApiUrl() { - System.out.print("Seqera Platform API endpoint [Default ${SEQERA_ENDPOINTS.prod}]: ") - System.out.flush() - - def input = readUserInput() - return input?.isEmpty() ? SEQERA_ENDPOINTS.prod : input - } - - private String readUserInput() { - def reader = new BufferedReader(new InputStreamReader(System.in)) - return reader.readLine()?.trim() - } - - private HttpURLConnection createHttpConnection(String url, String method, String authToken = null) { - def connection = new URL(url).openConnection() as HttpURLConnection - connection.requestMethod = method - connection.connectTimeout = API_TIMEOUT_MS - connection.readTimeout = API_TIMEOUT_MS - if (authToken) { - connection.setRequestProperty('Authorization', "Bearer ${authToken}") - } - return connection - } - - private void validateArgumentCount(List args, String commandName) { - if (args.size() > 0) { - throw new AbortOperationException("Too many arguments for ${commandName} command") - } - } - - private Map getWorkspaceDetailsFromApi(String accessToken, String endpoint, String workspaceId) { - try { - def userInfo = callUserInfoApi(accessToken, endpoint) - def userId = userInfo.id as String - - def connection = createHttpConnection("${endpoint}/user/${userId}/workspaces", 'GET', accessToken) - - if (connection.responseCode != 200) { - return null - } - - def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) as Map - def orgsAndWorkspaces = json.orgsAndWorkspaces as List - - def workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } - if (workspace) { - def ws = workspace as Map - return [ - orgName: ws.orgName, - workspaceName: ws.workspaceName, - workspaceFullName: ws.workspaceFullName - ] - } - - return null - } catch (Exception e) { - return null - } - } - - private Map getCloudEndpointInfo(String apiUrl) { - for (env in SEQERA_ENDPOINTS) { - def standardUrl = env.value - def legacyUrl = standardUrl.replace('://api.', '://') + '/api' - if (apiUrl == standardUrl || apiUrl == legacyUrl) { - return [isCloud: true, environment: env.key] - } - } - return [isCloud: false, environment: null] - } - - private boolean isCloudEndpoint(String apiUrl) { - return getCloudEndpointInfo(apiUrl).isCloud - } - - private Map callUserInfoApi(String accessToken, String apiUrl) { - def connection = createHttpConnection("${apiUrl}/user-info", 'GET', accessToken) - - if (connection.responseCode != 200) { - def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" - throw new RuntimeException("Failed to get user info: ${error}") - } - - def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) as Map - return json.user as Map - } - - private Path getConfigFile() { - return Const.APP_HOME_DIR.resolve('config') - } - - private Map readConfig() { - def configFile = getConfigFile() - if (!Files.exists(configFile)) { - return [:] - } - - try { - def configText = Files.readString(configFile) - def config = new ConfigSlurper().parse(configText) - return config.flatten() - } catch (Exception e) { - throw new RuntimeException("Failed to read config file ${configFile}: ${e.message}") - } - } - - private String cleanTowerConfig(String content) { - // Remove tower scoped blocks: tower { ... } - content = content.replaceAll(/(?ms)tower\s*\{.*?\}/, '') - // Remove individual tower.* lines - content = content.replaceAll(/(?m)^tower\..*$\n?/, '') - // Remove Seqera Platform configuration comment - content = content.replaceAll(/\/\/\s*Seqera Platform configuration\s*/, '') - // Clean up extra whitespace - return content.replaceAll(/\n\n+/, '\n\n').trim() + "\n\n" - } - - private void writeConfig(Map config, Map workspaceMetadata = null) { - def configFile = getConfigFile() - - // Create directory if it doesn't exist - if (!Files.exists(configFile.parent)) { - Files.createDirectories(configFile.parent) - } - - // Read existing config and clean out old tower blocks - def configText = new StringBuilder() - if (Files.exists(configFile)) { - def existingContent = Files.readString(configFile) - def cleanedContent = cleanTowerConfig(existingContent) - configText.append(cleanedContent) - } - - // Write tower config block - def towerConfig = config.findAll { key, value -> - key.toString().startsWith('tower.') && !key.toString().endsWith('.comment') - } - - configText.append("// Seqera Platform configuration\n") - configText.append("tower {\n") - towerConfig.each { key, value -> - def configKey = key.toString().substring(6) // Remove "tower." prefix - - if (value instanceof String) { - def line = " ${configKey} = '${value}'" - // Add workspace comment if this is workspaceId and we have metadata - if (configKey == 'workspaceId' && workspaceMetadata) { - line += " // ${workspaceMetadata.orgName} / ${workspaceMetadata.workspaceName} [${workspaceMetadata.workspaceFullName}]" - } - configText.append("${line}\n") - } else { - configText.append(" ${configKey} = ${value}\n") - } - } - configText.append("}\n") - - Files.writeString(configFile, configText.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) - } - // // nextflow auth login // class LoginCmd implements SubCmd { - // Auth0 configuration per environment - private static final Map AUTH0_CONFIG = [ - 'dev': [ - domain: 'seqera-development.eu.auth0.com', - clientId: 'Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ' - ], - 'stage': [ - domain: 'seqera-stage.eu.auth0.com', - clientId: '60cPDjI6YhoTPjyMTIBjGtxatSUwWswB' - ], - 'prod': [ - domain: 'seqera.eu.auth0.com', - clientId: 'FxCM8EJ76nNeHUDidSHkZfT8VtsrhHeL' - ] - ] - - String apiUrl - - private Map getAuth0Config(String environment) { - def config = AUTH0_CONFIG[environment] as Map - if (!config) { - throw new RuntimeException("Unknown environment: ${environment}") - } - return config - } - - @Override String getName() { 'login' } @Override void apply(List args) { - validateArgumentCount(args, name) - - // Check if TOWER_ACCESS_TOKEN environment variable is set - def envToken = System.getenv('TOWER_ACCESS_TOKEN') - if (envToken) { - println "" - ColorUtil.printColored("WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable.", "yellow bold") - ColorUtil.printColored("${ColorUtil.colorize('nextflow auth login', 'cyan')} sets credentials using Nextflow config files, which take precedence over the environment variable.", "dim") - ColorUtil.printColored(" however, caution is advised to avoid confusing behaviour.", "dim") - println "" - } - - // Check if tower.accessToken is already set - def config = readConfig() - def existingToken = config['tower.accessToken'] - if (existingToken) { - ColorUtil.printColored("Error: Authentication token is already configured in Nextflow config.", "red") - ColorUtil.printColored("Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") - println " Run ${ColorUtil.colorize('nextflow auth logout', 'cyan')} to remove the current authentication." - return + if (args.size() > 0) { + throw new AbortOperationException("Too many arguments for ${name} command") } - - ColorUtil.printColored("Nextflow authentication with Seqera Platform", "cyan bold") - ColorUtil.printColored(" - Authentication will be saved to: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") - - apiUrl = normalizeApiUrl(apiUrl) - ColorUtil.printColored(" - Seqera Platform API endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')} (can be customised with ${ColorUtil.colorize('-url', 'cyan')})", "dim") - - // Check if this is a cloud endpoint or enterprise - def endpointInfo = getCloudEndpointInfo(apiUrl) - if (endpointInfo.isCloud) { - try { - performAuth0Login(apiUrl, endpointInfo.environment as String) - } catch (Exception e) { - log.debug("Authentication failed", e) - println "" - throw new AbortOperationException("${e.message}") - } - } else { - // Enterprise endpoint - use PAT authentication - handleEnterpriseAuth(apiUrl) - } - } - - private void performAuth0Login(String apiUrl, String environment) { - // Get Auth0 configuration for this environment - def auth0Config = getAuth0Config(environment) - - // Start device authorization flow - def deviceAuth = requestDeviceAuthorization(auth0Config) - - println "" - ColorUtil.printColored("Confirmation code: ${ColorUtil.colorize(deviceAuth.user_code as String, 'yellow')}", "cyan bold") - def urlWithCode = "${deviceAuth.verification_uri}?user_code=${deviceAuth.user_code}" - println "${ColorUtil.colorize('Authentication URL:', 'cyan bold')} ${ColorUtil.colorize(urlWithCode, 'magenta')}" - ColorUtil.printColored("\n[ Press Enter to open in browser ]", "cyan bold") - System.in.read() // Wait for Enter key - - // Try to open browser automatically - def browserOpened = false - try { - // Method 1: Java Desktop API - if (java.awt.Desktop.isDesktopSupported()) { - def desktop = java.awt.Desktop.getDesktop() - if (desktop.isSupported(java.awt.Desktop.Action.BROWSE)) { - desktop.browse(new URI(urlWithCode)) - browserOpened = true - } - } - - // Method 2: Platform-specific commands - if (!browserOpened) { - def os = System.getProperty("os.name").toLowerCase() - def command = [] - - if (os.contains("mac") || os.contains("darwin")) { - command = ["open", urlWithCode] - } else if (os.contains("win")) { - command = ["cmd", "/c", "start", urlWithCode] - } else { - // Linux and other Unix-like systems - def browsers = ["xdg-open", "firefox", "google-chrome", "chromium", "safari"] - for (browser in browsers) { - try { - new ProcessBuilder(browser, urlWithCode).start() - browserOpened = true - break - } catch (Exception ignored) { - // Try next browser - } - } - } - - if (!browserOpened && command) { - new ProcessBuilder(command as String[]).start() - browserOpened = true - } - } - } catch (Exception ignored) { - // Will handle below - } - - if (!browserOpened) { - ColorUtil.printColored("Could not open browser automatically. Please copy the URL above and open it manually in your browser.", "yellow") - } - print("${ColorUtil.colorize('Waiting for authentication...', 'dim', true)}") - - try { - // Poll for device token - def tokenData = pollForDeviceToken(deviceAuth.device_code as String, deviceAuth.interval as Integer ?: 5, auth0Config) - def accessToken = tokenData['access_token'] as String - - // Verify login by calling /user-info - def userInfo = callUserInfoApi(accessToken, apiUrl) - ColorUtil.printColored("\n\nAuthentication successful!", "green") - - // Generate PAT - def pat = generatePAT(accessToken, apiUrl) - - // Save to config - saveAuthToConfig(pat, apiUrl) - - // Automatically run configuration - runConfiguration() - - } catch (Exception e) { - throw new RuntimeException("Authentication failed: ${e.message}", e) - } - } - - private void runConfiguration() { - try { - println "" - // Just run the existing config command - new ConfigCmd().apply([]) - } catch (Exception e) { - ColorUtil.printColored("Configuration setup failed: ${e.message}", "red") - ColorUtil.printColored("You can run 'nextflow auth config' later to set up your configuration.", "dim") - } - } - - private Map requestDeviceAuthorization(Map auth0Config) { - def params = [ - 'client_id': auth0Config.clientId, - 'scope': 'openid profile email offline_access', - 'audience': 'platform' - ] - return performAuth0Request("https://${auth0Config.domain}/oauth/device/code", params) - } - - private Map pollForDeviceToken(String deviceCode, int intervalSeconds, Map auth0Config) { - def tokenUrl = "https://${auth0Config.domain}/oauth/token" - def retryCount = 0 - - while (retryCount < AUTH_POLL_TIMEOUT_RETRIES) { - def params = [ - 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', - 'device_code': deviceCode, - 'client_id': auth0Config.clientId - ] - - try { - def result = performAuth0Request(tokenUrl, params) - return result - } catch (RuntimeException e) { - def message = e.message - if (message.contains('authorization_pending')) { - print "${ColorUtil.colorize('.', 'dim', true)}" - System.out.flush() - } else if (message.contains('slow_down')) { - intervalSeconds += 5 - print "${ColorUtil.colorize('.', 'dim', true)}" - System.out.flush() - } else if (message.contains('expired_token')) { - throw new RuntimeException("The device code has expired. Please try again.") - } else if (message.contains('access_denied')) { - throw new RuntimeException("Access denied by user") - } else { - throw e - } - } - - Thread.sleep(intervalSeconds * 1000) - retryCount++ - } - - throw new RuntimeException("Authentication timed out. Please try again.") - } - - - private void handleEnterpriseAuth(String apiUrl) { - println "" - ColorUtil.printColored("Please generate a Personal Access Token from your Seqera Platform instance.", "cyan bold") - println "You can create one at: ${ColorUtil.colorize(apiUrl.replace('/api', '').replace('://api.', '') + '/tokens', 'magenta')}" - println "" - - System.out.print("Enter your Personal Access Token: ") - System.out.flush() - - def console = System.console() - def pat = console ? - new String(console.readPassword()) : - new BufferedReader(new InputStreamReader(System.in)).readLine() - - if (!pat || pat.trim().isEmpty()) { - throw new AbortOperationException("Personal Access Token is required for Seqera Platform Enterprise authentication") - } - - // Save to config - saveAuthToConfig(pat.trim(), apiUrl) - ColorUtil.printColored("Personal Access Token saved to Nextflow config", "green") - ColorUtil.printColored("Config file: ${getConfigFile().toString()}", "magenta") - } - - private String generatePAT(String accessToken, String apiUrl) { - def tokensUrl = "${apiUrl}/tokens" - def username = System.getProperty("user.name") - def timestamp = new Date().format("yyyy-MM-dd-HH-mm") - def tokenName = "nextflow-${username}-${timestamp}" - - def requestBody = new groovy.json.JsonBuilder([name: tokenName]).toString() - - def connection = createHttpConnection(tokensUrl, 'POST', accessToken) - connection.setRequestProperty('Content-Type', 'application/json') - connection.doOutput = true - - connection.outputStream.withWriter { writer -> - writer.write(requestBody) - } - - if (connection.responseCode != 200) { - def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" - throw new RuntimeException("Failed to generate PAT: ${error}") - } - - def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) as Map - return json.accessKey as String - } - - private String normalizeApiUrl(String url) { - if (!url) { - return SEQERA_ENDPOINTS.prod - } - if (!url.startsWith('http://') && !url.startsWith('https://')) { - return 'https://' + url - } - return url + operation.login(apiUrl) } - private Map performAuth0Request(String url, Map params) { - def postData = params.collect { k, v -> - "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" - }.join('&') - - def connection = new URL(url).openConnection() as HttpURLConnection - connection.requestMethod = 'POST' - connection.connectTimeout = API_TIMEOUT_MS - connection.readTimeout = API_TIMEOUT_MS - connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded') - connection.doOutput = true - connection.outputStream.withWriter { writer -> - writer.write(postData) - } - - if (connection.responseCode == 200) { - def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) - return json as Map - } else { - def errorResponse = connection.errorStream?.text - if (errorResponse) { - def errorJson = new groovy.json.JsonSlurper().parseText(errorResponse) as Map - def error = errorJson.error - throw new RuntimeException("${error}: ${errorJson.error_description ?: ''}") - } else { - throw new RuntimeException("Request failed: HTTP ${connection.responseCode}") - } - } - } - - private void saveAuthToConfig(String accessToken, String apiUrl) { - def config = CmdAuth.this.readConfig() - config['tower.accessToken'] = accessToken - config['tower.endpoint'] = apiUrl - config['tower.enabled'] = true - - CmdAuth.this.writeConfig(config, null) - } @Override void usage(List result) { @@ -648,105 +182,12 @@ class CmdAuth extends CmdBase implements UsageAware { @Override void apply(List args) { - validateArgumentCount(args, name) - - // Check if TOWER_ACCESS_TOKEN environment variable is set - def envToken = System.getenv('TOWER_ACCESS_TOKEN') - if (envToken) { - println "" - ColorUtil.printColored("WARNING: TOWER_ACCESS_TOKEN environment variable is set.", "yellow bold") - println " ${ColorUtil.colorize('nextflow auth logout', 'dim cyan')}${ColorUtil.colorize(' only removes credentials from Nextflow config files.', 'dim')}" - ColorUtil.printColored(" The environment variable will remain unaffected.", "dim") - println "" - } - - // Check if tower.accessToken is set - def config = readConfig() - def existingToken = config['tower.accessToken'] - def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' - - if (!existingToken) { - ColorUtil.printColored("Error: No authentication token found in Nextflow config.", "red") - println "Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}" - return - } - - ColorUtil.printColored(" - Found authentication token in config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") - - // Prompt user for API URL if not already configured - def apiUrl = endpoint as String - if (!apiUrl || apiUrl.isEmpty()) { - apiUrl = promptForApiUrl() - } else { - ColorUtil.printColored(" - Using Seqera Platform endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')}", "dim") - } - - // Validate token by calling /user-info API - try { - def userInfo = callUserInfoApi(existingToken as String, apiUrl) - ColorUtil.printColored(" - Token is valid for user: ${ColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") - - // Only delete PAT from platform if this is a cloud endpoint - if (isCloudEndpoint(apiUrl)) { - def tokenId = decodeTokenId(existingToken as String) - deleteTokenViaApi(existingToken as String, apiUrl, tokenId) - } else { - println " - Enterprise installation detected - PAT will not be deleted from platform." - } - - removeAuthFromConfig() - - } catch (Exception e) { - println "Failed to validate or delete token: ${e.message}" - println "Removing token from config anyway..." - - // Remove from config even if API calls fail - removeAuthFromConfig() - ColorUtil.printColored("Token removed from Nextflow config.", "green") - } - } - - private String decodeTokenId(String token) { - try { - // Decode base64 token - def decoded = new String(Base64.decoder.decode(token), "UTF-8") - - // Parse JSON to extract token ID - def json = new groovy.json.JsonSlurper().parseText(decoded) as Map - def tokenId = json.tid - - if (!tokenId) { - throw new RuntimeException("No token ID found in decoded token") - } - - return tokenId.toString() - } catch (Exception e) { - throw new RuntimeException("Failed to decode token ID: ${e.message}") + if (args.size() > 0) { + throw new AbortOperationException("Too many arguments for ${name} command") } + operation.logout() } - private void deleteTokenViaApi(String token, String apiUrl, String tokenId) { - def connection = createHttpConnection("${apiUrl}/tokens/${tokenId}", 'DELETE', token) - - if (connection.responseCode != 200 && connection.responseCode != 204) { - def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" - throw new RuntimeException("Failed to delete token: ${error}") - } - - ColorUtil.printColored("\nToken successfully deleted from Seqera Platform.", "green") - } - - private void removeAuthFromConfig() { - def configFile = getConfigFile() - - if (Files.exists(configFile)) { - def existingContent = Files.readString(configFile) - def cleanedContent = cleanTowerConfig(existingContent) - Files.writeString(configFile, cleanedContent, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) - } - - ColorUtil.printColored("Authentication removed from Nextflow config.", "green") - } @Override void usage(List result) { @@ -772,357 +213,11 @@ class CmdAuth extends CmdBase implements UsageAware { @Override void apply(List args) { - validateArgumentCount(args, name) - - // Check if user is authenticated - def config = readConfig() - def existingToken = config['tower.accessToken'] ?: System.getenv('TOWER_ACCESS_TOKEN') - def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' - - if (!existingToken) { - println "No authentication found. Please run ${ColorUtil.colorize('nextflow auth login', 'cyan')} first." - return - } - - ColorUtil.printColored("Nextflow Seqera Platform configuration", "cyan bold") - ColorUtil.printColored(" - Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") - - // Check if token is from environment variable - if (!config['tower.accessToken'] && System.getenv('TOWER_ACCESS_TOKEN')) { - ColorUtil.printColored(" - Using access token from TOWER_ACCESS_TOKEN environment variable", "dim") - } - - try { - // Get user info to validate token and get user ID - def userInfo = callUserInfoApi(existingToken as String, endpoint as String) - ColorUtil.printColored(" - Authenticated as: ${ColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") - println "" - - // Track if any changes are made - def configChanged = false - - // Configure tower.enabled - configChanged |= configureEnabled(config) - - // Configure workspace - def workspaceResult = configureWorkspace(config, existingToken as String, endpoint as String, userInfo.id as String) - configChanged = configChanged || (workspaceResult.changed as boolean) - - // Save updated config only if changes were made - if (configChanged) { - writeConfig(config, workspaceResult.metadata as Map) - - // Show the new configuration - println "\nNew configuration:" - showCurrentConfig(config, existingToken as String, endpoint as String) - - ColorUtil.printColored("\nConfiguration saved to ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "green") - } else { - ColorUtil.printColored("\nNo configuration changes were made.", "dim") - } - - } catch (Exception e) { - throw new AbortOperationException("Failed to configure settings: ${e.message}") - } - } - - private void showCurrentConfig(Map config, String accessToken, String endpoint) { - // Show workflow monitoring status - def monitoringEnabled = config.get('tower.enabled', false) - println " ${ColorUtil.colorize('Workflow monitoring:', 'cyan')} ${monitoringEnabled ? ColorUtil.colorize('enabled', 'green') : ColorUtil.colorize('disabled', 'red')}" - - // Show workspace setting - def workspaceId = config.get('tower.workspaceId') - if (workspaceId) { - // Try to get workspace details from API for display - def workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpoint, workspaceId as String) - if (workspaceDetails) { - def details = workspaceDetails as Map - println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')} ${ColorUtil.colorize('[' + details.orgName + ' / ' + details.workspaceName + ']', 'dim', true)}" - } else { - println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')}" - } - } else { - println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize('None (Personal workspace)', 'magenta')}" - } - - // Show API endpoint - def apiEndpoint = config.get('tower.endpoint') ?: endpoint - println " ${ColorUtil.colorize('API endpoint:', 'cyan', true)} $apiEndpoint" - } - - private boolean configureEnabled(Map config) { - def currentEnabled = config.get('tower.enabled', false) - - println "Workflow monitoring settings. Current setting: ${currentEnabled ? ColorUtil.colorize('enabled', 'green') : ColorUtil.colorize('disabled', 'red')}" - ColorUtil.printColored(" When enabled, all workflow runs are automatically monitored by Seqera Platform", "dim") - ColorUtil.printColored(" When disabled, you can enable per-run with the ${ColorUtil.colorize('-with-tower', 'cyan')} flag", "dim") - println "" - - def promptText = "${ColorUtil.colorize('Enable workflow monitoring for all runs?', 'cyan bold', true)} (${currentEnabled ? ColorUtil.colorize('Y', 'green') + '/n' : 'y/' + ColorUtil.colorize('N', 'red')}): " - def input = promptForYesNo(promptText, currentEnabled) - - if (input == null) { - return false // No change - } else if (input && !currentEnabled) { - config['tower.enabled'] = true - return true - } else if (!input && currentEnabled) { - config.remove('tower.enabled') - return true - } - return false - } - - private Map configureWorkspace(Map config, String accessToken, String endpoint, String userId) { - // Check if TOWER_WORKFLOW_ID environment variable is set - def envWorkspaceId = System.getenv('TOWER_WORKFLOW_ID') - if (envWorkspaceId) { - println "\nDefault workspace: ${ColorUtil.colorize('TOWER_WORKFLOW_ID environment variable is set', 'yellow')}" - ColorUtil.printColored(" Not prompting for default workspace configuration as environment variable takes precedence", "dim") - return [changed: false, metadata: null] - } - - // Get all workspaces for the user - def workspaces = getUserWorkspaces(accessToken, endpoint, userId) - - if (!workspaces) { - println "\nNo workspaces found for your account." - return [changed: false, metadata: null] - } - - // Show current workspace setting - def currentWorkspaceId = config.get('tower.workspaceId') - def currentWorkspace = workspaces.find { ((Map)it).workspaceId.toString() == currentWorkspaceId?.toString() } - - def currentSetting - if (currentWorkspace) { - def workspace = currentWorkspace as Map - currentSetting = "${workspace.orgName} / ${workspace.workspaceName}" - } else { - currentSetting = "None (Personal workspace)" - } - - println "\nDefault workspace. Current setting: ${ColorUtil.colorize(currentSetting as String, 'cyan', true)}" - ColorUtil.printColored(" Workflow runs use this workspace by default", "dim") - // Group by organization - def orgWorkspaces = workspaces.groupBy { ((Map)it).orgName ?: 'Personal' } - - // If threshold or fewer total options, show all at once - if (workspaces.size() <= WORKSPACE_SELECTION_THRESHOLD) { - return selectWorkspaceFromAll(config, workspaces, currentWorkspaceId) - } else { - // Two-stage selection: org first, then workspace - return selectWorkspaceByOrg(config, orgWorkspaces, currentWorkspaceId) - } - } - - private Map selectWorkspaceFromAll(Map config, List workspaces, def currentWorkspaceId) { - println "\nAvailable workspaces:" - println " 0. ${ColorUtil.colorize('None (Personal workspace)', 'cyan', true)} ${ColorUtil.colorize('[no organization]', 'dim', true)}" - - workspaces.eachWithIndex { workspace, index -> - def ws = workspace as Map - def prefix = ws.orgName ? "${ColorUtil.colorize(ws.orgName as String, 'cyan', true)} / " : "" - println " ${index + 1}. ${prefix}${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}" - } - - // Show current workspace and prepare prompt - def currentWorkspace = workspaces.find { ((Map)it).workspaceId.toString() == currentWorkspaceId?.toString() } - def currentWorkspaceName - if (currentWorkspace) { - def workspace = currentWorkspace as Map - currentWorkspaceName = "${workspace.orgName} / ${workspace.workspaceName}" - } else { - currentWorkspaceName = "None (Personal workspace)" - } - - def prompt = "\nSelect workspace (0-${workspaces.size()}, press Enter to keep as '${currentWorkspaceName}'): " - def selection = promptForNumber(prompt, 0, workspaces.size(), true) - - if (selection == null) { - return [changed: false, metadata: null] + if (args.size() > 0) { + throw new AbortOperationException("Too many arguments for ${name} command") } - if (selection == 0) { - def hadWorkspaceId = config.containsKey('tower.workspaceId') - config.remove('tower.workspaceId') - return [changed: hadWorkspaceId, metadata: null] - } else { - def selectedWorkspace = workspaces[selection - 1] as Map - def selectedId = selectedWorkspace.workspaceId.toString() - def currentId = config.get('tower.workspaceId') - config['tower.workspaceId'] = selectedId - def metadata = [ - orgName: selectedWorkspace.orgName, - workspaceName: selectedWorkspace.workspaceName, - workspaceFullName: selectedWorkspace.workspaceFullName - ] - return [changed: currentId != selectedId, metadata: metadata] - } - } - - private Map selectWorkspaceByOrg(Map config, Map orgWorkspaces, def currentWorkspaceId) { - // Get current workspace info for prompts - def allWorkspaces = [] - orgWorkspaces.values().each { workspaceList -> - allWorkspaces.addAll(workspaceList as List) - } - def currentWorkspace = allWorkspaces.find { ((Map)it).workspaceId.toString() == currentWorkspaceId?.toString() } - def currentWorkspaceName - def currentWorkspaceDisplay - if (currentWorkspace) { - def workspace = currentWorkspace as Map - currentWorkspaceName = workspace.workspaceName as String - currentWorkspaceDisplay = "${workspace.orgName} / ${workspace.workspaceName}" - } else { - currentWorkspaceName = null - currentWorkspaceDisplay = "None" - } - - // First, select organization - def orgs = orgWorkspaces.keySet().toList() - - // Always add Personal as first option (it's never returned by the API but should always be available) - orgs.add(0, 'Personal') - - println "\nAvailable organizations:" - orgs.eachWithIndex { orgName, index -> - def displayName = orgName == 'Personal' ? 'None [Personal workspace]' : orgName - println " ${index + 1}. ${ColorUtil.colorize(displayName as String, 'cyan', true)}" - } - System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size()}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") - System.out.flush() - - def reader = new BufferedReader(new InputStreamReader(System.in)) - def orgSelection - while (true) { - def orgInput = reader.readLine()?.trim() - - if (orgInput.isEmpty()) { - return [changed: false, metadata: null] - } - - try { - orgSelection = Integer.parseInt(orgInput) - if (orgSelection >= 1 && orgSelection <= orgs.size()) { - break - } - } catch (NumberFormatException e) { - // Fall through to error message - } - ColorUtil.printColored("Invalid input. Please enter a number between 1 and ${orgs.size()}.", "red") - System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size()}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") - System.out.flush() - } - - def selectedOrgName = orgs[orgSelection - 1] - - // If Personal was selected, remove workspace ID (use personal workspace) - if (selectedOrgName == 'Personal') { - def hadWorkspaceId = config.containsKey('tower.workspaceId') - config.remove('tower.workspaceId') - return [changed: hadWorkspaceId, metadata: null] - } - - def orgWorkspaceList = orgWorkspaces[selectedOrgName] as List - - println "" - println "Select workspace in ${selectedOrgName}:" - - orgWorkspaceList.eachWithIndex { workspace, index -> - def ws = workspace as Map - println " ${index + 1}. ${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}" - } - - def maxSelection = orgWorkspaceList.size() - def wsSelection - while (true) { - System.out.print("${ColorUtil.colorize("Select workspace (1-${maxSelection}): ", 'dim', true)}") - System.out.flush() - - def wsInput = reader.readLine()?.trim() - if (wsInput.isEmpty()) { - ColorUtil.printColored("Please enter a selection.", "red") - continue - } - - try { - wsSelection = Integer.parseInt(wsInput) - if (wsSelection >= 1 && wsSelection <= maxSelection) { - break - } - } catch (NumberFormatException e) { - // Fall through to error message - } - ColorUtil.printColored("Invalid input. Please enter a number between 1 and ${maxSelection}.", "red") - } - - def selectedWorkspace = orgWorkspaceList[wsSelection - 1] as Map - def selectedId = selectedWorkspace.workspaceId.toString() - def currentId = config.get('tower.workspaceId') - config['tower.workspaceId'] = selectedId - def metadata = [ - orgName: selectedWorkspace.orgName, - workspaceName: selectedWorkspace.workspaceName, - workspaceFullName: selectedWorkspace.workspaceFullName - ] - return [changed: currentId != selectedId, metadata: metadata] - } - - private List getUserWorkspaces(String accessToken, String endpoint, String userId) { - def connection = createHttpConnection("${endpoint}/user/${userId}/workspaces", 'GET', accessToken) - - if (connection.responseCode != 200) { - def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" - throw new RuntimeException("Failed to get workspaces: ${error}") - } - - def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) as Map - def orgsAndWorkspaces = json.orgsAndWorkspaces as List - - return orgsAndWorkspaces.findAll { ((Map)it).workspaceId != null } - } - - private Boolean promptForYesNo(String prompt, Boolean defaultValue) { - while (true) { - System.out.print(prompt) - System.out.flush() - def input = readUserInput()?.toLowerCase() - - if (input?.isEmpty()) { - return null // Keep current setting - } else if (input in ['y', 'yes']) { - return true - } else if (input in ['n', 'no']) { - return false - } else { - ColorUtil.printColored("Invalid input. Please enter 'y', 'n', or press Enter to keep current setting.", "red") - } - } - } - - private Integer promptForNumber(String prompt, int min, int max, boolean allowEmpty = false) { - while (true) { - ColorUtil.printColored(prompt, "bold cyan") - System.out.flush() - def input = readUserInput() - - if (input?.isEmpty() && allowEmpty) { - return null - } - - try { - def number = Integer.parseInt(input) - if (number >= min && number <= max) { - return number - } - } catch (NumberFormatException e) { - // Fall through to error message - } - ColorUtil.printColored("Invalid input. Please enter a number between ${min} and ${max}.", "red") - } + operation.config() } @@ -1146,164 +241,10 @@ class CmdAuth extends CmdBase implements UsageAware { @Override void apply(List args) { - validateArgumentCount(args, name) - - def config = readConfig() - - // Collect all status information - List> statusRows = [] - def workspaceDisplayInfo = null - - // API endpoint - def endpointInfo = getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://api.cloud.seqera.io') - statusRows.add(['API endpoint', ColorUtil.colorize(endpointInfo.value as String, 'magenta'), endpointInfo.source as String]) - - // API connection check - def apiConnectionOk = checkApiConnection(endpointInfo.value as String) - def connectionColor = apiConnectionOk ? 'green' : 'red' - statusRows.add(['API connection', ColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) - - // Authentication check - def tokenInfo = getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') - if (tokenInfo.value) { - try { - def userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) - def currentUser = userInfo.userName as String - statusRows.add(['Authentication', "${ColorUtil.colorize('OK', 'green')} (user: ${ColorUtil.colorize(currentUser, 'cyan')})".toString(), tokenInfo.source as String]) - } catch (Exception e) { - statusRows.add(['Authentication', ColorUtil.colorize('ERROR', 'red'), 'failed']) - } - } else { - statusRows.add(['Authentication', "${ColorUtil.colorize('ERROR', 'red')} ${ColorUtil.colorize('(no token)', 'dim')}".toString(), 'not set']) - } - - // Monitoring enabled - def enabledInfo = getConfigValue(config, 'tower.enabled', null, 'false') - def enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' - def enabledColor = enabledValue == 'Yes' ? 'green' : 'yellow' - statusRows.add(['Workflow monitoring', ColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) - - // Default workspace - def workspaceInfo = getConfigValue(config, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') - if (workspaceInfo.value) { - // Try to get workspace name from API if we have a token - def workspaceDetails = null - if (tokenInfo.value) { - workspaceDetails = getWorkspaceDetailsFromApi(tokenInfo.value as String, endpointInfo.value as String, workspaceInfo.value as String) - } - - if (workspaceDetails) { - // Add workspace ID row - statusRows.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) - // Store workspace details for display after the table - workspaceDisplayInfo = workspaceDetails - } else { - statusRows.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue', true), workspaceInfo.source as String]) - } - } else { - statusRows.add(['Default workspace', ColorUtil.colorize('None (Personal workspace)', 'cyan', true), 'default']) - } - - // Print table - println "" - printStatusTable(statusRows) - - // Print workspace details if available - if (workspaceDisplayInfo) { - println "${' '*22}${ColorUtil.colorize(workspaceDisplayInfo.orgName as String, 'cyan')} / ${ColorUtil.colorize(workspaceDisplayInfo.workspaceName as String, 'cyan')} ${ColorUtil.colorize('[' + (workspaceDisplayInfo.workspaceFullName as String) + ']', 'dim')}" - } - } - - private void printStatusTable(List> rows) { - if (!rows) return - - // Calculate column widths (accounting for ANSI codes) - def col1Width = rows.collect { stripAnsiCodes(it[0]).length() }.max() - def col2Width = rows.collect { stripAnsiCodes(it[1]).length() }.max() - def col3Width = rows.collect { stripAnsiCodes(it[2]).length() }.max() - - // Add some padding - col1Width = Math.max(col1Width, 15) + 2 - col2Width = Math.max(col2Width, 15) + 2 - col3Width = Math.max(col3Width, 10) + 2 - - // Print table header - ColorUtil.printColored("${'Setting'.padRight(col1Width)} ${'Value'.padRight(col2Width)} Source", "cyan bold") - println "${'-' * col1Width} ${'-' * col2Width} ${'-' * col3Width}" - - // Print rows - rows.each { row -> - def paddedCol1 = padStringWithAnsi(row[0], col1Width) - def paddedCol2 = padStringWithAnsi(row[1], col2Width) - def paddedCol3 = ColorUtil.colorize(row[2], 'dim', true) - println "${paddedCol1} ${paddedCol2} ${paddedCol3}" - } - } - - private String stripAnsiCodes(String text) { - return text?.replaceAll(/\u001B\[[0-9;]*m/, '') ?: '' - } - - private String padStringWithAnsi(String text, int width) { - def plainText = stripAnsiCodes(text) - def padding = width - plainText.length() - return padding > 0 ? text + (' ' * padding) : text - } - - private String shortenPath(String path) { - def userHome = System.getProperty('user.home') - if (path.startsWith(userHome)) { - return '~' + path.substring(userHome.length()) - } - return path - } - - private Map getConfigValue(Map config, String configKey, String envVarName, String defaultValue = null) { - def configValue = config[configKey] - def envValue = envVarName ? System.getenv(envVarName) : null - def effectiveValue = configValue ?: envValue ?: defaultValue - - def source = null - if (configValue) { - source = shortenPath(getConfigFile().toString()) - } else if (envValue) { - source = "env var \$${envVarName}" - } else if (defaultValue) { - source = "default" - } - - return [ - value: effectiveValue, - source: source, - fromConfig: configValue != null, - fromEnv: envValue != null, - isDefault: !configValue && !envValue - ] - } - - private String getWorkspaceNameFromApi(String accessToken, String endpoint, String workspaceId) { - try { - def workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpoint, workspaceId) - if (workspaceDetails) { - return "${workspaceDetails.orgName} / ${workspaceDetails.workspaceName} [${workspaceDetails.workspaceFullName}]" - } - return null - } catch (Exception e) { - return null - } - } - - - private boolean checkApiConnection(String endpoint) { - try { - def connection = new URL("${endpoint}/service-info").openConnection() as HttpURLConnection - connection.requestMethod = 'GET' - connection.connectTimeout = API_TIMEOUT_MS - connection.readTimeout = API_TIMEOUT_MS - return connection.responseCode == 200 - } catch (Exception e) { - return false + if (args.size() > 0) { + throw new AbortOperationException("Too many arguments for ${name} command") } + operation.status() } diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy new file mode 100644 index 0000000000..f723ab1384 --- /dev/null +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -0,0 +1,1108 @@ +package io.seqera.tower.plugin.cli + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import nextflow.Const +import nextflow.cli.CmdAuth +import nextflow.cli.ColorUtil +import nextflow.exception.AbortOperationException + +import java.awt.Desktop +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption + +@Slf4j +@CompileStatic +class TowerAuthCommand implements CmdAuth.AuthCommand { + + static final Map SEQERA_ENDPOINTS = [ + 'prod': 'https://api.cloud.seqera.io', + 'stage': 'https://api.cloud.stage-seqera.io', + 'dev': 'https://api.cloud.dev-seqera.io' + ] + static final int API_TIMEOUT_MS = 10000 + static final int AUTH_POLL_TIMEOUT_RETRIES = 60 + static final int AUTH_POLL_INTERVAL_SECONDS = 5 + static final int WORKSPACE_SELECTION_THRESHOLD = 8 // Max workspaces to show in single list; above this uses org-first selection + private static final Map AUTH0_CONFIG = [ + 'dev' : [ + domain : 'seqera-development.eu.auth0.com', + clientId: 'Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ' + ], + 'stage': [ + domain : 'seqera-stage.eu.auth0.com', + clientId: '60cPDjI6YhoTPjyMTIBjGtxatSUwWswB' + ], + 'prod' : [ + domain : 'seqera.eu.auth0.com', + clientId: 'FxCM8EJ76nNeHUDidSHkZfT8VtsrhHeL' + ] + ] + + private Map getAuth0Config(String environment) { + def config = AUTH0_CONFIG[environment] as Map + if( !config ) { + throw new RuntimeException("Unknown environment: ${environment}") + } + return config + } + + @Override + void login(String apiUrl) { + + // Check if TOWER_ACCESS_TOKEN environment variable is set + def envToken = System.getenv('TOWER_ACCESS_TOKEN') + if( envToken ) { + println "" + ColorUtil.printColored("WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable.", "yellow bold") + ColorUtil.printColored("${ColorUtil.colorize('nextflow auth login', 'cyan')} sets credentials using Nextflow config files, which take precedence over the environment variable.", "dim") + ColorUtil.printColored(" however, caution is advised to avoid confusing behaviour.", "dim") + println "" + } + + // Check if tower.accessToken is already set + def config = readConfig() + def existingToken = config['tower.accessToken'] + if( existingToken ) { + ColorUtil.printColored("Error: Authentication token is already configured in Nextflow config.", "red") + ColorUtil.printColored("Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") + println " Run ${ColorUtil.colorize('nextflow auth logout', 'cyan')} to remove the current authentication." + return + } + + ColorUtil.printColored("Nextflow authentication with Seqera Platform", "cyan bold") + ColorUtil.printColored(" - Authentication will be saved to: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") + + apiUrl = normalizeApiUrl(apiUrl) + ColorUtil.printColored(" - Seqera Platform API endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')} (can be customised with ${ColorUtil.colorize('-url', 'cyan')})", "dim") + + // Check if this is a cloud endpoint or enterprise + def endpointInfo = getCloudEndpointInfo(apiUrl) + if( endpointInfo.isCloud ) { + try { + performAuth0Login(apiUrl, endpointInfo.environment as String) + } catch( Exception e ) { + log.debug("Authentication failed", e) + println "" + throw new AbortOperationException("${e.message}") + } + } else { + // Enterprise endpoint - use PAT authentication + handleEnterpriseAuth(apiUrl) + } + } + + private void performAuth0Login(String apiUrl, String environment) { + // Get Auth0 configuration for this environment + def auth0Config = getAuth0Config(environment) + + // Start device authorization flow + def deviceAuth = requestDeviceAuthorization(auth0Config) + + println "" + ColorUtil.printColored("Confirmation code: ${ColorUtil.colorize(deviceAuth.user_code as String, 'yellow')}", "cyan bold") + def urlWithCode = "${deviceAuth.verification_uri}?user_code=${deviceAuth.user_code}" + println "${ColorUtil.colorize('Authentication URL:', 'cyan bold')} ${ColorUtil.colorize(urlWithCode, 'magenta')}" + ColorUtil.printColored("\n[ Press Enter to open in browser ]", "cyan bold") + System.in.read() // Wait for Enter key + + // Try to open browser automatically + def browserOpened = false + try { + // Method 1: Java Desktop API + if( Desktop.isDesktopSupported() ) { + def desktop = Desktop.getDesktop() + if( desktop.isSupported(Desktop.Action.BROWSE) ) { + desktop.browse(new URI(urlWithCode)) + browserOpened = true + } + } + + // Method 2: Platform-specific commands + if( !browserOpened ) { + def os = System.getProperty("os.name").toLowerCase() + def command = [] + + if( os.contains("mac") || os.contains("darwin") ) { + command = ["open", urlWithCode] + } else if( os.contains("win") ) { + command = ["cmd", "/c", "start", urlWithCode] + } else { + // Linux and other Unix-like systems + def browsers = ["xdg-open", "firefox", "google-chrome", "chromium", "safari"] + for( browser in browsers ) { + try { + new ProcessBuilder(browser, urlWithCode).start() + browserOpened = true + break + } catch( Exception ignored ) { + // Try next browser + } + } + } + + if( !browserOpened && command ) { + new ProcessBuilder(command as String[]).start() + browserOpened = true + } + } + } catch( Exception ignored ) { + // Will handle below + } + + if( !browserOpened ) { + ColorUtil.printColored("Could not open browser automatically. Please copy the URL above and open it manually in your browser.", "yellow") + } + print("${ColorUtil.colorize('Waiting for authentication...', 'dim', true)}") + + try { + // Poll for device token + def tokenData = pollForDeviceToken(deviceAuth.device_code as String, deviceAuth.interval as Integer ?: 5, auth0Config) + def accessToken = tokenData['access_token'] as String + + // Verify login by calling /user-info + def userInfo = callUserInfoApi(accessToken, apiUrl) + ColorUtil.printColored("\n\nAuthentication successful!", "green") + + // Generate PAT + def pat = generatePAT(accessToken, apiUrl) + + // Save to config + saveAuthToConfig(pat, apiUrl) + + // Automatically run configuration + runConfiguration() + + } catch( Exception e ) { + throw new RuntimeException("Authentication failed: ${e.message}", e) + } + } + + private void runConfiguration() { + try { + println "" + // Just run the existing config command + config() + } catch( Exception e ) { + ColorUtil.printColored("Configuration setup failed: ${e.message}", "red") + ColorUtil.printColored("You can run 'nextflow auth config' later to set up your configuration.", "dim") + } + } + + private Map requestDeviceAuthorization(Map auth0Config) { + def params = [ + 'client_id': auth0Config.clientId, + 'scope' : 'openid profile email offline_access', + 'audience' : 'platform' + ] + return performAuth0Request("https://${auth0Config.domain}/oauth/device/code", params) + } + + private Map pollForDeviceToken(String deviceCode, int intervalSeconds, Map auth0Config) { + def tokenUrl = "https://${auth0Config.domain}/oauth/token" + def retryCount = 0 + + while( retryCount < AUTH_POLL_TIMEOUT_RETRIES ) { + def params = [ + 'grant_type' : 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': deviceCode, + 'client_id' : auth0Config.clientId + ] + + try { + def result = performAuth0Request(tokenUrl, params) + return result + } catch( RuntimeException e ) { + def message = e.message + if( message.contains('authorization_pending') ) { + print "${ColorUtil.colorize('.', 'dim', true)}" + System.out.flush() + } else if( message.contains('slow_down') ) { + intervalSeconds += 5 + print "${ColorUtil.colorize('.', 'dim', true)}" + System.out.flush() + } else if( message.contains('expired_token') ) { + throw new RuntimeException("The device code has expired. Please try again.") + } else if( message.contains('access_denied') ) { + throw new RuntimeException("Access denied by user") + } else { + throw e + } + } + + Thread.sleep(intervalSeconds * 1000) + retryCount++ + } + + throw new RuntimeException("Authentication timed out. Please try again.") + } + + + private void handleEnterpriseAuth(String apiUrl) { + println "" + ColorUtil.printColored("Please generate a Personal Access Token from your Seqera Platform instance.", "cyan bold") + println "You can create one at: ${ColorUtil.colorize(apiUrl.replace('/api', '').replace('://api.', '') + '/tokens', 'magenta')}" + println "" + + System.out.print("Enter your Personal Access Token: ") + System.out.flush() + + def console = System.console() + def pat = console ? + new String(console.readPassword()) : + new BufferedReader(new InputStreamReader(System.in)).readLine() + + if( !pat || pat.trim().isEmpty() ) { + throw new AbortOperationException("Personal Access Token is required for Seqera Platform Enterprise authentication") + } + + // Save to config + saveAuthToConfig(pat.trim(), apiUrl) + ColorUtil.printColored("Personal Access Token saved to Nextflow config", "green") + ColorUtil.printColored("Config file: ${getConfigFile().toString()}", "magenta") + } + + private String generatePAT(String accessToken, String apiUrl) { + def tokensUrl = "${apiUrl}/tokens" + def username = System.getProperty("user.name") + def timestamp = new Date().format("yyyy-MM-dd-HH-mm") + def tokenName = "nextflow-${username}-${timestamp}" + + def requestBody = new groovy.json.JsonBuilder([name: tokenName]).toString() + + def connection = createHttpConnection(tokensUrl, 'POST', accessToken) + connection.setRequestProperty('Content-Type', 'application/json') + connection.doOutput = true + + connection.outputStream.withWriter { writer -> + writer.write(requestBody) + } + + if( connection.responseCode != 200 ) { + def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + throw new RuntimeException("Failed to generate PAT: ${error}") + } + + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) as Map + return json.accessKey as String + } + + private String normalizeApiUrl(String url) { + if( !url ) { + return SEQERA_ENDPOINTS.prod + } + if( !url.startsWith('http://') && !url.startsWith('https://') ) { + return 'https://' + url + } + return url + } + + private Map performAuth0Request(String url, Map params) { + def postData = params.collect { k, v -> + "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" + }.join('&') + + def connection = new URL(url).openConnection() as HttpURLConnection + connection.requestMethod = 'POST' + connection.connectTimeout = API_TIMEOUT_MS + connection.readTimeout = API_TIMEOUT_MS + connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded') + connection.doOutput = true + + connection.outputStream.withWriter { writer -> + writer.write(postData) + } + + if( connection.responseCode == 200 ) { + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) + return json as Map + } else { + def errorResponse = connection.errorStream?.text + if( errorResponse ) { + def errorJson = new groovy.json.JsonSlurper().parseText(errorResponse) as Map + def error = errorJson.error + throw new RuntimeException("${error}: ${errorJson.error_description ?: ''}") + } else { + throw new RuntimeException("Request failed: HTTP ${connection.responseCode}") + } + } + } + + private void saveAuthToConfig(String accessToken, String apiUrl) { + def config = readConfig() + config['tower.accessToken'] = accessToken + config['tower.endpoint'] = apiUrl + config['tower.enabled'] = true + + writeConfig(config, null) + } + + @Override + void logout() { + // Check if TOWER_ACCESS_TOKEN environment variable is set + def envToken = System.getenv('TOWER_ACCESS_TOKEN') + if( envToken ) { + println "" + ColorUtil.printColored("WARNING: TOWER_ACCESS_TOKEN environment variable is set.", "yellow bold") + println " ${ColorUtil.colorize('nextflow auth logout', 'dim cyan')}${ColorUtil.colorize(' only removes credentials from Nextflow config files.', 'dim')}" + ColorUtil.printColored(" The environment variable will remain unaffected.", "dim") + println "" + } + + // Check if tower.accessToken is set + def config = readConfig() + def existingToken = config['tower.accessToken'] + def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' + + if( !existingToken ) { + ColorUtil.printColored("Error: No authentication token found in Nextflow config.", "red") + println "Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}" + return + } + + ColorUtil.printColored(" - Found authentication token in config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") + + // Prompt user for API URL if not already configured + def apiUrl = endpoint as String + if( !apiUrl || apiUrl.isEmpty() ) { + apiUrl = promptForApiUrl() + } else { + ColorUtil.printColored(" - Using Seqera Platform endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')}", "dim") + } + + // Validate token by calling /user-info API + try { + def userInfo = callUserInfoApi(existingToken as String, apiUrl) + ColorUtil.printColored(" - Token is valid for user: ${ColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") + + // Only delete PAT from platform if this is a cloud endpoint + if( isCloudEndpoint(apiUrl) ) { + def tokenId = decodeTokenId(existingToken as String) + deleteTokenViaApi(existingToken as String, apiUrl, tokenId) + } else { + println " - Enterprise installation detected - PAT will not be deleted from platform." + } + + removeAuthFromConfig() + + } catch( Exception e ) { + println "Failed to validate or delete token: ${e.message}" + println "Removing token from config anyway..." + + // Remove from config even if API calls fail + removeAuthFromConfig() + ColorUtil.printColored("Token removed from Nextflow config.", "green") + } + } + + private String decodeTokenId(String token) { + try { + // Decode base64 token + def decoded = new String(Base64.decoder.decode(token), "UTF-8") + + // Parse JSON to extract token ID + def json = new groovy.json.JsonSlurper().parseText(decoded) as Map + def tokenId = json.tid + + if( !tokenId ) { + throw new RuntimeException("No token ID found in decoded token") + } + + return tokenId.toString() + } catch( Exception e ) { + throw new RuntimeException("Failed to decode token ID: ${e.message}") + } + } + + private void deleteTokenViaApi(String token, String apiUrl, String tokenId) { + def connection = createHttpConnection("${apiUrl}/tokens/${tokenId}", 'DELETE', token) + + if( connection.responseCode != 200 && connection.responseCode != 204 ) { + def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + throw new RuntimeException("Failed to delete token: ${error}") + } + + ColorUtil.printColored("\nToken successfully deleted from Seqera Platform.", "green") + } + + private void removeAuthFromConfig() { + def configFile = getConfigFile() + + if( Files.exists(configFile) ) { + def existingContent = Files.readString(configFile) + def cleanedContent = cleanTowerConfig(existingContent) + Files.writeString(configFile, cleanedContent, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + } + + ColorUtil.printColored("Authentication removed from Nextflow config.", "green") + } + + @Override + void config() { + def config = readConfig() + def existingToken = config['tower.accessToken'] ?: System.getenv('TOWER_ACCESS_TOKEN') + def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' + + if( !existingToken ) { + println "No authentication found. Please run ${ColorUtil.colorize('nextflow auth login', 'cyan')} first." + return + } + + ColorUtil.printColored("Nextflow Seqera Platform configuration", "cyan bold") + ColorUtil.printColored(" - Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") + + // Check if token is from environment variable + if( !config['tower.accessToken'] && System.getenv('TOWER_ACCESS_TOKEN') ) { + ColorUtil.printColored(" - Using access token from TOWER_ACCESS_TOKEN environment variable", "dim") + } + + try { + // Get user info to validate token and get user ID + def userInfo = callUserInfoApi(existingToken as String, endpoint as String) + ColorUtil.printColored(" - Authenticated as: ${ColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") + println "" + + // Track if any changes are made + def configChanged = false + + // Configure tower.enabled + configChanged |= configureEnabled(config) + + // Configure workspace + def workspaceResult = configureWorkspace(config, existingToken as String, endpoint as String, userInfo.id as String) + configChanged = configChanged || (workspaceResult.changed as boolean) + + // Save updated config only if changes were made + if( configChanged ) { + writeConfig(config, workspaceResult.metadata as Map) + + // Show the new configuration + println "\nNew configuration:" + showCurrentConfig(config, existingToken as String, endpoint as String) + + ColorUtil.printColored("\nConfiguration saved to ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "green") + } else { + ColorUtil.printColored("\nNo configuration changes were made.", "dim") + } + + } catch( Exception e ) { + throw new AbortOperationException("Failed to configure settings: ${e.message}") + } + } + + private void showCurrentConfig(Map config, String accessToken, String endpoint) { + // Show workflow monitoring status + def monitoringEnabled = config.get('tower.enabled', false) + println " ${ColorUtil.colorize('Workflow monitoring:', 'cyan')} ${monitoringEnabled ? ColorUtil.colorize('enabled', 'green') : ColorUtil.colorize('disabled', 'red')}" + + // Show workspace setting + def workspaceId = config.get('tower.workspaceId') + if( workspaceId ) { + // Try to get workspace details from API for display + def workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpoint, workspaceId as String) + if( workspaceDetails ) { + def details = workspaceDetails as Map + println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')} ${ColorUtil.colorize('[' + details.orgName + ' / ' + details.workspaceName + ']', 'dim', true)}" + } else { + println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')}" + } + } else { + println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize('None (Personal workspace)', 'magenta')}" + } + + // Show API endpoint + def apiEndpoint = config.get('tower.endpoint') ?: endpoint + println " ${ColorUtil.colorize('API endpoint:', 'cyan', true)} $apiEndpoint" + } + + private boolean configureEnabled(Map config) { + def currentEnabled = config.get('tower.enabled', false) + + println "Workflow monitoring settings. Current setting: ${currentEnabled ? ColorUtil.colorize('enabled', 'green') : ColorUtil.colorize('disabled', 'red')}" + ColorUtil.printColored(" When enabled, all workflow runs are automatically monitored by Seqera Platform", "dim") + ColorUtil.printColored(" When disabled, you can enable per-run with the ${ColorUtil.colorize('-with-tower', 'cyan')} flag", "dim") + println "" + + def promptText = "${ColorUtil.colorize('Enable workflow monitoring for all runs?', 'cyan bold', true)} (${currentEnabled ? ColorUtil.colorize('Y', 'green') + '/n' : 'y/' + ColorUtil.colorize('N', 'red')}): " + def input = promptForYesNo(promptText, currentEnabled) + + if( input == null ) { + return false // No change + } else if( input && !currentEnabled ) { + config['tower.enabled'] = true + return true + } else if( !input && currentEnabled ) { + config.remove('tower.enabled') + return true + } + return false + } + + private Map configureWorkspace(Map config, String accessToken, String endpoint, String userId) { + // Check if TOWER_WORKFLOW_ID environment variable is set + def envWorkspaceId = System.getenv('TOWER_WORKFLOW_ID') + if( envWorkspaceId ) { + println "\nDefault workspace: ${ColorUtil.colorize('TOWER_WORKFLOW_ID environment variable is set', 'yellow')}" + ColorUtil.printColored(" Not prompting for default workspace configuration as environment variable takes precedence", "dim") + return [changed: false, metadata: null] + } + + // Get all workspaces for the user + def workspaces = getUserWorkspaces(accessToken, endpoint, userId) + + if( !workspaces ) { + println "\nNo workspaces found for your account." + return [changed: false, metadata: null] + } + + // Show current workspace setting + def currentWorkspaceId = config.get('tower.workspaceId') + def currentWorkspace = workspaces.find { ((Map) it).workspaceId.toString() == currentWorkspaceId?.toString() } + + def currentSetting + if( currentWorkspace ) { + def workspace = currentWorkspace as Map + currentSetting = "${workspace.orgName} / ${workspace.workspaceName}" + } else { + currentSetting = "None (Personal workspace)" + } + + println "\nDefault workspace. Current setting: ${ColorUtil.colorize(currentSetting as String, 'cyan', true)}" + ColorUtil.printColored(" Workflow runs use this workspace by default", "dim") + // Group by organization + def orgWorkspaces = workspaces.groupBy { ((Map) it).orgName ?: 'Personal' } + + // If threshold or fewer total options, show all at once + if( workspaces.size() <= WORKSPACE_SELECTION_THRESHOLD ) { + return selectWorkspaceFromAll(config, workspaces, currentWorkspaceId) + } else { + // Two-stage selection: org first, then workspace + return selectWorkspaceByOrg(config, orgWorkspaces, currentWorkspaceId) + } + } + + private Map selectWorkspaceFromAll(Map config, List workspaces, def currentWorkspaceId) { + println "\nAvailable workspaces:" + println " 0. ${ColorUtil.colorize('None (Personal workspace)', 'cyan', true)} ${ColorUtil.colorize('[no organization]', 'dim', true)}" + + workspaces.eachWithIndex { workspace, index -> + def ws = workspace as Map + def prefix = ws.orgName ? "${ColorUtil.colorize(ws.orgName as String, 'cyan', true)} / " : "" + println " ${index + 1}. ${prefix}${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}" + } + + // Show current workspace and prepare prompt + def currentWorkspace = workspaces.find { ((Map) it).workspaceId.toString() == currentWorkspaceId?.toString() } + def currentWorkspaceName + if( currentWorkspace ) { + def workspace = currentWorkspace as Map + currentWorkspaceName = "${workspace.orgName} / ${workspace.workspaceName}" + } else { + currentWorkspaceName = "None (Personal workspace)" + } + + def prompt = "\nSelect workspace (0-${workspaces.size()}, press Enter to keep as '${currentWorkspaceName}'): " + def selection = promptForNumber(prompt, 0, workspaces.size(), true) + + if( selection == null ) { + return [changed: false, metadata: null] + } + + if( selection == 0 ) { + def hadWorkspaceId = config.containsKey('tower.workspaceId') + config.remove('tower.workspaceId') + return [changed: hadWorkspaceId, metadata: null] + } else { + def selectedWorkspace = workspaces[selection - 1] as Map + def selectedId = selectedWorkspace.workspaceId.toString() + def currentId = config.get('tower.workspaceId') + config['tower.workspaceId'] = selectedId + def metadata = [ + orgName : selectedWorkspace.orgName, + workspaceName : selectedWorkspace.workspaceName, + workspaceFullName: selectedWorkspace.workspaceFullName + ] + return [changed: currentId != selectedId, metadata: metadata] + } + } + + private Map selectWorkspaceByOrg(Map config, Map orgWorkspaces, def currentWorkspaceId) { + // Get current workspace info for prompts + def allWorkspaces = [] + orgWorkspaces.values().each { workspaceList -> + allWorkspaces.addAll(workspaceList as List) + } + def currentWorkspace = allWorkspaces.find { ((Map) it).workspaceId.toString() == currentWorkspaceId?.toString() } + def currentWorkspaceName + def currentWorkspaceDisplay + if( currentWorkspace ) { + def workspace = currentWorkspace as Map + currentWorkspaceName = workspace.workspaceName as String + currentWorkspaceDisplay = "${workspace.orgName} / ${workspace.workspaceName}" + } else { + currentWorkspaceName = null + currentWorkspaceDisplay = "None" + } + + // First, select organization + def orgs = orgWorkspaces.keySet().toList() + + // Always add Personal as first option (it's never returned by the API but should always be available) + orgs.add(0, 'Personal') + + println "\nAvailable organizations:" + orgs.eachWithIndex { orgName, index -> + def displayName = orgName == 'Personal' ? 'None [Personal workspace]' : orgName + println " ${index + 1}. ${ColorUtil.colorize(displayName as String, 'cyan', true)}" + } + System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size()}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") + System.out.flush() + + def reader = new BufferedReader(new InputStreamReader(System.in)) + def orgSelection + while( true ) { + def orgInput = reader.readLine()?.trim() + + if( orgInput.isEmpty() ) { + return [changed: false, metadata: null] + } + + try { + orgSelection = Integer.parseInt(orgInput) + if( orgSelection >= 1 && orgSelection <= orgs.size() ) { + break + } + } catch( NumberFormatException e ) { + // Fall through to error message + } + ColorUtil.printColored("Invalid input. Please enter a number between 1 and ${orgs.size()}.", "red") + System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size()}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") + System.out.flush() + } + + def selectedOrgName = orgs[orgSelection - 1] + + // If Personal was selected, remove workspace ID (use personal workspace) + if( selectedOrgName == 'Personal' ) { + def hadWorkspaceId = config.containsKey('tower.workspaceId') + config.remove('tower.workspaceId') + return [changed: hadWorkspaceId, metadata: null] + } + + def orgWorkspaceList = orgWorkspaces[selectedOrgName] as List + + println "" + println "Select workspace in ${selectedOrgName}:" + + orgWorkspaceList.eachWithIndex { workspace, index -> + def ws = workspace as Map + println " ${index + 1}. ${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}" + } + + def maxSelection = orgWorkspaceList.size() + def wsSelection + while( true ) { + System.out.print("${ColorUtil.colorize("Select workspace (1-${maxSelection}): ", 'dim', true)}") + System.out.flush() + + def wsInput = reader.readLine()?.trim() + if( wsInput.isEmpty() ) { + ColorUtil.printColored("Please enter a selection.", "red") + continue + } + + try { + wsSelection = Integer.parseInt(wsInput) + if( wsSelection >= 1 && wsSelection <= maxSelection ) { + break + } + } catch( NumberFormatException e ) { + // Fall through to error message + } + ColorUtil.printColored("Invalid input. Please enter a number between 1 and ${maxSelection}.", "red") + } + + def selectedWorkspace = orgWorkspaceList[wsSelection - 1] as Map + def selectedId = selectedWorkspace.workspaceId.toString() + def currentId = config.get('tower.workspaceId') + config['tower.workspaceId'] = selectedId + def metadata = [ + orgName : selectedWorkspace.orgName, + workspaceName : selectedWorkspace.workspaceName, + workspaceFullName: selectedWorkspace.workspaceFullName + ] + return [changed: currentId != selectedId, metadata: metadata] + } + + private List getUserWorkspaces(String accessToken, String endpoint, String userId) { + def connection = createHttpConnection("${endpoint}/user/${userId}/workspaces", 'GET', accessToken) + + if( connection.responseCode != 200 ) { + def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + throw new RuntimeException("Failed to get workspaces: ${error}") + } + + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) as Map + def orgsAndWorkspaces = json.orgsAndWorkspaces as List + + return orgsAndWorkspaces.findAll { ((Map) it).workspaceId != null } + } + + private Boolean promptForYesNo(String prompt, Boolean defaultValue) { + while( true ) { + System.out.print(prompt) + System.out.flush() + def input = readUserInput()?.toLowerCase() + + if( input?.isEmpty() ) { + return null // Keep current setting + } else if( input in ['y', 'yes'] ) { + return true + } else if( input in ['n', 'no'] ) { + return false + } else { + ColorUtil.printColored("Invalid input. Please enter 'y', 'n', or press Enter to keep current setting.", "red") + } + } + } + + private Integer promptForNumber(String prompt, int min, int max, boolean allowEmpty = false) { + while( true ) { + ColorUtil.printColored(prompt, "bold cyan") + System.out.flush() + def input = readUserInput() + + if( input?.isEmpty() && allowEmpty ) { + return null + } + + try { + def number = Integer.parseInt(input) + if( number >= min && number <= max ) { + return number + } + } catch( NumberFormatException e ) { + // Fall through to error message + } + ColorUtil.printColored("Invalid input. Please enter a number between ${min} and ${max}.", "red") + } + } + + @Override + void status() { + def config = readConfig() + + // Collect all status information + List> statusRows = [] + def workspaceDisplayInfo = null + + // API endpoint + def endpointInfo = getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://api.cloud.seqera.io') + statusRows.add(['API endpoint', ColorUtil.colorize(endpointInfo.value as String, 'magenta'), endpointInfo.source as String]) + + // API connection check + def apiConnectionOk = checkApiConnection(endpointInfo.value as String) + def connectionColor = apiConnectionOk ? 'green' : 'red' + statusRows.add(['API connection', ColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) + + // Authentication check + def tokenInfo = getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') + if( tokenInfo.value ) { + try { + def userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) + def currentUser = userInfo.userName as String + statusRows.add(['Authentication', "${ColorUtil.colorize('OK', 'green')} (user: ${ColorUtil.colorize(currentUser, 'cyan')})".toString(), tokenInfo.source as String]) + } catch( Exception e ) { + statusRows.add(['Authentication', ColorUtil.colorize('ERROR', 'red'), 'failed']) + } + } else { + statusRows.add(['Authentication', "${ColorUtil.colorize('ERROR', 'red')} ${ColorUtil.colorize('(no token)', 'dim')}".toString(), 'not set']) + } + + // Monitoring enabled + def enabledInfo = getConfigValue(config, 'tower.enabled', null, 'false') + def enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' + def enabledColor = enabledValue == 'Yes' ? 'green' : 'yellow' + statusRows.add(['Workflow monitoring', ColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) + + // Default workspace + def workspaceInfo = getConfigValue(config, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') + if( workspaceInfo.value ) { + // Try to get workspace name from API if we have a token + def workspaceDetails = null + if( tokenInfo.value ) { + workspaceDetails = getWorkspaceDetailsFromApi(tokenInfo.value as String, endpointInfo.value as String, workspaceInfo.value as String) + } + + if( workspaceDetails ) { + // Add workspace ID row + statusRows.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) + // Store workspace details for display after the table + workspaceDisplayInfo = workspaceDetails + } else { + statusRows.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue', true), workspaceInfo.source as String]) + } + } else { + statusRows.add(['Default workspace', ColorUtil.colorize('None (Personal workspace)', 'cyan', true), 'default']) + } + + // Print table + println "" + printStatusTable(statusRows) + + // Print workspace details if available + if( workspaceDisplayInfo ) { + println "${' ' * 22}${ColorUtil.colorize(workspaceDisplayInfo.orgName as String, 'cyan')} / ${ColorUtil.colorize(workspaceDisplayInfo.workspaceName as String, 'cyan')} ${ColorUtil.colorize('[' + (workspaceDisplayInfo.workspaceFullName as String) + ']', 'dim')}" + } + } + + private void printStatusTable(List> rows) { + if( !rows ) return + + // Calculate column widths (accounting for ANSI codes) + def col1Width = rows.collect { stripAnsiCodes(it[0]).length() }.max() + def col2Width = rows.collect { stripAnsiCodes(it[1]).length() }.max() + def col3Width = rows.collect { stripAnsiCodes(it[2]).length() }.max() + + // Add some padding + col1Width = Math.max(col1Width, 15) + 2 + col2Width = Math.max(col2Width, 15) + 2 + col3Width = Math.max(col3Width, 10) + 2 + + // Print table header + ColorUtil.printColored("${'Setting'.padRight(col1Width)} ${'Value'.padRight(col2Width)} Source", "cyan bold") + println "${'-' * col1Width} ${'-' * col2Width} ${'-' * col3Width}" + + // Print rows + rows.each { row -> + def paddedCol1 = padStringWithAnsi(row[0], col1Width) + def paddedCol2 = padStringWithAnsi(row[1], col2Width) + def paddedCol3 = ColorUtil.colorize(row[2], 'dim', true) + println "${paddedCol1} ${paddedCol2} ${paddedCol3}" + } + } + + private String stripAnsiCodes(String text) { + return text?.replaceAll(/\u001B\[[0-9;]*m/, '') ?: '' + } + + private String padStringWithAnsi(String text, int width) { + def plainText = stripAnsiCodes(text) + def padding = width - plainText.length() + return padding > 0 ? text + (' ' * padding) : text + } + + private String shortenPath(String path) { + def userHome = System.getProperty('user.home') + if( path.startsWith(userHome) ) { + return '~' + path.substring(userHome.length()) + } + return path + } + + private Map getConfigValue(Map config, String configKey, String envVarName, String defaultValue = null) { + def configValue = config[configKey] + def envValue = envVarName ? System.getenv(envVarName) : null + def effectiveValue = configValue ?: envValue ?: defaultValue + + def source = null + if( configValue ) { + source = shortenPath(getConfigFile().toString()) + } else if( envValue ) { + source = "env var \$${envVarName}" + } else if( defaultValue ) { + source = "default" + } + + return [ + value : effectiveValue, + source : source, + fromConfig: configValue != null, + fromEnv : envValue != null, + isDefault : !configValue && !envValue + ] + } + + private String getWorkspaceNameFromApi(String accessToken, String endpoint, String workspaceId) { + try { + def workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpoint, workspaceId) + if( workspaceDetails ) { + return "${workspaceDetails.orgName} / ${workspaceDetails.workspaceName} [${workspaceDetails.workspaceFullName}]" + } + return null + } catch( Exception e ) { + return null + } + } + + private boolean checkApiConnection(String endpoint) { + try { + def connection = new URL("${endpoint}/service-info").openConnection() as HttpURLConnection + connection.requestMethod = 'GET' + connection.connectTimeout = API_TIMEOUT_MS + connection.readTimeout = API_TIMEOUT_MS + return connection.responseCode == 200 + } catch( Exception e ) { + return false + } + } + + private String promptForApiUrl() { + System.out.print("Seqera Platform API endpoint [Default ${SEQERA_ENDPOINTS.prod}]: ") + System.out.flush() + + def input = readUserInput() + return input?.isEmpty() ? SEQERA_ENDPOINTS.prod : input + } + + private String readUserInput() { + def reader = new BufferedReader(new InputStreamReader(System.in)) + return reader.readLine()?.trim() + } + + private HttpURLConnection createHttpConnection(String url, String method, String authToken = null) { + def connection = new URL(url).openConnection() as HttpURLConnection + connection.requestMethod = method + connection.connectTimeout = API_TIMEOUT_MS + connection.readTimeout = API_TIMEOUT_MS + if (authToken) { + connection.setRequestProperty('Authorization', "Bearer ${authToken}") + } + return connection + } + + private Map getWorkspaceDetailsFromApi(String accessToken, String endpoint, String workspaceId) { + try { + def userInfo = callUserInfoApi(accessToken, endpoint) + def userId = userInfo.id as String + + def connection = createHttpConnection("${endpoint}/user/${userId}/workspaces", 'GET', accessToken) + + if (connection.responseCode != 200) { + return null + } + + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) as Map + def orgsAndWorkspaces = json.orgsAndWorkspaces as List + + def workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } + if (workspace) { + def ws = workspace as Map + return [ + orgName: ws.orgName, + workspaceName: ws.workspaceName, + workspaceFullName: ws.workspaceFullName + ] + } + + return null + } catch (Exception e) { + return null + } + } + + private Map getCloudEndpointInfo(String apiUrl) { + for (env in SEQERA_ENDPOINTS) { + def standardUrl = env.value + def legacyUrl = standardUrl.replace('://api.', '://') + '/api' + if (apiUrl == standardUrl || apiUrl == legacyUrl) { + return [isCloud: true, environment: env.key] + } + } + return [isCloud: false, environment: null] + } + + private boolean isCloudEndpoint(String apiUrl) { + return getCloudEndpointInfo(apiUrl).isCloud + } + + private Map callUserInfoApi(String accessToken, String apiUrl) { + def connection = createHttpConnection("${apiUrl}/user-info", 'GET', accessToken) + + if (connection.responseCode != 200) { + def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + throw new RuntimeException("Failed to get user info: ${error}") + } + + def response = connection.inputStream.text + def json = new groovy.json.JsonSlurper().parseText(response) as Map + return json.user as Map + } + + private Path getConfigFile() { + return Const.APP_HOME_DIR.resolve('config') + } + + private Map readConfig() { + def configFile = getConfigFile() + if (!Files.exists(configFile)) { + return [:] + } + + try { + def configText = Files.readString(configFile) + def config = new ConfigSlurper().parse(configText) + return config.flatten() + } catch (Exception e) { + throw new RuntimeException("Failed to read config file ${configFile}: ${e.message}") + } + } + + private String cleanTowerConfig(String content) { + // Remove tower scoped blocks: tower { ... } + content = content.replaceAll(/(?ms)tower\s*\{.*?\}/, '') + // Remove individual tower.* lines + content = content.replaceAll(/(?m)^tower\..*$\n?/, '') + // Remove Seqera Platform configuration comment + content = content.replaceAll(/\/\/\s*Seqera Platform configuration\s*/, '') + // Clean up extra whitespace + return content.replaceAll(/\n\n+/, '\n\n').trim() + "\n\n" + } + + private void writeConfig(Map config, Map workspaceMetadata = null) { + def configFile = getConfigFile() + + // Create directory if it doesn't exist + if (!Files.exists(configFile.parent)) { + Files.createDirectories(configFile.parent) + } + + // Read existing config and clean out old tower blocks + def configText = new StringBuilder() + if (Files.exists(configFile)) { + def existingContent = Files.readString(configFile) + def cleanedContent = cleanTowerConfig(existingContent) + configText.append(cleanedContent) + } + + // Write tower config block + def towerConfig = config.findAll { key, value -> + key.toString().startsWith('tower.') && !key.toString().endsWith('.comment') + } + + configText.append("// Seqera Platform configuration\n") + configText.append("tower {\n") + towerConfig.each { key, value -> + def configKey = key.toString().substring(6) // Remove "tower." prefix + + if (value instanceof String) { + def line = " ${configKey} = '${value}'" + // Add workspace comment if this is workspaceId and we have metadata + if (configKey == 'workspaceId' && workspaceMetadata) { + line += " // ${workspaceMetadata.orgName} / ${workspaceMetadata.workspaceName} [${workspaceMetadata.workspaceFullName}]" + } + configText.append("${line}\n") + } else { + configText.append(" ${configKey} = ${value}\n") + } + } + configText.append("}\n") + + Files.writeString(configFile, configText.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + } +} From 4bdf46238ff6252edc98e049ae789cb867230b66 Mon Sep 17 00:00:00 2001 From: jorgee Date: Mon, 29 Sep 2025 13:40:53 +0200 Subject: [PATCH 31/66] Fix log Signed-off-by: jorgee --- modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 831c13bbe4..e92f7d69d7 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -122,7 +122,7 @@ class CmdAuth extends CmdBase implements UsageAware { // load the command operations this.operation = Plugins.getExtension(AuthCommand) if( !operation ) - throw new IllegalStateException("Unable to load lineage extensions.") + throw new IllegalStateException("Unable to load auth extensions.") // consume the first argument getCmd(args).apply(args.drop(1)) } From 0273b40f6941399252abbfd00470987f09cc7483 Mon Sep 17 00:00:00 2001 From: jorgee Date: Tue, 30 Sep 2025 10:14:06 +0200 Subject: [PATCH 32/66] Fixing nf-tower load in AuthCommand Signed-off-by: jorgee --- plugins/nf-tower/build.gradle | 3 ++- .../src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/nf-tower/build.gradle b/plugins/nf-tower/build.gradle index 84cb337235..42257e6df7 100644 --- a/plugins/nf-tower/build.gradle +++ b/plugins/nf-tower/build.gradle @@ -15,7 +15,7 @@ plugins { } nextflowPlugin { - nextflowVersion = '25.07.0-edge' + nextflowVersion = '25.08.0-edge' provider = "${nextflowPluginProvider}" description = 'Integrates with Seqera Platform for comprehensive workflow monitoring, resource tracking, and cache management capabilities' @@ -25,6 +25,7 @@ nextflowPlugin { 'io.seqera.tower.plugin.TowerConfig', 'io.seqera.tower.plugin.TowerFactory', 'io.seqera.tower.plugin.TowerFusionToken', + 'io.seqera.tower.plugin.cli.AuthCommandImpl' ] } diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index f723ab1384..199b9c6e02 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -14,7 +14,7 @@ import java.nio.file.StandardOpenOption @Slf4j @CompileStatic -class TowerAuthCommand implements CmdAuth.AuthCommand { +class AuthCommandImpl implements CmdAuth.AuthCommand { static final Map SEQERA_ENDPOINTS = [ 'prod': 'https://api.cloud.seqera.io', From 00b4590ef1b1e000b618ec58d2d12a480b708fd7 Mon Sep 17 00:00:00 2001 From: jorgee Date: Tue, 30 Sep 2025 18:04:51 +0200 Subject: [PATCH 33/66] Spliting auth tests in nexflow and nf-tower Signed-off-by: jorgee --- .../main/groovy/nextflow/cli/CmdAuth.groovy | 19 ++- .../groovy/nextflow/cli/CmdAuthTest.groovy | 158 ++---------------- .../plugin/cli/AuthCommandImplTest.groovy | 148 ++++++++++++++++ 3 files changed, 178 insertions(+), 147 deletions(-) create mode 100644 plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index e92f7d69d7..00f2f528dc 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -46,7 +46,7 @@ import java.util.regex.Pattern */ @Slf4j @CompileStatic -@Parameters(commandDescription = "Manage authentication") +@Parameters(commandDescription = "Manage Seqera Platform authentication") class CmdAuth extends CmdBase implements UsageAware { interface SubCmd { @@ -115,18 +115,23 @@ class CmdAuth extends CmdBase implements UsageAware { usage() return } - // setup the plugins system and load the secrets provider - Plugins.init() - // load the config - Plugins.start('nf-tower') - // load the command operations - this.operation = Plugins.getExtension(AuthCommand) + // load the Auth command implementation + this.operation = loadOperation() if( !operation ) throw new IllegalStateException("Unable to load auth extensions.") // consume the first argument getCmd(args).apply(args.drop(1)) } + protected AuthCommand loadOperation(){ + // setup the plugins system and load the secrets provider + Plugins.init() + // load the config + Plugins.start('nf-tower') + // get Auth command operations implementation from plugins + return Plugins.getExtension(AuthCommand) + } + protected SubCmd getCmd(List args) { def cmd = commands.find { it.name == args[0] } if (cmd) { diff --git a/modules/nextflow/src/test/groovy/nextflow/cli/CmdAuthTest.groovy b/modules/nextflow/src/test/groovy/nextflow/cli/CmdAuthTest.groovy index 0e9ed6e159..948a086e5f 100644 --- a/modules/nextflow/src/test/groovy/nextflow/cli/CmdAuthTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/cli/CmdAuthTest.groovy @@ -22,7 +22,6 @@ import spock.lang.Specification import spock.lang.TempDir import test.OutputCapture -import java.nio.file.Files import java.nio.file.Path /** @@ -46,16 +45,6 @@ class CmdAuthTest extends Specification { cmd.getName() == 'auth' } - def 'should define correct constants'() { - expect: - CmdAuth.SEQERA_ENDPOINTS.prod == 'https://api.cloud.seqera.io' - CmdAuth.SEQERA_ENDPOINTS.stage == 'https://api.cloud.stage-seqera.io' - CmdAuth.SEQERA_ENDPOINTS.dev == 'https://api.cloud.dev-seqera.io' - CmdAuth.API_TIMEOUT_MS == 10000 - CmdAuth.AUTH_POLL_TIMEOUT_RETRIES == 60 - CmdAuth.AUTH_POLL_INTERVAL_SECONDS == 5 - CmdAuth.WORKSPACE_SELECTION_THRESHOLD == 8 - } def 'should show usage when no args provided'() { given: @@ -92,13 +81,15 @@ class CmdAuthTest extends Specification { def 'should throw error for unknown command'() { given: - def cmd = new CmdAuth() + def cmd = Spy(CmdAuth) cmd.args = ['unknown'] + def operation = Mock(CmdAuth.AuthCommand) when: cmd.run() then: + 1 * cmd.loadOperation() >> operation def ex = thrown(AbortOperationException) ex.message.contains('Unknown auth sub-command: unknown') } @@ -117,163 +108,70 @@ class CmdAuthTest extends Specification { ex.message.contains('login') } - def 'should identify cloud endpoints correctly'() { - given: - def cmd = new CmdAuth() - - expect: - cmd.getCloudEndpointInfo('https://api.cloud.seqera.io').isCloud == true - cmd.getCloudEndpointInfo('https://api.cloud.seqera.io').environment == 'prod' - cmd.getCloudEndpointInfo('https://api.cloud.stage-seqera.io').isCloud == true - cmd.getCloudEndpointInfo('https://api.cloud.stage-seqera.io').environment == 'stage' - cmd.getCloudEndpointInfo('https://api.cloud.dev-seqera.io').isCloud == true - cmd.getCloudEndpointInfo('https://api.cloud.dev-seqera.io').environment == 'dev' - cmd.getCloudEndpointInfo('https://cloud.seqera.io/api').isCloud == true - cmd.getCloudEndpointInfo('https://cloud.seqera.io/api').environment == 'prod' - cmd.getCloudEndpointInfo('https://enterprise.example.com').isCloud == false - cmd.getCloudEndpointInfo('https://enterprise.example.com').environment == null - } - - def 'should identify cloud endpoint from URL'() { - given: - def cmd = new CmdAuth() - - expect: - cmd.isCloudEndpoint('https://api.cloud.seqera.io') == true - cmd.isCloudEndpoint('https://api.cloud.stage-seqera.io') == true - cmd.isCloudEndpoint('https://enterprise.example.com') == false - } - - def 'should validate argument count correctly'() { - given: - def cmd = new CmdAuth() - - when: - cmd.validateArgumentCount(['extra'], 'test') - - then: - def ex = thrown(AbortOperationException) - ex.message == 'Too many arguments for test command' - - when: - cmd.validateArgumentCount([], 'test') - - then: - noExceptionThrown() - } - - def 'should read config correctly'() { - given: - def cmd = new CmdAuth() - - expect: - // readConfig method should return a Map - cmd.readConfig() instanceof Map - } - - def 'should clean tower config from existing content'() { - given: - def cmd = new CmdAuth() - def content = ''' -// Some other config -process { - executor = 'local' -} - -// Seqera Platform configuration -tower { - accessToken = 'old-token' - enabled = true -} - -tower.endpoint = 'old-endpoint' - -// More config -params.test = true -''' - - when: - def cleaned = cmd.cleanTowerConfig(content) - - then: - !cleaned.contains('tower {') - !cleaned.contains('accessToken = \'old-token\'') - !cleaned.contains('tower.endpoint') - !cleaned.contains('Seqera Platform configuration') - cleaned.contains('process {') - cleaned.contains('params.test = true') - } - - def 'should handle config writing'() { - given: - def cmd = new CmdAuth() - def config = [ - 'tower.accessToken': 'test-token', - 'tower.enabled': true - ] - - when: - cmd.writeConfig(config, null) - - then: - noExceptionThrown() - } - def 'login command should validate too many arguments'() { given: - def cmd = new CmdAuth() + def cmd = Spy(CmdAuth) + def operation = Mock(CmdAuth.AuthCommand) cmd.args = ['login', 'extra'] when: cmd.run() then: + 1 * cmd.loadOperation() >> operation def ex = thrown(AbortOperationException) ex.message.contains('Too many arguments for login command') } def 'logout command should validate too many arguments'() { given: - def cmd = new CmdAuth() + def cmd = Spy(CmdAuth) + def operation = Mock(CmdAuth.AuthCommand) cmd.args = ['logout', 'extra'] when: cmd.run() then: + 1 * cmd.loadOperation() >> operation def ex = thrown(AbortOperationException) ex.message.contains('Too many arguments for logout command') } def 'config command should validate too many arguments'() { given: - def cmd = new CmdAuth() + def cmd = Spy(CmdAuth) + def operation = Mock(CmdAuth.AuthCommand) cmd.args = ['config', 'extra'] when: cmd.run() then: + 1 * cmd.loadOperation() >> operation def ex = thrown(AbortOperationException) ex.message.contains('Too many arguments for config command') } def 'status command should validate too many arguments'() { given: - def cmd = new CmdAuth() + def cmd = Spy(CmdAuth) + def operation = Mock(CmdAuth.AuthCommand) cmd.args = ['status', 'extra'] when: cmd.run() then: + 1 * cmd.loadOperation() >> operation def ex = thrown(AbortOperationException) ex.message.contains('Too many arguments for status command') } def 'login command should use provided API URL'() { given: - def cmd = new CmdAuth() + def cmd = Spy(CmdAuth) + def operation = Mock(CmdAuth.AuthCommand) cmd.args = ['login'] cmd.apiUrl = 'https://api.example.com' @@ -287,6 +185,7 @@ params.test = true cmd.run() then: + 1 * cmd.loadOperation() >> operation loginCmd.apiUrl == 'https://api.example.com' } @@ -301,25 +200,4 @@ params.test = true cmd.commands.find { it.name == 'config' } != null cmd.commands.find { it.name == 'status' } != null } - - def 'should create HTTP connection with correct properties'() { - given: - def cmd = new CmdAuth() - - when: - def connection = cmd.createHttpConnection('https://example.com', 'GET', 'test-token') - - then: - connection.requestMethod == 'GET' - connection.connectTimeout == CmdAuth.API_TIMEOUT_MS - connection.readTimeout == CmdAuth.API_TIMEOUT_MS - - when: - def connectionNoAuth = cmd.createHttpConnection('https://example.com', 'POST') - - then: - connectionNoAuth.requestMethod == 'POST' - connectionNoAuth.connectTimeout == CmdAuth.API_TIMEOUT_MS - connectionNoAuth.readTimeout == CmdAuth.API_TIMEOUT_MS - } } \ No newline at end of file diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy new file mode 100644 index 0000000000..df635ce99c --- /dev/null +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy @@ -0,0 +1,148 @@ +/* + * 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.cli + +import nextflow.exception.AbortOperationException +import org.junit.Rule +import spock.lang.Specification +import spock.lang.TempDir +import test.OutputCapture + +import java.nio.file.Path + +/** + * Test CmdAuth functionality + * + * @author Phil Ewels + */ +class AuthCommandImplTest extends Specification { + + @Rule + OutputCapture capture = new OutputCapture() + + @TempDir + Path tempDir + + + + + + def 'should identify cloud endpoints correctly'() { + given: + def cmd = new AuthCommandImpl() + + expect: + cmd.getCloudEndpointInfo('https://api.cloud.seqera.io').isCloud == true + cmd.getCloudEndpointInfo('https://api.cloud.seqera.io').environment == 'prod' + cmd.getCloudEndpointInfo('https://api.cloud.stage-seqera.io').isCloud == true + cmd.getCloudEndpointInfo('https://api.cloud.stage-seqera.io').environment == 'stage' + cmd.getCloudEndpointInfo('https://api.cloud.dev-seqera.io').isCloud == true + cmd.getCloudEndpointInfo('https://api.cloud.dev-seqera.io').environment == 'dev' + cmd.getCloudEndpointInfo('https://cloud.seqera.io/api').isCloud == true + cmd.getCloudEndpointInfo('https://cloud.seqera.io/api').environment == 'prod' + cmd.getCloudEndpointInfo('https://enterprise.example.com').isCloud == false + cmd.getCloudEndpointInfo('https://enterprise.example.com').environment == null + } + + def 'should identify cloud endpoint from URL'() { + given: + def cmd = new AuthCommandImpl() + + expect: + cmd.isCloudEndpoint('https://api.cloud.seqera.io') == true + cmd.isCloudEndpoint('https://api.cloud.stage-seqera.io') == true + cmd.isCloudEndpoint('https://enterprise.example.com') == false + } + + def 'should read config correctly'() { + given: + def cmd = new AuthCommandImpl() + + expect: + // readConfig method should return a Map + cmd.readConfig() instanceof Map + } + + def 'should clean tower config from existing content'() { + given: + def cmd = new AuthCommandImpl() + def content = ''' +// Some other config +process { + executor = 'local' +} + +// Seqera Platform configuration +tower { + accessToken = 'old-token' + enabled = true +} + +tower.endpoint = 'old-endpoint' + +// More config +params.test = true +''' + + when: + def cleaned = cmd.cleanTowerConfig(content) + + then: + !cleaned.contains('tower {') + !cleaned.contains('accessToken = \'old-token\'') + !cleaned.contains('tower.endpoint') + !cleaned.contains('Seqera Platform configuration') + cleaned.contains('process {') + cleaned.contains('params.test = true') + } + + def 'should handle config writing'() { + given: + def cmd = new AuthCommandImpl() + def config = [ + 'tower.accessToken': 'test-token', + 'tower.enabled': true + ] + + when: + cmd.writeConfig(config, null) + + then: + noExceptionThrown() + } + + def 'should create HTTP connection with correct properties'() { + given: + def cmd = new AuthCommandImpl() + + when: + def connection = cmd.createHttpConnection('https://example.com', 'GET', 'test-token') + + then: + connection.requestMethod == 'GET' + connection.connectTimeout == AuthCommandImpl.API_TIMEOUT_MS + connection.readTimeout == AuthCommandImpl.API_TIMEOUT_MS + + when: + def connectionNoAuth = cmd.createHttpConnection('https://example.com', 'POST') + + then: + connectionNoAuth.requestMethod == 'POST' + connectionNoAuth.connectTimeout == AuthCommandImpl.API_TIMEOUT_MS + connectionNoAuth.readTimeout == AuthCommandImpl.API_TIMEOUT_MS + } +} \ No newline at end of file From 1f5686274525cd2d225ef84c2991135a4ce205b4 Mon Sep 17 00:00:00 2001 From: jorgee Date: Wed, 1 Oct 2025 13:50:13 +0200 Subject: [PATCH 34/66] change modifications in .nextflow/config by /.nexflow/.login and include in config Signed-off-by: jorgee --- .../tower/plugin/cli/AuthCommandImpl.groovy | 308 ++++++++++-------- .../plugin/cli/AuthCommandImplTest.groovy | 43 +-- 2 files changed, 175 insertions(+), 176 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 199b9c6e02..589b7943e1 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -1,10 +1,13 @@ package io.seqera.tower.plugin.cli +import groovy.json.JsonBuilder +import groovy.json.JsonSlurper import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import nextflow.Const import nextflow.cli.CmdAuth import nextflow.cli.ColorUtil +import nextflow.config.ConfigBuilder import nextflow.exception.AbortOperationException import java.awt.Desktop @@ -16,38 +19,27 @@ import java.nio.file.StandardOpenOption @CompileStatic class AuthCommandImpl implements CmdAuth.AuthCommand { - static final Map SEQERA_ENDPOINTS = [ - 'prod': 'https://api.cloud.seqera.io', - 'stage': 'https://api.cloud.stage-seqera.io', - 'dev': 'https://api.cloud.dev-seqera.io' - ] static final int API_TIMEOUT_MS = 10000 static final int AUTH_POLL_TIMEOUT_RETRIES = 60 static final int AUTH_POLL_INTERVAL_SECONDS = 5 static final int WORKSPACE_SELECTION_THRESHOLD = 8 // Max workspaces to show in single list; above this uses org-first selection - private static final Map AUTH0_CONFIG = [ - 'dev' : [ + private static final String DEFAULT_API_ENDPOINT = 'https://api.cloud.seqera.io' + + private static final Map SEQERA_API_TO_AUTH0 = [ + 'https://api.cloud.dev-seqera.io' : [ domain : 'seqera-development.eu.auth0.com', clientId: 'Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ' ], - 'stage': [ + 'https://api.cloud.stage-seqera.io': [ domain : 'seqera-stage.eu.auth0.com', clientId: '60cPDjI6YhoTPjyMTIBjGtxatSUwWswB' ], - 'prod' : [ + 'https://api.cloud.seqera.io' : [ domain : 'seqera.eu.auth0.com', clientId: 'FxCM8EJ76nNeHUDidSHkZfT8VtsrhHeL' ] ] - private Map getAuth0Config(String environment) { - def config = AUTH0_CONFIG[environment] as Map - if( !config ) { - throw new RuntimeException("Unknown environment: ${environment}") - } - return config - } - @Override void login(String apiUrl) { @@ -61,18 +53,17 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { println "" } - // Check if tower.accessToken is already set - def config = readConfig() - def existingToken = config['tower.accessToken'] - if( existingToken ) { + // Check if .login file already exists + def loginFile = getLoginFile() + if( Files.exists(loginFile) ) { ColorUtil.printColored("Error: Authentication token is already configured in Nextflow config.", "red") - ColorUtil.printColored("Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") + ColorUtil.printColored("Login file: ${ColorUtil.colorize(loginFile.toString(), 'magenta')}", "dim") println " Run ${ColorUtil.colorize('nextflow auth logout', 'cyan')} to remove the current authentication." return } ColorUtil.printColored("Nextflow authentication with Seqera Platform", "cyan bold") - ColorUtil.printColored(" - Authentication will be saved to: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") + ColorUtil.printColored(" - Authentication will be saved to: ${ColorUtil.colorize(getLoginFile().toString(), 'magenta')}", "dim") apiUrl = normalizeApiUrl(apiUrl) ColorUtil.printColored(" - Seqera Platform API endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')} (can be customised with ${ColorUtil.colorize('-url', 'cyan')})", "dim") @@ -81,7 +72,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { def endpointInfo = getCloudEndpointInfo(apiUrl) if( endpointInfo.isCloud ) { try { - performAuth0Login(apiUrl, endpointInfo.environment as String) + performAuth0Login(endpointInfo.endpoint as String, endpointInfo.auth as Map) } catch( Exception e ) { log.debug("Authentication failed", e) println "" @@ -93,9 +84,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } } - private void performAuth0Login(String apiUrl, String environment) { - // Get Auth0 configuration for this environment - def auth0Config = getAuth0Config(environment) + private void performAuth0Login(String apiUrl, Map auth0Config) { // Start device authorization flow def deviceAuth = requestDeviceAuthorization(auth0Config) @@ -108,6 +97,37 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { System.in.read() // Wait for Enter key // Try to open browser automatically + boolean browserOpened = openBrowser(urlWithCode) + + if( !browserOpened ) { + ColorUtil.printColored("Could not open browser automatically. Please copy the URL above and open it manually in your browser.", "yellow") + } + print("${ColorUtil.colorize('Waiting for authentication...', 'dim', true)}") + + try { + // Poll for device token + def tokenData = pollForDeviceToken(deviceAuth.device_code as String, deviceAuth.interval as Integer ?: 5, auth0Config) + def accessToken = tokenData['access_token'] as String + + // Verify login by calling /user-info + def userInfo = callUserInfoApi(accessToken, apiUrl) + ColorUtil.printColored("\n\nAuthentication successful!", "green") + + // Generate PAT + def pat = generatePAT(accessToken, apiUrl) + + // Save to config + saveAuthToConfig(pat, apiUrl) + + // Automatically run configuration + runConfiguration() + + } catch( Exception e ) { + throw new RuntimeException("Authentication failed: ${e.message}", e) + } + } + + private boolean openBrowser(GString urlWithCode) { def browserOpened = false try { // Method 1: Java Desktop API @@ -147,36 +167,10 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { browserOpened = true } } - } catch( Exception ignored ) { - // Will handle below - } - - if( !browserOpened ) { - ColorUtil.printColored("Could not open browser automatically. Please copy the URL above and open it manually in your browser.", "yellow") - } - print("${ColorUtil.colorize('Waiting for authentication...', 'dim', true)}") - - try { - // Poll for device token - def tokenData = pollForDeviceToken(deviceAuth.device_code as String, deviceAuth.interval as Integer ?: 5, auth0Config) - def accessToken = tokenData['access_token'] as String - - // Verify login by calling /user-info - def userInfo = callUserInfoApi(accessToken, apiUrl) - ColorUtil.printColored("\n\nAuthentication successful!", "green") - - // Generate PAT - def pat = generatePAT(accessToken, apiUrl) - - // Save to config - saveAuthToConfig(pat, apiUrl) - - // Automatically run configuration - runConfiguration() - } catch( Exception e ) { - throw new RuntimeException("Authentication failed: ${e.message}", e) + log.debug("Exception opening browser", e) } + return browserOpened } private void runConfiguration() { @@ -259,17 +253,16 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Save to config saveAuthToConfig(pat.trim(), apiUrl) - ColorUtil.printColored("Personal Access Token saved to Nextflow config", "green") - ColorUtil.printColored("Config file: ${getConfigFile().toString()}", "magenta") + ColorUtil.printColored("Personal Access Token saved to Nextflow login config (${getLoginFile().toString()})", "green") } private String generatePAT(String accessToken, String apiUrl) { def tokensUrl = "${apiUrl}/tokens" def username = System.getProperty("user.name") def timestamp = new Date().format("yyyy-MM-dd-HH-mm") - def tokenName = "nextflow-${username}-${timestamp}" + def tokenName = "nextflow-auth-${username}-${timestamp}" - def requestBody = new groovy.json.JsonBuilder([name: tokenName]).toString() + def requestBody = new JsonBuilder([name: tokenName]).toString() def connection = createHttpConnection(tokensUrl, 'POST', accessToken) connection.setRequestProperty('Content-Type', 'application/json') @@ -285,13 +278,13 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) as Map + def json = new JsonSlurper().parseText(response) as Map return json.accessKey as String } private String normalizeApiUrl(String url) { if( !url ) { - return SEQERA_ENDPOINTS.prod + return DEFAULT_API_ENDPOINT } if( !url.startsWith('http://') && !url.startsWith('https://') ) { return 'https://' + url @@ -317,12 +310,12 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { if( connection.responseCode == 200 ) { def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) + def json = new JsonSlurper().parseText(response) return json as Map } else { def errorResponse = connection.errorStream?.text if( errorResponse ) { - def errorJson = new groovy.json.JsonSlurper().parseText(errorResponse) as Map + def errorJson = new JsonSlurper().parseText(errorResponse) as Map def error = errorJson.error throw new RuntimeException("${error}: ${errorJson.error_description ?: ''}") } else { @@ -332,7 +325,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private void saveAuthToConfig(String accessToken, String apiUrl) { - def config = readConfig() + def config = [:] config['tower.accessToken'] = accessToken config['tower.endpoint'] = apiUrl config['tower.enabled'] = true @@ -342,6 +335,24 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void logout() { + // Check if .login file exists + def loginFile = getLoginFile() + if( !Files.exists(loginFile) ) { + ColorUtil.printColored("No previous login found.", "green") + return + } + // Read token from .login file + def loginConfig = readLoginFile() + def existingToken = loginConfig['tower.accessToken'] + def apiUrl = loginConfig['tower.endpoint'] as String ?: DEFAULT_API_ENDPOINT + + if( !existingToken ) { + ColorUtil.printColored("WARN: No authentication token found in login file.", "yellow bold") + println "Removing file: ${ColorUtil.colorize(loginFile.toString(), 'magenta')}" + removeAuthFromConfig() + return + } + // Check if TOWER_ACCESS_TOKEN environment variable is set def envToken = System.getenv('TOWER_ACCESS_TOKEN') if( envToken ) { @@ -352,26 +363,8 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { println "" } - // Check if tower.accessToken is set - def config = readConfig() - def existingToken = config['tower.accessToken'] - def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' - - if( !existingToken ) { - ColorUtil.printColored("Error: No authentication token found in Nextflow config.", "red") - println "Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}" - return - } - - ColorUtil.printColored(" - Found authentication token in config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") - - // Prompt user for API URL if not already configured - def apiUrl = endpoint as String - if( !apiUrl || apiUrl.isEmpty() ) { - apiUrl = promptForApiUrl() - } else { - ColorUtil.printColored(" - Using Seqera Platform endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')}", "dim") - } + ColorUtil.printColored(" - Found authentication token in login file: ${ColorUtil.colorize(loginFile.toString(), 'magenta')}", "dim") + ColorUtil.printColored(" - Using Seqera Platform endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')}", "dim") // Validate token by calling /user-info API try { @@ -385,7 +378,6 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } else { println " - Enterprise installation detected - PAT will not be deleted from platform." } - removeAuthFromConfig() } catch( Exception e ) { @@ -394,7 +386,6 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Remove from config even if API calls fail removeAuthFromConfig() - ColorUtil.printColored("Token removed from Nextflow config.", "green") } } @@ -404,7 +395,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { def decoded = new String(Base64.decoder.decode(token), "UTF-8") // Parse JSON to extract token ID - def json = new groovy.json.JsonSlurper().parseText(decoded) as Map + def json = new JsonSlurper().parseText(decoded) as Map def tokenId = json.tid if( !tokenId ) { @@ -430,11 +421,18 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private void removeAuthFromConfig() { def configFile = getConfigFile() + def loginFile = getLoginFile() + // Remove includeConfig line from main config file if( Files.exists(configFile) ) { def existingContent = Files.readString(configFile) - def cleanedContent = cleanTowerConfig(existingContent) - Files.writeString(configFile, cleanedContent, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + def updatedContent = removeIncludeConfigLine(existingContent) + Files.writeString(configFile, updatedContent.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + } + + // Delete .login file + if( Files.exists(loginFile) ) { + Files.delete(loginFile) } ColorUtil.printColored("Authentication removed from Nextflow config.", "green") @@ -442,9 +440,12 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void config() { + // Read from both main config and .login file def config = readConfig() + + // Token can come from .login file or environment variable def existingToken = config['tower.accessToken'] ?: System.getenv('TOWER_ACCESS_TOKEN') - def endpoint = config['tower.endpoint'] ?: 'https://api.cloud.seqera.io' + def endpoint = config['tower.endpoint'] ?: DEFAULT_API_ENDPOINT if( !existingToken ) { println "No authentication found. Please run ${ColorUtil.colorize('nextflow auth login', 'cyan')} first." @@ -452,7 +453,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } ColorUtil.printColored("Nextflow Seqera Platform configuration", "cyan bold") - ColorUtil.printColored(" - Config file: ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "dim") + ColorUtil.printColored(" - Config file: ${ColorUtil.colorize(getLoginFile().toString(), 'magenta')}", "dim") // Check if token is from environment variable if( !config['tower.accessToken'] && System.getenv('TOWER_ACCESS_TOKEN') ) { @@ -483,7 +484,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { println "\nNew configuration:" showCurrentConfig(config, existingToken as String, endpoint as String) - ColorUtil.printColored("\nConfiguration saved to ${ColorUtil.colorize(getConfigFile().toString(), 'magenta')}", "green") + ColorUtil.printColored("\nConfiguration saved to ${ColorUtil.colorize(getLoginFile().toString(), 'magenta')}", "green") } else { ColorUtil.printColored("\nNo configuration changes were made.", "dim") } @@ -675,7 +676,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { if( orgSelection >= 1 && orgSelection <= orgs.size() ) { break } - } catch( NumberFormatException e ) { + } catch( NumberFormatException ignored ) { // Fall through to error message } ColorUtil.printColored("Invalid input. Please enter a number between 1 and ${orgs.size()}.", "red") @@ -719,7 +720,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { if( wsSelection >= 1 && wsSelection <= maxSelection ) { break } - } catch( NumberFormatException e ) { + } catch( NumberFormatException ignored ) { // Fall through to error message } ColorUtil.printColored("Invalid input. Please enter a number between 1 and ${maxSelection}.", "red") @@ -746,7 +747,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) as Map + def json = new JsonSlurper().parseText(response) as Map def orgsAndWorkspaces = json.orgsAndWorkspaces as List return orgsAndWorkspaces.findAll { ((Map) it).workspaceId != null } @@ -759,7 +760,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { def input = readUserInput()?.toLowerCase() if( input?.isEmpty() ) { - return null // Keep current setting + return defaultValue } else if( input in ['y', 'yes'] ) { return true } else if( input in ['n', 'no'] ) { @@ -795,13 +796,13 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void status() { def config = readConfig() - + def loginConfig = readLoginFile() // Collect all status information List> statusRows = [] def workspaceDisplayInfo = null // API endpoint - def endpointInfo = getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://api.cloud.seqera.io') + def endpointInfo = getConfigValue(config, loginConfig, 'tower.endpoint', 'TOWER_API_ENDPOINT', DEFAULT_API_ENDPOINT) statusRows.add(['API endpoint', ColorUtil.colorize(endpointInfo.value as String, 'magenta'), endpointInfo.source as String]) // API connection check @@ -810,7 +811,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { statusRows.add(['API connection', ColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) // Authentication check - def tokenInfo = getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') + def tokenInfo = getConfigValue(config, loginConfig, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') if( tokenInfo.value ) { try { def userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) @@ -824,13 +825,13 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Monitoring enabled - def enabledInfo = getConfigValue(config, 'tower.enabled', null, 'false') + def enabledInfo = getConfigValue(config, loginConfig,'tower.enabled', null, 'false') def enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' def enabledColor = enabledValue == 'Yes' ? 'green' : 'yellow' statusRows.add(['Workflow monitoring', ColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) // Default workspace - def workspaceInfo = getConfigValue(config, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') + def workspaceInfo = getConfigValue(config, loginConfig, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') if( workspaceInfo.value ) { // Try to get workspace name from API if we have a token def workspaceDetails = null @@ -904,13 +905,17 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return path } - private Map getConfigValue(Map config, String configKey, String envVarName, String defaultValue = null) { + private Map getConfigValue(Map config, Map login, String configKey, String envVarName, String defaultValue = null) { + //Checks where the config value came from + def loginValue = login[configKey] def configValue = config[configKey] def envValue = envVarName ? System.getenv(envVarName) : null def effectiveValue = configValue ?: envValue ?: defaultValue def source = null - if( configValue ) { + if( loginValue ) { + source = shortenPath(getLoginFile().toString()) + } else if( configValue ) { source = shortenPath(getConfigFile().toString()) } else if( envValue ) { source = "env var \$${envVarName}" @@ -952,14 +957,14 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private String promptForApiUrl() { - System.out.print("Seqera Platform API endpoint [Default ${SEQERA_ENDPOINTS.prod}]: ") + System.out.print("Seqera Platform API endpoint [Default ${DEFAULT_API_ENDPOINT}]: ") System.out.flush() def input = readUserInput() - return input?.isEmpty() ? SEQERA_ENDPOINTS.prod : input + return input?.isEmpty() ? DEFAULT_API_ENDPOINT : input } - private String readUserInput() { + private static String readUserInput() { def reader = new BufferedReader(new InputStreamReader(System.in)) return reader.readLine()?.trim() } @@ -987,7 +992,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) as Map + def json = new JsonSlurper().parseText(response) as Map def orgsAndWorkspaces = json.orgsAndWorkspaces as List def workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } @@ -1007,14 +1012,14 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private Map getCloudEndpointInfo(String apiUrl) { - for (env in SEQERA_ENDPOINTS) { - def standardUrl = env.value + for (env in SEQERA_API_TO_AUTH0) { + def standardUrl = env.key as String def legacyUrl = standardUrl.replace('://api.', '://') + '/api' if (apiUrl == standardUrl || apiUrl == legacyUrl) { - return [isCloud: true, environment: env.key] + return [isCloud: true, endpoint: env.key, auth: env.value] } } - return [isCloud: false, environment: null] + return [isCloud: false, endpoint: apiUrl, auth: null] } private boolean isCloudEndpoint(String apiUrl) { @@ -1030,7 +1035,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } def response = connection.inputStream.text - def json = new groovy.json.JsonSlurper().parseText(response) as Map + def json = new JsonSlurper().parseText(response) as Map return json.user as Map } @@ -1038,8 +1043,12 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return Const.APP_HOME_DIR.resolve('config') } - private Map readConfig() { - def configFile = getConfigFile() + private Path getLoginFile() { + return Const.APP_HOME_DIR.resolve('.login') + } + + private Map readLoginFile() { + def configFile = getLoginFile() if (!Files.exists(configFile)) { return [:] } @@ -1053,40 +1062,28 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } } - private String cleanTowerConfig(String content) { - // Remove tower scoped blocks: tower { ... } - content = content.replaceAll(/(?ms)tower\s*\{.*?\}/, '') - // Remove individual tower.* lines - content = content.replaceAll(/(?m)^tower\..*$\n?/, '') - // Remove Seqera Platform configuration comment - content = content.replaceAll(/\/\/\s*Seqera Platform configuration\s*/, '') - // Clean up extra whitespace - return content.replaceAll(/\n\n+/, '\n\n').trim() + "\n\n" + private Map readConfig() { + def builder = new ConfigBuilder().setHomeDir(Const.APP_HOME_DIR).setCurrentDir(Const.APP_HOME_DIR) + return builder.buildConfigObject().flatten() } private void writeConfig(Map config, Map workspaceMetadata = null) { def configFile = getConfigFile() + def loginFile = getLoginFile() // Create directory if it doesn't exist if (!Files.exists(configFile.parent)) { Files.createDirectories(configFile.parent) } - // Read existing config and clean out old tower blocks - def configText = new StringBuilder() - if (Files.exists(configFile)) { - def existingContent = Files.readString(configFile) - def cleanedContent = cleanTowerConfig(existingContent) - configText.append(cleanedContent) - } - - // Write tower config block + // Write tower config to .login file def towerConfig = config.findAll { key, value -> key.toString().startsWith('tower.') && !key.toString().endsWith('.comment') } - configText.append("// Seqera Platform configuration\n") - configText.append("tower {\n") + def loginConfigText = new StringBuilder() + loginConfigText.append("// Seqera Platform configuration\n") + loginConfigText.append("tower {\n") towerConfig.each { key, value -> def configKey = key.toString().substring(6) // Remove "tower." prefix @@ -1096,13 +1093,48 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { if (configKey == 'workspaceId' && workspaceMetadata) { line += " // ${workspaceMetadata.orgName} / ${workspaceMetadata.workspaceName} [${workspaceMetadata.workspaceFullName}]" } - configText.append("${line}\n") + loginConfigText.append("${line}\n") } else { - configText.append(" ${configKey} = ${value}\n") + loginConfigText.append(" ${configKey} = ${value}\n") } } - configText.append("}\n") + loginConfigText.append("}\n") - Files.writeString(configFile, configText.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + // Write the .login file + Files.writeString(loginFile, loginConfigText.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + + // Add includeConfig line to main config file if it doesn't exist + addIncludeConfigToMainFile(configFile) + } + + private void addIncludeConfigToMainFile(Path configFile) { + def includeConfigLine = "includeConfig '.login'" + + def configContent = "" + if (Files.exists(configFile)) { + configContent = Files.readString(configFile) + // Check if includeConfig line already exists + if (configContent.contains(includeConfigLine)) { + return // Already exists, nothing to do + } + } + + // Append the includeConfig line + def updatedContent = configContent + if (!updatedContent.isEmpty() && !updatedContent.endsWith("\n")) { + updatedContent += "\n" + } + updatedContent += "\n${includeConfigLine}\n" + + Files.writeString(configFile, updatedContent, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + } + + private String removeIncludeConfigLine(String content) { + // Remove the includeConfig '.login' line + def lines = content.split('\n') + def filteredLines = lines.findAll { line -> + !line.trim().equals("includeConfig '.login'") + } + return filteredLines.join('\n') } } diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy index df635ce99c..fc30247590 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy @@ -47,15 +47,15 @@ class AuthCommandImplTest extends Specification { expect: cmd.getCloudEndpointInfo('https://api.cloud.seqera.io').isCloud == true - cmd.getCloudEndpointInfo('https://api.cloud.seqera.io').environment == 'prod' + cmd.getCloudEndpointInfo('https://api.cloud.seqera.io').auth.domain == 'seqera.eu.auth0.com' cmd.getCloudEndpointInfo('https://api.cloud.stage-seqera.io').isCloud == true - cmd.getCloudEndpointInfo('https://api.cloud.stage-seqera.io').environment == 'stage' + cmd.getCloudEndpointInfo('https://api.cloud.stage-seqera.io').auth.domain == 'seqera-stage.eu.auth0.com' cmd.getCloudEndpointInfo('https://api.cloud.dev-seqera.io').isCloud == true - cmd.getCloudEndpointInfo('https://api.cloud.dev-seqera.io').environment == 'dev' + cmd.getCloudEndpointInfo('https://api.cloud.dev-seqera.io').auth.domain == 'seqera-development.eu.auth0.com' cmd.getCloudEndpointInfo('https://cloud.seqera.io/api').isCloud == true - cmd.getCloudEndpointInfo('https://cloud.seqera.io/api').environment == 'prod' + cmd.getCloudEndpointInfo('https://cloud.seqera.io/api').auth.domain == 'seqera.eu.auth0.com' cmd.getCloudEndpointInfo('https://enterprise.example.com').isCloud == false - cmd.getCloudEndpointInfo('https://enterprise.example.com').environment == null + cmd.getCloudEndpointInfo('https://enterprise.example.com').auth == null } def 'should identify cloud endpoint from URL'() { @@ -77,39 +77,6 @@ class AuthCommandImplTest extends Specification { cmd.readConfig() instanceof Map } - def 'should clean tower config from existing content'() { - given: - def cmd = new AuthCommandImpl() - def content = ''' -// Some other config -process { - executor = 'local' -} - -// Seqera Platform configuration -tower { - accessToken = 'old-token' - enabled = true -} - -tower.endpoint = 'old-endpoint' - -// More config -params.test = true -''' - - when: - def cleaned = cmd.cleanTowerConfig(content) - - then: - !cleaned.contains('tower {') - !cleaned.contains('accessToken = \'old-token\'') - !cleaned.contains('tower.endpoint') - !cleaned.contains('Seqera Platform configuration') - cleaned.contains('process {') - cleaned.contains('params.test = true') - } - def 'should handle config writing'() { given: def cmd = new AuthCommandImpl() From 4f0773efd6409cf48f02bf76056a32ae6c5b9085 Mon Sep 17 00:00:00 2001 From: jorgee Date: Thu, 2 Oct 2025 10:29:49 +0200 Subject: [PATCH 35/66] clean implementation and fix tests Signed-off-by: jorgee --- .../tower/plugin/cli/AuthCommandImpl.groovy | 605 ++++++++---------- .../plugin/cli/AuthCommandImplTest.groovy | 1 - 2 files changed, 280 insertions(+), 326 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 589b7943e1..577e4aaa7a 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -4,6 +4,7 @@ import groovy.json.JsonBuilder import groovy.json.JsonSlurper import groovy.transform.CompileStatic import groovy.util.logging.Slf4j +import io.seqera.http.HxClient import nextflow.Const import nextflow.cli.CmdAuth import nextflow.cli.ColorUtil @@ -11,9 +12,13 @@ import nextflow.config.ConfigBuilder import nextflow.exception.AbortOperationException import java.awt.Desktop +import java.net.URI +import java.net.http.HttpRequest +import java.net.http.HttpResponse import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardOpenOption +import java.time.Duration @Slf4j @CompileStatic @@ -40,11 +45,25 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { ] ] + /** + * Creates an HxClient instance with optional authentication token + */ + private HxClient createHttpClient(String authToken = null) { + final builder = HxClient.newBuilder() + .connectTimeout(Duration.ofMillis(API_TIMEOUT_MS)) + + if (authToken) { + builder.bearerToken(authToken) + } + + return builder.build() + } + @Override void login(String apiUrl) { // Check if TOWER_ACCESS_TOKEN environment variable is set - def envToken = System.getenv('TOWER_ACCESS_TOKEN') + final envToken = System.getenv('TOWER_ACCESS_TOKEN') if( envToken ) { println "" ColorUtil.printColored("WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable.", "yellow bold") @@ -54,7 +73,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Check if .login file already exists - def loginFile = getLoginFile() + final loginFile = getLoginFile() if( Files.exists(loginFile) ) { ColorUtil.printColored("Error: Authentication token is already configured in Nextflow config.", "red") ColorUtil.printColored("Login file: ${ColorUtil.colorize(loginFile.toString(), 'magenta')}", "dim") @@ -69,13 +88,12 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { ColorUtil.printColored(" - Seqera Platform API endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')} (can be customised with ${ColorUtil.colorize('-url', 'cyan')})", "dim") // Check if this is a cloud endpoint or enterprise - def endpointInfo = getCloudEndpointInfo(apiUrl) + final endpointInfo = getCloudEndpointInfo(apiUrl) if( endpointInfo.isCloud ) { try { performAuth0Login(endpointInfo.endpoint as String, endpointInfo.auth as Map) } catch( Exception e ) { log.debug("Authentication failed", e) - println "" throw new AbortOperationException("${e.message}") } } else { @@ -87,11 +105,11 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private void performAuth0Login(String apiUrl, Map auth0Config) { // Start device authorization flow - def deviceAuth = requestDeviceAuthorization(auth0Config) + final deviceAuth = requestDeviceAuthorization(auth0Config) println "" ColorUtil.printColored("Confirmation code: ${ColorUtil.colorize(deviceAuth.user_code as String, 'yellow')}", "cyan bold") - def urlWithCode = "${deviceAuth.verification_uri}?user_code=${deviceAuth.user_code}" + final urlWithCode = "${deviceAuth.verification_uri}?user_code=${deviceAuth.user_code}" println "${ColorUtil.colorize('Authentication URL:', 'cyan bold')} ${ColorUtil.colorize(urlWithCode, 'magenta')}" ColorUtil.printColored("\n[ Press Enter to open in browser ]", "cyan bold") System.in.read() // Wait for Enter key @@ -106,21 +124,26 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { try { // Poll for device token - def tokenData = pollForDeviceToken(deviceAuth.device_code as String, deviceAuth.interval as Integer ?: 5, auth0Config) - def accessToken = tokenData['access_token'] as String + final tokenData = pollForDeviceToken(deviceAuth.device_code as String, deviceAuth.interval as Integer ?: AUTH_POLL_INTERVAL_SECONDS, auth0Config) + final accessToken = tokenData['access_token'] as String // Verify login by calling /user-info - def userInfo = callUserInfoApi(accessToken, apiUrl) + final userInfo = callUserInfoApi(accessToken, apiUrl) ColorUtil.printColored("\n\nAuthentication successful!", "green") // Generate PAT - def pat = generatePAT(accessToken, apiUrl) + final pat = generatePAT(accessToken, apiUrl) // Save to config saveAuthToConfig(pat, apiUrl) // Automatically run configuration - runConfiguration() + try { + config() + } catch( Exception e ) { + ColorUtil.printColored("Configuration setup failed: ${e.message}", "red") + ColorUtil.printColored("You can run 'nextflow auth config' later to set up your configuration.", "dim") + } } catch( Exception e ) { throw new RuntimeException("Authentication failed: ${e.message}", e) @@ -132,7 +155,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { try { // Method 1: Java Desktop API if( Desktop.isDesktopSupported() ) { - def desktop = Desktop.getDesktop() + final desktop = Desktop.getDesktop() if( desktop.isSupported(Desktop.Action.BROWSE) ) { desktop.browse(new URI(urlWithCode)) browserOpened = true @@ -141,31 +164,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Method 2: Platform-specific commands if( !browserOpened ) { - def os = System.getProperty("os.name").toLowerCase() - def command = [] - - if( os.contains("mac") || os.contains("darwin") ) { - command = ["open", urlWithCode] - } else if( os.contains("win") ) { - command = ["cmd", "/c", "start", urlWithCode] - } else { - // Linux and other Unix-like systems - def browsers = ["xdg-open", "firefox", "google-chrome", "chromium", "safari"] - for( browser in browsers ) { - try { - new ProcessBuilder(browser, urlWithCode).start() - browserOpened = true - break - } catch( Exception ignored ) { - // Try next browser - } - } - } - - if( !browserOpened && command ) { - new ProcessBuilder(command as String[]).start() - browserOpened = true - } + browserOpened = runBrowserCommand(urlWithCode) } } catch( Exception e ) { log.debug("Exception opening browser", e) @@ -173,19 +172,37 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return browserOpened } - private void runConfiguration() { - try { - println "" - // Just run the existing config command - config() - } catch( Exception e ) { - ColorUtil.printColored("Configuration setup failed: ${e.message}", "red") - ColorUtil.printColored("You can run 'nextflow auth config' later to set up your configuration.", "dim") + private boolean runBrowserCommand(GString urlWithCode) { + def command = [] + def browserOpened = false + final os = System.getProperty("os.name").toLowerCase() + if( os.contains("mac") || os.contains("darwin") ) { + command = ["open", urlWithCode] + } else if( os.contains("win") ) { + command = ["cmd", "/c", "start", urlWithCode] + } else { + // Linux and other Unix-like systems + final browsers = ["xdg-open", "firefox", "google-chrome", "chromium", "safari"] + for( browser in browsers ) { + try { + new ProcessBuilder(browser, urlWithCode).start() + browserOpened = true + break + } catch( Exception ignored ) { + // Try next browser + } + } + } + + if( !browserOpened && command ) { + new ProcessBuilder(command as String[]).start() + browserOpened = true } + return browserOpened } private Map requestDeviceAuthorization(Map auth0Config) { - def params = [ + final params = [ 'client_id': auth0Config.clientId, 'scope' : 'openid profile email offline_access', 'audience' : 'platform' @@ -194,21 +211,21 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private Map pollForDeviceToken(String deviceCode, int intervalSeconds, Map auth0Config) { - def tokenUrl = "https://${auth0Config.domain}/oauth/token" + final tokenUrl = "https://${auth0Config.domain}/oauth/token" def retryCount = 0 while( retryCount < AUTH_POLL_TIMEOUT_RETRIES ) { - def params = [ + final params = [ 'grant_type' : 'urn:ietf:params:oauth:grant-type:device_code', 'device_code': deviceCode, 'client_id' : auth0Config.clientId ] try { - def result = performAuth0Request(tokenUrl, params) + final result = performAuth0Request(tokenUrl, params) return result } catch( RuntimeException e ) { - def message = e.message + final message = e.message if( message.contains('authorization_pending') ) { print "${ColorUtil.colorize('.', 'dim', true)}" System.out.flush() @@ -239,46 +256,51 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { println "You can create one at: ${ColorUtil.colorize(apiUrl.replace('/api', '').replace('://api.', '') + '/tokens', 'magenta')}" println "" - System.out.print("Enter your Personal Access Token: ") - System.out.flush() - - def console = System.console() - def pat = console ? - new String(console.readPassword()) : - new BufferedReader(new InputStreamReader(System.in)).readLine() + final pat = promptPAT() - if( !pat || pat.trim().isEmpty() ) { + if( !pat ) { throw new AbortOperationException("Personal Access Token is required for Seqera Platform Enterprise authentication") } // Save to config - saveAuthToConfig(pat.trim(), apiUrl) + saveAuthToConfig(pat, apiUrl) ColorUtil.printColored("Personal Access Token saved to Nextflow login config (${getLoginFile().toString()})", "green") } + private String promptPAT(){ + System.out.print("Enter your Personal Access Token: ") + System.out.flush() + + final console = System.console() + final pat = console ? + new String(console.readPassword()) : + new BufferedReader(new InputStreamReader(System.in)).readLine() + return pat.trim() + } + private String generatePAT(String accessToken, String apiUrl) { - def tokensUrl = "${apiUrl}/tokens" - def username = System.getProperty("user.name") - def timestamp = new Date().format("yyyy-MM-dd-HH-mm") - def tokenName = "nextflow-auth-${username}-${timestamp}" + final tokensUrl = "${apiUrl}/tokens" + final username = System.getProperty("user.name") + final timestamp = new Date().format("yyyy-MM-dd-HH-mm") + final tokenName = "nextflow-auth-${username}-${timestamp}" - def requestBody = new JsonBuilder([name: tokenName]).toString() + final requestBody = new JsonBuilder([name: tokenName]).toString() - def connection = createHttpConnection(tokensUrl, 'POST', accessToken) - connection.setRequestProperty('Content-Type', 'application/json') - connection.doOutput = true + final client = createHttpClient(accessToken) + final request = HttpRequest.newBuilder() + .uri(URI.create(tokensUrl)) + .header('Content-Type', 'application/json') + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build() - connection.outputStream.withWriter { writer -> - writer.write(requestBody) - } + final response = client.send(request, HttpResponse.BodyHandlers.ofString()) - if( connection.responseCode != 200 ) { - def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + if( response.statusCode() != 200 ) { + final error = response.body() ?: "HTTP ${response.statusCode()}" throw new RuntimeException("Failed to generate PAT: ${error}") } - def response = connection.inputStream.text - def json = new JsonSlurper().parseText(response) as Map + final json = new JsonSlurper().parseText(response.body()) as Map return json.accessKey as String } @@ -293,39 +315,36 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private Map performAuth0Request(String url, Map params) { - def postData = params.collect { k, v -> + final postData = params.collect { k, v -> "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" }.join('&') - def connection = new URL(url).openConnection() as HttpURLConnection - connection.requestMethod = 'POST' - connection.connectTimeout = API_TIMEOUT_MS - connection.readTimeout = API_TIMEOUT_MS - connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded') - connection.doOutput = true + final client = createHttpClient() + final request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header('Content-Type', 'application/x-www-form-urlencoded') + .POST(HttpRequest.BodyPublishers.ofString(postData)) + .build() - connection.outputStream.withWriter { writer -> - writer.write(postData) - } + final response = client.send(request, HttpResponse.BodyHandlers.ofString()) - if( connection.responseCode == 200 ) { - def response = connection.inputStream.text - def json = new JsonSlurper().parseText(response) + if( response.statusCode() == 200 ) { + final json = new JsonSlurper().parseText(response.body()) return json as Map } else { - def errorResponse = connection.errorStream?.text - if( errorResponse ) { - def errorJson = new JsonSlurper().parseText(errorResponse) as Map - def error = errorJson.error + final errorBody = response.body() + if( errorBody ) { + final errorJson = new JsonSlurper().parseText(errorBody) as Map + final error = errorJson.error throw new RuntimeException("${error}: ${errorJson.error_description ?: ''}") } else { - throw new RuntimeException("Request failed: HTTP ${connection.responseCode}") + throw new RuntimeException("Request failed: HTTP ${response.statusCode()}") } } } private void saveAuthToConfig(String accessToken, String apiUrl) { - def config = [:] + final config = [:] config['tower.accessToken'] = accessToken config['tower.endpoint'] = apiUrl config['tower.enabled'] = true @@ -336,15 +355,15 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void logout() { // Check if .login file exists - def loginFile = getLoginFile() + final loginFile = getLoginFile() if( !Files.exists(loginFile) ) { ColorUtil.printColored("No previous login found.", "green") return } // Read token from .login file - def loginConfig = readLoginFile() - def existingToken = loginConfig['tower.accessToken'] - def apiUrl = loginConfig['tower.endpoint'] as String ?: DEFAULT_API_ENDPOINT + final loginConfig = readLoginFile() + final existingToken = loginConfig['tower.accessToken'] + final apiUrl = loginConfig['tower.endpoint'] as String ?: DEFAULT_API_ENDPOINT if( !existingToken ) { ColorUtil.printColored("WARN: No authentication token found in login file.", "yellow bold") @@ -354,7 +373,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Check if TOWER_ACCESS_TOKEN environment variable is set - def envToken = System.getenv('TOWER_ACCESS_TOKEN') + final envToken = System.getenv('TOWER_ACCESS_TOKEN') if( envToken ) { println "" ColorUtil.printColored("WARNING: TOWER_ACCESS_TOKEN environment variable is set.", "yellow bold") @@ -368,12 +387,12 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Validate token by calling /user-info API try { - def userInfo = callUserInfoApi(existingToken as String, apiUrl) + final userInfo = callUserInfoApi(existingToken as String, apiUrl) ColorUtil.printColored(" - Token is valid for user: ${ColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") // Only delete PAT from platform if this is a cloud endpoint if( isCloudEndpoint(apiUrl) ) { - def tokenId = decodeTokenId(existingToken as String) + final tokenId = decodeTokenId(existingToken as String) deleteTokenViaApi(existingToken as String, apiUrl, tokenId) } else { println " - Enterprise installation detected - PAT will not be deleted from platform." @@ -392,11 +411,11 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private String decodeTokenId(String token) { try { // Decode base64 token - def decoded = new String(Base64.decoder.decode(token), "UTF-8") + final decoded = new String(Base64.decoder.decode(token), "UTF-8") // Parse JSON to extract token ID - def json = new JsonSlurper().parseText(decoded) as Map - def tokenId = json.tid + final json = new JsonSlurper().parseText(decoded) as Map + final tokenId = json.tid if( !tokenId ) { throw new RuntimeException("No token ID found in decoded token") @@ -409,10 +428,16 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private void deleteTokenViaApi(String token, String apiUrl, String tokenId) { - def connection = createHttpConnection("${apiUrl}/tokens/${tokenId}", 'DELETE', token) + final client = createHttpClient(token) + final request = HttpRequest.newBuilder() + .uri(URI.create("${apiUrl}/tokens/${tokenId}")) + .DELETE() + .build() - if( connection.responseCode != 200 && connection.responseCode != 204 ) { - def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + final response = client.send(request, HttpResponse.BodyHandlers.ofString()) + + if( response.statusCode() != 200 && response.statusCode() != 204 ) { + final error = response.body() ?: "HTTP ${response.statusCode()}" throw new RuntimeException("Failed to delete token: ${error}") } @@ -420,13 +445,13 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private void removeAuthFromConfig() { - def configFile = getConfigFile() - def loginFile = getLoginFile() + final configFile = getConfigFile() + final loginFile = getLoginFile() // Remove includeConfig line from main config file if( Files.exists(configFile) ) { - def existingContent = Files.readString(configFile) - def updatedContent = removeIncludeConfigLine(existingContent) + final existingContent = Files.readString(configFile) + final updatedContent = removeIncludeConfigLine(existingContent) Files.writeString(configFile, updatedContent.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) } @@ -441,11 +466,11 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void config() { // Read from both main config and .login file - def config = readConfig() + final config = readConfig() // Token can come from .login file or environment variable - def existingToken = config['tower.accessToken'] ?: System.getenv('TOWER_ACCESS_TOKEN') - def endpoint = config['tower.endpoint'] ?: DEFAULT_API_ENDPOINT + final existingToken = config['tower.accessToken'] ?: System.getenv('TOWER_ACCESS_TOKEN') + final endpoint = config['tower.endpoint'] ?: DEFAULT_API_ENDPOINT if( !existingToken ) { println "No authentication found. Please run ${ColorUtil.colorize('nextflow auth login', 'cyan')} first." @@ -462,7 +487,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { try { // Get user info to validate token and get user ID - def userInfo = callUserInfoApi(existingToken as String, endpoint as String) + final userInfo = callUserInfoApi(existingToken as String, endpoint as String) ColorUtil.printColored(" - Authenticated as: ${ColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") println "" @@ -473,7 +498,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { configChanged |= configureEnabled(config) // Configure workspace - def workspaceResult = configureWorkspace(config, existingToken as String, endpoint as String, userInfo.id as String) + final workspaceResult = configureWorkspace(config, existingToken as String, endpoint as String, userInfo.id as String) configChanged = configChanged || (workspaceResult.changed as boolean) // Save updated config only if changes were made @@ -496,16 +521,16 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private void showCurrentConfig(Map config, String accessToken, String endpoint) { // Show workflow monitoring status - def monitoringEnabled = config.get('tower.enabled', false) + final monitoringEnabled = config.get('tower.enabled', false) println " ${ColorUtil.colorize('Workflow monitoring:', 'cyan')} ${monitoringEnabled ? ColorUtil.colorize('enabled', 'green') : ColorUtil.colorize('disabled', 'red')}" // Show workspace setting - def workspaceId = config.get('tower.workspaceId') + final workspaceId = config.get('tower.workspaceId') if( workspaceId ) { // Try to get workspace details from API for display - def workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpoint, workspaceId as String) + final workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpoint, workspaceId as String) if( workspaceDetails ) { - def details = workspaceDetails as Map + final details = workspaceDetails as Map println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')} ${ColorUtil.colorize('[' + details.orgName + ' / ' + details.workspaceName + ']', 'dim', true)}" } else { println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')}" @@ -515,20 +540,20 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Show API endpoint - def apiEndpoint = config.get('tower.endpoint') ?: endpoint + final apiEndpoint = config.get('tower.endpoint') ?: endpoint println " ${ColorUtil.colorize('API endpoint:', 'cyan', true)} $apiEndpoint" } private boolean configureEnabled(Map config) { - def currentEnabled = config.get('tower.enabled', false) + final currentEnabled = config.get('tower.enabled', false) println "Workflow monitoring settings. Current setting: ${currentEnabled ? ColorUtil.colorize('enabled', 'green') : ColorUtil.colorize('disabled', 'red')}" ColorUtil.printColored(" When enabled, all workflow runs are automatically monitored by Seqera Platform", "dim") ColorUtil.printColored(" When disabled, you can enable per-run with the ${ColorUtil.colorize('-with-tower', 'cyan')} flag", "dim") println "" - def promptText = "${ColorUtil.colorize('Enable workflow monitoring for all runs?', 'cyan bold', true)} (${currentEnabled ? ColorUtil.colorize('Y', 'green') + '/n' : 'y/' + ColorUtil.colorize('N', 'red')}): " - def input = promptForYesNo(promptText, currentEnabled) + final promptText = "${ColorUtil.colorize('Enable workflow monitoring for all runs?', 'cyan bold', true)} (${currentEnabled ? ColorUtil.colorize('Y', 'green') + '/n' : 'y/' + ColorUtil.colorize('N', 'red')}): " + final input = promptForYesNo(promptText, currentEnabled) if( input == null ) { return false // No change @@ -544,7 +569,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private Map configureWorkspace(Map config, String accessToken, String endpoint, String userId) { // Check if TOWER_WORKFLOW_ID environment variable is set - def envWorkspaceId = System.getenv('TOWER_WORKFLOW_ID') + final envWorkspaceId = System.getenv('TOWER_WORKFLOW_ID') if( envWorkspaceId ) { println "\nDefault workspace: ${ColorUtil.colorize('TOWER_WORKFLOW_ID environment variable is set', 'yellow')}" ColorUtil.printColored(" Not prompting for default workspace configuration as environment variable takes precedence", "dim") @@ -552,7 +577,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Get all workspaces for the user - def workspaces = getUserWorkspaces(accessToken, endpoint, userId) + final workspaces = getUserWorkspaces(accessToken, endpoint, userId) if( !workspaces ) { println "\nNo workspaces found for your account." @@ -560,21 +585,14 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Show current workspace setting - def currentWorkspaceId = config.get('tower.workspaceId') - def currentWorkspace = workspaces.find { ((Map) it).workspaceId.toString() == currentWorkspaceId?.toString() } + final currentWorkspaceId = config.get('tower.workspaceId') - def currentSetting - if( currentWorkspace ) { - def workspace = currentWorkspace as Map - currentSetting = "${workspace.orgName} / ${workspace.workspaceName}" - } else { - currentSetting = "None (Personal workspace)" - } + String currentSetting = getCurrentWorkspaceName(workspaces, config.get('tower.workspaceId')) - println "\nDefault workspace. Current setting: ${ColorUtil.colorize(currentSetting as String, 'cyan', true)}" + println "\nDefault workspace. Current setting: ${ColorUtil.colorize(currentSetting, 'cyan', true)}" ColorUtil.printColored(" Workflow runs use this workspace by default", "dim") // Group by organization - def orgWorkspaces = workspaces.groupBy { ((Map) it).orgName ?: 'Personal' } + final orgWorkspaces = workspaces.groupBy { ((Map) it).orgName ?: 'Personal' } // If threshold or fewer total options, show all at once if( workspaces.size() <= WORKSPACE_SELECTION_THRESHOLD ) { @@ -585,43 +603,41 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } } - private Map selectWorkspaceFromAll(Map config, List workspaces, def currentWorkspaceId) { + private String getCurrentWorkspaceName(List workspaces, currentWorkspaceId) { + final currentWorkspace = workspaces.find { ((Map) it).workspaceId.toString() == currentWorkspaceId?.toString() } as Map + return currentWorkspace ? "${currentWorkspace.orgName} / ${currentWorkspace.workspaceName}" : "None (Personal workspace)" + } + + private Map selectWorkspaceFromAll(Map config, List workspaces, final currentWorkspaceId) { println "\nAvailable workspaces:" println " 0. ${ColorUtil.colorize('None (Personal workspace)', 'cyan', true)} ${ColorUtil.colorize('[no organization]', 'dim', true)}" workspaces.eachWithIndex { workspace, index -> - def ws = workspace as Map - def prefix = ws.orgName ? "${ColorUtil.colorize(ws.orgName as String, 'cyan', true)} / " : "" + final ws = workspace as Map + final prefix = ws.orgName ? "${ColorUtil.colorize(ws.orgName as String, 'cyan', true)} / " : "" println " ${index + 1}. ${prefix}${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}" } // Show current workspace and prepare prompt - def currentWorkspace = workspaces.find { ((Map) it).workspaceId.toString() == currentWorkspaceId?.toString() } - def currentWorkspaceName - if( currentWorkspace ) { - def workspace = currentWorkspace as Map - currentWorkspaceName = "${workspace.orgName} / ${workspace.workspaceName}" - } else { - currentWorkspaceName = "None (Personal workspace)" - } + final currentWorkspaceName = getCurrentWorkspaceName(workspaces, currentWorkspaceId) - def prompt = "\nSelect workspace (0-${workspaces.size()}, press Enter to keep as '${currentWorkspaceName}'): " - def selection = promptForNumber(prompt, 0, workspaces.size(), true) + final prompt = ColorUtil.colorize("\nSelect workspace (0-${workspaces.size()}, press Enter to keep as '${currentWorkspaceName}'): ","bold cyan") + final selection = promptForNumber(prompt, 0, workspaces.size(), true) if( selection == null ) { return [changed: false, metadata: null] } if( selection == 0 ) { - def hadWorkspaceId = config.containsKey('tower.workspaceId') + final hadWorkspaceId = config.containsKey('tower.workspaceId') config.remove('tower.workspaceId') return [changed: hadWorkspaceId, metadata: null] } else { - def selectedWorkspace = workspaces[selection - 1] as Map - def selectedId = selectedWorkspace.workspaceId.toString() - def currentId = config.get('tower.workspaceId') + final selectedWorkspace = workspaces[selection - 1] as Map + final selectedId = selectedWorkspace.workspaceId.toString() + final currentId = config.get('tower.workspaceId') config['tower.workspaceId'] = selectedId - def metadata = [ + final metadata = [ orgName : selectedWorkspace.orgName, workspaceName : selectedWorkspace.workspaceName, workspaceFullName: selectedWorkspace.workspaceFullName @@ -630,107 +646,56 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } } - private Map selectWorkspaceByOrg(Map config, Map orgWorkspaces, def currentWorkspaceId) { + private Map selectWorkspaceByOrg(Map config, Map orgWorkspaces, final currentWorkspaceId) { // Get current workspace info for prompts - def allWorkspaces = [] + final allWorkspaces = [] orgWorkspaces.values().each { workspaceList -> allWorkspaces.addAll(workspaceList as List) } - def currentWorkspace = allWorkspaces.find { ((Map) it).workspaceId.toString() == currentWorkspaceId?.toString() } - def currentWorkspaceName - def currentWorkspaceDisplay - if( currentWorkspace ) { - def workspace = currentWorkspace as Map - currentWorkspaceName = workspace.workspaceName as String - currentWorkspaceDisplay = "${workspace.orgName} / ${workspace.workspaceName}" - } else { - currentWorkspaceName = null - currentWorkspaceDisplay = "None" - } + final currentWorkspaceDisplay = getCurrentWorkspaceName(allWorkspaces, currentWorkspaceId) // First, select organization - def orgs = orgWorkspaces.keySet().toList() + final orgs = orgWorkspaces.keySet().toList() // Always add Personal as first option (it's never returned by the API but should always be available) orgs.add(0, 'Personal') println "\nAvailable organizations:" orgs.eachWithIndex { orgName, index -> - def displayName = orgName == 'Personal' ? 'None [Personal workspace]' : orgName + final displayName = orgName == 'Personal' ? 'None [Personal workspace]' : orgName println " ${index + 1}. ${ColorUtil.colorize(displayName as String, 'cyan', true)}" } - System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size()}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") - System.out.flush() - - def reader = new BufferedReader(new InputStreamReader(System.in)) - def orgSelection - while( true ) { - def orgInput = reader.readLine()?.trim() - - if( orgInput.isEmpty() ) { - return [changed: false, metadata: null] - } - - try { - orgSelection = Integer.parseInt(orgInput) - if( orgSelection >= 1 && orgSelection <= orgs.size() ) { - break - } - } catch( NumberFormatException ignored ) { - // Fall through to error message - } - ColorUtil.printColored("Invalid input. Please enter a number between 1 and ${orgs.size()}.", "red") - System.out.print("${ColorUtil.colorize("Select organization (1-${orgs.size()}, leave blank to keep as '${currentWorkspaceDisplay}'): ", 'dim', true)}") - System.out.flush() - } + final orgSelection = promptForNumber(ColorUtil.colorize("Select organization (1-${orgs.size()}, leave blank to keep as '${currentWorkspaceDisplay}'): "), 1, orgs.size(),true) + if (!orgSelection) + return [changed: false, metadata: null] - def selectedOrgName = orgs[orgSelection - 1] + final selectedOrgName = orgs[orgSelection - 1] // If Personal was selected, remove workspace ID (use personal workspace) if( selectedOrgName == 'Personal' ) { - def hadWorkspaceId = config.containsKey('tower.workspaceId') + final hadWorkspaceId = config.containsKey('tower.workspaceId') config.remove('tower.workspaceId') return [changed: hadWorkspaceId, metadata: null] } - def orgWorkspaceList = orgWorkspaces[selectedOrgName] as List + final orgWorkspaceList = orgWorkspaces[selectedOrgName] as List println "" println "Select workspace in ${selectedOrgName}:" orgWorkspaceList.eachWithIndex { workspace, index -> - def ws = workspace as Map + final ws = workspace as Map println " ${index + 1}. ${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}" } - def maxSelection = orgWorkspaceList.size() - def wsSelection - while( true ) { - System.out.print("${ColorUtil.colorize("Select workspace (1-${maxSelection}): ", 'dim', true)}") - System.out.flush() - - def wsInput = reader.readLine()?.trim() - if( wsInput.isEmpty() ) { - ColorUtil.printColored("Please enter a selection.", "red") - continue - } - - try { - wsSelection = Integer.parseInt(wsInput) - if( wsSelection >= 1 && wsSelection <= maxSelection ) { - break - } - } catch( NumberFormatException ignored ) { - // Fall through to error message - } - ColorUtil.printColored("Invalid input. Please enter a number between 1 and ${maxSelection}.", "red") - } + final maxSelection = orgWorkspaceList.size() + final wsSelection = promptForNumber(ColorUtil.colorize("Select workspace (1-${maxSelection}): ", 'dim', true),1, maxSelection,false) - def selectedWorkspace = orgWorkspaceList[wsSelection - 1] as Map - def selectedId = selectedWorkspace.workspaceId.toString() - def currentId = config.get('tower.workspaceId') + final selectedWorkspace = orgWorkspaceList[wsSelection - 1] as Map + final selectedId = selectedWorkspace.workspaceId.toString() + final currentId = config.get('tower.workspaceId') config['tower.workspaceId'] = selectedId - def metadata = [ + final metadata = [ orgName : selectedWorkspace.orgName, workspaceName : selectedWorkspace.workspaceName, workspaceFullName: selectedWorkspace.workspaceFullName @@ -739,25 +704,28 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private List getUserWorkspaces(String accessToken, String endpoint, String userId) { - def connection = createHttpConnection("${endpoint}/user/${userId}/workspaces", 'GET', accessToken) + final client = createHttpClient(accessToken) + final request = HttpRequest.newBuilder() + .uri(URI.create("${endpoint}/user/${userId}/workspaces")) + .GET() + .build() - if( connection.responseCode != 200 ) { - def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + 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 workspaces: ${error}") } - def response = connection.inputStream.text - def json = new JsonSlurper().parseText(response) as Map - def orgsAndWorkspaces = json.orgsAndWorkspaces as List + final json = new JsonSlurper().parseText(response.body()) as Map + final orgsAndWorkspaces = json.orgsAndWorkspaces as List return orgsAndWorkspaces.findAll { ((Map) it).workspaceId != null } } private Boolean promptForYesNo(String prompt, Boolean defaultValue) { while( true ) { - System.out.print(prompt) - System.out.flush() - def input = readUserInput()?.toLowerCase() + final input = readUserInput(prompt)?.toLowerCase() if( input?.isEmpty() ) { return defaultValue @@ -773,20 +741,18 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private Integer promptForNumber(String prompt, int min, int max, boolean allowEmpty = false) { while( true ) { - ColorUtil.printColored(prompt, "bold cyan") - System.out.flush() - def input = readUserInput() + final input = readUserInput(prompt) if( input?.isEmpty() && allowEmpty ) { return null } try { - def number = Integer.parseInt(input) + final number = Integer.parseInt(input) if( number >= min && number <= max ) { return number } - } catch( NumberFormatException e ) { + } catch( NumberFormatException ignored ) { // Fall through to error message } ColorUtil.printColored("Invalid input. Please enter a number between ${min} and ${max}.", "red") @@ -795,27 +761,27 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void status() { - def config = readConfig() - def loginConfig = readLoginFile() + final config = readConfig() + final loginConfig = readLoginFile() // Collect all status information List> statusRows = [] def workspaceDisplayInfo = null // API endpoint - def endpointInfo = getConfigValue(config, loginConfig, 'tower.endpoint', 'TOWER_API_ENDPOINT', DEFAULT_API_ENDPOINT) + final endpointInfo = getConfigValue(config, loginConfig, 'tower.endpoint', 'TOWER_API_ENDPOINT', DEFAULT_API_ENDPOINT) statusRows.add(['API endpoint', ColorUtil.colorize(endpointInfo.value as String, 'magenta'), endpointInfo.source as String]) // API connection check - def apiConnectionOk = checkApiConnection(endpointInfo.value as String) - def connectionColor = apiConnectionOk ? 'green' : 'red' + final apiConnectionOk = checkApiConnection(endpointInfo.value as String) + final connectionColor = apiConnectionOk ? 'green' : 'red' statusRows.add(['API connection', ColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) // Authentication check - def tokenInfo = getConfigValue(config, loginConfig, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') + final tokenInfo = getConfigValue(config, loginConfig, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') if( tokenInfo.value ) { try { - def userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) - def currentUser = userInfo.userName as String + final userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) + final currentUser = userInfo.userName as String statusRows.add(['Authentication', "${ColorUtil.colorize('OK', 'green')} (user: ${ColorUtil.colorize(currentUser, 'cyan')})".toString(), tokenInfo.source as String]) } catch( Exception e ) { statusRows.add(['Authentication', ColorUtil.colorize('ERROR', 'red'), 'failed']) @@ -825,13 +791,13 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Monitoring enabled - def enabledInfo = getConfigValue(config, loginConfig,'tower.enabled', null, 'false') - def enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' - def enabledColor = enabledValue == 'Yes' ? 'green' : 'yellow' + final enabledInfo = getConfigValue(config, loginConfig,'tower.enabled', null, 'false') + final enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' + final enabledColor = enabledValue == 'Yes' ? 'green' : 'yellow' statusRows.add(['Workflow monitoring', ColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) // Default workspace - def workspaceInfo = getConfigValue(config, loginConfig, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') + final workspaceInfo = getConfigValue(config, loginConfig, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') if( workspaceInfo.value ) { // Try to get workspace name from API if we have a token def workspaceDetails = null @@ -880,9 +846,9 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Print rows rows.each { row -> - def paddedCol1 = padStringWithAnsi(row[0], col1Width) - def paddedCol2 = padStringWithAnsi(row[1], col2Width) - def paddedCol3 = ColorUtil.colorize(row[2], 'dim', true) + final paddedCol1 = padStringWithAnsi(row[0], col1Width) + final paddedCol2 = padStringWithAnsi(row[1], col2Width) + final paddedCol3 = ColorUtil.colorize(row[2], 'dim', true) println "${paddedCol1} ${paddedCol2} ${paddedCol3}" } } @@ -892,13 +858,13 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private String padStringWithAnsi(String text, int width) { - def plainText = stripAnsiCodes(text) - def padding = width - plainText.length() + final plainText = stripAnsiCodes(text) + final padding = width - plainText.length() return padding > 0 ? text + (' ' * padding) : text } private String shortenPath(String path) { - def userHome = System.getProperty('user.home') + final userHome = System.getProperty('user.home') if( path.startsWith(userHome) ) { return '~' + path.substring(userHome.length()) } @@ -907,10 +873,10 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private Map getConfigValue(Map config, Map login, String configKey, String envVarName, String defaultValue = null) { //Checks where the config value came from - def loginValue = login[configKey] - def configValue = config[configKey] - def envValue = envVarName ? System.getenv(envVarName) : null - def effectiveValue = configValue ?: envValue ?: defaultValue + final loginValue = login[configKey] + final configValue = config[configKey] + final envValue = envVarName ? System.getenv(envVarName) : null + final effectiveValue = configValue ?: envValue ?: defaultValue def source = null if( loginValue ) { @@ -932,72 +898,56 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { ] } - private String getWorkspaceNameFromApi(String accessToken, String endpoint, String workspaceId) { - try { - def workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpoint, workspaceId) - if( workspaceDetails ) { - return "${workspaceDetails.orgName} / ${workspaceDetails.workspaceName} [${workspaceDetails.workspaceFullName}]" - } - return null - } catch( Exception e ) { - return null - } - } - private boolean checkApiConnection(String endpoint) { try { - def connection = new URL("${endpoint}/service-info").openConnection() as HttpURLConnection - connection.requestMethod = 'GET' - connection.connectTimeout = API_TIMEOUT_MS - connection.readTimeout = API_TIMEOUT_MS - return connection.responseCode == 200 + final client = createHttpClient() + final request = HttpRequest.newBuilder() + .uri(URI.create("${endpoint}/service-info")) + .GET() + .build() + + final response = client.send(request, HttpResponse.BodyHandlers.ofString()) + return response.statusCode() == 200 } catch( Exception e ) { return false } } - private String promptForApiUrl() { - System.out.print("Seqera Platform API endpoint [Default ${DEFAULT_API_ENDPOINT}]: ") - System.out.flush() - - def input = readUserInput() - return input?.isEmpty() ? DEFAULT_API_ENDPOINT : input - } - - private static String readUserInput() { - def reader = new BufferedReader(new InputStreamReader(System.in)) - return reader.readLine()?.trim() - } - - private HttpURLConnection createHttpConnection(String url, String method, String authToken = null) { - def connection = new URL(url).openConnection() as HttpURLConnection - connection.requestMethod = method - connection.connectTimeout = API_TIMEOUT_MS - connection.readTimeout = API_TIMEOUT_MS - if (authToken) { - connection.setRequestProperty('Authorization', "Bearer ${authToken}") + private static String readUserInput(String message = null) { + if (message) { + System.out.print(message) + System.out.flush() } - return connection + final console = System.console() + final line = console != null + ? console.readLine() + : new BufferedReader(new InputStreamReader(System.in)).readLine() + return line?.trim() } private Map getWorkspaceDetailsFromApi(String accessToken, String endpoint, String workspaceId) { try { - def userInfo = callUserInfoApi(accessToken, endpoint) - def userId = userInfo.id as String + final userInfo = callUserInfoApi(accessToken, endpoint) + final userId = userInfo.id as String + + final client = createHttpClient(accessToken) + final request = HttpRequest.newBuilder() + .uri(URI.create("${endpoint}/user/${userId}/workspaces")) + .GET() + .build() - def connection = createHttpConnection("${endpoint}/user/${userId}/workspaces", 'GET', accessToken) + final response = client.send(request, HttpResponse.BodyHandlers.ofString()) - if (connection.responseCode != 200) { + if (response.statusCode() != 200) { return null } - def response = connection.inputStream.text - def json = new JsonSlurper().parseText(response) as Map - def orgsAndWorkspaces = json.orgsAndWorkspaces as List + final json = new JsonSlurper().parseText(response.body()) as Map + final orgsAndWorkspaces = json.orgsAndWorkspaces as List - def workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } + final workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } if (workspace) { - def ws = workspace as Map + final ws = workspace as Map return [ orgName: ws.orgName, workspaceName: ws.workspaceName, @@ -1013,8 +963,8 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private Map getCloudEndpointInfo(String apiUrl) { for (env in SEQERA_API_TO_AUTH0) { - def standardUrl = env.key as String - def legacyUrl = standardUrl.replace('://api.', '://') + '/api' + final standardUrl = env.key as String + final legacyUrl = standardUrl.replace('://api.', '://') + '/api' if (apiUrl == standardUrl || apiUrl == legacyUrl) { return [isCloud: true, endpoint: env.key, auth: env.value] } @@ -1027,15 +977,20 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private Map callUserInfoApi(String accessToken, String apiUrl) { - def connection = createHttpConnection("${apiUrl}/user-info", 'GET', accessToken) + final client = createHttpClient(accessToken) + final request = HttpRequest.newBuilder() + .uri(URI.create("${apiUrl}/user-info")) + .GET() + .build() + + final response = client.send(request, HttpResponse.BodyHandlers.ofString()) - if (connection.responseCode != 200) { - def error = connection.errorStream?.text ?: "HTTP ${connection.responseCode}" + if (response.statusCode() != 200) { + final error = response.body() ?: "HTTP ${response.statusCode()}" throw new RuntimeException("Failed to get user info: ${error}") } - def response = connection.inputStream.text - def json = new JsonSlurper().parseText(response) as Map + final json = new JsonSlurper().parseText(response.body()) as Map return json.user as Map } @@ -1048,14 +1003,14 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private Map readLoginFile() { - def configFile = getLoginFile() + final configFile = getLoginFile() if (!Files.exists(configFile)) { return [:] } try { - def configText = Files.readString(configFile) - def config = new ConfigSlurper().parse(configText) + final configText = Files.readString(configFile) + final config = new ConfigSlurper().parse(configText) return config.flatten() } catch (Exception e) { throw new RuntimeException("Failed to read config file ${configFile}: ${e.message}") @@ -1063,13 +1018,13 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private Map readConfig() { - def builder = new ConfigBuilder().setHomeDir(Const.APP_HOME_DIR).setCurrentDir(Const.APP_HOME_DIR) + final builder = new ConfigBuilder().setHomeDir(Const.APP_HOME_DIR).setCurrentDir(Const.APP_HOME_DIR) return builder.buildConfigObject().flatten() } private void writeConfig(Map config, Map workspaceMetadata = null) { - def configFile = getConfigFile() - def loginFile = getLoginFile() + final configFile = getConfigFile() + final loginFile = getLoginFile() // Create directory if it doesn't exist if (!Files.exists(configFile.parent)) { @@ -1077,15 +1032,15 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Write tower config to .login file - def towerConfig = config.findAll { key, value -> + final towerConfig = config.findAll { key, value -> key.toString().startsWith('tower.') && !key.toString().endsWith('.comment') } - def loginConfigText = new StringBuilder() + final loginConfigText = new StringBuilder() loginConfigText.append("// Seqera Platform configuration\n") loginConfigText.append("tower {\n") towerConfig.each { key, value -> - def configKey = key.toString().substring(6) // Remove "tower." prefix + final configKey = key.toString().substring(6) // Remove "tower." prefix if (value instanceof String) { def line = " ${configKey} = '${value}'" @@ -1108,7 +1063,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private void addIncludeConfigToMainFile(Path configFile) { - def includeConfigLine = "includeConfig '.login'" + final includeConfigLine = "includeConfig '.login'" def configContent = "" if (Files.exists(configFile)) { @@ -1131,8 +1086,8 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private String removeIncludeConfigLine(String content) { // Remove the includeConfig '.login' line - def lines = content.split('\n') - def filteredLines = lines.findAll { line -> + final lines = content.split('\n') + final filteredLines = lines.findAll { line -> !line.trim().equals("includeConfig '.login'") } return filteredLines.join('\n') diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy index fc30247590..f60336f332 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy @@ -16,7 +16,6 @@ package io.seqera.tower.plugin.cli -import nextflow.exception.AbortOperationException import org.junit.Rule import spock.lang.Specification import spock.lang.TempDir From b1d53010cf7b348300ad0d5e29fd6c186b261e1a Mon Sep 17 00:00:00 2001 From: jorgee Date: Thu, 2 Oct 2025 13:27:08 +0200 Subject: [PATCH 36/66] include more tests Signed-off-by: jorgee --- .../tower/plugin/cli/AuthCommandImpl.groovy | 90 +- .../plugin/cli/AuthCommandImplTest.groovy | 1236 ++++++++++++++++- 2 files changed, 1275 insertions(+), 51 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 577e4aaa7a..a6b22c5a3b 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -2,10 +2,12 @@ package io.seqera.tower.plugin.cli import groovy.json.JsonBuilder import groovy.json.JsonSlurper +import groovy.transform.Canonical import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.seqera.http.HxClient import nextflow.Const +import nextflow.SysEnv import nextflow.cli.CmdAuth import nextflow.cli.ColorUtil import nextflow.config.ConfigBuilder @@ -48,7 +50,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { /** * Creates an HxClient instance with optional authentication token */ - private HxClient createHttpClient(String authToken = null) { + protected HxClient createHttpClient(String authToken = null) { final builder = HxClient.newBuilder() .connectTimeout(Duration.ofMillis(API_TIMEOUT_MS)) @@ -63,7 +65,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { void login(String apiUrl) { // Check if TOWER_ACCESS_TOKEN environment variable is set - final envToken = System.getenv('TOWER_ACCESS_TOKEN') + final envToken = SysEnv.get('TOWER_ACCESS_TOKEN') if( envToken ) { println "" ColorUtil.printColored("WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable.", "yellow bold") @@ -102,7 +104,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } } - private void performAuth0Login(String apiUrl, Map auth0Config) { + protected void performAuth0Login(String apiUrl, Map auth0Config) { // Start device authorization flow final deviceAuth = requestDeviceAuthorization(auth0Config) @@ -172,7 +174,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return browserOpened } - private boolean runBrowserCommand(GString urlWithCode) { + protected boolean runBrowserCommand(GString urlWithCode) { def command = [] def browserOpened = false final os = System.getProperty("os.name").toLowerCase() @@ -250,7 +252,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } - private void handleEnterpriseAuth(String apiUrl) { + protected void handleEnterpriseAuth(String apiUrl) { println "" ColorUtil.printColored("Please generate a Personal Access Token from your Seqera Platform instance.", "cyan bold") println "You can create one at: ${ColorUtil.colorize(apiUrl.replace('/api', '').replace('://api.', '') + '/tokens', 'magenta')}" @@ -314,7 +316,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return url } - private Map performAuth0Request(String url, Map params) { + protected Map performAuth0Request(String url, Map params) { final postData = params.collect { k, v -> "${URLEncoder.encode(k.toString(), 'UTF-8')}=${URLEncoder.encode(v.toString(), 'UTF-8')}" }.join('&') @@ -373,7 +375,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Check if TOWER_ACCESS_TOKEN environment variable is set - final envToken = System.getenv('TOWER_ACCESS_TOKEN') + final envToken = SysEnv.get('TOWER_ACCESS_TOKEN') if( envToken ) { println "" ColorUtil.printColored("WARNING: TOWER_ACCESS_TOKEN environment variable is set.", "yellow bold") @@ -469,7 +471,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { final config = readConfig() // Token can come from .login file or environment variable - final existingToken = config['tower.accessToken'] ?: System.getenv('TOWER_ACCESS_TOKEN') + final existingToken = config['tower.accessToken'] ?: SysEnv.get('TOWER_ACCESS_TOKEN') final endpoint = config['tower.endpoint'] ?: DEFAULT_API_ENDPOINT if( !existingToken ) { @@ -481,7 +483,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { ColorUtil.printColored(" - Config file: ${ColorUtil.colorize(getLoginFile().toString(), 'magenta')}", "dim") // Check if token is from environment variable - if( !config['tower.accessToken'] && System.getenv('TOWER_ACCESS_TOKEN') ) { + if( !config['tower.accessToken'] && SysEnv.get('TOWER_ACCESS_TOKEN') ) { ColorUtil.printColored(" - Using access token from TOWER_ACCESS_TOKEN environment variable", "dim") } @@ -569,7 +571,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private Map configureWorkspace(Map config, String accessToken, String endpoint, String userId) { // Check if TOWER_WORKFLOW_ID environment variable is set - final envWorkspaceId = System.getenv('TOWER_WORKFLOW_ID') + final envWorkspaceId = SysEnv.get('TOWER_WORKFLOW_ID') if( envWorkspaceId ) { println "\nDefault workspace: ${ColorUtil.colorize('TOWER_WORKFLOW_ID environment variable is set', 'yellow')}" ColorUtil.printColored(" Not prompting for default workspace configuration as environment variable takes precedence", "dim") @@ -763,18 +765,22 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { void status() { final config = readConfig() final loginConfig = readLoginFile() + + printStatus(collectStatus(config, loginConfig)) + } + + private ConfigStatus collectStatus(Map config, Map loginConfig) { // Collect all status information - List> statusRows = [] - def workspaceDisplayInfo = null + final status = new ConfigStatus([], null) // API endpoint final endpointInfo = getConfigValue(config, loginConfig, 'tower.endpoint', 'TOWER_API_ENDPOINT', DEFAULT_API_ENDPOINT) - statusRows.add(['API endpoint', ColorUtil.colorize(endpointInfo.value as String, 'magenta'), endpointInfo.source as String]) + status.table.add(['API endpoint', ColorUtil.colorize(endpointInfo.value as String, 'magenta'), endpointInfo.source as String]) // API connection check final apiConnectionOk = checkApiConnection(endpointInfo.value as String) final connectionColor = apiConnectionOk ? 'green' : 'red' - statusRows.add(['API connection', ColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) + status.table.add(['API connection', ColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) // Authentication check final tokenInfo = getConfigValue(config, loginConfig, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') @@ -782,19 +788,19 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { try { final userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) final currentUser = userInfo.userName as String - statusRows.add(['Authentication', "${ColorUtil.colorize('OK', 'green')} (user: ${ColorUtil.colorize(currentUser, 'cyan')})".toString(), tokenInfo.source as String]) + status.table.add(['Authentication', "${ColorUtil.colorize('OK', 'green')} (user: ${ColorUtil.colorize(currentUser, 'cyan')})".toString(), tokenInfo.source as String]) } catch( Exception e ) { - statusRows.add(['Authentication', ColorUtil.colorize('ERROR', 'red'), 'failed']) + status.table.add(['Authentication', ColorUtil.colorize('ERROR', 'red'), 'failed']) } } else { - statusRows.add(['Authentication', "${ColorUtil.colorize('ERROR', 'red')} ${ColorUtil.colorize('(no token)', 'dim')}".toString(), 'not set']) + status.table.add(['Authentication', "${ColorUtil.colorize('ERROR', 'red')} ${ColorUtil.colorize('(no token)', 'dim')}".toString(), 'not set']) } // Monitoring enabled - final enabledInfo = getConfigValue(config, loginConfig,'tower.enabled', null, 'false') + final enabledInfo = getConfigValue(config, loginConfig, 'tower.enabled', null, 'false') final enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' final enabledColor = enabledValue == 'Yes' ? 'green' : 'yellow' - statusRows.add(['Workflow monitoring', ColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) + status.table.add(['Workflow monitoring', ColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) // Default workspace final workspaceInfo = getConfigValue(config, loginConfig, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') @@ -807,23 +813,25 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { if( workspaceDetails ) { // Add workspace ID row - statusRows.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) + status.table.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) // Store workspace details for display after the table - workspaceDisplayInfo = workspaceDetails + status.workspaceInfo = workspaceDetails } else { - statusRows.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue', true), workspaceInfo.source as String]) + status.table.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue', true), workspaceInfo.source as String]) } } else { - statusRows.add(['Default workspace', ColorUtil.colorize('None (Personal workspace)', 'cyan', true), 'default']) + status.table.add(['Default workspace', ColorUtil.colorize('None (Personal workspace)', 'cyan', true), 'default']) } - + return status + } + private void printStatus(ConfigStatus status){ // Print table println "" - printStatusTable(statusRows) + printStatusTable(status.table) // Print workspace details if available - if( workspaceDisplayInfo ) { - println "${' ' * 22}${ColorUtil.colorize(workspaceDisplayInfo.orgName as String, 'cyan')} / ${ColorUtil.colorize(workspaceDisplayInfo.workspaceName as String, 'cyan')} ${ColorUtil.colorize('[' + (workspaceDisplayInfo.workspaceFullName as String) + ']', 'dim')}" + if( status.workspaceInfo ) { + println "${' ' * 22}${ColorUtil.colorize(status.workspaceInfo.orgName as String, 'cyan')} / ${ColorUtil.colorize(status.workspaceInfo.workspaceName as String, 'cyan')} ${ColorUtil.colorize('[' + (status.workspaceInfo.workspaceFullName as String) + ']', 'dim')}" } } @@ -875,8 +883,8 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { //Checks where the config value came from final loginValue = login[configKey] final configValue = config[configKey] - final envValue = envVarName ? System.getenv(envVarName) : null - final effectiveValue = configValue ?: envValue ?: defaultValue + final envValue = envVarName ? SysEnv.get(envVarName) : null + final effectiveValue = loginValue ?: configValue ?: envValue ?: defaultValue def source = null if( loginValue ) { @@ -892,13 +900,13 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return [ value : effectiveValue, source : source, - fromConfig: configValue != null, + fromConfig: configValue != null || loginValue != null, fromEnv : envValue != null, - isDefault : !configValue && !envValue + isDefault : !loginValue && !configValue && !envValue ] } - private boolean checkApiConnection(String endpoint) { + protected boolean checkApiConnection(String endpoint) { try { final client = createHttpClient() final request = HttpRequest.newBuilder() @@ -913,7 +921,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } } - private static String readUserInput(String message = null) { + protected static String readUserInput(String message = null) { if (message) { System.out.print(message) System.out.flush() @@ -925,7 +933,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return line?.trim() } - private Map getWorkspaceDetailsFromApi(String accessToken, String endpoint, String workspaceId) { + protected Map getWorkspaceDetailsFromApi(String accessToken, String endpoint, String workspaceId) { try { final userInfo = callUserInfoApi(accessToken, endpoint) final userId = userInfo.id as String @@ -976,7 +984,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return getCloudEndpointInfo(apiUrl).isCloud } - private Map callUserInfoApi(String accessToken, String apiUrl) { + protected Map callUserInfoApi(String accessToken, String apiUrl) { final client = createHttpClient(accessToken) final request = HttpRequest.newBuilder() .uri(URI.create("${apiUrl}/user-info")) @@ -994,11 +1002,11 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return json.user as Map } - private Path getConfigFile() { + protected Path getConfigFile() { return Const.APP_HOME_DIR.resolve('config') } - private Path getLoginFile() { + protected Path getLoginFile() { return Const.APP_HOME_DIR.resolve('.login') } @@ -1023,6 +1031,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private void writeConfig(Map config, Map workspaceMetadata = null) { + log.debug("Getting config ") final configFile = getConfigFile() final loginFile = getLoginFile() @@ -1092,4 +1101,11 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } return filteredLines.join('\n') } + @Canonical + static class ConfigStatus { + List> table + Map workspaceInfo + } } + + diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy index f60336f332..fe4f0087ed 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy @@ -16,11 +16,16 @@ package io.seqera.tower.plugin.cli +import io.seqera.http.HxClient +import nextflow.Const +import nextflow.SysEnv import org.junit.Rule import spock.lang.Specification import spock.lang.TempDir import test.OutputCapture +import java.net.http.HttpResponse +import java.nio.file.Files import java.nio.file.Path /** @@ -37,9 +42,6 @@ class AuthCommandImplTest extends Specification { Path tempDir - - - def 'should identify cloud endpoints correctly'() { given: def cmd = new AuthCommandImpl() @@ -81,7 +83,7 @@ class AuthCommandImplTest extends Specification { def cmd = new AuthCommandImpl() def config = [ 'tower.accessToken': 'test-token', - 'tower.enabled': true + 'tower.enabled' : true ] when: @@ -91,24 +93,1230 @@ class AuthCommandImplTest extends Specification { noExceptionThrown() } - def 'should create HTTP connection with correct properties'() { + + def 'should normalize API URL correctly'() { + given: + def cmd = new AuthCommandImpl() + + expect: + cmd.normalizeApiUrl(null) == 'https://api.cloud.seqera.io' + cmd.normalizeApiUrl('') == 'https://api.cloud.seqera.io' + cmd.normalizeApiUrl('https://example.com') == 'https://example.com' + cmd.normalizeApiUrl('http://example.com') == 'http://example.com' + cmd.normalizeApiUrl('example.com') == 'https://example.com' + cmd.normalizeApiUrl('api.cloud.seqera.io') == 'https://api.cloud.seqera.io' + } + + def 'should decode token ID correctly'() { + given: + def cmd = new AuthCommandImpl() + def tokenData = '{"tid":"token-123","exp":1234567890}' + def encodedToken = Base64.encoder.encodeToString(tokenData.bytes) + + when: + def tokenId = cmd.decodeTokenId(encodedToken) + + then: + tokenId == 'token-123' + } + + def 'should fail to decode token without ID'() { + given: + def cmd = new AuthCommandImpl() + def tokenData = '{"exp":1234567890}' + def encodedToken = Base64.encoder.encodeToString(tokenData.bytes) + + when: + cmd.decodeTokenId(encodedToken) + + then: + thrown(RuntimeException) + } + + def 'should fail to decode invalid token'() { + given: + def cmd = new AuthCommandImpl() + def invalidToken = 'not-a-valid-base64-token' + + when: + cmd.decodeTokenId(invalidToken) + + then: + thrown(RuntimeException) + } + + def 'should remove includeConfig line correctly'() { + given: + def cmd = new AuthCommandImpl() + def content = """ +// Some config +param1 = 'value1' + +includeConfig '.login' + +param2 = 'value2' +""" + + when: + def result = cmd.removeIncludeConfigLine(content) + + then: + !result.contains("includeConfig '.login'") + result.contains('param1 = \'value1\'') + result.contains('param2 = \'value2\'') + } + + def 'should handle content without includeConfig line'() { + given: + def cmd = new AuthCommandImpl() + def content = """ +// Some config +param1 = 'value1' +param2 = 'value2'""" + + when: + def result = cmd.removeIncludeConfigLine(content) + + then: + result == content + } + + def 'should get config file path'() { + given: + def cmd = new AuthCommandImpl() + + when: + def configFile = cmd.getConfigFile() + + then: + configFile == Const.APP_HOME_DIR.resolve('config') + } + + def 'should get login file path'() { + given: + def cmd = new AuthCommandImpl() + + when: + def loginFile = cmd.getLoginFile() + + then: + loginFile == Const.APP_HOME_DIR.resolve('.login') + } + + def 'should read empty login file'() { + given: + def cmd = new AuthCommandImpl() + + when: + def config = cmd.readLoginFile() + + then: + config instanceof Map + config.isEmpty() || config.size() >= 0 + } + + def 'should write config to .login file'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login') + def configFile = tempDir.resolve('config') + + cmd.getLoginFile() >> loginFile + cmd.getConfigFile() >> configFile + + def config = [ + 'tower.accessToken': 'test-token-123', + 'tower.endpoint' : 'https://api.cloud.seqera.io', + 'tower.enabled' : true + ] + + when: + cmd.writeConfig(config, null) + + then: + Files.exists(loginFile) + def loginContent = Files.readString(loginFile) + loginContent.contains('accessToken = \'test-token-123\'') + loginContent.contains('endpoint = \'https://api.cloud.seqera.io\'') + loginContent.contains('enabled = true') + } + + def 'should write config with workspace metadata'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login') + def configFile = tempDir.resolve('config') + + cmd.getLoginFile() >> loginFile + cmd.getConfigFile() >> configFile + + def config = [ + 'tower.accessToken': 'test-token-123', + 'tower.endpoint' : 'https://api.cloud.seqera.io', + 'tower.workspaceId': '12345' + ] + def metadata = [ + orgName : 'TestOrg', + workspaceName : 'TestWorkspace', + workspaceFullName: 'test-org/test-workspace' + ] + + when: + cmd.writeConfig(config, metadata) + + then: + Files.exists(loginFile) + def loginContent = Files.readString(loginFile) + loginContent.contains('workspaceId = \'12345\'') + loginContent.contains('// TestOrg / TestWorkspace [test-org/test-workspace]') + } + + def 'should add includeConfig line to main config file'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login') + def configFile = tempDir.resolve('config') + + cmd.getLoginFile() >> loginFile + cmd.getConfigFile() >> configFile + + // Create initial config file + Files.writeString(configFile, "// Existing config\nparam1 = 'value1'\n") + + def config = [ + 'tower.accessToken': 'test-token-123' + ] + + when: + cmd.writeConfig(config, null) + + then: + Files.exists(configFile) + def configContent = Files.readString(configFile) + configContent.contains("includeConfig '.login'") + configContent.contains('param1 = \'value1\'') + } + + def 'should not duplicate includeConfig line'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login') + def configFile = tempDir.resolve('config') + + cmd.getLoginFile() >> loginFile + cmd.getConfigFile() >> configFile + + // Create config file with existing includeConfig line + Files.writeString(configFile, "// Config\nincludeConfig '.login'\nparam1 = 'value1'\n") + + def config = [ + 'tower.accessToken': 'test-token-123' + ] + + when: + cmd.writeConfig(config, null) + + then: + Files.exists(configFile) + def configContent = Files.readString(configFile) + configContent.count("includeConfig '.login'") == 1 + } + + def 'should get current workspace name when workspace exists'() { + given: + def cmd = new AuthCommandImpl() + def workspaces = [ + [workspaceId: '123', orgName: 'TestOrg', workspaceName: 'TestWorkspace'], + [workspaceId: '456', orgName: 'OtherOrg', workspaceName: 'OtherWorkspace'] + ] + + when: + def result = cmd.getCurrentWorkspaceName(workspaces, '123') + + then: + result == 'TestOrg / TestWorkspace' + } + + def 'should get default workspace name when workspace does not exist'() { + given: + def cmd = new AuthCommandImpl() + def workspaces = [ + [workspaceId: '123', orgName: 'TestOrg', workspaceName: 'TestWorkspace'] + ] + + when: + def result = cmd.getCurrentWorkspaceName(workspaces, '999') + + then: + result == 'None (Personal workspace)' + } + + def 'should get config value from login file'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login') + cmd.getLoginFile() >> loginFile + + def config = [:] + def login = ['tower.accessToken': 'token-from-login'] + + when: + def result = cmd.getConfigValue(config, login, 'tower.accessToken', 'TOWER_ACCESS_TOKEN', null) + + then: + result.value == 'token-from-login' + result.source.endsWith('.login') + result.fromConfig == true + result.fromEnv == false + result.isDefault == false + } + + def 'should get config value from main config file'() { + given: + def cmd = Spy(AuthCommandImpl) + def configFile = tempDir.resolve('config') + cmd.getConfigFile() >> configFile + + def config = ['tower.endpoint': 'https://example.com'] + def login = [:] + + when: + def result = cmd.getConfigValue(config, login, 'tower.endpoint', 'TOWER_API_ENDPOINT', null) + + then: + result.value == 'https://example.com' + result.source.endsWith('config') + result.fromConfig == true + result.fromEnv == false + result.isDefault == false + } + + def 'should get config value from environment variable'() { + given: + def cmd = Spy(AuthCommandImpl) + + def config = [:] + def login = [:] + + and: + SysEnv.push( ['TOWER_ACCESS_TOKEN': 'token-from-env']) + + when: + def result = cmd.getConfigValue(config, login, 'tower.accessToken', 'TOWER_ACCESS_TOKEN', null) + + + then: + result.value == 'token-from-env' + result.source == 'env var $TOWER_ACCESS_TOKEN' + result.fromConfig == false + result.fromEnv == true + result.isDefault == false + + cleanup: + SysEnv.pop() + } + + def 'should get config value from default'() { + given: + def cmd = new AuthCommandImpl() + + def config = [:] + def login = [:] + + when: + def result = cmd.getConfigValue(config, login, 'tower.endpoint', null, 'https://default.example.com') + + then: + result.value == 'https://default.example.com' + result.source == 'default' + result.fromConfig == false + result.fromEnv == false + result.isDefault == true + } + + def 'should prioritize login over config and env'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login') + cmd.getLoginFile() >> loginFile + + def config = ['tower.accessToken': 'token-from-config'] + def login = ['tower.accessToken': 'token-from-login'] + + SysEnv.push(['TOWER_ACCESS_TOKEN': 'token-from-env']) + + when: + def result = cmd.getConfigValue(config, login, 'tower.accessToken', 'TOWER_ACCESS_TOKEN', 'default-token') + + then: + result.value == 'token-from-login' + result.source.endsWith('.login') + + cleanup: + SysEnv.pop() + } + + def 'should prioritize config over env when login is empty'() { + given: + def cmd = Spy(AuthCommandImpl) + def configFile = tempDir.resolve('config') + cmd.getConfigFile() >> configFile + + def config = ['tower.endpoint': 'https://config.example.com'] + def login = [:] + + SysEnv.push(['TOWER_API_ENDPOINT': 'https://env.example.com']) + + when: + def result = cmd.getConfigValue(config, login, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://default.example.com') + + + then: + result.value == 'https://config.example.com' + result.source.endsWith('config') + + cleanup: + SysEnv.pop() + } + + def 'should handle null environment variable name'() { + given: + def cmd = new AuthCommandImpl() + + def config = ['tower.enabled': true] + def login = [:] + + when: + def result = cmd.getConfigValue(config, login, 'tower.enabled', null, 'false') + + then: + result.value == true + result.fromConfig == true + result.fromEnv == false + } + + def 'should print status table with simple values'() { + given: + def cmd = new AuthCommandImpl() + def rows = [ + ['Setting1', 'Value1', 'Source1'], + ['Setting2', 'Value2', 'Source2'], + ['Setting3', 'Value3', 'Source3'] + ] + + when: + cmd.printStatusTable(rows) + def output = capture.toString() + + then: + output.contains('Setting') + output.contains('Value') + output.contains('Source') + output.contains('Setting1') + output.contains('Value1') + output.contains('Source1') + output.contains('Setting2') + output.contains('Value2') + output.contains('Source2') + output.contains('Setting3') + output.contains('Value3') + output.contains('Source3') + output.contains('---') // Table separator + } + + def 'should print status table with colored values'() { + given: + def cmd = new AuthCommandImpl() + def rows = [ + ['API endpoint', nextflow.cli.ColorUtil.colorize('https://api.cloud.seqera.io', 'magenta'), 'config'], + ['Authentication', nextflow.cli.ColorUtil.colorize('OK', 'green'), 'env var'] + ] + + when: + cmd.printStatusTable(rows) + def output = capture.toString() + + then: + output.contains('API endpoint') + output.contains('https://api.cloud.seqera.io') + output.contains('Authentication') + output.contains('OK') + output.contains('config') + output.contains('env var') + } + + def 'should handle empty status table'() { + given: + def cmd = new AuthCommandImpl() + def rows = [] + + when: + cmd.printStatusTable(rows) + def output = capture.toString() + + then: + output.isEmpty() + } + + def 'should handle null status table'() { + given: + def cmd = new AuthCommandImpl() + + when: + cmd.printStatusTable(null) + def output = capture.toString() + + then: + output.isEmpty() + } + + def 'should strip ANSI codes correctly'() { + given: + def cmd = new AuthCommandImpl() + def textWithAnsi = '\u001B[31mRed Text\u001B[0m' + def textWithMultipleAnsi = '\u001B[1;32mBold Green\u001B[0m \u001B[34mBlue\u001B[0m' + + when: + def stripped1 = cmd.stripAnsiCodes(textWithAnsi) + def stripped2 = cmd.stripAnsiCodes(textWithMultipleAnsi) + + then: + stripped1 == 'Red Text' + stripped2 == 'Bold Green Blue' + } + + def 'should handle null text when stripping ANSI codes'() { given: def cmd = new AuthCommandImpl() when: - def connection = cmd.createHttpConnection('https://example.com', 'GET', 'test-token') + def result = cmd.stripAnsiCodes(null) then: - connection.requestMethod == 'GET' - connection.connectTimeout == AuthCommandImpl.API_TIMEOUT_MS - connection.readTimeout == AuthCommandImpl.API_TIMEOUT_MS + result == '' + } + + def 'should handle empty text when stripping ANSI codes'() { + given: + def cmd = new AuthCommandImpl() + + when: + def result = cmd.stripAnsiCodes('') + + then: + result == '' + } + + def 'should pad string with ANSI codes correctly'() { + given: + def cmd = new AuthCommandImpl() + def plainText = 'Hello' + def coloredText = '\u001B[32mHello\u001B[0m' + + when: + def paddedPlain = cmd.padStringWithAnsi(plainText, 10) + def paddedColored = cmd.padStringWithAnsi(coloredText, 10) + + then: + // Plain text should be padded to 10 characters + cmd.stripAnsiCodes(paddedPlain).length() == 10 + // Colored text should preserve ANSI codes but pad visible text to 10 characters + cmd.stripAnsiCodes(paddedColored).length() == 10 + paddedColored.contains('\u001B[32m') // ANSI codes preserved + } + + def 'should not pad if text is already at target width'() { + given: + def cmd = new AuthCommandImpl() + def text = 'Hello' + + when: + def result = cmd.padStringWithAnsi(text, 5) + + then: + result == 'Hello' + } + + def 'should not pad if text exceeds target width'() { + given: + def cmd = new AuthCommandImpl() + def text = 'Hello World' + + when: + def result = cmd.padStringWithAnsi(text, 5) + + then: + result == 'Hello World' + } + + def 'should shorten path with user home'() { + given: + def cmd = new AuthCommandImpl() + def userHome = System.getProperty('user.home') + def path = "${userHome}/some/path/config" + + when: + def result = cmd.shortenPath(path) + + then: + result == '~/some/path/config' + } + + def 'should not shorten path without user home'() { + given: + def cmd = new AuthCommandImpl() + def path = '/etc/config' + + when: + def result = cmd.shortenPath(path) + + then: + result == '/etc/config' + } + + def 'should print status table with varying column widths'() { + given: + def cmd = new AuthCommandImpl() + def rows = [ + ['Short', 'V', 'S'], + ['Very Long Setting Name', 'Very Long Value Here', 'Very Long Source'] + ] + + when: + cmd.printStatusTable(rows) + def output = capture.toString() + + then: + // Should handle different column widths properly + output.contains('Short') + output.contains('Very Long Setting Name') + output.contains('Very Long Value Here') + output.contains('Very Long Source') + // All rows should be aligned + def lines = output.split('\n') + lines.size() >= 4 // Header + separator + 2 data rows + } + + def 'should apply minimum column widths'() { + given: + def cmd = new AuthCommandImpl() + def rows = [ + ['A', 'B', 'C'] + ] + + when: + cmd.printStatusTable(rows) + def output = capture.toString() + + then: + // Even with short values, should apply minimum widths + output.contains('Setting') // Header + output.contains('Value') + output.contains('Source') + // Columns should be padded to at least minimum width + def lines = output.split('\n') + lines.size() >= 3 // Header + separator + data row + } + + def 'should collect status with valid authentication'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = [:] + def loginConfig = [ + 'tower.accessToken': 'test-token', + 'tower.endpoint': 'https://api.cloud.seqera.io', + 'tower.enabled': true + ] + + // Mock API calls + cmd.checkApiConnection(_) >> true + cmd.callUserInfoApi(_, _) >> [userName: 'testuser', id: '123'] + cmd.getWorkspaceDetailsFromApi(_, _, _) >> null + + when: + def status = cmd.collectStatus(config, loginConfig) + + then: + status != null + status.table.size() == 5 // endpoint, connection, auth, monitoring, workspace + // Check API endpoint row + status.table[0][0] == 'API endpoint' + status.table[0][1].contains('https://api.cloud.seqera.io') + // Check API connection row + status.table[1][0] == 'API connection' + status.table[1][1].contains('OK') + // Check authentication row + status.table[2][0] == 'Authentication' + status.table[2][1].contains('OK') + status.table[2][1].contains('testuser') + // Check monitoring row + status.table[3][0] == 'Workflow monitoring' + status.table[3][1].contains('Yes') + // Check workspace row + status.table[4][0] == 'Default workspace' + status.table[4][1].contains('Personal workspace') + } + + def 'should collect status without authentication'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = [:] + def loginConfig = [:] + + cmd.checkApiConnection(_) >> true + + when: + def status = cmd.collectStatus(config, loginConfig) + + then: + status != null + status.table.size() == 5 + // Authentication should show error + status.table[2][0] == 'Authentication' + status.table[2][1].contains('ERROR') + status.table[2][1].contains('no token') + status.table[2][2] == 'not set' + } + + def 'should collect status with failed API connection'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = [:] + def loginConfig = ['tower.endpoint': 'https://unreachable.example.com'] + + cmd.checkApiConnection(_) >> false + + when: + def status = cmd.collectStatus(config, loginConfig) + + then: + status != null + status.table[1][0] == 'API connection' + status.table[1][1].contains('ERROR') + } + + def 'should collect status with failed authentication'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = [:] + def loginConfig = ['tower.accessToken': 'invalid-token'] + + cmd.checkApiConnection(_) >> true + cmd.callUserInfoApi(_, _) >> { throw new RuntimeException('Invalid token') } + + when: + def status = cmd.collectStatus(config, loginConfig) + + then: + status != null + status.table[2][0] == 'Authentication' + status.table[2][1].contains('ERROR') + status.table[2][2] == 'failed' + } + + def 'should collect status with monitoring enabled'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = [:] + def loginConfig = ['tower.enabled': true] + + cmd.checkApiConnection(_) >> true + + when: + def status = cmd.collectStatus(config, loginConfig) + + then: + status != null + status.table[3][0] == 'Workflow monitoring' + status.table[3][1].contains('Yes') + } + + def 'should collect status with monitoring disabled'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = [:] + def loginConfig = ['tower.enabled': false] + + cmd.checkApiConnection(_) >> true + + when: + def status = cmd.collectStatus(config, loginConfig) + + then: + status != null + status.table[3][0] == 'Workflow monitoring' + status.table[3][1].contains('No') + } + + def 'should collect status with workspace details'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = [:] + def loginConfig = [ + 'tower.accessToken': 'test-token', + 'tower.workspaceId': '12345' + ] + + cmd.checkApiConnection(_) >> true + cmd.callUserInfoApi(_, _) >> [userName: 'testuser', id: '123'] + cmd.getWorkspaceDetailsFromApi(_, _, _) >> [ + orgName: 'TestOrg', + workspaceName: 'TestWorkspace', + workspaceFullName: 'test-org/test-workspace' + ] + + when: + def status = cmd.collectStatus(config, loginConfig) + + then: + status != null + status.table[4][0] == 'Default workspace' + status.table[4][1].contains('12345') + status.workspaceInfo != null + status.workspaceInfo.orgName == 'TestOrg' + status.workspaceInfo.workspaceName == 'TestWorkspace' + } + + def 'should collect status with workspace ID but no details'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = [:] + def loginConfig = [ + 'tower.accessToken': 'test-token', + 'tower.workspaceId': '12345' + ] + + cmd.checkApiConnection(_) >> true + cmd.callUserInfoApi(_, _) >> [userName: 'testuser', id: '123'] + cmd.getWorkspaceDetailsFromApi(_, _, _) >> null + + when: + def status = cmd.collectStatus(config, loginConfig) + + then: + status != null + status.table[4][0] == 'Default workspace' + status.table[4][1].contains('12345') + status.workspaceInfo == null + } + + def 'should collect status from environment variables'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = [:] + def loginConfig = [:] + + cmd.checkApiConnection(_) >> true + cmd.callUserInfoApi(_, _) >> [userName: 'envuser', id: '456'] + cmd.getWorkspaceDetailsFromApi(_,_,_) >> [:] + + SysEnv.push(['TOWER_ACCESS_TOKEN': 'env-token', + 'TOWER_API_ENDPOINT': 'https://env.example.com', + 'TOWER_WORKFLOW_ID': 'ws-123'] ) + + when: + def status = cmd.collectStatus(config, loginConfig) + + + then: + status != null + status.table[0][1].contains('https://env.example.com') + status.table[0][2] == 'env var $TOWER_API_ENDPOINT' + status.table[2][1].contains('envuser') + status.table[2][2].contains('env var $TOWER_ACCESS_TOKEN') + status.table[4][2].contains('env var $TOWER_WORKFLOW_ID') + + cleanup: + SysEnv.pop() + } + + def 'should collect status with default values'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = [:] + def loginConfig = [:] + + cmd.checkApiConnection(_) >> true + + when: + def status = cmd.collectStatus(config, loginConfig) + + then: + status != null + // Should use default endpoint + status.table[0][1].contains('https://api.cloud.seqera.io') + status.table[0][2] == 'default' + // Should show monitoring disabled by default + status.table[3][1].contains('No') + status.table[3][2] == 'default' + } + + def 'should collect status with mixed sources'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login') + def configFile = tempDir.resolve('config') + + cmd.getLoginFile() >> loginFile + cmd.getConfigFile() >> configFile + + def config = ['tower.enabled': true] + def loginConfig = ['tower.accessToken': 'login-token'] + + cmd.checkApiConnection(_) >> true + cmd.callUserInfoApi(_, _) >> [userName: 'mixeduser', id: '789'] + SysEnv.push(['TOWER_WORKFLOW_ID': 'ws-env']) + + when: + def status = cmd.collectStatus(config, loginConfig) + + + then: + status != null + // Token from login file + status.table[2][2].endsWith('.login') + // Enabled from config file + status.table[3][2].endsWith('config') + // Workspace from env var + status.table[4][2].contains('env var $TOWER_WORKFLOW_ID') + + cleanup: + SysEnv.pop() + } + + def 'should print status correctly'() { + given: + def cmd = new AuthCommandImpl() + def status = new AuthCommandImpl.ConfigStatus( + [ + ['API endpoint', 'https://api.cloud.seqera.io', 'default'], + ['Authentication', 'OK', 'config'] + ], + [ + orgName: 'TestOrg', + workspaceName: 'TestWorkspace', + workspaceFullName: 'test-org/test-workspace' + ] + ) + + when: + cmd.printStatus(status) + def output = capture.toString() + + then: + output.contains('API endpoint') + output.contains('https://api.cloud.seqera.io') + output.contains('Authentication') + output.contains('OK') + output.contains('TestOrg') + output.contains('TestWorkspace') + } + + def 'should print status without workspace info'() { + given: + def cmd = new AuthCommandImpl() + def status = new AuthCommandImpl.ConfigStatus( + [ + ['API endpoint', 'https://api.cloud.seqera.io', 'default'] + ], + null + ) + + when: + cmd.printStatus(status) + def output = capture.toString() + + then: + output.contains('API endpoint') + !output.contains('TestOrg') + } + + def 'should detect existing login file and prevent duplicate login'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login') + Files.createFile(loginFile) + + cmd.getLoginFile() >> loginFile + + when: + cmd.login('https://api.cloud.seqera.io') + def output = capture.toString() + + then: + output.contains('Error: Authentication token is already configured') + output.contains('nextflow auth logout') + } + + def 'should warn when TOWER_ACCESS_TOKEN env var is set during login'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login-not-exists') + + cmd.getLoginFile() >> loginFile + cmd.performAuth0Login(_, _) >> { /* mock to prevent actual login */ } + SysEnv.push(['TOWER_ACCESS_TOKEN': 'env-token']) + + when: + cmd.login('https://api.cloud.seqera.io') + def output = capture.toString() + + then: + output.contains('WARNING: Authentication token is already configured via TOWER_ACCESS_TOKEN environment variable') + + cleanup: + SysEnv.pop() + } + + def 'should normalize API URL during login'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login-not-exists') + + cmd.getLoginFile() >> loginFile + + when: + cmd.login('api.cloud.seqera.io') + + then: + 1 * cmd.performAuth0Login('https://api.cloud.seqera.io', _) >> { /* mock to prevent actual login */ } + } + + def 'should route to enterprise auth for non-cloud endpoints'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login-not-exists') + + cmd.getLoginFile() >> loginFile + + when: + cmd.login('https://enterprise.example.com') + + then: + 1 * cmd.handleEnterpriseAuth('https://enterprise.example.com') >> {/* mock to prevent actual login */ } + 0 * cmd.performAuth0Login(_, _) + } + + def 'should route to Auth0 login for cloud endpoints'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login-not-exists') + + cmd.getLoginFile() >> loginFile + + when: + cmd.login('https://api.cloud.seqera.io') + + then: + 1 * cmd.performAuth0Login('https://api.cloud.seqera.io', _) >> { /* mock to prevent actual login */ } + 0 * cmd.handleEnterpriseAuth(_) + } + + def 'should save auth to config after successful PAT generation'() { + given: + def cmd = Spy(AuthCommandImpl) + def loginFile = tempDir.resolve('.login') + def configFile = tempDir.resolve('config') + + cmd.getLoginFile() >> loginFile + cmd.getConfigFile() >> configFile + + def config = [ + 'tower.accessToken': 'generated-pat-token', + 'tower.endpoint': 'https://api.cloud.seqera.io', + 'tower.enabled': true + ] + + when: + cmd.saveAuthToConfig('generated-pat-token', 'https://api.cloud.seqera.io') + + then: + Files.exists(loginFile) + def content = Files.readString(loginFile) + content.contains('accessToken = \'generated-pat-token\'') + content.contains('endpoint = \'https://api.cloud.seqera.io\'') + content.contains('enabled = true') + } + + def 'should perform Auth0 request correctly'() { + given: + def cmd = Spy(AuthCommandImpl) + + cmd.createHttpClient(_) >> { + // Mock HxClient that returns successful response + def mockClient = Mock(HxClient) + def mockResponse = Mock(HttpResponse) + mockResponse.statusCode() >> 200 + mockResponse.body() >> '{"access_token":"test-token","expires_in":3600}' + mockClient.send(_, _) >> mockResponse + return mockClient + } + + def params = [ + 'client_id': 'test-client-id', + 'scope': 'openid profile' + ] + + when: + def result = cmd.performAuth0Request('https://auth.example.com/oauth/token', params) + + then: + result != null + result['access_token'] == 'test-token' + result['expires_in'] == 3600 + } + + def 'should handle Auth0 request errors'() { + given: + def cmd = Spy(AuthCommandImpl) + cmd.createHttpClient(_) >> { + def mockClient = Mock(HxClient) + def mockResponse = Mock(HttpResponse) + mockResponse.statusCode() >> 400 + mockResponse.body() >> '{"error":"invalid_grant","error_description":"Invalid credentials"}' + mockClient.send(_, _) >> mockResponse + return mockClient + } + + def params = ['client_id': 'test-client-id'] + + when: + cmd.performAuth0Request('https://auth.example.com/oauth/token', params) + + then: + def ex = thrown(RuntimeException) + ex.message.contains('invalid_grant') + ex.message.contains('Invalid credentials') + } + + def 'should generate PAT with correct token name'() { + given: + def cmd = Spy(AuthCommandImpl) + def username = System.getProperty('user.name') + + cmd.createHttpClient(_) >> { + def mockClient = Mock(HxClient) + def mockResponse = Mock(HttpResponse) + mockResponse.statusCode() >> 200 + mockResponse.body() >> '{"accessKey":"generated-pat-123","id":"token-id-456"}' + mockClient.send(_, _) >> mockResponse + return mockClient + } + + when: + def pat = cmd.generatePAT('auth-token', 'https://api.cloud.seqera.io') + + then: + pat == 'generated-pat-123' + } + + def 'should fail PAT generation on error response'() { + given: + def cmd = Spy(AuthCommandImpl) + + cmd.createHttpClient(_) >> { + def mockClient = Mock(HxClient) + def mockResponse = Mock(HttpResponse) + mockResponse.statusCode() >> 401 + mockResponse.body() >> 'Unauthorized' + mockClient.send(_, _) >> mockResponse + return mockClient + } + + when: + cmd.generatePAT('invalid-token', 'https://api.cloud.seqera.io') + + then: + def ex = thrown(RuntimeException) + ex.message.contains('Failed to generate PAT') + } + + def 'should request device authorization with correct parameters'() { + given: + def cmd = Spy(AuthCommandImpl) + def auth0Config = [ + domain: 'seqera.eu.auth0.com', + clientId: 'test-client-id' + ] + + cmd.performAuth0Request(_, _) >> [ + device_code: 'device-code-123', + user_code: 'ABCD-1234', + verification_uri: 'https://seqera.eu.auth0.com/activate', + interval: 5 + ] + + when: + def result = cmd.requestDeviceAuthorization(auth0Config) + + then: + result['device_code'] == 'device-code-123' + result['user_code'] == 'ABCD-1234' + result['verification_uri'] == 'https://seqera.eu.auth0.com/activate' + } + + def 'should poll for device token and return on success'() { + given: + def cmd = Spy(AuthCommandImpl) + def auth0Config = [ + domain: 'seqera.eu.auth0.com', + clientId: 'test-client-id' + ] + + // First call returns pending, second call returns token + int callCount = 0 + cmd.performAuth0Request(_, _) >> { + if (callCount++ == 0) { + throw new RuntimeException('authorization_pending') + } else { + return [access_token: 'final-token', token_type: 'Bearer'] + } + } + + when: + def result = cmd.pollForDeviceToken('device-code-123', 1, auth0Config) + + then: + result['access_token'] == 'final-token' + } + + def 'should handle expired token during polling'() { + given: + def cmd = Spy(AuthCommandImpl) + def auth0Config = [ + domain: 'seqera.eu.auth0.com', + clientId: 'test-client-id' + ] + + cmd.performAuth0Request(_, _) >> { + throw new RuntimeException('expired_token') + } + + when: + cmd.pollForDeviceToken('device-code-123', 1, auth0Config) + + then: + def ex = thrown(RuntimeException) + ex.message.contains('expired') + } + + def 'should handle access denied during polling'() { + given: + def cmd = Spy(AuthCommandImpl) + def auth0Config = [ + domain: 'seqera.eu.auth0.com', + clientId: 'test-client-id' + ] + + cmd.performAuth0Request(_, _) >> { + throw new RuntimeException('access_denied') + } when: - def connectionNoAuth = cmd.createHttpConnection('https://example.com', 'POST') + cmd.pollForDeviceToken('device-code-123', 1, auth0Config) then: - connectionNoAuth.requestMethod == 'POST' - connectionNoAuth.connectTimeout == AuthCommandImpl.API_TIMEOUT_MS - connectionNoAuth.readTimeout == AuthCommandImpl.API_TIMEOUT_MS + def ex = thrown(RuntimeException) + ex.message.contains('denied') } -} \ No newline at end of file +} From 29a2a54aa6a4c600b2e0390f9b1e4fcf1e0f0425 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Mon, 6 Oct 2025 10:24:19 +0200 Subject: [PATCH 37/66] Catch user cancellation of auth flow cleanly Signed-off-by: Phil Ewels --- .../tower/plugin/cli/AuthCommandImpl.groovy | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index a6b22c5a3b..8ca91f28a0 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -114,7 +114,19 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { final urlWithCode = "${deviceAuth.verification_uri}?user_code=${deviceAuth.user_code}" println "${ColorUtil.colorize('Authentication URL:', 'cyan bold')} ${ColorUtil.colorize(urlWithCode, 'magenta')}" ColorUtil.printColored("\n[ Press Enter to open in browser ]", "cyan bold") - System.in.read() // Wait for Enter key + + // Wait for Enter key with proper interrupt handling + try { + final input = System.in.read() + if( input == -1 ) { + throw new AbortOperationException("Authentication cancelled") + } + } catch( InterruptedException e ) { + Thread.currentThread().interrupt() + throw new AbortOperationException("Authentication cancelled") + } catch( IOException e ) { + throw new AbortOperationException("Failed to read input: ${e.message}") + } // Try to open browser automatically boolean browserOpened = openBrowser(urlWithCode) @@ -147,6 +159,9 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { ColorUtil.printColored("You can run 'nextflow auth config' later to set up your configuration.", "dim") } + } catch( InterruptedException e ) { + Thread.currentThread().interrupt() + throw new AbortOperationException("Authentication cancelled") } catch( Exception e ) { throw new RuntimeException("Authentication failed: ${e.message}", e) } @@ -217,6 +232,11 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { def retryCount = 0 while( retryCount < AUTH_POLL_TIMEOUT_RETRIES ) { + // Check for interrupt + if( Thread.currentThread().isInterrupted() ) { + throw new InterruptedException("Authentication polling interrupted") + } + final params = [ 'grant_type' : 'urn:ietf:params:oauth:grant-type:device_code', 'device_code': deviceCode, From 4c2270f321eca78af6beaa184469780868018284 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Mon, 6 Oct 2025 11:25:02 +0200 Subject: [PATCH 38/66] Add confirmation when running 'auth logout' Signed-off-by: Phil Ewels --- .../tower/plugin/cli/AuthCommandImpl.groovy | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 8ca91f28a0..9d64ae123f 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -411,23 +411,40 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { try { final userInfo = callUserInfoApi(existingToken as String, apiUrl) ColorUtil.printColored(" - Token is valid for user: ${ColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") + } catch( Exception e ) { + ColorUtil.printColored("Failed to validate token: ${e.message}", "red") + } + + // Check if we need to delete from platform + final shouldDeleteFromPlatform = isCloudEndpoint(apiUrl) + + ColorUtil.printColored("\nRunning this command will:", "yellow bold") + ColorUtil.printColored(" • Remove local Nextflow configuration: ${ColorUtil.colorize(loginFile.toString(), 'magenta')}", "yellow") + if( shouldDeleteFromPlatform ) { + ColorUtil.printColored(" • Delete the corresponding access token from Seqera Platform: ${ColorUtil.colorize(apiUrl, 'magenta')}", "yellow") + } else { + println "" + ColorUtil.printColored("Warning: Access token not deleted, as using enterprise installation: ${ColorUtil.colorize(apiUrl, 'magenta')}", "yellow") + } + + final confirmed = promptForYesNo("\n${ColorUtil.colorize('Continue with logout?', 'cyan bold')} (${ColorUtil.colorize('Y', 'green')}/n): ", true) + + if( !confirmed ) { + println("Logout cancelled.") + return + } - // Only delete PAT from platform if this is a cloud endpoint - if( isCloudEndpoint(apiUrl) ) { + // Only delete PAT from platform if this is a cloud endpoint + if( shouldDeleteFromPlatform ) { + try { final tokenId = decodeTokenId(existingToken as String) deleteTokenViaApi(existingToken as String, apiUrl, tokenId) - } else { - println " - Enterprise installation detected - PAT will not be deleted from platform." + } catch( Exception e ) { + ColorUtil.printColored("Error removing token: ${e.message}", "red") } - removeAuthFromConfig() - - } catch( Exception e ) { - println "Failed to validate or delete token: ${e.message}" - println "Removing token from config anyway..." - - // Remove from config even if API calls fail - removeAuthFromConfig() } + + removeAuthFromConfig() } private String decodeTokenId(String token) { From 21088fa72c1640a2643f5bc35bfa139eab980d97 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Mon, 6 Oct 2025 13:31:43 +0200 Subject: [PATCH 39/66] Rename .login to seqera_auth.config Also update code to use name 'auth' instead of 'login' for things, to follow the main command name and config filename. Signed-off-by: Phil Ewels --- .../tower/plugin/cli/AuthCommandImpl.groovy | 112 +++++------ .../plugin/cli/AuthCommandImplTest.groovy | 176 +++++++++--------- 2 files changed, 144 insertions(+), 144 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 9d64ae123f..3dfc0036f1 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -74,17 +74,17 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { println "" } - // Check if .login file already exists - final loginFile = getLoginFile() - if( Files.exists(loginFile) ) { + // Check if seqera_auth.config file already exists + final authFile = getAuthFile() + if( Files.exists(authFile) ) { ColorUtil.printColored("Error: Authentication token is already configured in Nextflow config.", "red") - ColorUtil.printColored("Login file: ${ColorUtil.colorize(loginFile.toString(), 'magenta')}", "dim") + ColorUtil.printColored("Auth file: ${ColorUtil.colorize(authFile.toString(), 'magenta')}", "dim") println " Run ${ColorUtil.colorize('nextflow auth logout', 'cyan')} to remove the current authentication." return } ColorUtil.printColored("Nextflow authentication with Seqera Platform", "cyan bold") - ColorUtil.printColored(" - Authentication will be saved to: ${ColorUtil.colorize(getLoginFile().toString(), 'magenta')}", "dim") + ColorUtil.printColored(" - Authentication will be saved to: ${ColorUtil.colorize(getAuthFile().toString(), 'magenta')}", "dim") apiUrl = normalizeApiUrl(apiUrl) ColorUtil.printColored(" - Seqera Platform API endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')} (can be customised with ${ColorUtil.colorize('-url', 'cyan')})", "dim") @@ -286,7 +286,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Save to config saveAuthToConfig(pat, apiUrl) - ColorUtil.printColored("Personal Access Token saved to Nextflow login config (${getLoginFile().toString()})", "green") + ColorUtil.printColored("Personal Access Token saved to Nextflow auth config (${getAuthFile().toString()})", "green") } private String promptPAT(){ @@ -376,20 +376,20 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void logout() { - // Check if .login file exists - final loginFile = getLoginFile() - if( !Files.exists(loginFile) ) { + // Check if seqera_auth.config file exists + final authFile = getAuthFile() + if( !Files.exists(authFile) ) { ColorUtil.printColored("No previous login found.", "green") return } - // Read token from .login file - final loginConfig = readLoginFile() - final existingToken = loginConfig['tower.accessToken'] - final apiUrl = loginConfig['tower.endpoint'] as String ?: DEFAULT_API_ENDPOINT + // Read token from seqera_auth.config file + final authConfig = readAuthFile() + final existingToken = authConfig['tower.accessToken'] + final apiUrl = authConfig['tower.endpoint'] as String ?: DEFAULT_API_ENDPOINT if( !existingToken ) { - ColorUtil.printColored("WARN: No authentication token found in login file.", "yellow bold") - println "Removing file: ${ColorUtil.colorize(loginFile.toString(), 'magenta')}" + ColorUtil.printColored("WARN: No authentication token found in auth file.", "yellow bold") + println "Removing file: ${ColorUtil.colorize(authFile.toString(), 'magenta')}" removeAuthFromConfig() return } @@ -404,7 +404,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { println "" } - ColorUtil.printColored(" - Found authentication token in login file: ${ColorUtil.colorize(loginFile.toString(), 'magenta')}", "dim") + ColorUtil.printColored(" - Found authentication token in auth file: ${ColorUtil.colorize(authFile.toString(), 'magenta')}", "dim") ColorUtil.printColored(" - Using Seqera Platform endpoint: ${ColorUtil.colorize(apiUrl, 'magenta')}", "dim") // Validate token by calling /user-info API @@ -419,7 +419,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { final shouldDeleteFromPlatform = isCloudEndpoint(apiUrl) ColorUtil.printColored("\nRunning this command will:", "yellow bold") - ColorUtil.printColored(" • Remove local Nextflow configuration: ${ColorUtil.colorize(loginFile.toString(), 'magenta')}", "yellow") + ColorUtil.printColored(" • Remove local Nextflow configuration: ${ColorUtil.colorize(authFile.toString(), 'magenta')}", "yellow") if( shouldDeleteFromPlatform ) { ColorUtil.printColored(" • Delete the corresponding access token from Seqera Platform: ${ColorUtil.colorize(apiUrl, 'magenta')}", "yellow") } else { @@ -485,7 +485,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private void removeAuthFromConfig() { final configFile = getConfigFile() - final loginFile = getLoginFile() + final authFile = getAuthFile() // Remove includeConfig line from main config file if( Files.exists(configFile) ) { @@ -494,9 +494,9 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { Files.writeString(configFile, updatedContent.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) } - // Delete .login file - if( Files.exists(loginFile) ) { - Files.delete(loginFile) + // Delete seqera_auth.config file + if( Files.exists(authFile) ) { + Files.delete(authFile) } ColorUtil.printColored("Authentication removed from Nextflow config.", "green") @@ -504,10 +504,10 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void config() { - // Read from both main config and .login file + // Read from both main config and seqera_auth.config file final config = readConfig() - // Token can come from .login file or environment variable + // Token can come from seqera_auth.config file or environment variable final existingToken = config['tower.accessToken'] ?: SysEnv.get('TOWER_ACCESS_TOKEN') final endpoint = config['tower.endpoint'] ?: DEFAULT_API_ENDPOINT @@ -517,7 +517,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } ColorUtil.printColored("Nextflow Seqera Platform configuration", "cyan bold") - ColorUtil.printColored(" - Config file: ${ColorUtil.colorize(getLoginFile().toString(), 'magenta')}", "dim") + ColorUtil.printColored(" - Config file: ${ColorUtil.colorize(getAuthFile().toString(), 'magenta')}", "dim") // Check if token is from environment variable if( !config['tower.accessToken'] && SysEnv.get('TOWER_ACCESS_TOKEN') ) { @@ -548,7 +548,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { println "\nNew configuration:" showCurrentConfig(config, existingToken as String, endpoint as String) - ColorUtil.printColored("\nConfiguration saved to ${ColorUtil.colorize(getLoginFile().toString(), 'magenta')}", "green") + ColorUtil.printColored("\nConfiguration saved to ${ColorUtil.colorize(getAuthFile().toString(), 'magenta')}", "green") } else { ColorUtil.printColored("\nNo configuration changes were made.", "dim") } @@ -801,17 +801,17 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void status() { final config = readConfig() - final loginConfig = readLoginFile() + final authConfig = readAuthFile() - printStatus(collectStatus(config, loginConfig)) + printStatus(collectStatus(config, authConfig)) } - private ConfigStatus collectStatus(Map config, Map loginConfig) { + private ConfigStatus collectStatus(Map config, Map authConfig) { // Collect all status information final status = new ConfigStatus([], null) // API endpoint - final endpointInfo = getConfigValue(config, loginConfig, 'tower.endpoint', 'TOWER_API_ENDPOINT', DEFAULT_API_ENDPOINT) + final endpointInfo = getConfigValue(config, authConfig, 'tower.endpoint', 'TOWER_API_ENDPOINT', DEFAULT_API_ENDPOINT) status.table.add(['API endpoint', ColorUtil.colorize(endpointInfo.value as String, 'magenta'), endpointInfo.source as String]) // API connection check @@ -820,7 +820,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { status.table.add(['API connection', ColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) // Authentication check - final tokenInfo = getConfigValue(config, loginConfig, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') + final tokenInfo = getConfigValue(config, authConfig, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') if( tokenInfo.value ) { try { final userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) @@ -834,13 +834,13 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Monitoring enabled - final enabledInfo = getConfigValue(config, loginConfig, 'tower.enabled', null, 'false') + final enabledInfo = getConfigValue(config, authConfig, 'tower.enabled', null, 'false') final enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' final enabledColor = enabledValue == 'Yes' ? 'green' : 'yellow' status.table.add(['Workflow monitoring', ColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) // Default workspace - final workspaceInfo = getConfigValue(config, loginConfig, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') + final workspaceInfo = getConfigValue(config, authConfig, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') if( workspaceInfo.value ) { // Try to get workspace name from API if we have a token def workspaceDetails = null @@ -916,16 +916,16 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return path } - private Map getConfigValue(Map config, Map login, String configKey, String envVarName, String defaultValue = null) { + private Map getConfigValue(Map config, Map auth, String configKey, String envVarName, String defaultValue = null) { //Checks where the config value came from - final loginValue = login[configKey] + final authValue = auth[configKey] final configValue = config[configKey] final envValue = envVarName ? SysEnv.get(envVarName) : null - final effectiveValue = loginValue ?: configValue ?: envValue ?: defaultValue + final effectiveValue = authValue ?: configValue ?: envValue ?: defaultValue def source = null - if( loginValue ) { - source = shortenPath(getLoginFile().toString()) + if( authValue ) { + source = shortenPath(getAuthFile().toString()) } else if( configValue ) { source = shortenPath(getConfigFile().toString()) } else if( envValue ) { @@ -937,9 +937,9 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return [ value : effectiveValue, source : source, - fromConfig: configValue != null || loginValue != null, + fromConfig: configValue != null || authValue != null, fromEnv : envValue != null, - isDefault : !loginValue && !configValue && !envValue + isDefault : !authValue && !configValue && !envValue ] } @@ -1043,12 +1043,12 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return Const.APP_HOME_DIR.resolve('config') } - protected Path getLoginFile() { - return Const.APP_HOME_DIR.resolve('.login') + protected Path getAuthFile() { + return Const.APP_HOME_DIR.resolve('seqera_auth.config') } - private Map readLoginFile() { - final configFile = getLoginFile() + private Map readAuthFile() { + final configFile = getAuthFile() if (!Files.exists(configFile)) { return [:] } @@ -1070,21 +1070,21 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private void writeConfig(Map config, Map workspaceMetadata = null) { log.debug("Getting config ") final configFile = getConfigFile() - final loginFile = getLoginFile() + final authFile = getAuthFile() // Create directory if it doesn't exist if (!Files.exists(configFile.parent)) { Files.createDirectories(configFile.parent) } - // Write tower config to .login file + // Write tower config to seqera_auth.config file final towerConfig = config.findAll { key, value -> key.toString().startsWith('tower.') && !key.toString().endsWith('.comment') } - final loginConfigText = new StringBuilder() - loginConfigText.append("// Seqera Platform configuration\n") - loginConfigText.append("tower {\n") + final authConfigText = new StringBuilder() + authConfigText.append("// Seqera Platform configuration\n") + authConfigText.append("tower {\n") towerConfig.each { key, value -> final configKey = key.toString().substring(6) // Remove "tower." prefix @@ -1094,22 +1094,22 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { if (configKey == 'workspaceId' && workspaceMetadata) { line += " // ${workspaceMetadata.orgName} / ${workspaceMetadata.workspaceName} [${workspaceMetadata.workspaceFullName}]" } - loginConfigText.append("${line}\n") + authConfigText.append("${line}\n") } else { - loginConfigText.append(" ${configKey} = ${value}\n") + authConfigText.append(" ${configKey} = ${value}\n") } } - loginConfigText.append("}\n") + authConfigText.append("}\n") - // Write the .login file - Files.writeString(loginFile, loginConfigText.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + // Write the seqera_auth.config file + Files.writeString(authFile, authConfigText.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) // Add includeConfig line to main config file if it doesn't exist addIncludeConfigToMainFile(configFile) } private void addIncludeConfigToMainFile(Path configFile) { - final includeConfigLine = "includeConfig '.login'" + final includeConfigLine = "includeConfig 'seqera_auth.config'" def configContent = "" if (Files.exists(configFile)) { @@ -1131,10 +1131,10 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private String removeIncludeConfigLine(String content) { - // Remove the includeConfig '.login' line + // Remove the includeConfig 'seqera_auth.config' line final lines = content.split('\n') final filteredLines = lines.findAll { line -> - !line.trim().equals("includeConfig '.login'") + !line.trim().equals("includeConfig 'seqera_auth.config'") } return filteredLines.join('\n') } diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy index fe4f0087ed..1c209a87ee 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy @@ -152,7 +152,7 @@ class AuthCommandImplTest extends Specification { // Some config param1 = 'value1' -includeConfig '.login' +includeConfig 'seqera_auth.config' param2 = 'value2' """ @@ -161,7 +161,7 @@ param2 = 'value2' def result = cmd.removeIncludeConfigLine(content) then: - !result.contains("includeConfig '.login'") + !result.contains("includeConfig 'seqera_auth.config'") result.contains('param1 = \'value1\'') result.contains('param2 = \'value2\'') } @@ -192,36 +192,36 @@ param2 = 'value2'""" configFile == Const.APP_HOME_DIR.resolve('config') } - def 'should get login file path'() { + def 'should get auth file path'() { given: def cmd = new AuthCommandImpl() when: - def loginFile = cmd.getLoginFile() + def authFile = cmd.getAuthFile() then: - loginFile == Const.APP_HOME_DIR.resolve('.login') + authFile == Const.APP_HOME_DIR.resolve('seqera_auth.config') } - def 'should read empty login file'() { + def 'should read empty auth file'() { given: def cmd = new AuthCommandImpl() when: - def config = cmd.readLoginFile() + def config = cmd.readAuthFile() then: config instanceof Map config.isEmpty() || config.size() >= 0 } - def 'should write config to .login file'() { + def 'should write config to seqera_auth.config file'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login') + def authFile = tempDir.resolve('seqera_auth.config') def configFile = tempDir.resolve('config') - cmd.getLoginFile() >> loginFile + cmd.getAuthFile() >> authFile cmd.getConfigFile() >> configFile def config = [ @@ -234,20 +234,20 @@ param2 = 'value2'""" cmd.writeConfig(config, null) then: - Files.exists(loginFile) - def loginContent = Files.readString(loginFile) - loginContent.contains('accessToken = \'test-token-123\'') - loginContent.contains('endpoint = \'https://api.cloud.seqera.io\'') - loginContent.contains('enabled = true') + Files.exists(authFile) + def authContent = Files.readString(authFile) + authContent.contains('accessToken = \'test-token-123\'') + authContent.contains('endpoint = \'https://api.cloud.seqera.io\'') + authContent.contains('enabled = true') } def 'should write config with workspace metadata'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login') + def authFile = tempDir.resolve('seqera_auth.config') def configFile = tempDir.resolve('config') - cmd.getLoginFile() >> loginFile + cmd.getAuthFile() >> authFile cmd.getConfigFile() >> configFile def config = [ @@ -265,19 +265,19 @@ param2 = 'value2'""" cmd.writeConfig(config, metadata) then: - Files.exists(loginFile) - def loginContent = Files.readString(loginFile) - loginContent.contains('workspaceId = \'12345\'') - loginContent.contains('// TestOrg / TestWorkspace [test-org/test-workspace]') + Files.exists(authFile) + def authContent = Files.readString(authFile) + authContent.contains('workspaceId = \'12345\'') + authContent.contains('// TestOrg / TestWorkspace [test-org/test-workspace]') } def 'should add includeConfig line to main config file'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login') + def authFile = tempDir.resolve('seqera_auth.config') def configFile = tempDir.resolve('config') - cmd.getLoginFile() >> loginFile + cmd.getAuthFile() >> authFile cmd.getConfigFile() >> configFile // Create initial config file @@ -293,21 +293,21 @@ param2 = 'value2'""" then: Files.exists(configFile) def configContent = Files.readString(configFile) - configContent.contains("includeConfig '.login'") + configContent.contains("includeConfig 'seqera_auth.config'") configContent.contains('param1 = \'value1\'') } def 'should not duplicate includeConfig line'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login') + def authFile = tempDir.resolve('seqera_auth.config') def configFile = tempDir.resolve('config') - cmd.getLoginFile() >> loginFile + cmd.getAuthFile() >> authFile cmd.getConfigFile() >> configFile // Create config file with existing includeConfig line - Files.writeString(configFile, "// Config\nincludeConfig '.login'\nparam1 = 'value1'\n") + Files.writeString(configFile, "// Config\nincludeConfig 'seqera_auth.config'\nparam1 = 'value1'\n") def config = [ 'tower.accessToken': 'test-token-123' @@ -319,7 +319,7 @@ param2 = 'value2'""" then: Files.exists(configFile) def configContent = Files.readString(configFile) - configContent.count("includeConfig '.login'") == 1 + configContent.count("includeConfig 'seqera_auth.config'") == 1 } def 'should get current workspace name when workspace exists'() { @@ -354,18 +354,18 @@ param2 = 'value2'""" def 'should get config value from login file'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login') - cmd.getLoginFile() >> loginFile + def authFile = tempDir.resolve('seqera_auth.config') + cmd.getAuthFile() >> authFile def config = [:] - def login = ['tower.accessToken': 'token-from-login'] + def auth =['tower.accessToken': 'token-from-login'] when: - def result = cmd.getConfigValue(config, login, 'tower.accessToken', 'TOWER_ACCESS_TOKEN', null) + def result = cmd.getConfigValue(config, auth,'tower.accessToken', 'TOWER_ACCESS_TOKEN', null) then: result.value == 'token-from-login' - result.source.endsWith('.login') + result.source.endsWith('seqera_auth.config') result.fromConfig == true result.fromEnv == false result.isDefault == false @@ -378,10 +378,10 @@ param2 = 'value2'""" cmd.getConfigFile() >> configFile def config = ['tower.endpoint': 'https://example.com'] - def login = [:] + def auth =[:] when: - def result = cmd.getConfigValue(config, login, 'tower.endpoint', 'TOWER_API_ENDPOINT', null) + def result = cmd.getConfigValue(config, auth,'tower.endpoint', 'TOWER_API_ENDPOINT', null) then: result.value == 'https://example.com' @@ -396,13 +396,13 @@ param2 = 'value2'""" def cmd = Spy(AuthCommandImpl) def config = [:] - def login = [:] + def auth =[:] and: SysEnv.push( ['TOWER_ACCESS_TOKEN': 'token-from-env']) when: - def result = cmd.getConfigValue(config, login, 'tower.accessToken', 'TOWER_ACCESS_TOKEN', null) + def result = cmd.getConfigValue(config, auth,'tower.accessToken', 'TOWER_ACCESS_TOKEN', null) then: @@ -421,10 +421,10 @@ param2 = 'value2'""" def cmd = new AuthCommandImpl() def config = [:] - def login = [:] + def auth =[:] when: - def result = cmd.getConfigValue(config, login, 'tower.endpoint', null, 'https://default.example.com') + def result = cmd.getConfigValue(config, auth,'tower.endpoint', null, 'https://default.example.com') then: result.value == 'https://default.example.com' @@ -437,20 +437,20 @@ param2 = 'value2'""" def 'should prioritize login over config and env'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login') - cmd.getLoginFile() >> loginFile + def authFile = tempDir.resolve('seqera_auth.config') + cmd.getAuthFile() >> authFile def config = ['tower.accessToken': 'token-from-config'] - def login = ['tower.accessToken': 'token-from-login'] + def auth =['tower.accessToken': 'token-from-login'] SysEnv.push(['TOWER_ACCESS_TOKEN': 'token-from-env']) when: - def result = cmd.getConfigValue(config, login, 'tower.accessToken', 'TOWER_ACCESS_TOKEN', 'default-token') + def result = cmd.getConfigValue(config, auth,'tower.accessToken', 'TOWER_ACCESS_TOKEN', 'default-token') then: result.value == 'token-from-login' - result.source.endsWith('.login') + result.source.endsWith('seqera_auth.config') cleanup: SysEnv.pop() @@ -463,12 +463,12 @@ param2 = 'value2'""" cmd.getConfigFile() >> configFile def config = ['tower.endpoint': 'https://config.example.com'] - def login = [:] + def auth =[:] SysEnv.push(['TOWER_API_ENDPOINT': 'https://env.example.com']) when: - def result = cmd.getConfigValue(config, login, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://default.example.com') + def result = cmd.getConfigValue(config, auth,'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://default.example.com') then: @@ -484,10 +484,10 @@ param2 = 'value2'""" def cmd = new AuthCommandImpl() def config = ['tower.enabled': true] - def login = [:] + def auth =[:] when: - def result = cmd.getConfigValue(config, login, 'tower.enabled', null, 'false') + def result = cmd.getConfigValue(config, auth,'tower.enabled', null, 'false') then: result.value == true @@ -722,7 +722,7 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def loginConfig = [ + def authConfig = [ 'tower.accessToken': 'test-token', 'tower.endpoint': 'https://api.cloud.seqera.io', 'tower.enabled': true @@ -734,7 +734,7 @@ param2 = 'value2'""" cmd.getWorkspaceDetailsFromApi(_, _, _) >> null when: - def status = cmd.collectStatus(config, loginConfig) + def status = cmd.collectStatus(config, authConfig) then: status != null @@ -761,12 +761,12 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def loginConfig = [:] + def authConfig = [:] cmd.checkApiConnection(_) >> true when: - def status = cmd.collectStatus(config, loginConfig) + def status = cmd.collectStatus(config, authConfig) then: status != null @@ -782,12 +782,12 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def loginConfig = ['tower.endpoint': 'https://unreachable.example.com'] + def authConfig = ['tower.endpoint': 'https://unreachable.example.com'] cmd.checkApiConnection(_) >> false when: - def status = cmd.collectStatus(config, loginConfig) + def status = cmd.collectStatus(config, authConfig) then: status != null @@ -799,13 +799,13 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def loginConfig = ['tower.accessToken': 'invalid-token'] + def authConfig = ['tower.accessToken': 'invalid-token'] cmd.checkApiConnection(_) >> true cmd.callUserInfoApi(_, _) >> { throw new RuntimeException('Invalid token') } when: - def status = cmd.collectStatus(config, loginConfig) + def status = cmd.collectStatus(config, authConfig) then: status != null @@ -818,12 +818,12 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def loginConfig = ['tower.enabled': true] + def authConfig = ['tower.enabled': true] cmd.checkApiConnection(_) >> true when: - def status = cmd.collectStatus(config, loginConfig) + def status = cmd.collectStatus(config, authConfig) then: status != null @@ -835,12 +835,12 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def loginConfig = ['tower.enabled': false] + def authConfig = ['tower.enabled': false] cmd.checkApiConnection(_) >> true when: - def status = cmd.collectStatus(config, loginConfig) + def status = cmd.collectStatus(config, authConfig) then: status != null @@ -852,7 +852,7 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def loginConfig = [ + def authConfig = [ 'tower.accessToken': 'test-token', 'tower.workspaceId': '12345' ] @@ -866,7 +866,7 @@ param2 = 'value2'""" ] when: - def status = cmd.collectStatus(config, loginConfig) + def status = cmd.collectStatus(config, authConfig) then: status != null @@ -881,7 +881,7 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def loginConfig = [ + def authConfig = [ 'tower.accessToken': 'test-token', 'tower.workspaceId': '12345' ] @@ -891,7 +891,7 @@ param2 = 'value2'""" cmd.getWorkspaceDetailsFromApi(_, _, _) >> null when: - def status = cmd.collectStatus(config, loginConfig) + def status = cmd.collectStatus(config, authConfig) then: status != null @@ -904,7 +904,7 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def loginConfig = [:] + def authConfig = [:] cmd.checkApiConnection(_) >> true cmd.callUserInfoApi(_, _) >> [userName: 'envuser', id: '456'] @@ -915,7 +915,7 @@ param2 = 'value2'""" 'TOWER_WORKFLOW_ID': 'ws-123'] ) when: - def status = cmd.collectStatus(config, loginConfig) + def status = cmd.collectStatus(config, authConfig) then: @@ -934,12 +934,12 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def loginConfig = [:] + def authConfig = [:] cmd.checkApiConnection(_) >> true when: - def status = cmd.collectStatus(config, loginConfig) + def status = cmd.collectStatus(config, authConfig) then: status != null @@ -954,21 +954,21 @@ param2 = 'value2'""" def 'should collect status with mixed sources'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login') + def authFile = tempDir.resolve('seqera_auth.config') def configFile = tempDir.resolve('config') - cmd.getLoginFile() >> loginFile + cmd.getAuthFile() >> authFile cmd.getConfigFile() >> configFile def config = ['tower.enabled': true] - def loginConfig = ['tower.accessToken': 'login-token'] + def authConfig = ['tower.accessToken': 'login-token'] cmd.checkApiConnection(_) >> true cmd.callUserInfoApi(_, _) >> [userName: 'mixeduser', id: '789'] SysEnv.push(['TOWER_WORKFLOW_ID': 'ws-env']) when: - def status = cmd.collectStatus(config, loginConfig) + def status = cmd.collectStatus(config, authConfig) then: @@ -1031,13 +1031,13 @@ param2 = 'value2'""" !output.contains('TestOrg') } - def 'should detect existing login file and prevent duplicate login'() { + def 'should detect existing auth file and prevent duplicate login'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login') - Files.createFile(loginFile) + def authFile = tempDir.resolve('seqera_auth.config') + Files.createFile(authFile) - cmd.getLoginFile() >> loginFile + cmd.getAuthFile() >> authFile when: cmd.login('https://api.cloud.seqera.io') @@ -1051,9 +1051,9 @@ param2 = 'value2'""" def 'should warn when TOWER_ACCESS_TOKEN env var is set during login'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login-not-exists') + def authFile = tempDir.resolve('seqera_auth.config-not-exists') - cmd.getLoginFile() >> loginFile + cmd.getAuthFile() >> authFile cmd.performAuth0Login(_, _) >> { /* mock to prevent actual login */ } SysEnv.push(['TOWER_ACCESS_TOKEN': 'env-token']) @@ -1071,9 +1071,9 @@ param2 = 'value2'""" def 'should normalize API URL during login'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login-not-exists') + def authFile = tempDir.resolve('seqera_auth.config-not-exists') - cmd.getLoginFile() >> loginFile + cmd.getAuthFile() >> authFile when: cmd.login('api.cloud.seqera.io') @@ -1085,9 +1085,9 @@ param2 = 'value2'""" def 'should route to enterprise auth for non-cloud endpoints'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login-not-exists') + def authFile = tempDir.resolve('seqera_auth.config-not-exists') - cmd.getLoginFile() >> loginFile + cmd.getAuthFile() >> authFile when: cmd.login('https://enterprise.example.com') @@ -1100,9 +1100,9 @@ param2 = 'value2'""" def 'should route to Auth0 login for cloud endpoints'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login-not-exists') + def authFile = tempDir.resolve('seqera_auth.config-not-exists') - cmd.getLoginFile() >> loginFile + cmd.getAuthFile() >> authFile when: cmd.login('https://api.cloud.seqera.io') @@ -1115,10 +1115,10 @@ param2 = 'value2'""" def 'should save auth to config after successful PAT generation'() { given: def cmd = Spy(AuthCommandImpl) - def loginFile = tempDir.resolve('.login') + def authFile = tempDir.resolve('seqera_auth.config') def configFile = tempDir.resolve('config') - cmd.getLoginFile() >> loginFile + cmd.getAuthFile() >> authFile cmd.getConfigFile() >> configFile def config = [ @@ -1131,8 +1131,8 @@ param2 = 'value2'""" cmd.saveAuthToConfig('generated-pat-token', 'https://api.cloud.seqera.io') then: - Files.exists(loginFile) - def content = Files.readString(loginFile) + Files.exists(authFile) + def content = Files.readString(authFile) content.contains('accessToken = \'generated-pat-token\'') content.contains('endpoint = \'https://api.cloud.seqera.io\'') content.contains('enabled = true') From e148ed52afb77480706088b0a95724ad81ad3b35 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Mon, 6 Oct 2025 14:23:03 +0200 Subject: [PATCH 40/66] Tweaks to help text, status table printing Signed-off-by: Phil Ewels --- .../src/main/groovy/nextflow/cli/CmdAuth.groovy | 5 ++++- .../tower/plugin/cli/AuthCommandImpl.groovy | 15 +++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 00f2f528dc..571698826e 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -96,7 +96,10 @@ class CmdAuth extends CmdBase implements UsageAware { result << 'Usage: nextflow auth [options]' result << '' result << 'Commands:' - commands.collect { it.name }.sort().each { result << " $it".toString() } + result << ' login Authenticate with Seqera Platform' + result << ' logout Remove authentication and revoke access token' + result << ' status Show current authentication status and configuration' + result << ' config Configure Seqera Platform settings' result << '' } else { def sub = commands.find { it.name == args[0] } diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 3dfc0036f1..d19d54b224 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -549,8 +549,6 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { showCurrentConfig(config, existingToken as String, endpoint as String) ColorUtil.printColored("\nConfiguration saved to ${ColorUtil.colorize(getAuthFile().toString(), 'magenta')}", "green") - } else { - ColorUtil.printColored("\nNo configuration changes were made.", "dim") } } catch( Exception e ) { @@ -850,14 +848,18 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { if( workspaceDetails ) { // Add workspace ID row - status.table.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue'), workspaceInfo.source as String]) + status.table.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'cyan'), workspaceInfo.source as String]) // Store workspace details for display after the table status.workspaceInfo = workspaceDetails } else { - status.table.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'blue', true), workspaceInfo.source as String]) + status.table.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'cyan', true), workspaceInfo.source as String]) } } else { - status.table.add(['Default workspace', ColorUtil.colorize('None (Personal workspace)', 'cyan', true), 'default']) + if( tokenInfo.value ) { + status.table.add(['Default workspace', ColorUtil.colorize('None (Personal workspace)', 'cyan', true), 'default']) + } else { + status.table.add(['Default workspace', ColorUtil.colorize('None', 'cyan', true), 'default']) + } } return status } @@ -868,7 +870,8 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Print workspace details if available if( status.workspaceInfo ) { - println "${' ' * 22}${ColorUtil.colorize(status.workspaceInfo.orgName as String, 'cyan')} / ${ColorUtil.colorize(status.workspaceInfo.workspaceName as String, 'cyan')} ${ColorUtil.colorize('[' + (status.workspaceInfo.workspaceFullName as String) + ']', 'dim')}" + ColorUtil.printColored("${" " * 22}$status.workspaceInfo.orgName / $status.workspaceInfo.workspaceName", "cyan") + ColorUtil.printColored("${" " * 22}$status.workspaceInfo.workspaceFullName", "dim") } } From f28cbc30b656f0e22156f61e5154e7d0ee58507a Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Mon, 6 Oct 2025 14:32:47 +0200 Subject: [PATCH 41/66] Improve CLI output when running config with workspaces Signed-off-by: Phil Ewels --- .../main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index d19d54b224..de91f1b333 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -658,8 +658,8 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Show current workspace and prepare prompt final currentWorkspaceName = getCurrentWorkspaceName(workspaces, currentWorkspaceId) - final prompt = ColorUtil.colorize("\nSelect workspace (0-${workspaces.size()}, press Enter to keep as '${currentWorkspaceName}'): ","bold cyan") - final selection = promptForNumber(prompt, 0, workspaces.size(), true) + println("\n${ColorUtil.colorize('Leave blank to keep current setting', 'bold')} (${ColorUtil.colorize(currentWorkspaceName, 'cyan')}),") + final selection = promptForNumber(ColorUtil.colorize("or select workspace (0-${workspaces.size()}): ", 'bold', true), 0, workspaces.size(), true) if( selection == null ) { return [changed: false, metadata: null] @@ -702,7 +702,8 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { final displayName = orgName == 'Personal' ? 'None [Personal workspace]' : orgName println " ${index + 1}. ${ColorUtil.colorize(displayName as String, 'cyan', true)}" } - final orgSelection = promptForNumber(ColorUtil.colorize("Select organization (1-${orgs.size()}, leave blank to keep as '${currentWorkspaceDisplay}'): "), 1, orgs.size(),true) + println("\n${ColorUtil.colorize('Leave blank to keep current setting', 'bold')} (${ColorUtil.colorize(currentWorkspaceDisplay, 'cyan')}),") + final orgSelection = promptForNumber(ColorUtil.colorize("or select organization (1-${orgs.size()}): ", 'bold', true), 1, orgs.size(),true) if (!orgSelection) return [changed: false, metadata: null] From b5852d7f36a1a4d0be22b0bb9dce3b870ee5a333 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Mon, 6 Oct 2025 15:47:06 +0200 Subject: [PATCH 42/66] Update tests Signed-off-by: Phil Ewels --- .../io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy index 1c209a87ee..b7d378db2d 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy @@ -973,8 +973,8 @@ param2 = 'value2'""" then: status != null - // Token from login file - status.table[2][2].endsWith('.login') + // Token from auth file + status.table[2][2].endsWith('seqera_auth.config') // Enabled from config file status.table[3][2].endsWith('config') // Workspace from env var From 7f84c61ce3e5821779461cdda3a6b8b70d1c2e6c Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Mon, 6 Oct 2025 21:07:56 +0200 Subject: [PATCH 43/66] Check that the default workspace has CEs, and prompt to set one as primary Signed-off-by: Phil Ewels --- .../tower/plugin/cli/AuthCommandImpl.groovy | 143 +++++++++++++++++- 1 file changed, 139 insertions(+), 4 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index de91f1b333..2b5fb52a33 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -275,7 +275,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { protected void handleEnterpriseAuth(String apiUrl) { println "" ColorUtil.printColored("Please generate a Personal Access Token from your Seqera Platform instance.", "cyan bold") - println "You can create one at: ${ColorUtil.colorize(apiUrl.replace('/api', '').replace('://api.', '') + '/tokens', 'magenta')}" + println "You can create one at: ${ColorUtil.colorize(getWebUrlFromApiEndpoint(apiUrl) + '/tokens', 'magenta')}" println "" final pat = promptPAT() @@ -289,6 +289,13 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { ColorUtil.printColored("Personal Access Token saved to Nextflow auth config (${getAuthFile().toString()})", "green") } + private String getWebUrlFromApiEndpoint(String apiEndpoint) { + // Convert API endpoint to web URL + // e.g., https://api.cloud.seqera.io -> https://cloud.seqera.io + // https://cloud.seqera.io/api -> https://cloud.seqera.io + return apiEndpoint.replace('://api.', '://').replace('/api', '') + } + private String promptPAT(){ System.out.print("Enter your Personal Access Token: ") System.out.flush() @@ -551,6 +558,17 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { ColorUtil.printColored("\nConfiguration saved to ${ColorUtil.colorize(getAuthFile().toString(), 'magenta')}", "green") } + // Configure compute environment for the workspace (always run after workspace selection) + final currentWorkspaceId = config.get('tower.workspaceId') as String + if( currentWorkspaceId ) { + // Get workspace metadata if not already available (e.g., when user kept existing workspace) + def workspaceMetadata = workspaceResult.metadata as Map + if( !workspaceMetadata ) { + workspaceMetadata = getWorkspaceDetailsFromApi(existingToken as String, endpoint as String, currentWorkspaceId) + } + configureComputeEnvironment(existingToken as String, endpoint as String, currentWorkspaceId, workspaceMetadata) + } + } catch( Exception e ) { throw new AbortOperationException("Failed to configure settings: ${e.message}") } @@ -647,12 +665,16 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private Map selectWorkspaceFromAll(Map config, List workspaces, final currentWorkspaceId) { println "\nAvailable workspaces:" - println " 0. ${ColorUtil.colorize('None (Personal workspace)', 'cyan', true)} ${ColorUtil.colorize('[no organization]', 'dim', true)}" + final isPersonalWorkspace = !currentWorkspaceId + final currentIndicator = isPersonalWorkspace ? ColorUtil.colorize(' (current)', 'yellow bold') : '' + println " 0. ${ColorUtil.colorize('None (Personal workspace)', 'cyan', true)} ${ColorUtil.colorize('[no organization]', 'dim', true)}${currentIndicator}" workspaces.eachWithIndex { workspace, index -> final ws = workspace as Map + final isCurrent = ws.workspaceId.toString() == currentWorkspaceId?.toString() + final currentInd = isCurrent ? ColorUtil.colorize(' (current)', 'yellow bold') : '' final prefix = ws.orgName ? "${ColorUtil.colorize(ws.orgName as String, 'cyan', true)} / " : "" - println " ${index + 1}. ${prefix}${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}" + println " ${index + 1}. ${prefix}${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}${currentInd}" } // Show current workspace and prepare prompt @@ -723,7 +745,9 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { orgWorkspaceList.eachWithIndex { workspace, index -> final ws = workspace as Map - println " ${index + 1}. ${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}" + final isCurrent = ws.workspaceId.toString() == currentWorkspaceId?.toString() + final currentInd = isCurrent ? ColorUtil.colorize(' (current)', 'yellow bold') : '' + println " ${index + 1}. ${ColorUtil.colorize(ws.workspaceName as String, 'magenta', true)} ${ColorUtil.colorize('[' + (ws.workspaceFullName as String) + ']', 'dim', true)}${currentInd}" } final maxSelection = orgWorkspaceList.size() @@ -761,6 +785,117 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return orgsAndWorkspaces.findAll { ((Map) it).workspaceId != null } } + private List getComputeEnvironments(String accessToken, String endpoint, String workspaceId) { + final client = createHttpClient(accessToken) + final uri = workspaceId ? + "${endpoint}/compute-envs?workspaceId=${workspaceId}" : + "${endpoint}/compute-envs" + + final request = HttpRequest.newBuilder() + .uri(URI.create(uri)) + .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 compute environments: ${error}") + } + + final json = new JsonSlurper().parseText(response.body()) as Map + return json.computeEnvs as List ?: [] + } + + private void setPrimaryComputeEnvironment(String accessToken, String endpoint, String computeEnvId, String workspaceId) { + final client = createHttpClient(accessToken) + final uri = "${endpoint}/compute-envs/${computeEnvId}/primary?workspaceId=${workspaceId}" + + final requestBody = new JsonBuilder([:]).toString() + + final request = HttpRequest.newBuilder() + .uri(URI.create(uri)) + .header('Content-Type', 'application/json') + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build() + + final response = client.send(request, HttpResponse.BodyHandlers.ofString()) + + if( response.statusCode() != 200 && response.statusCode() != 204 ) { + final error = response.body() ?: "HTTP ${response.statusCode()}" + throw new RuntimeException("Failed to set primary compute environment: ${error}") + } + } + + private void configureComputeEnvironment(String accessToken, String endpoint, String workspaceId, Map workspaceMetadata) { + try { + // Get compute environments for the workspace + final computeEnvs = getComputeEnvironments(accessToken, endpoint, workspaceId) + + // If there are zero compute environments, log a warning and provide a link + if( computeEnvs.isEmpty() ) { + println "" + ColorUtil.printColored("Warning: No compute environments found in this workspace.", "red") + ColorUtil.printColored(" You must create a compute environment to run pipelines.", "yellow") + + // Generate the web URL for creating a compute environment + if( workspaceMetadata ) { + final orgName = workspaceMetadata.orgName as String + final workspaceName = workspaceMetadata.workspaceName as String + final webUrl = getWebUrlFromApiEndpoint(endpoint) + final createUrl = "${webUrl}/orgs/${orgName}/workspaces/${workspaceName}/compute-envs/new" + println " Create one at: ${ColorUtil.colorize(createUrl, 'magenta')}" + } + return + } + + // Find which compute environment is already set as primary + final primaryEnv = computeEnvs.find { ((Map) it).primary == true } + final currentPrimaryName = primaryEnv ? (primaryEnv as Map).name as String : 'None' + + // Show current setting + println "" + println "Primary compute environment. Current setting: ${ColorUtil.colorize(currentPrimaryName, 'cyan', true)}" + ColorUtil.printColored(" Setting a primary compute environment allows you to run pipelines without", "dim") + ColorUtil.printColored(" explicitly specifying a compute environment every time.", "dim") + println "" + println "Available compute environments:" + + computeEnvs.eachWithIndex { ce, index -> + final env = ce as Map + final name = env.name as String + final platform = env.platform as String + final isPrimary = env.primary == true + final currentIndicator = isPrimary ? ColorUtil.colorize(' (current)', 'yellow bold') : '' + println " ${index + 1}. ${ColorUtil.colorize(name, 'cyan', true)} ${ColorUtil.colorize('[' + platform + ']', 'dim', true)}${currentIndicator}" + } + + println "" + println "${ColorUtil.colorize('Leave blank to keep current setting', 'bold')} (${ColorUtil.colorize(currentPrimaryName, 'cyan')}),".toString() + final selection = promptForNumber(ColorUtil.colorize("or select compute environment (1-${computeEnvs.size()}): ", 'bold', true), 1, computeEnvs.size(), true) + + if( selection == null ) { + return + } + + final selectedEnv = computeEnvs[selection - 1] as Map + final computeEnvId = selectedEnv.id as String + + // Only set if different from current primary + if( primaryEnv && (primaryEnv as Map).id == computeEnvId ) { + // User selected the same one that's already primary + return + } + + // Set the selected compute environment as primary + setPrimaryComputeEnvironment(accessToken, endpoint, computeEnvId, workspaceId) + ColorUtil.printColored("Primary compute environment set to: ${ColorUtil.colorize(selectedEnv.name as String, 'cyan')}", "green") + + } catch( Exception e ) { + ColorUtil.printColored("Warning: Failed to configure compute environment: ${e.message}", "yellow") + } + } + private Boolean promptForYesNo(String prompt, Boolean defaultValue) { while( true ) { final input = readUserInput(prompt)?.toLowerCase() From c53a4eebb0514711b639f2fdd27cc888e09b7143 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Mon, 6 Oct 2025 22:59:32 +0200 Subject: [PATCH 44/66] auth status: show primary CE and default work directory Signed-off-by: Phil Ewels --- .../tower/plugin/cli/AuthCommandImpl.groovy | 80 +++++++++++++++---- 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 2b5fb52a33..85732d9076 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -942,7 +942,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private ConfigStatus collectStatus(Map config, Map authConfig) { // Collect all status information - final status = new ConfigStatus([], null) + final status = new ConfigStatus([], null, null) // API endpoint final endpointInfo = getConfigValue(config, authConfig, 'tower.endpoint', 'TOWER_API_ENDPOINT', DEFAULT_API_ENDPOINT) @@ -983,9 +983,10 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } if( workspaceDetails ) { - // Add workspace ID row + // Add workspace ID row and remember its index + status.workspaceRowIndex = status.table.size() status.table.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'cyan'), workspaceInfo.source as String]) - // Store workspace details for display after the table + // Store workspace details for display after this row (outside table structure) status.workspaceInfo = workspaceDetails } else { status.table.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'cyan', true), workspaceInfo.source as String]) @@ -997,22 +998,66 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { status.table.add(['Default workspace', ColorUtil.colorize('None', 'cyan', true), 'default']) } } + + // Primary compute environment and work directory + def primaryEnv = null + if( tokenInfo.value && workspaceInfo.value ) { + try { + final computeEnvs = getComputeEnvironments(tokenInfo.value as String, endpointInfo.value as String, workspaceInfo.value as String) + primaryEnv = computeEnvs.find { ((Map) it).primary == true } as Map + } catch( Exception e ) { + status.table.add(['Primary compute env', ColorUtil.colorize('Error fetching', 'red'), '']) + status.table.add(['Default work dir', ColorUtil.colorize('N/A', 'dim', true), '']) + return status + } + } + + if( primaryEnv ) { + final envName = primaryEnv.name as String + final envPlatform = primaryEnv.platform as String + final displayValue = "${ColorUtil.colorize(envName, 'cyan')} ${ColorUtil.colorize('[' + envPlatform + ']', 'dim yellow', true)}".toString() + status.table.add(['Primary compute env', displayValue, 'workspace']) + status.table.add(['Default work dir', ColorUtil.colorize(primaryEnv.workDir as String, 'magenta'), 'compute env']) + } else { + final ceValue = (!tokenInfo.value || !workspaceInfo.value) ? 'N/A' : 'None' + final ceColor = ceValue == 'None' ? 'yellow' : 'dim' + status.table.add(['Primary compute env', ColorUtil.colorize(ceValue, ceColor, true), 'workspace']) + status.table.add(['Default work dir', ColorUtil.colorize(ceValue, ceColor, true), 'compute env']) + } + return status } private void printStatus(ConfigStatus status){ - // Print table println "" - printStatusTable(status.table) - // Print workspace details if available - if( status.workspaceInfo ) { - ColorUtil.printColored("${" " * 22}$status.workspaceInfo.orgName / $status.workspaceInfo.workspaceName", "cyan") - ColorUtil.printColored("${" " * 22}$status.workspaceInfo.workspaceFullName", "dim") + // Generate all table lines with consistent column widths + final tableLines = generateStatusTableLines(status.table) + + if( status.workspaceInfo && status.workspaceRowIndex != null ) { + // Insert workspace details after the workspace row + // workspaceRowIndex is the row in the data array (0-indexed) + // In tableLines: [0]=header, [1]=separator, [2]=first data row, etc. + // So workspace row is at index: workspaceRowIndex + 2 + // We want to insert AFTER it, so: workspaceRowIndex + 2 + 1 = workspaceRowIndex + 3 + final insertAfterLine = status.workspaceRowIndex + 3 + + final workspaceDetails = [ + ColorUtil.colorize("${" " * 22}$status.workspaceInfo.orgName / $status.workspaceInfo.workspaceName", "cyan", true), + ColorUtil.colorize("${" " * 22}$status.workspaceInfo.workspaceFullName", "dim", true) + ] + + // Insert workspace details into the output + tableLines.addAll(insertAfterLine, workspaceDetails) } + + // Print all lines + tableLines.each { println it } } - private void printStatusTable(List> rows) { - if( !rows ) return + private List generateStatusTableLines(List> rows) { + if( !rows ) return [] + + final List lines = [] // Calculate column widths (accounting for ANSI codes) def col1Width = rows.collect { stripAnsiCodes(it[0]).length() }.max() @@ -1024,17 +1069,19 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { col2Width = Math.max(col2Width, 15) + 2 col3Width = Math.max(col3Width, 10) + 2 - // Print table header - ColorUtil.printColored("${'Setting'.padRight(col1Width)} ${'Value'.padRight(col2Width)} Source", "cyan bold") - println "${'-' * col1Width} ${'-' * col2Width} ${'-' * col3Width}" + // Add table header + lines.add(ColorUtil.colorize("${'Setting'.padRight(col1Width)} ${'Value'.padRight(col2Width)} Source", "cyan bold")) + lines.add("${'-' * col1Width} ${'-' * col2Width} ${'-' * col3Width}".toString()) - // Print rows + // Add rows rows.each { row -> final paddedCol1 = padStringWithAnsi(row[0], col1Width) final paddedCol2 = padStringWithAnsi(row[1], col2Width) final paddedCol3 = ColorUtil.colorize(row[2], 'dim', true) - println "${paddedCol1} ${paddedCol2} ${paddedCol3}" + lines.add("${paddedCol1} ${paddedCol2} ${paddedCol3}".toString()) } + + return lines } private String stripAnsiCodes(String text) { @@ -1281,6 +1328,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { static class ConfigStatus { List> table Map workspaceInfo + Integer workspaceRowIndex // Track which row has the workspace so we can insert details after it } } From 3f11aa8c0f1ee7b93d90214dca1fca3aadd15ce3 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Mon, 6 Oct 2025 23:17:40 +0200 Subject: [PATCH 45/66] Sort selection lists Signed-off-by: Phil Ewels --- .../tower/plugin/cli/AuthCommandImpl.groovy | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 85732d9076..571bfcca29 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -669,7 +669,15 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { final currentIndicator = isPersonalWorkspace ? ColorUtil.colorize(' (current)', 'yellow bold') : '' println " 0. ${ColorUtil.colorize('None (Personal workspace)', 'cyan', true)} ${ColorUtil.colorize('[no organization]', 'dim', true)}${currentIndicator}" - workspaces.eachWithIndex { workspace, index -> + // Sort workspaces by org name, then workspace name + final sortedWorkspaces = workspaces.sort { a, b -> + final aMap = a as Map + final bMap = b as Map + final orgCompare = (aMap.orgName as String ?: '').compareToIgnoreCase(bMap.orgName as String ?: '') + orgCompare != 0 ? orgCompare : (aMap.workspaceName as String ?: '').compareToIgnoreCase(bMap.workspaceName as String ?: '') + } + + sortedWorkspaces.eachWithIndex { workspace, index -> final ws = workspace as Map final isCurrent = ws.workspaceId.toString() == currentWorkspaceId?.toString() final currentInd = isCurrent ? ColorUtil.colorize(' (current)', 'yellow bold') : '' @@ -678,10 +686,10 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Show current workspace and prepare prompt - final currentWorkspaceName = getCurrentWorkspaceName(workspaces, currentWorkspaceId) + final currentWorkspaceName = getCurrentWorkspaceName(sortedWorkspaces, currentWorkspaceId) println("\n${ColorUtil.colorize('Leave blank to keep current setting', 'bold')} (${ColorUtil.colorize(currentWorkspaceName, 'cyan')}),") - final selection = promptForNumber(ColorUtil.colorize("or select workspace (0-${workspaces.size()}): ", 'bold', true), 0, workspaces.size(), true) + final selection = promptForNumber(ColorUtil.colorize("or select workspace (0-${sortedWorkspaces.size()}): ", 'bold', true), 0, sortedWorkspaces.size(), true) if( selection == null ) { return [changed: false, metadata: null] @@ -692,7 +700,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { config.remove('tower.workspaceId') return [changed: hadWorkspaceId, metadata: null] } else { - final selectedWorkspace = workspaces[selection - 1] as Map + final selectedWorkspace = sortedWorkspaces[selection - 1] as Map final selectedId = selectedWorkspace.workspaceId.toString() final currentId = config.get('tower.workspaceId') config['tower.workspaceId'] = selectedId @@ -714,7 +722,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { final currentWorkspaceDisplay = getCurrentWorkspaceName(allWorkspaces, currentWorkspaceId) // First, select organization - final orgs = orgWorkspaces.keySet().toList() + final orgs = orgWorkspaces.keySet().toList().sort { (it as String).toLowerCase() } // Always add Personal as first option (it's never returned by the API but should always be available) orgs.add(0, 'Personal') @@ -738,7 +746,11 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return [changed: hadWorkspaceId, metadata: null] } - final orgWorkspaceList = orgWorkspaces[selectedOrgName] as List + final orgWorkspaceList = (orgWorkspaces[selectedOrgName] as List).sort { a, b -> + final aMap = a as Map + final bMap = b as Map + (aMap.workspaceName as String ?: '').compareToIgnoreCase(bMap.workspaceName as String ?: '') + } println "" println "Select workspace in ${selectedOrgName}:" @@ -751,7 +763,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } final maxSelection = orgWorkspaceList.size() - final wsSelection = promptForNumber(ColorUtil.colorize("Select workspace (1-${maxSelection}): ", 'dim', true),1, maxSelection,false) + final wsSelection = promptForNumber(ColorUtil.colorize("\nSelect workspace (1-${maxSelection}): ", 'bold', true), 1, maxSelection,false) final selectedWorkspace = orgWorkspaceList[wsSelection - 1] as Map final selectedId = selectedWorkspace.workspaceId.toString() @@ -861,24 +873,31 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { println "" println "Available compute environments:" - computeEnvs.eachWithIndex { ce, index -> + // Sort compute environments by name + final sortedComputeEnvs = computeEnvs.sort { a, b -> + final aMap = a as Map + final bMap = b as Map + (aMap.name as String ?: '').compareToIgnoreCase(bMap.name as String ?: '') + } + + sortedComputeEnvs.eachWithIndex { ce, index -> final env = ce as Map final name = env.name as String final platform = env.platform as String final isPrimary = env.primary == true final currentIndicator = isPrimary ? ColorUtil.colorize(' (current)', 'yellow bold') : '' - println " ${index + 1}. ${ColorUtil.colorize(name, 'cyan', true)} ${ColorUtil.colorize('[' + platform + ']', 'dim', true)}${currentIndicator}" + println " ${index + 1}. ${ColorUtil.colorize(name, 'cyan', true)} ${ColorUtil.colorize('[' + platform + ']', 'dim yellow', true)}${currentIndicator}" } println "" println "${ColorUtil.colorize('Leave blank to keep current setting', 'bold')} (${ColorUtil.colorize(currentPrimaryName, 'cyan')}),".toString() - final selection = promptForNumber(ColorUtil.colorize("or select compute environment (1-${computeEnvs.size()}): ", 'bold', true), 1, computeEnvs.size(), true) + final selection = promptForNumber(ColorUtil.colorize("or select compute environment (1-${sortedComputeEnvs.size()}): ", 'bold', true), 1, sortedComputeEnvs.size(), true) if( selection == null ) { return } - final selectedEnv = computeEnvs[selection - 1] as Map + final selectedEnv = sortedComputeEnvs[selection - 1] as Map final computeEnvId = selectedEnv.id as String // Only set if different from current primary From 2f35683c88a9365a8a117b3180fcb5310ab01e24 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Tue, 7 Oct 2025 01:10:47 +0200 Subject: [PATCH 46/66] Update tests Signed-off-by: Phil Ewels --- .../plugin/cli/AuthCommandImplTest.groovy | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy index b7d378db2d..99e698cb67 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy @@ -505,8 +505,8 @@ param2 = 'value2'""" ] when: - cmd.printStatusTable(rows) - def output = capture.toString() + def lines = cmd.generateStatusTableLines(rows) + def output = lines.join('\n') then: output.contains('Setting') @@ -533,8 +533,8 @@ param2 = 'value2'""" ] when: - cmd.printStatusTable(rows) - def output = capture.toString() + def lines = cmd.generateStatusTableLines(rows) + def output = lines.join('\n') then: output.contains('API endpoint') @@ -551,11 +551,10 @@ param2 = 'value2'""" def rows = [] when: - cmd.printStatusTable(rows) - def output = capture.toString() + def lines = cmd.generateStatusTableLines(rows) then: - output.isEmpty() + lines.isEmpty() } def 'should handle null status table'() { @@ -563,11 +562,10 @@ param2 = 'value2'""" def cmd = new AuthCommandImpl() when: - cmd.printStatusTable(null) - def output = capture.toString() + def lines = cmd.generateStatusTableLines(null) then: - output.isEmpty() + lines.isEmpty() } def 'should strip ANSI codes correctly'() { @@ -683,8 +681,8 @@ param2 = 'value2'""" ] when: - cmd.printStatusTable(rows) - def output = capture.toString() + def lines = cmd.generateStatusTableLines(rows) + def output = lines.join('\n') then: // Should handle different column widths properly @@ -693,7 +691,6 @@ param2 = 'value2'""" output.contains('Very Long Value Here') output.contains('Very Long Source') // All rows should be aligned - def lines = output.split('\n') lines.size() >= 4 // Header + separator + 2 data rows } @@ -705,8 +702,8 @@ param2 = 'value2'""" ] when: - cmd.printStatusTable(rows) - def output = capture.toString() + def lines = cmd.generateStatusTableLines(rows) + def output = lines.join('\n') then: // Even with short values, should apply minimum widths @@ -714,7 +711,6 @@ param2 = 'value2'""" output.contains('Value') output.contains('Source') // Columns should be padded to at least minimum width - def lines = output.split('\n') lines.size() >= 3 // Header + separator + data row } @@ -732,13 +728,14 @@ param2 = 'value2'""" cmd.checkApiConnection(_) >> true cmd.callUserInfoApi(_, _) >> [userName: 'testuser', id: '123'] cmd.getWorkspaceDetailsFromApi(_, _, _) >> null + cmd.getComputeEnvironments(_, _, _) >> [] when: def status = cmd.collectStatus(config, authConfig) then: status != null - status.table.size() == 5 // endpoint, connection, auth, monitoring, workspace + status.table.size() == 7 // endpoint, connection, auth, monitoring, workspace, compute env, work dir // Check API endpoint row status.table[0][0] == 'API endpoint' status.table[0][1].contains('https://api.cloud.seqera.io') @@ -770,7 +767,7 @@ param2 = 'value2'""" then: status != null - status.table.size() == 5 + status.table.size() == 7 // endpoint, connection, auth, monitoring, workspace, compute env, work dir // Authentication should show error status.table[2][0] == 'Authentication' status.table[2][1].contains('ERROR') @@ -996,7 +993,8 @@ param2 = 'value2'""" orgName: 'TestOrg', workspaceName: 'TestWorkspace', workspaceFullName: 'test-org/test-workspace' - ] + ], + 1 // workspaceRowIndex ) when: @@ -1019,6 +1017,7 @@ param2 = 'value2'""" [ ['API endpoint', 'https://api.cloud.seqera.io', 'default'] ], + null, null ) From e42c958f500442b78e0def0d11bf96bf1eba8e46 Mon Sep 17 00:00:00 2001 From: Christopher Hakkaart Date: Tue, 7 Oct 2025 14:14:33 +1300 Subject: [PATCH 47/66] Add nextflow auth docs Signed-off-by: Christopher Hakkaart --- docs/install.md | 4 ++- docs/reference/cli.md | 73 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/docs/install.md b/docs/install.md index 19f8f874b7..9b50f6a4db 100644 --- a/docs/install.md +++ b/docs/install.md @@ -172,4 +172,6 @@ Launching from Seqera Platform provides you with: - Advanced analytics with resource optimization. Seqera Cloud Basic is free for small teams. Researchers at qualifying academic institutions can apply for free access to Seqera Cloud Pro. -See the [Seqera Platform documentation](https://docs.seqera.io/platform) for tutorials to get started. +See the [Seqera Platform documentation](https://docs.seqera.io/platform) to get started. + +If you have installed Nextflow locally, you can use the {ref}`nextflow auth ` command to authenticate with [Seqera Platform](https://seqera.io/platform/) and automatically configure workflow monitoring. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 580499efef..39289ad719 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -52,6 +52,79 @@ Available options: ## Commands +(cli-auth)= + +### `auth` + +:::{versionadded} 25.09.0-edge +::: + +Manage Seqera Platform authentication. + +**Usage** + +```console +$ nextflow auth [options] +``` + +**Description** + +The `auth` command provides authentication and configuration management for Seqera Platform. It supports OAuth2 authentication for Seqera Platform Cloud and Personal Access Token (PAT) authentication for Enterprise installations. Credentials are saved to `~/.nextflow/seqera_auth.config`. + +**Options** + +`-h, -help` +: Prints the command usage. + +`-u, -url` (`https://api.cloud.seqera.io`) +: Specifies the Seqera Platform API endpoint. + +**Subcommands** + +`login` +: Authenticates with Seqera Platform and save credentials. + +`logout` +: Removes authentication and revoke access token. + +`config` +: Configures Seqera Platform settings (workspace, compute environment, monitoring). + +`status` +: Shows current authentication status and configuration. + +**Examples** + +Authenticate with Seqera Platform Cloud: + +```console +$ nextflow auth login +``` + +Authenticate with an Enterprise installation: + +```console +$ nextflow auth login -u https://tower.example.com/api +``` + +View current authentication status: + +```console +$ nextflow auth status +``` + +Configure Seqera Platform settings: + +```console +$ nextflow auth config +``` + +Remove authentication: + +```console +$ nextflow auth logout +``` + (cli-clean)= ### `clean` From 805843f48e98afa4dd126f83831bc0892318accb Mon Sep 17 00:00:00 2001 From: Christopher Hakkaart Date: Tue, 7 Oct 2025 14:48:18 +1300 Subject: [PATCH 48/66] Update langauge for cli commands Signed-off-by: Christopher Hakkaart --- docs/reference/cli.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 39289ad719..269532b03e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -69,33 +69,33 @@ $ nextflow auth [options] **Description** -The `auth` command provides authentication and configuration management for Seqera Platform. It supports OAuth2 authentication for Seqera Platform Cloud and Personal Access Token (PAT) authentication for Enterprise installations. Credentials are saved to `~/.nextflow/seqera_auth.config`. +The `auth` command provides authentication and configuration management for Seqera. It supports OAuth2 authentication for Seqera Cloud and Personal Access Token (PAT) authentication for Seqera Enterprise installations. Credentials are saved to `~/.nextflow/seqera_auth.config`. **Options** `-h, -help` -: Prints the command usage. +: Prints the command usage information. -`-u, -url` (`https://api.cloud.seqera.io`) -: Specifies the Seqera Platform API endpoint. +`-u, -url` +: Specifies your Seqera API endpoint (default: `https://api.cloud.seqera.io`) **Subcommands** `login` -: Authenticates with Seqera Platform and save credentials. +: Authenticates with Seqera, saves credentials, and configures monitoring and workspaces. `logout` -: Removes authentication and revoke access token. +: Removes Seqera authentication and revokes access token. `config` -: Configures Seqera Platform settings (workspace, compute environment, monitoring). +: Sets Seqera monitoring, workspace, and workspace. `status` -: Shows current authentication status and configuration. +: Shows Seqera authentication status and configuration. **Examples** -Authenticate with Seqera Platform Cloud: +Authenticate with Seqera Cloud: ```console $ nextflow auth login @@ -113,7 +113,7 @@ View current authentication status: $ nextflow auth status ``` -Configure Seqera Platform settings: +Configure Seqera settings: ```console $ nextflow auth config From 864c209a73f84a5ae148eb8aff0bd49d15a31074 Mon Sep 17 00:00:00 2001 From: Christopher Hakkaart Date: Tue, 7 Oct 2025 14:53:43 +1300 Subject: [PATCH 49/66] Update language in install Signed-off-by: Christopher Hakkaart --- docs/install.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/install.md b/docs/install.md index 9b50f6a4db..fb2eeb6e55 100644 --- a/docs/install.md +++ b/docs/install.md @@ -164,7 +164,7 @@ The standalone distribution will still download core and third-party plugins as You can launch workflows directly from [Seqera Platform](https://seqera.io/platform/) without installing Nextflow locally. -Launching from Seqera Platform provides you with: +Launching from Seqera provides you with: - User-friendly launch interfaces. - Automated cloud infrastructure creation. @@ -172,6 +172,6 @@ Launching from Seqera Platform provides you with: - Advanced analytics with resource optimization. Seqera Cloud Basic is free for small teams. Researchers at qualifying academic institutions can apply for free access to Seqera Cloud Pro. -See the [Seqera Platform documentation](https://docs.seqera.io/platform) to get started. +See [Seqera Platform Cloud](https://docs.seqera.io/platform) to get started. -If you have installed Nextflow locally, you can use the {ref}`nextflow auth ` command to authenticate with [Seqera Platform](https://seqera.io/platform/) and automatically configure workflow monitoring. +If you have installed Nextflow locally, you can use the {ref}`nextflow auth ` command to authenticate with [Seqera](https://seqera.io/platform/) and automatically configure workflow monitoring. From a0f633a27bda208e61a5613d3441e14f7b98d547 Mon Sep 17 00:00:00 2001 From: Christopher Hakkaart Date: Tue, 7 Oct 2025 14:58:29 +1300 Subject: [PATCH 50/66] Revise text Signed-off-by: Christopher Hakkaart --- docs/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install.md b/docs/install.md index fb2eeb6e55..98e1c86134 100644 --- a/docs/install.md +++ b/docs/install.md @@ -174,4 +174,4 @@ Launching from Seqera provides you with: Seqera Cloud Basic is free for small teams. Researchers at qualifying academic institutions can apply for free access to Seqera Cloud Pro. See [Seqera Platform Cloud](https://docs.seqera.io/platform) to get started. -If you have installed Nextflow locally, you can use the {ref}`nextflow auth ` command to authenticate with [Seqera](https://seqera.io/platform/) and automatically configure workflow monitoring. +If you have installed Nextflow locally, you can use the {ref}`nextflow auth ` command to authenticate with Seqera and automatically configure workflow monitoring. From f17a8c2ecace1cada23fb9f1eea04954124e97aa Mon Sep 17 00:00:00 2001 From: Christopher Hakkaart Date: Tue, 7 Oct 2025 20:49:38 +1300 Subject: [PATCH 51/66] Address comments Signed-off-by: Christopher Hakkaart --- docs/reference/cli.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 269532b03e..d7451a3ab5 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -69,26 +69,28 @@ $ nextflow auth [options] **Description** -The `auth` command provides authentication and configuration management for Seqera. It supports OAuth2 authentication for Seqera Cloud and Personal Access Token (PAT) authentication for Seqera Enterprise installations. Credentials are saved to `~/.nextflow/seqera_auth.config`. +The `auth` command provides authentication and configuration management for Seqera. For Seqera Cloud, it uses an OAuth2 authentication flow generate and save a Personal Access Token (PAT) locally. For Seqera Enterprise installations, it uses direct PAT authentication. Credentials are saved to `~/.nextflow/seqera_auth.config`. **Options** `-h, -help` : Prints the command usage information. -`-u, -url` -: Specifies your Seqera API endpoint (default: `https://api.cloud.seqera.io`) - **Subcommands** `login` -: Authenticates with Seqera, saves credentials, and configures monitoring and workspaces. +: Authenticates with Seqera and saves credentials. Sets Seqera primary compute environment, monitoring, and workspace. +: The following options are available: + + `-u, -url` + : Specifies your Seqera API endpoint (default: `https://api.cloud.seqera.io`) `logout` -: Removes Seqera authentication and revokes access token. +: Removes Seqera authentication and revokes the Seqera Cloud access token (if applicable). + `config` -: Sets Seqera monitoring, workspace, and workspace. +: Sets Seqera primary compute environment, monitoring, and workspace. `status` : Shows Seqera authentication status and configuration. From 8df0ae84d3b26b17d11306589d2b3ddb74a6d72b Mon Sep 17 00:00:00 2001 From: Christopher Hakkaart Date: Tue, 7 Oct 2025 20:52:47 +1300 Subject: [PATCH 52/66] Remove extra space Signed-off-by: Christopher Hakkaart --- docs/reference/cli.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d7451a3ab5..d87045a192 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -88,7 +88,6 @@ The `auth` command provides authentication and configuration management for Seqe `logout` : Removes Seqera authentication and revokes the Seqera Cloud access token (if applicable). - `config` : Sets Seqera primary compute environment, monitoring, and workspace. From 0ae13cfb0b03672c2bb54969d964f97eebcbbf6f Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Tue, 7 Oct 2025 22:27:41 +0200 Subject: [PATCH 53/66] Rename seqera_auth.config to seqera-auth.config Signed-off-by: Phil Ewels --- docs/reference/cli.md | 4 +- .../tower/plugin/cli/AuthCommandImpl.groovy | 24 +++++----- .../plugin/cli/AuthCommandImplTest.groovy | 46 +++++++++---------- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d87045a192..bd6af897e3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -69,7 +69,7 @@ $ nextflow auth [options] **Description** -The `auth` command provides authentication and configuration management for Seqera. For Seqera Cloud, it uses an OAuth2 authentication flow generate and save a Personal Access Token (PAT) locally. For Seqera Enterprise installations, it uses direct PAT authentication. Credentials are saved to `~/.nextflow/seqera_auth.config`. +The `auth` command provides authentication and configuration management for Seqera. For Seqera Cloud, it uses an OAuth2 authentication flow generate and save a Personal Access Token (PAT) locally. For Seqera Enterprise installations, it uses direct PAT authentication. Credentials are saved to `~/.nextflow/seqera-auth.config`. **Options** @@ -99,7 +99,7 @@ The `auth` command provides authentication and configuration management for Seqe Authenticate with Seqera Cloud: ```console -$ nextflow auth login +$ nextflow auth login ``` Authenticate with an Enterprise installation: diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 571bfcca29..7f1b87359b 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -74,7 +74,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { println "" } - // Check if seqera_auth.config file already exists + // Check if seqera-auth.config file already exists final authFile = getAuthFile() if( Files.exists(authFile) ) { ColorUtil.printColored("Error: Authentication token is already configured in Nextflow config.", "red") @@ -383,13 +383,13 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void logout() { - // Check if seqera_auth.config file exists + // Check if seqera-auth.config file exists final authFile = getAuthFile() if( !Files.exists(authFile) ) { ColorUtil.printColored("No previous login found.", "green") return } - // Read token from seqera_auth.config file + // Read token from seqera-auth.config file final authConfig = readAuthFile() final existingToken = authConfig['tower.accessToken'] final apiUrl = authConfig['tower.endpoint'] as String ?: DEFAULT_API_ENDPOINT @@ -501,7 +501,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { Files.writeString(configFile, updatedContent.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) } - // Delete seqera_auth.config file + // Delete seqera-auth.config file if( Files.exists(authFile) ) { Files.delete(authFile) } @@ -511,10 +511,10 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void config() { - // Read from both main config and seqera_auth.config file + // Read from both main config and seqera-auth.config file final config = readConfig() - // Token can come from seqera_auth.config file or environment variable + // Token can come from seqera-auth.config file or environment variable final existingToken = config['tower.accessToken'] ?: SysEnv.get('TOWER_ACCESS_TOKEN') final endpoint = config['tower.endpoint'] ?: DEFAULT_API_ENDPOINT @@ -1249,7 +1249,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } protected Path getAuthFile() { - return Const.APP_HOME_DIR.resolve('seqera_auth.config') + return Const.APP_HOME_DIR.resolve('seqera-auth.config') } private Map readAuthFile() { @@ -1282,7 +1282,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { Files.createDirectories(configFile.parent) } - // Write tower config to seqera_auth.config file + // Write tower config to seqera-auth.config file final towerConfig = config.findAll { key, value -> key.toString().startsWith('tower.') && !key.toString().endsWith('.comment') } @@ -1306,7 +1306,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } authConfigText.append("}\n") - // Write the seqera_auth.config file + // Write the seqera-auth.config file Files.writeString(authFile, authConfigText.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) // Add includeConfig line to main config file if it doesn't exist @@ -1314,7 +1314,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private void addIncludeConfigToMainFile(Path configFile) { - final includeConfigLine = "includeConfig 'seqera_auth.config'" + final includeConfigLine = "includeConfig 'seqera-auth.config'" def configContent = "" if (Files.exists(configFile)) { @@ -1336,10 +1336,10 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private String removeIncludeConfigLine(String content) { - // Remove the includeConfig 'seqera_auth.config' line + // Remove the includeConfig 'seqera-auth.config' line final lines = content.split('\n') final filteredLines = lines.findAll { line -> - !line.trim().equals("includeConfig 'seqera_auth.config'") + !line.trim().equals("includeConfig 'seqera-auth.config'") } return filteredLines.join('\n') } diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy index 99e698cb67..8c47bbb4a3 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy @@ -152,7 +152,7 @@ class AuthCommandImplTest extends Specification { // Some config param1 = 'value1' -includeConfig 'seqera_auth.config' +includeConfig 'seqera-auth.config' param2 = 'value2' """ @@ -161,7 +161,7 @@ param2 = 'value2' def result = cmd.removeIncludeConfigLine(content) then: - !result.contains("includeConfig 'seqera_auth.config'") + !result.contains("includeConfig 'seqera-auth.config'") result.contains('param1 = \'value1\'') result.contains('param2 = \'value2\'') } @@ -200,7 +200,7 @@ param2 = 'value2'""" def authFile = cmd.getAuthFile() then: - authFile == Const.APP_HOME_DIR.resolve('seqera_auth.config') + authFile == Const.APP_HOME_DIR.resolve('seqera-auth.config') } def 'should read empty auth file'() { @@ -215,10 +215,10 @@ param2 = 'value2'""" config.isEmpty() || config.size() >= 0 } - def 'should write config to seqera_auth.config file'() { + def 'should write config to seqera-auth.config file'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config') + def authFile = tempDir.resolve('seqera-auth.config') def configFile = tempDir.resolve('config') cmd.getAuthFile() >> authFile @@ -244,7 +244,7 @@ param2 = 'value2'""" def 'should write config with workspace metadata'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config') + def authFile = tempDir.resolve('seqera-auth.config') def configFile = tempDir.resolve('config') cmd.getAuthFile() >> authFile @@ -274,7 +274,7 @@ param2 = 'value2'""" def 'should add includeConfig line to main config file'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config') + def authFile = tempDir.resolve('seqera-auth.config') def configFile = tempDir.resolve('config') cmd.getAuthFile() >> authFile @@ -293,21 +293,21 @@ param2 = 'value2'""" then: Files.exists(configFile) def configContent = Files.readString(configFile) - configContent.contains("includeConfig 'seqera_auth.config'") + configContent.contains("includeConfig 'seqera-auth.config'") configContent.contains('param1 = \'value1\'') } def 'should not duplicate includeConfig line'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config') + def authFile = tempDir.resolve('seqera-auth.config') def configFile = tempDir.resolve('config') cmd.getAuthFile() >> authFile cmd.getConfigFile() >> configFile // Create config file with existing includeConfig line - Files.writeString(configFile, "// Config\nincludeConfig 'seqera_auth.config'\nparam1 = 'value1'\n") + Files.writeString(configFile, "// Config\nincludeConfig 'seqera-auth.config'\nparam1 = 'value1'\n") def config = [ 'tower.accessToken': 'test-token-123' @@ -319,7 +319,7 @@ param2 = 'value2'""" then: Files.exists(configFile) def configContent = Files.readString(configFile) - configContent.count("includeConfig 'seqera_auth.config'") == 1 + configContent.count("includeConfig 'seqera-auth.config'") == 1 } def 'should get current workspace name when workspace exists'() { @@ -354,7 +354,7 @@ param2 = 'value2'""" def 'should get config value from login file'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config') + def authFile = tempDir.resolve('seqera-auth.config') cmd.getAuthFile() >> authFile def config = [:] @@ -365,7 +365,7 @@ param2 = 'value2'""" then: result.value == 'token-from-login' - result.source.endsWith('seqera_auth.config') + result.source.endsWith('seqera-auth.config') result.fromConfig == true result.fromEnv == false result.isDefault == false @@ -437,7 +437,7 @@ param2 = 'value2'""" def 'should prioritize login over config and env'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config') + def authFile = tempDir.resolve('seqera-auth.config') cmd.getAuthFile() >> authFile def config = ['tower.accessToken': 'token-from-config'] @@ -450,7 +450,7 @@ param2 = 'value2'""" then: result.value == 'token-from-login' - result.source.endsWith('seqera_auth.config') + result.source.endsWith('seqera-auth.config') cleanup: SysEnv.pop() @@ -951,7 +951,7 @@ param2 = 'value2'""" def 'should collect status with mixed sources'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config') + def authFile = tempDir.resolve('seqera-auth.config') def configFile = tempDir.resolve('config') cmd.getAuthFile() >> authFile @@ -971,7 +971,7 @@ param2 = 'value2'""" then: status != null // Token from auth file - status.table[2][2].endsWith('seqera_auth.config') + status.table[2][2].endsWith('seqera-auth.config') // Enabled from config file status.table[3][2].endsWith('config') // Workspace from env var @@ -1033,7 +1033,7 @@ param2 = 'value2'""" def 'should detect existing auth file and prevent duplicate login'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config') + def authFile = tempDir.resolve('seqera-auth.config') Files.createFile(authFile) cmd.getAuthFile() >> authFile @@ -1050,7 +1050,7 @@ param2 = 'value2'""" def 'should warn when TOWER_ACCESS_TOKEN env var is set during login'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config-not-exists') + def authFile = tempDir.resolve('seqera-auth.config-not-exists') cmd.getAuthFile() >> authFile cmd.performAuth0Login(_, _) >> { /* mock to prevent actual login */ } @@ -1070,7 +1070,7 @@ param2 = 'value2'""" def 'should normalize API URL during login'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config-not-exists') + def authFile = tempDir.resolve('seqera-auth.config-not-exists') cmd.getAuthFile() >> authFile @@ -1084,7 +1084,7 @@ param2 = 'value2'""" def 'should route to enterprise auth for non-cloud endpoints'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config-not-exists') + def authFile = tempDir.resolve('seqera-auth.config-not-exists') cmd.getAuthFile() >> authFile @@ -1099,7 +1099,7 @@ param2 = 'value2'""" def 'should route to Auth0 login for cloud endpoints'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config-not-exists') + def authFile = tempDir.resolve('seqera-auth.config-not-exists') cmd.getAuthFile() >> authFile @@ -1114,7 +1114,7 @@ param2 = 'value2'""" def 'should save auth to config after successful PAT generation'() { given: def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera_auth.config') + def authFile = tempDir.resolve('seqera-auth.config') def configFile = tempDir.resolve('config') cmd.getAuthFile() >> authFile From 8f3e2eb9fa11f86baaafed238c8b53e339325059 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Tue, 7 Oct 2025 22:36:14 +0200 Subject: [PATCH 54/66] Move Auth0 keys into PlatformHelper library Signed-off-by: Phil Ewels --- .../nextflow/platform/PlatformHelper.groovy | 38 ++++++++++++++++ .../tower/plugin/cli/AuthCommandImpl.groovy | 44 ++++++++++--------- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy b/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy index 0b701ccbe7..dc09b12ae4 100644 --- a/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy @@ -27,6 +27,44 @@ class PlatformHelper { return result.stripEnd('/') } + /** + * Get the Auth0 domain for a given Platform API endpoint + * + * @param endpoint the Platform API endpoint + * @return the Auth0 domain, or null if not a cloud endpoint + */ + static String getAuthDomain(String endpoint) { + switch(endpoint) { + case 'https://api.cloud.dev-seqera.io': + return 'seqera-development.eu.auth0.com' + case 'https://api.cloud.stage-seqera.io': + return 'seqera-stage.eu.auth0.com' + case 'https://api.cloud.seqera.io': + return 'seqera.eu.auth0.com' + default: + return null + } + } + + /** + * Get the Auth0 client ID for a given Platform API endpoint + * + * @param endpoint the Platform API endpoint + * @return the Auth0 client ID, or null if not a cloud endpoint + */ + static String getAuthClientId(String endpoint) { + switch(endpoint) { + case 'https://api.cloud.dev-seqera.io': + return 'Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ' + case 'https://api.cloud.stage-seqera.io': + return '60cPDjI6YhoTPjyMTIBjGtxatSUwWswB' + case 'https://api.cloud.seqera.io': + return 'FxCM8EJ76nNeHUDidSHkZfT8VtsrhHeL' + default: + return null + } + } + /** * Return the configured Platform access token: if `TOWER_WORKFLOW_ID` is provided in the environment, it means * we are running in a Platform-made run and we should ONLY retrieve the token from the environment. Otherwise, diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 7f1b87359b..632902b3be 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -12,6 +12,7 @@ import nextflow.cli.CmdAuth import nextflow.cli.ColorUtil import nextflow.config.ConfigBuilder import nextflow.exception.AbortOperationException +import nextflow.platform.PlatformHelper import java.awt.Desktop import java.net.URI @@ -32,21 +33,6 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { static final int WORKSPACE_SELECTION_THRESHOLD = 8 // Max workspaces to show in single list; above this uses org-first selection private static final String DEFAULT_API_ENDPOINT = 'https://api.cloud.seqera.io' - private static final Map SEQERA_API_TO_AUTH0 = [ - 'https://api.cloud.dev-seqera.io' : [ - domain : 'seqera-development.eu.auth0.com', - clientId: 'Ep2LhYiYmuV9hhz0dH6dbXVq0S7s7SWZ' - ], - 'https://api.cloud.stage-seqera.io': [ - domain : 'seqera-stage.eu.auth0.com', - clientId: '60cPDjI6YhoTPjyMTIBjGtxatSUwWswB' - ], - 'https://api.cloud.seqera.io' : [ - domain : 'seqera.eu.auth0.com', - clientId: 'FxCM8EJ76nNeHUDidSHkZfT8VtsrhHeL' - ] - ] - /** * Creates an HxClient instance with optional authentication token */ @@ -1212,13 +1198,29 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } private Map getCloudEndpointInfo(String apiUrl) { - for (env in SEQERA_API_TO_AUTH0) { - final standardUrl = env.key as String - final legacyUrl = standardUrl.replace('://api.', '://') + '/api' - if (apiUrl == standardUrl || apiUrl == legacyUrl) { - return [isCloud: true, endpoint: env.key, auth: env.value] - } + // Check if this is a standard cloud endpoint + final authDomain = PlatformHelper.getAuthDomain(apiUrl) + if (authDomain) { + final clientId = PlatformHelper.getAuthClientId(apiUrl) + return [ + isCloud: true, + endpoint: apiUrl, + auth: [domain: authDomain, clientId: clientId] + ] } + + // Check for legacy URL format (e.g., https://cloud.seqera.io/api) + final legacyToStandard = apiUrl.replace('://cloud.', '://api.cloud.').replace('/api', '') + final legacyAuthDomain = PlatformHelper.getAuthDomain(legacyToStandard) + if (legacyAuthDomain) { + final clientId = PlatformHelper.getAuthClientId(legacyToStandard) + return [ + isCloud: true, + endpoint: legacyToStandard, + auth: [domain: legacyAuthDomain, clientId: clientId] + ] + } + return [isCloud: false, endpoint: apiUrl, auth: null] } From cf05f3ffa32918741c0b1dca9bdc347c5e1dbb86 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Tue, 7 Oct 2025 22:49:22 +0200 Subject: [PATCH 55/66] Refactor getting token to use PlatformHelper.getAccessToken Signed-off-by: Phil Ewels --- .../tower/plugin/cli/AuthCommandImpl.groovy | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 632902b3be..4784e4be1a 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -501,7 +501,8 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { final config = readConfig() // Token can come from seqera-auth.config file or environment variable - final existingToken = config['tower.accessToken'] ?: SysEnv.get('TOWER_ACCESS_TOKEN') + final towerConfig = config.findAll { it.key.toString().startsWith('tower.') } + final existingToken = PlatformHelper.getAccessToken(towerConfig, SysEnv.get()) final endpoint = config['tower.endpoint'] ?: DEFAULT_API_ENDPOINT if( !existingToken ) { @@ -513,7 +514,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { ColorUtil.printColored(" - Config file: ${ColorUtil.colorize(getAuthFile().toString(), 'magenta')}", "dim") // Check if token is from environment variable - if( !config['tower.accessToken'] && SysEnv.get('TOWER_ACCESS_TOKEN') ) { + if( !towerConfig['accessToken'] && SysEnv.get('TOWER_ACCESS_TOKEN') ) { ColorUtil.printColored(" - Using access token from TOWER_ACCESS_TOKEN environment variable", "dim") } @@ -959,12 +960,24 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { status.table.add(['API connection', ColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) // Authentication check - final tokenInfo = getConfigValue(config, authConfig, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') - if( tokenInfo.value ) { + final towerConfig = config.findAll { it.key.toString().startsWith('tower.') } + final accessToken = PlatformHelper.getAccessToken(towerConfig, SysEnv.get()) + + // Determine source for display + def tokenSource = 'not set' + if (authConfig['tower.accessToken']) { + tokenSource = shortenPath(getAuthFile().toString()) + } else if (config['tower.accessToken']) { + tokenSource = shortenPath(getConfigFile().toString()) + } else if (SysEnv.get('TOWER_ACCESS_TOKEN')) { + tokenSource = 'env var $TOWER_ACCESS_TOKEN' + } + + if( accessToken ) { try { - final userInfo = callUserInfoApi(tokenInfo.value as String, endpointInfo.value as String) + final userInfo = callUserInfoApi(accessToken, endpointInfo.value as String) final currentUser = userInfo.userName as String - status.table.add(['Authentication', "${ColorUtil.colorize('OK', 'green')} (user: ${ColorUtil.colorize(currentUser, 'cyan')})".toString(), tokenInfo.source as String]) + status.table.add(['Authentication', "${ColorUtil.colorize('OK', 'green')} (user: ${ColorUtil.colorize(currentUser, 'cyan')})".toString(), tokenSource]) } catch( Exception e ) { status.table.add(['Authentication', ColorUtil.colorize('ERROR', 'red'), 'failed']) } @@ -983,8 +996,8 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { if( workspaceInfo.value ) { // Try to get workspace name from API if we have a token def workspaceDetails = null - if( tokenInfo.value ) { - workspaceDetails = getWorkspaceDetailsFromApi(tokenInfo.value as String, endpointInfo.value as String, workspaceInfo.value as String) + if( accessToken ) { + workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpointInfo.value as String, workspaceInfo.value as String) } if( workspaceDetails ) { @@ -997,7 +1010,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { status.table.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'cyan', true), workspaceInfo.source as String]) } } else { - if( tokenInfo.value ) { + if( accessToken ) { status.table.add(['Default workspace', ColorUtil.colorize('None (Personal workspace)', 'cyan', true), 'default']) } else { status.table.add(['Default workspace', ColorUtil.colorize('None', 'cyan', true), 'default']) @@ -1006,9 +1019,9 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Primary compute environment and work directory def primaryEnv = null - if( tokenInfo.value && workspaceInfo.value ) { + if( accessToken && workspaceInfo.value ) { try { - final computeEnvs = getComputeEnvironments(tokenInfo.value as String, endpointInfo.value as String, workspaceInfo.value as String) + final computeEnvs = getComputeEnvironments(accessToken, endpointInfo.value as String, workspaceInfo.value as String) primaryEnv = computeEnvs.find { ((Map) it).primary == true } as Map } catch( Exception e ) { status.table.add(['Primary compute env', ColorUtil.colorize('Error fetching', 'red'), '']) @@ -1024,7 +1037,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { status.table.add(['Primary compute env', displayValue, 'workspace']) status.table.add(['Default work dir', ColorUtil.colorize(primaryEnv.workDir as String, 'magenta'), 'compute env']) } else { - final ceValue = (!tokenInfo.value || !workspaceInfo.value) ? 'N/A' : 'None' + final ceValue = (!accessToken || !workspaceInfo.value) ? 'N/A' : 'None' final ceColor = ceValue == 'None' ? 'yellow' : 'dim' status.table.add(['Primary compute env', ColorUtil.colorize(ceValue, ceColor, true), 'workspace']) status.table.add(['Default work dir', ColorUtil.colorize(ceValue, ceColor, true), 'compute env']) From bb0ebe8683daf527cce828253275f40d1cd6baa2 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Tue, 7 Oct 2025 22:54:03 +0200 Subject: [PATCH 56/66] Remove unused AbortOperationException Signed-off-by: Phil Ewels --- .../main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy | 5 ----- 1 file changed, 5 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 4784e4be1a..f6f54632c3 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -218,11 +218,6 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { def retryCount = 0 while( retryCount < AUTH_POLL_TIMEOUT_RETRIES ) { - // Check for interrupt - if( Thread.currentThread().isInterrupted() ) { - throw new InterruptedException("Authentication polling interrupted") - } - final params = [ 'grant_type' : 'urn:ietf:params:oauth:grant-type:device_code', 'device_code': deviceCode, From 7c0c8e43209805a2240baa38f5b8928c878629cf Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Tue, 7 Oct 2025 22:57:54 +0200 Subject: [PATCH 57/66] Always log.debug when catching exceptions Signed-off-by: Phil Ewels --- .../main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index f6f54632c3..1b18a34c29 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -191,7 +191,8 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { new ProcessBuilder(browser, urlWithCode).start() browserOpened = true break - } catch( Exception ignored ) { + } catch( Exception e ) { + log.debug("Failed to open browser ${browser}: ${e.message}") // Try next browser } } @@ -1153,6 +1154,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { final response = client.send(request, HttpResponse.BodyHandlers.ofString()) return response.statusCode() == 200 } catch( Exception e ) { + log.debug("Failed to connect to API endpoint ${endpoint}: ${e.message}", e) return false } } @@ -1201,6 +1203,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return null } catch (Exception e) { + log.debug("Failed to get workspace details for workspace ${workspaceId}: ${e.message}", e) return null } } From 217cb76a71bdb8b75757269a3afce466d06d5935 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Tue, 7 Oct 2025 23:50:35 +0200 Subject: [PATCH 58/66] Fix regression with PlatformHelper.getAccessToken Function does not expect tower. prefixes on config keys Signed-off-by: Phil Ewels --- .../seqera/tower/plugin/cli/AuthCommandImpl.groovy | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 1b18a34c29..88d059c386 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -494,10 +494,12 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void config() { // Read from both main config and seqera-auth.config file - final config = readConfig() + final builder = new ConfigBuilder().setHomeDir(Const.APP_HOME_DIR).setCurrentDir(Const.APP_HOME_DIR) + final configObject = builder.buildConfigObject() + final config = configObject.flatten() - // Token can come from seqera-auth.config file or environment variable - final towerConfig = config.findAll { it.key.toString().startsWith('tower.') } + // Navigate to tower config section (returns map without 'tower.' prefix) + final towerConfig = configObject.navigate('tower') as Map ?: [:] final existingToken = PlatformHelper.getAccessToken(towerConfig, SysEnv.get()) final endpoint = config['tower.endpoint'] ?: DEFAULT_API_ENDPOINT @@ -956,7 +958,9 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { status.table.add(['API connection', ColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) // Authentication check - final towerConfig = config.findAll { it.key.toString().startsWith('tower.') } + // Navigate to tower config section to get config without 'tower.' prefix + final builder = new ConfigBuilder().setHomeDir(Const.APP_HOME_DIR).setCurrentDir(Const.APP_HOME_DIR) + final towerConfig = builder.buildConfigObject().navigate('tower') as Map ?: [:] final accessToken = PlatformHelper.getAccessToken(towerConfig, SysEnv.get()) // Determine source for display @@ -1297,7 +1301,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Write tower config to seqera-auth.config file final towerConfig = config.findAll { key, value -> - key.toString().startsWith('tower.') && !key.toString().endsWith('.comment') + key.toString().startsWith('tower.') } final authConfigText = new StringBuilder() From 342d33abd16ee289b8358cd97dfb6baf0a47b1dd Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 8 Oct 2025 00:03:31 +0200 Subject: [PATCH 59/66] Update tests Signed-off-by: Phil Ewels --- .../tower/plugin/cli/AuthCommandImpl.groovy | 26 ++++++++++--------- .../plugin/cli/AuthCommandImplTest.groovy | 2 ++ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 88d059c386..94bea4e908 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -958,9 +958,9 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { status.table.add(['API connection', ColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) // Authentication check - // Navigate to tower config section to get config without 'tower.' prefix - final builder = new ConfigBuilder().setHomeDir(Const.APP_HOME_DIR).setCurrentDir(Const.APP_HOME_DIR) - final towerConfig = builder.buildConfigObject().navigate('tower') as Map ?: [:] + // Extract tower config and strip prefix for PlatformHelper + final towerConfig = config.findAll { it.key.toString().startsWith('tower.') } + .collectEntries { k, v -> [(k.toString().substring(6)): v] } final accessToken = PlatformHelper.getAccessToken(towerConfig, SysEnv.get()) // Determine source for display @@ -1225,15 +1225,17 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Check for legacy URL format (e.g., https://cloud.seqera.io/api) - final legacyToStandard = apiUrl.replace('://cloud.', '://api.cloud.').replace('/api', '') - final legacyAuthDomain = PlatformHelper.getAuthDomain(legacyToStandard) - if (legacyAuthDomain) { - final clientId = PlatformHelper.getAuthClientId(legacyToStandard) - return [ - isCloud: true, - endpoint: legacyToStandard, - auth: [domain: legacyAuthDomain, clientId: clientId] - ] + if (apiUrl.contains('://cloud.') && apiUrl.endsWith('/api')) { + final legacyToStandard = apiUrl.replace('://cloud.', '://api.cloud.').replaceAll('/api$', '') + final legacyAuthDomain = PlatformHelper.getAuthDomain(legacyToStandard) + if (legacyAuthDomain) { + final clientId = PlatformHelper.getAuthClientId(legacyToStandard) + return [ + isCloud: true, + endpoint: legacyToStandard, + auth: [domain: legacyAuthDomain, clientId: clientId] + ] + } } return [isCloud: false, endpoint: apiUrl, auth: null] diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy index 8c47bbb4a3..717f6cb1c6 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy @@ -53,7 +53,9 @@ class AuthCommandImplTest extends Specification { cmd.getCloudEndpointInfo('https://api.cloud.stage-seqera.io').auth.domain == 'seqera-stage.eu.auth0.com' cmd.getCloudEndpointInfo('https://api.cloud.dev-seqera.io').isCloud == true cmd.getCloudEndpointInfo('https://api.cloud.dev-seqera.io').auth.domain == 'seqera-development.eu.auth0.com' + // Legacy URL format is normalized to standard format cmd.getCloudEndpointInfo('https://cloud.seqera.io/api').isCloud == true + cmd.getCloudEndpointInfo('https://cloud.seqera.io/api').endpoint == 'https://api.cloud.seqera.io' cmd.getCloudEndpointInfo('https://cloud.seqera.io/api').auth.domain == 'seqera.eu.auth0.com' cmd.getCloudEndpointInfo('https://enterprise.example.com').isCloud == false cmd.getCloudEndpointInfo('https://enterprise.example.com').auth == null From 84c35847ef4c2abf0d2099f49eb210ed95b2afad Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 8 Oct 2025 00:48:48 +0200 Subject: [PATCH 60/66] Tone down the colours a little Signed-off-by: Phil Ewels --- .../tower/plugin/cli/AuthCommandImpl.groovy | 72 ++++++------------- 1 file changed, 22 insertions(+), 50 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 94bea4e908..c06f52d286 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -69,7 +69,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return } - ColorUtil.printColored("Nextflow authentication with Seqera Platform", "cyan bold") + println("Nextflow authentication with Seqera Platform") ColorUtil.printColored(" - Authentication will be saved to: ${ColorUtil.colorize(getAuthFile().toString(), 'magenta')}", "dim") apiUrl = normalizeApiUrl(apiUrl) @@ -96,10 +96,10 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { final deviceAuth = requestDeviceAuthorization(auth0Config) println "" - ColorUtil.printColored("Confirmation code: ${ColorUtil.colorize(deviceAuth.user_code as String, 'yellow')}", "cyan bold") + println "Confirmation code: ${ColorUtil.colorize(deviceAuth.user_code as String, 'yellow')}" final urlWithCode = "${deviceAuth.verification_uri}?user_code=${deviceAuth.user_code}" - println "${ColorUtil.colorize('Authentication URL:', 'cyan bold')} ${ColorUtil.colorize(urlWithCode, 'magenta')}" - ColorUtil.printColored("\n[ Press Enter to open in browser ]", "cyan bold") + println "Authentication URL: ${ColorUtil.colorize(urlWithCode, 'magenta')}" + ColorUtil.printColored("\n[ Press Enter to open in browser ]", "bold") // Wait for Enter key with proper interrupt handling try { @@ -129,7 +129,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Verify login by calling /user-info final userInfo = callUserInfoApi(accessToken, apiUrl) - ColorUtil.printColored("\n\nAuthentication successful!", "green") + println "\n\n${ColorUtil.colorize('✔', 'green', true)} Authentication successful" // Generate PAT final pat = generatePAT(accessToken, apiUrl) @@ -139,7 +139,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Automatically run configuration try { - config() + config(false) } catch( Exception e ) { ColorUtil.printColored("Configuration setup failed: ${e.message}", "red") ColorUtil.printColored("You can run 'nextflow auth config' later to set up your configuration.", "dim") @@ -368,7 +368,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Check if seqera-auth.config file exists final authFile = getAuthFile() if( !Files.exists(authFile) ) { - ColorUtil.printColored("No previous login found.", "green") + println "No previous login found.\n" return } // Read token from seqera-auth.config file @@ -399,7 +399,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Validate token by calling /user-info API try { final userInfo = callUserInfoApi(existingToken as String, apiUrl) - ColorUtil.printColored(" - Token is valid for user: ${ColorUtil.colorize(userInfo.userName as String, 'cyan bold')}", "dim") + ColorUtil.printColored(" - Token is valid for user: ${ColorUtil.colorize(userInfo.userName as String, 'bold')}", "dim") } catch( Exception e ) { ColorUtil.printColored("Failed to validate token: ${e.message}", "red") } @@ -416,7 +416,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { ColorUtil.printColored("Warning: Access token not deleted, as using enterprise installation: ${ColorUtil.colorize(apiUrl, 'magenta')}", "yellow") } - final confirmed = promptForYesNo("\n${ColorUtil.colorize('Continue with logout?', 'cyan bold')} (${ColorUtil.colorize('Y', 'green')}/n): ", true) + final confirmed = promptForYesNo("\n${ColorUtil.colorize('Continue with logout?', 'bold')} (${ColorUtil.colorize('Y', 'green')}/n): ", true) if( !confirmed ) { println("Logout cancelled.") @@ -469,7 +469,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { throw new RuntimeException("Failed to delete token: ${error}") } - ColorUtil.printColored("\nToken successfully deleted from Seqera Platform.", "green") + println "\n${ColorUtil.colorize('✔', 'green', true)} Token successfully deleted from Seqera Platform." } private void removeAuthFromConfig() { @@ -488,11 +488,11 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { Files.delete(authFile) } - ColorUtil.printColored("Authentication removed from Nextflow config.", "green") + println "${ColorUtil.colorize('✔', 'green', true)} Authentication removed from Nextflow config." } @Override - void config() { + void config(Boolean showHeader = true) { // Read from both main config and seqera-auth.config file final builder = new ConfigBuilder().setHomeDir(Const.APP_HOME_DIR).setCurrentDir(Const.APP_HOME_DIR) final configObject = builder.buildConfigObject() @@ -508,12 +508,14 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return } - ColorUtil.printColored("Nextflow Seqera Platform configuration", "cyan bold") - ColorUtil.printColored(" - Config file: ${ColorUtil.colorize(getAuthFile().toString(), 'magenta')}", "dim") + if (showHeader) { + println "Nextflow Seqera Platform configuration" + ColorUtil.printColored(" - Config file: ${ColorUtil.colorize(getAuthFile().toString(), 'magenta')}", "dim") - // Check if token is from environment variable - if( !towerConfig['accessToken'] && SysEnv.get('TOWER_ACCESS_TOKEN') ) { - ColorUtil.printColored(" - Using access token from TOWER_ACCESS_TOKEN environment variable", "dim") + // Check if token is from environment variable + if( !towerConfig['accessToken'] && SysEnv.get('TOWER_ACCESS_TOKEN') ) { + ColorUtil.printColored(" - Using access token from TOWER_ACCESS_TOKEN environment variable", "dim") + } } try { @@ -535,12 +537,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Save updated config only if changes were made if( configChanged ) { writeConfig(config, workspaceResult.metadata as Map) - - // Show the new configuration - println "\nNew configuration:" - showCurrentConfig(config, existingToken as String, endpoint as String) - - ColorUtil.printColored("\nConfiguration saved to ${ColorUtil.colorize(getAuthFile().toString(), 'magenta')}", "green") + println "\n${ColorUtil.colorize('✔', 'green', true)} Configuration saved to ${ColorUtil.colorize(getAuthFile().toString(), 'magenta')}" } // Configure compute environment for the workspace (always run after workspace selection) @@ -559,31 +556,6 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } } - private void showCurrentConfig(Map config, String accessToken, String endpoint) { - // Show workflow monitoring status - final monitoringEnabled = config.get('tower.enabled', false) - println " ${ColorUtil.colorize('Workflow monitoring:', 'cyan')} ${monitoringEnabled ? ColorUtil.colorize('enabled', 'green') : ColorUtil.colorize('disabled', 'red')}" - - // Show workspace setting - final workspaceId = config.get('tower.workspaceId') - if( workspaceId ) { - // Try to get workspace details from API for display - final workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpoint, workspaceId as String) - if( workspaceDetails ) { - final details = workspaceDetails as Map - println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')} ${ColorUtil.colorize('[' + details.orgName + ' / ' + details.workspaceName + ']', 'dim', true)}" - } else { - println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize(workspaceId as String, 'magenta')}" - } - } else { - println " ${ColorUtil.colorize('Default workspace:', 'cyan')} ${ColorUtil.colorize('None (Personal workspace)', 'magenta')}" - } - - // Show API endpoint - final apiEndpoint = config.get('tower.endpoint') ?: endpoint - println " ${ColorUtil.colorize('API endpoint:', 'cyan', true)} $apiEndpoint" - } - private boolean configureEnabled(Map config) { final currentEnabled = config.get('tower.enabled', false) @@ -592,7 +564,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { ColorUtil.printColored(" When disabled, you can enable per-run with the ${ColorUtil.colorize('-with-tower', 'cyan')} flag", "dim") println "" - final promptText = "${ColorUtil.colorize('Enable workflow monitoring for all runs?', 'cyan bold', true)} (${currentEnabled ? ColorUtil.colorize('Y', 'green') + '/n' : 'y/' + ColorUtil.colorize('N', 'red')}): " + final promptText = "${ColorUtil.colorize('Enable workflow monitoring for all runs?', 'bold', true)} (${currentEnabled ? ColorUtil.colorize('Y', 'green') + '/n' : 'y/' + ColorUtil.colorize('N', 'red')}): " final input = promptForYesNo(promptText, currentEnabled) if( input == null ) { @@ -1088,7 +1060,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { col3Width = Math.max(col3Width, 10) + 2 // Add table header - lines.add(ColorUtil.colorize("${'Setting'.padRight(col1Width)} ${'Value'.padRight(col2Width)} Source", "cyan bold")) + lines.add(ColorUtil.colorize("${'Setting'.padRight(col1Width)} ${'Value'.padRight(col2Width)} Source", "bold")) lines.add("${'-' * col1Width} ${'-' * col2Width} ${'-' * col3Width}".toString()) // Add rows From 991579fe9e45d7d3c29b72e2fc2fccd0c35dc136 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 8 Oct 2025 09:57:08 +0200 Subject: [PATCH 61/66] Use PlatformHelper for endpoint and workflow ID too Simplify code around status table for finding auth value origins Signed-off-by: Phil Ewels --- .../tower/plugin/cli/AuthCommandImpl.groovy | 82 +++++------ .../plugin/cli/AuthCommandImplTest.groovy | 139 +++++++----------- 2 files changed, 91 insertions(+), 130 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index c06f52d286..14d2a38e11 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -911,89 +911,84 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { @Override void status() { final config = readConfig() - final authConfig = readAuthFile() - - printStatus(collectStatus(config, authConfig)) + printStatus(collectStatus(config)) } - private ConfigStatus collectStatus(Map config, Map authConfig) { + private ConfigStatus collectStatus(Map config) { // Collect all status information final status = new ConfigStatus([], null, null) - // API endpoint - final endpointInfo = getConfigValue(config, authConfig, 'tower.endpoint', 'TOWER_API_ENDPOINT', DEFAULT_API_ENDPOINT) - status.table.add(['API endpoint', ColorUtil.colorize(endpointInfo.value as String, 'magenta'), endpointInfo.source as String]) + // Extract tower config and strip prefix for PlatformHelper + final towerConfig = config.findAll { it.key.toString().startsWith('tower.') } + .collectEntries { k, v -> [(k.toString().substring(6)): v] } + + // API endpoint - use PlatformHelper + final String endpoint = PlatformHelper.getEndpoint(towerConfig, SysEnv.get()) + final endpointInfo = getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', DEFAULT_API_ENDPOINT) + status.table.add(['API endpoint', ColorUtil.colorize(endpoint, 'magenta'), endpointInfo.source as String]) // API connection check - final apiConnectionOk = checkApiConnection(endpointInfo.value as String) + final apiConnectionOk = checkApiConnection(endpoint) final connectionColor = apiConnectionOk ? 'green' : 'red' - status.table.add(['API connection', ColorUtil.colorize(apiConnectionOk ? 'OK' : 'ERROR', connectionColor), '']) + status.table.add(['API connection', ColorUtil.colorize(apiConnectionOk ? '✔ OK' : 'ERROR', connectionColor), '']) - // Authentication check - // Extract tower config and strip prefix for PlatformHelper - final towerConfig = config.findAll { it.key.toString().startsWith('tower.') } - .collectEntries { k, v -> [(k.toString().substring(6)): v] } - final accessToken = PlatformHelper.getAccessToken(towerConfig, SysEnv.get()) + // Authentication check - use PlatformHelper + final String accessToken = PlatformHelper.getAccessToken(towerConfig, SysEnv.get()) // Determine source for display - def tokenSource = 'not set' - if (authConfig['tower.accessToken']) { - tokenSource = shortenPath(getAuthFile().toString()) - } else if (config['tower.accessToken']) { - tokenSource = shortenPath(getConfigFile().toString()) - } else if (SysEnv.get('TOWER_ACCESS_TOKEN')) { - tokenSource = 'env var $TOWER_ACCESS_TOKEN' - } + final tokenInfo = getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') + final String tokenSource = tokenInfo.source ?: 'not set' if( accessToken ) { try { - final userInfo = callUserInfoApi(accessToken, endpointInfo.value as String) + final userInfo = callUserInfoApi(accessToken, endpoint) final currentUser = userInfo.userName as String - status.table.add(['Authentication', "${ColorUtil.colorize('OK', 'green')} (user: ${ColorUtil.colorize(currentUser, 'cyan')})".toString(), tokenSource]) + status.table.add(['Authentication', "${ColorUtil.colorize('✔ OK', 'green')} (user: ${ColorUtil.colorize(currentUser, 'cyan')})".toString(), tokenSource]) } catch( Exception e ) { status.table.add(['Authentication', ColorUtil.colorize('ERROR', 'red'), 'failed']) } } else { - status.table.add(['Authentication', "${ColorUtil.colorize('ERROR', 'red')} ${ColorUtil.colorize('(no token)', 'dim')}".toString(), 'not set']) + status.table.add(['Authentication', ColorUtil.colorize('Not set', 'red'), 'not set']) } // Monitoring enabled - final enabledInfo = getConfigValue(config, authConfig, 'tower.enabled', null, 'false') + final enabledInfo = getConfigValue(config, 'tower.enabled', null, 'false') final enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' final enabledColor = enabledValue == 'Yes' ? 'green' : 'yellow' status.table.add(['Workflow monitoring', ColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) - // Default workspace - final workspaceInfo = getConfigValue(config, authConfig, 'tower.workspaceId', 'TOWER_WORKFLOW_ID') - if( workspaceInfo.value ) { + // Default workspace - use PlatformHelper + final String workspaceId = PlatformHelper.getWorkspaceId(towerConfig, SysEnv.get()) + final workspaceInfo = getConfigValue(config, 'tower.workspaceId', 'TOWER_WORKSPACE_ID') + if( workspaceId ) { // Try to get workspace name from API if we have a token def workspaceDetails = null if( accessToken ) { - workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpointInfo.value as String, workspaceInfo.value as String) + workspaceDetails = getWorkspaceDetailsFromApi(accessToken, endpoint, workspaceId) } if( workspaceDetails ) { // Add workspace ID row and remember its index status.workspaceRowIndex = status.table.size() - status.table.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'cyan'), workspaceInfo.source as String]) + status.table.add(['Default workspace', ColorUtil.colorize(workspaceId, 'cyan'), workspaceInfo.source as String]) // Store workspace details for display after this row (outside table structure) status.workspaceInfo = workspaceDetails } else { - status.table.add(['Default workspace', ColorUtil.colorize(workspaceInfo.value as String, 'cyan', true), workspaceInfo.source as String]) + status.table.add(['Default workspace', ColorUtil.colorize(workspaceId, 'cyan', true), workspaceInfo.source as String]) } } else { if( accessToken ) { status.table.add(['Default workspace', ColorUtil.colorize('None (Personal workspace)', 'cyan', true), 'default']) } else { - status.table.add(['Default workspace', ColorUtil.colorize('None', 'cyan', true), 'default']) + status.table.add(['Default workspace', ColorUtil.colorize('N/A', 'dim', true), 'default']) } } // Primary compute environment and work directory def primaryEnv = null - if( accessToken && workspaceInfo.value ) { + if( accessToken && workspaceId ) { try { - final computeEnvs = getComputeEnvironments(accessToken, endpointInfo.value as String, workspaceInfo.value as String) + final computeEnvs = getComputeEnvironments(accessToken, endpoint, workspaceId) primaryEnv = computeEnvs.find { ((Map) it).primary == true } as Map } catch( Exception e ) { status.table.add(['Primary compute env', ColorUtil.colorize('Error fetching', 'red'), '']) @@ -1009,7 +1004,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { status.table.add(['Primary compute env', displayValue, 'workspace']) status.table.add(['Default work dir', ColorUtil.colorize(primaryEnv.workDir as String, 'magenta'), 'compute env']) } else { - final ceValue = (!accessToken || !workspaceInfo.value) ? 'N/A' : 'None' + final ceValue = (!accessToken || !workspaceId) ? 'N/A' : 'None' final ceColor = ceValue == 'None' ? 'yellow' : 'dim' status.table.add(['Primary compute env', ColorUtil.colorize(ceValue, ceColor, true), 'workspace']) status.table.add(['Default work dir', ColorUtil.colorize(ceValue, ceColor, true), 'compute env']) @@ -1092,18 +1087,15 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return path } - private Map getConfigValue(Map config, Map auth, String configKey, String envVarName, String defaultValue = null) { + private Map getConfigValue(Map config, String configKey, String envVarName, String defaultValue = null) { //Checks where the config value came from - final authValue = auth[configKey] final configValue = config[configKey] final envValue = envVarName ? SysEnv.get(envVarName) : null - final effectiveValue = authValue ?: configValue ?: envValue ?: defaultValue + final effectiveValue = configValue ?: envValue ?: defaultValue def source = null - if( authValue ) { - source = shortenPath(getAuthFile().toString()) - } else if( configValue ) { - source = shortenPath(getConfigFile().toString()) + if( configValue ) { + source = "nextflow config" } else if( envValue ) { source = "env var \$${envVarName}" } else if( defaultValue ) { @@ -1113,9 +1105,9 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return [ value : effectiveValue, source : source, - fromConfig: configValue != null || authValue != null, + fromConfig: configValue != null, fromEnv : envValue != null, - isDefault : !authValue && !configValue && !envValue + isDefault : !configValue && !envValue ] } diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy index 717f6cb1c6..dfc07c2d69 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy @@ -353,41 +353,33 @@ param2 = 'value2'""" result == 'None (Personal workspace)' } - def 'should get config value from login file'() { + def 'should get config value from config'() { given: - def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera-auth.config') - cmd.getAuthFile() >> authFile - - def config = [:] - def auth =['tower.accessToken': 'token-from-login'] + def cmd = new AuthCommandImpl() + def config = ['tower.accessToken': 'token-from-config'] when: - def result = cmd.getConfigValue(config, auth,'tower.accessToken', 'TOWER_ACCESS_TOKEN', null) + def result = cmd.getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN', null) then: - result.value == 'token-from-login' - result.source.endsWith('seqera-auth.config') + result.value == 'token-from-config' + result.source == 'nextflow config' result.fromConfig == true result.fromEnv == false result.isDefault == false } - def 'should get config value from main config file'() { + def 'should get config value from config for endpoint'() { given: - def cmd = Spy(AuthCommandImpl) - def configFile = tempDir.resolve('config') - cmd.getConfigFile() >> configFile - + def cmd = new AuthCommandImpl() def config = ['tower.endpoint': 'https://example.com'] - def auth =[:] when: - def result = cmd.getConfigValue(config, auth,'tower.endpoint', 'TOWER_API_ENDPOINT', null) + def result = cmd.getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', null) then: result.value == 'https://example.com' - result.source.endsWith('config') + result.source == 'nextflow config' result.fromConfig == true result.fromEnv == false result.isDefault == false @@ -395,17 +387,14 @@ param2 = 'value2'""" def 'should get config value from environment variable'() { given: - def cmd = Spy(AuthCommandImpl) - + def cmd = new AuthCommandImpl() def config = [:] - def auth =[:] and: SysEnv.push( ['TOWER_ACCESS_TOKEN': 'token-from-env']) when: - def result = cmd.getConfigValue(config, auth,'tower.accessToken', 'TOWER_ACCESS_TOKEN', null) - + def result = cmd.getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN', null) then: result.value == 'token-from-env' @@ -421,12 +410,10 @@ param2 = 'value2'""" def 'should get config value from default'() { given: def cmd = new AuthCommandImpl() - def config = [:] - def auth =[:] when: - def result = cmd.getConfigValue(config, auth,'tower.endpoint', null, 'https://default.example.com') + def result = cmd.getConfigValue(config, 'tower.endpoint', null, 'https://default.example.com') then: result.value == 'https://default.example.com' @@ -436,46 +423,37 @@ param2 = 'value2'""" result.isDefault == true } - def 'should prioritize login over config and env'() { + def 'should prioritize config over env'() { given: - def cmd = Spy(AuthCommandImpl) - def authFile = tempDir.resolve('seqera-auth.config') - cmd.getAuthFile() >> authFile - + def cmd = new AuthCommandImpl() def config = ['tower.accessToken': 'token-from-config'] - def auth =['tower.accessToken': 'token-from-login'] SysEnv.push(['TOWER_ACCESS_TOKEN': 'token-from-env']) when: - def result = cmd.getConfigValue(config, auth,'tower.accessToken', 'TOWER_ACCESS_TOKEN', 'default-token') + def result = cmd.getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN', 'default-token') then: - result.value == 'token-from-login' - result.source.endsWith('seqera-auth.config') + result.value == 'token-from-config' + result.source == 'nextflow config' cleanup: SysEnv.pop() } - def 'should prioritize config over env when login is empty'() { + def 'should prioritize config over env for endpoint'() { given: - def cmd = Spy(AuthCommandImpl) - def configFile = tempDir.resolve('config') - cmd.getConfigFile() >> configFile - + def cmd = new AuthCommandImpl() def config = ['tower.endpoint': 'https://config.example.com'] - def auth =[:] SysEnv.push(['TOWER_API_ENDPOINT': 'https://env.example.com']) when: - def result = cmd.getConfigValue(config, auth,'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://default.example.com') - + def result = cmd.getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://default.example.com') then: result.value == 'https://config.example.com' - result.source.endsWith('config') + result.source == 'nextflow config' cleanup: SysEnv.pop() @@ -484,12 +462,10 @@ param2 = 'value2'""" def 'should handle null environment variable name'() { given: def cmd = new AuthCommandImpl() - def config = ['tower.enabled': true] - def auth =[:] when: - def result = cmd.getConfigValue(config, auth,'tower.enabled', null, 'false') + def result = cmd.getConfigValue(config, 'tower.enabled', null, 'false') then: result.value == true @@ -719,8 +695,7 @@ param2 = 'value2'""" def 'should collect status with valid authentication'() { given: def cmd = Spy(AuthCommandImpl) - def config = [:] - def authConfig = [ + def config = [ 'tower.accessToken': 'test-token', 'tower.endpoint': 'https://api.cloud.seqera.io', 'tower.enabled': true @@ -733,7 +708,7 @@ param2 = 'value2'""" cmd.getComputeEnvironments(_, _, _) >> [] when: - def status = cmd.collectStatus(config, authConfig) + def status = cmd.collectStatus(config) then: status != null @@ -760,12 +735,11 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def authConfig = [:] cmd.checkApiConnection(_) >> true when: - def status = cmd.collectStatus(config, authConfig) + def status = cmd.collectStatus(config) then: status != null @@ -780,13 +754,12 @@ param2 = 'value2'""" def 'should collect status with failed API connection'() { given: def cmd = Spy(AuthCommandImpl) - def config = [:] - def authConfig = ['tower.endpoint': 'https://unreachable.example.com'] + def config = ['tower.endpoint': 'https://unreachable.example.com'] cmd.checkApiConnection(_) >> false when: - def status = cmd.collectStatus(config, authConfig) + def status = cmd.collectStatus(config) then: status != null @@ -797,14 +770,13 @@ param2 = 'value2'""" def 'should collect status with failed authentication'() { given: def cmd = Spy(AuthCommandImpl) - def config = [:] - def authConfig = ['tower.accessToken': 'invalid-token'] + def config = ['tower.accessToken': 'invalid-token'] cmd.checkApiConnection(_) >> true cmd.callUserInfoApi(_, _) >> { throw new RuntimeException('Invalid token') } when: - def status = cmd.collectStatus(config, authConfig) + def status = cmd.collectStatus(config) then: status != null @@ -816,13 +788,12 @@ param2 = 'value2'""" def 'should collect status with monitoring enabled'() { given: def cmd = Spy(AuthCommandImpl) - def config = [:] - def authConfig = ['tower.enabled': true] + def config = ['tower.enabled': true] cmd.checkApiConnection(_) >> true when: - def status = cmd.collectStatus(config, authConfig) + def status = cmd.collectStatus(config) then: status != null @@ -833,13 +804,12 @@ param2 = 'value2'""" def 'should collect status with monitoring disabled'() { given: def cmd = Spy(AuthCommandImpl) - def config = [:] - def authConfig = ['tower.enabled': false] + def config = ['tower.enabled': false] cmd.checkApiConnection(_) >> true when: - def status = cmd.collectStatus(config, authConfig) + def status = cmd.collectStatus(config) then: status != null @@ -850,8 +820,7 @@ param2 = 'value2'""" def 'should collect status with workspace details'() { given: def cmd = Spy(AuthCommandImpl) - def config = [:] - def authConfig = [ + def config = [ 'tower.accessToken': 'test-token', 'tower.workspaceId': '12345' ] @@ -865,7 +834,7 @@ param2 = 'value2'""" ] when: - def status = cmd.collectStatus(config, authConfig) + def status = cmd.collectStatus(config) then: status != null @@ -879,8 +848,7 @@ param2 = 'value2'""" def 'should collect status with workspace ID but no details'() { given: def cmd = Spy(AuthCommandImpl) - def config = [:] - def authConfig = [ + def config = [ 'tower.accessToken': 'test-token', 'tower.workspaceId': '12345' ] @@ -890,7 +858,7 @@ param2 = 'value2'""" cmd.getWorkspaceDetailsFromApi(_, _, _) >> null when: - def status = cmd.collectStatus(config, authConfig) + def status = cmd.collectStatus(config) then: status != null @@ -903,7 +871,6 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def authConfig = [:] cmd.checkApiConnection(_) >> true cmd.callUserInfoApi(_, _) >> [userName: 'envuser', id: '456'] @@ -911,10 +878,11 @@ param2 = 'value2'""" SysEnv.push(['TOWER_ACCESS_TOKEN': 'env-token', 'TOWER_API_ENDPOINT': 'https://env.example.com', - 'TOWER_WORKFLOW_ID': 'ws-123'] ) + 'TOWER_WORKFLOW_ID': 'workflow-123', + 'TOWER_WORKSPACE_ID': 'ws-123'] ) when: - def status = cmd.collectStatus(config, authConfig) + def status = cmd.collectStatus(config) then: @@ -923,7 +891,7 @@ param2 = 'value2'""" status.table[0][2] == 'env var $TOWER_API_ENDPOINT' status.table[2][1].contains('envuser') status.table[2][2].contains('env var $TOWER_ACCESS_TOKEN') - status.table[4][2].contains('env var $TOWER_WORKFLOW_ID') + status.table[4][2].contains('env var $TOWER_WORKSPACE_ID') cleanup: SysEnv.pop() @@ -933,12 +901,11 @@ param2 = 'value2'""" given: def cmd = Spy(AuthCommandImpl) def config = [:] - def authConfig = [:] cmd.checkApiConnection(_) >> true when: - def status = cmd.collectStatus(config, authConfig) + def status = cmd.collectStatus(config) then: status != null @@ -959,25 +926,27 @@ param2 = 'value2'""" cmd.getAuthFile() >> authFile cmd.getConfigFile() >> configFile - def config = ['tower.enabled': true] - def authConfig = ['tower.accessToken': 'login-token'] + def config = [ + 'tower.enabled': true, + 'tower.accessToken': 'login-token' + ] cmd.checkApiConnection(_) >> true cmd.callUserInfoApi(_, _) >> [userName: 'mixeduser', id: '789'] - SysEnv.push(['TOWER_WORKFLOW_ID': 'ws-env']) + SysEnv.push(['TOWER_WORKSPACE_ID': '99999']) when: - def status = cmd.collectStatus(config, authConfig) + def status = cmd.collectStatus(config) then: status != null - // Token from auth file - status.table[2][2].endsWith('seqera-auth.config') - // Enabled from config file - status.table[3][2].endsWith('config') + // Token from nextflow config + status.table[2][2] == 'nextflow config' + // Enabled from nextflow config + status.table[3][2] == 'nextflow config' // Workspace from env var - status.table[4][2].contains('env var $TOWER_WORKFLOW_ID') + status.table[4][2].contains('env var $TOWER_WORKSPACE_ID') cleanup: SysEnv.pop() From 50b16715ab88242db89df5a685705f1635e68372 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 8 Oct 2025 10:11:28 +0200 Subject: [PATCH 62/66] Update test case Signed-off-by: Phil Ewels --- .../io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy index dfc07c2d69..c6f1b4b9e2 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy @@ -744,10 +744,9 @@ param2 = 'value2'""" then: status != null status.table.size() == 7 // endpoint, connection, auth, monitoring, workspace, compute env, work dir - // Authentication should show error + // Authentication should show not set status.table[2][0] == 'Authentication' - status.table[2][1].contains('ERROR') - status.table[2][1].contains('no token') + status.table[2][1].contains('Not set') status.table[2][2] == 'not set' } From 1adf5223b30a2fdd2911cc05cea98d39021a3c48 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 8 Oct 2025 23:00:12 +0200 Subject: [PATCH 63/66] Show configured url in help text default Signed-off-by: Phil Ewels --- .../src/main/groovy/nextflow/cli/CmdAuth.groovy | 10 +++++++++- .../io/seqera/tower/plugin/cli/AuthCommandImpl.groovy | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy index 571698826e..99089ad87a 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -24,6 +24,7 @@ import nextflow.Const import nextflow.SysEnv import nextflow.config.ConfigBuilder import nextflow.exception.AbortOperationException +import nextflow.platform.PlatformHelper import nextflow.plugin.Plugins import org.fusesource.jansi.Ansi import org.pf4j.ExtensionPoint @@ -168,11 +169,18 @@ class CmdAuth extends CmdBase implements UsageAware { @Override void usage(List result) { + // Read config to get the actual resolved endpoint value + final builder = new ConfigBuilder().setHomeDir(Const.APP_HOME_DIR).setCurrentDir(Const.APP_HOME_DIR) + final config = builder.buildConfigObject().flatten() + final towerConfig = config.findAll { it.key.toString().startsWith('tower.') } + .collectEntries { k, v -> [(k.toString().substring(6)): v] } + def defaultEndpoint = PlatformHelper.getEndpoint(towerConfig, SysEnv.get()) + result << 'Authenticate with Seqera Platform' result << "Usage: nextflow auth $name [-u ]".toString() result << '' result << 'Options:' - result << ' -u, -url Seqera Platform API endpoint (default: https://api.cloud.seqera.io)' + result << " -u, -url Seqera Platform API endpoint (default: ${defaultEndpoint})".toString() result << '' result << 'This command will:' result << ' 1. Display a URL and device code for OAuth2 authentication (Cloud) or prompt for PAT (Enterprise)' diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 14d2a38e11..55112f7395 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -945,7 +945,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { final currentUser = userInfo.userName as String status.table.add(['Authentication', "${ColorUtil.colorize('✔ OK', 'green')} (user: ${ColorUtil.colorize(currentUser, 'cyan')})".toString(), tokenSource]) } catch( Exception e ) { - status.table.add(['Authentication', ColorUtil.colorize('ERROR', 'red'), 'failed']) + status.table.add(['Authentication', ColorUtil.colorize('✘ Connection check failed', 'red'), tokenSource]) } } else { status.table.add(['Authentication', ColorUtil.colorize('Not set', 'red'), 'not set']) From 95caa00bcae772ff99d0552eab97214d9ba0b4a0 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Wed, 8 Oct 2025 23:11:22 +0200 Subject: [PATCH 64/66] Don't hardcode default endpoint Signed-off-by: Phil Ewels --- .../tower/plugin/cli/AuthCommandImpl.groovy | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy index 55112f7395..03e344141b 100644 --- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -31,7 +31,6 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { static final int AUTH_POLL_TIMEOUT_RETRIES = 60 static final int AUTH_POLL_INTERVAL_SECONDS = 5 static final int WORKSPACE_SELECTION_THRESHOLD = 8 // Max workspaces to show in single list; above this uses org-first selection - private static final String DEFAULT_API_ENDPOINT = 'https://api.cloud.seqera.io' /** * Creates an HxClient instance with optional authentication token @@ -317,7 +316,11 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { private String normalizeApiUrl(String url) { if( !url ) { - return DEFAULT_API_ENDPOINT + // Read config to get the actual resolved endpoint value + final builder = new ConfigBuilder().setHomeDir(Const.APP_HOME_DIR).setCurrentDir(Const.APP_HOME_DIR) + final configObject = builder.buildConfigObject() + final towerConfig = configObject.navigate('tower') as Map ?: [:] + return PlatformHelper.getEndpoint(towerConfig, SysEnv.get()) } if( !url.startsWith('http://') && !url.startsWith('https://') ) { return 'https://' + url @@ -374,7 +377,10 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Read token from seqera-auth.config file final authConfig = readAuthFile() final existingToken = authConfig['tower.accessToken'] - final apiUrl = authConfig['tower.endpoint'] as String ?: DEFAULT_API_ENDPOINT + // Extract tower config for PlatformHelper (strip 'tower.' prefix) + final towerConfig = authConfig.findAll { it.key.toString().startsWith('tower.') } + .collectEntries { k, v -> [(k.toString().substring(6)): v] } + final apiUrl = PlatformHelper.getEndpoint(towerConfig, SysEnv.get()) if( !existingToken ) { ColorUtil.printColored("WARN: No authentication token found in auth file.", "yellow bold") @@ -501,7 +507,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // Navigate to tower config section (returns map without 'tower.' prefix) final towerConfig = configObject.navigate('tower') as Map ?: [:] final existingToken = PlatformHelper.getAccessToken(towerConfig, SysEnv.get()) - final endpoint = config['tower.endpoint'] ?: DEFAULT_API_ENDPOINT + final endpoint = PlatformHelper.getEndpoint(towerConfig, SysEnv.get()) if( !existingToken ) { println "No authentication found. Please run ${ColorUtil.colorize('nextflow auth login', 'cyan')} first." @@ -924,8 +930,8 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { // API endpoint - use PlatformHelper final String endpoint = PlatformHelper.getEndpoint(towerConfig, SysEnv.get()) - final endpointInfo = getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', DEFAULT_API_ENDPOINT) - status.table.add(['API endpoint', ColorUtil.colorize(endpoint, 'magenta'), endpointInfo.source as String]) + final endpointInfo = getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT') + status.table.add(['API endpoint', ColorUtil.colorize(endpoint, 'magenta'), (endpointInfo.source ?: 'default') as String]) // API connection check final apiConnectionOk = checkApiConnection(endpoint) @@ -952,7 +958,7 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { } // Monitoring enabled - final enabledInfo = getConfigValue(config, 'tower.enabled', null, 'false') + final enabledInfo = getConfigValue(config, 'tower.enabled', null) final enabledValue = enabledInfo.value?.toString()?.toLowerCase() in ['true', '1', 'yes'] ? 'Yes' : 'No' final enabledColor = enabledValue == 'Yes' ? 'green' : 'yellow' status.table.add(['Workflow monitoring', ColorUtil.colorize(enabledValue, enabledColor), (enabledInfo.source ?: 'default') as String]) @@ -1087,19 +1093,17 @@ class AuthCommandImpl implements CmdAuth.AuthCommand { return path } - private Map getConfigValue(Map config, String configKey, String envVarName, String defaultValue = null) { + private Map getConfigValue(Map config, String configKey, String envVarName) { //Checks where the config value came from final configValue = config[configKey] final envValue = envVarName ? SysEnv.get(envVarName) : null - final effectiveValue = configValue ?: envValue ?: defaultValue + final effectiveValue = configValue ?: envValue def source = null if( configValue ) { source = "nextflow config" } else if( envValue ) { source = "env var \$${envVarName}" - } else if( defaultValue ) { - source = "default" } return [ From 238e387cb46e5269097d5d7be64c81371395ebc9 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Thu, 9 Oct 2025 14:32:04 +0200 Subject: [PATCH 65/66] Update modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy [ci skip] Co-authored-by: Phil Ewels Signed-off-by: Paolo Di Tommaso --- .../src/main/groovy/nextflow/platform/PlatformHelper.groovy | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy b/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy index dc09b12ae4..aacc00e914 100644 --- a/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy @@ -35,6 +35,8 @@ class PlatformHelper { */ static String getAuthDomain(String endpoint) { switch(endpoint) { + case SysEnv.get('TOWER_AUTH_DOMAIN'): + return SysEnv.get('TOWER_AUTH_DOMAIN') case 'https://api.cloud.dev-seqera.io': return 'seqera-development.eu.auth0.com' case 'https://api.cloud.stage-seqera.io': From b01e8fda0cfc9a72076fadee3b1d46e898e56a9f Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Thu, 9 Oct 2025 16:08:00 +0200 Subject: [PATCH 66/66] Add missing import Signed-off-by: Phil Ewels --- .../src/main/groovy/nextflow/platform/PlatformHelper.groovy | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy b/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy index aacc00e914..daa2cc871c 100644 --- a/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy @@ -3,6 +3,7 @@ package nextflow.platform import groovy.transform.CompileStatic import nextflow.Global import nextflow.Session +import nextflow.SysEnv /** * Helper methods for Platform-related operations