diff --git a/docs/install.md b/docs/install.md index 19f8f874b7..98e1c86134 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,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 [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 and automatically configure workflow monitoring. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 580499efef..bd6af897e3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -52,6 +52,80 @@ 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. 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. + +**Subcommands** + +`login` +: 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 the Seqera Cloud access token (if applicable). + +`config` +: Sets Seqera primary compute environment, monitoring, and workspace. + +`status` +: Shows Seqera authentication status and configuration. + +**Examples** + +Authenticate with Seqera 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 settings: + +```console +$ nextflow auth config +``` + +Remove authentication: + +```console +$ nextflow auth logout +``` + (cli-clean)= ### `clean` 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..99089ad87a --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdAuth.groovy @@ -0,0 +1,281 @@ +/* + * 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 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 + +import java.nio.file.Paths + +import static org.fusesource.jansi.Ansi.* + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.regex.Pattern + +/** + * Implements the 'nextflow auth' commands + * + * @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) + } + + interface AuthCommand extends ExtensionPoint { + void login(String url) + void logout() + void config() + void status() + } + + static public final String NAME = 'auth' + + private List commands = [] + + private AuthCommand operation + + String getName() { + return NAME + } + + @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()) + commands.add(new ConfigCmd()) + commands.add(new StatusCmd()) + } + + 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:' + 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] } + 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 + } + // 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) { + 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) + } + + // + // nextflow auth login + // + class LoginCmd implements SubCmd { + + @Override + String getName() { 'login' } + + @Override + void apply(List args) { + if (args.size() > 0) { + throw new AbortOperationException("Too many arguments for ${name} command") + } + operation.login(apiUrl) + } + + + + @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: ${defaultEndpoint})".toString() + result << '' + result << 'This command will:' + 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 << '' + } + } + + class LogoutCmd implements SubCmd { + + @Override + String getName() { 'logout' } + + @Override + void apply(List args) { + if (args.size() > 0) { + throw new AbortOperationException("Too many arguments for ${name} command") + } + operation.logout() + } + + + @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 << '' + } + } + + // + // 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 ${name} command") + } + + operation.config() + } + + + @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 << '' + } + } + + class StatusCmd implements SubCmd { + + @Override + String getName() { 'status' } + + @Override + void apply(List args) { + if (args.size() > 0) { + throw new AbortOperationException("Too many arguments for ${name} command") + } + operation.status() + } + + + @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 << '' + } + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/ColorUtil.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/ColorUtil.groovy new file mode 100644 index 0000000000..f375ddad77 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/cli/ColorUtil.groovy @@ -0,0 +1,159 @@ +/* + * 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 + * + * @author Phil Ewels + */ +@CompileStatic +class ColorUtil { + + /** + * 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 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, true) // Always do full reset for printColored + } + + /** + * Format text with color using format keywords + * 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 = '', boolean fullReset = false) { + 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 (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(text) + + 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() + } + + /** + * 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 + } + } + +} \ No newline at end of file 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/platform/PlatformHelper.groovy b/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy index 0b701ccbe7..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 @@ -27,6 +28,46 @@ 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 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': + 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/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 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..948a086e5f --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/cli/CmdAuthTest.groovy @@ -0,0 +1,203 @@ +/* + * 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.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 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 = 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') + } + + 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 'login command should validate too many arguments'() { + given: + 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 = 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 = 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 = 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 = Spy(CmdAuth) + def operation = Mock(CmdAuth.AuthCommand) + 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: + 1 * cmd.loadOperation() >> operation + 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 + } +} \ 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 diff --git a/plugins/nf-tower/build.gradle b/plugins/nf-tower/build.gradle index 71db4cda5e..4d9b001b6f 100644 --- a/plugins/nf-tower/build.gradle +++ b/plugins/nf-tower/build.gradle @@ -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 new file mode 100644 index 0000000000..03e344141b --- /dev/null +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/cli/AuthCommandImpl.groovy @@ -0,0 +1,1341 @@ +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 +import nextflow.exception.AbortOperationException +import nextflow.platform.PlatformHelper + +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 +class AuthCommandImpl implements CmdAuth.AuthCommand { + + 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 + + /** + * Creates an HxClient instance with optional authentication token + */ + protected 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 + 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") + 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 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("Auth file: ${ColorUtil.colorize(authFile.toString(), 'magenta')}", "dim") + println " Run ${ColorUtil.colorize('nextflow auth logout', 'cyan')} to remove the current authentication." + return + } + + println("Nextflow authentication with Seqera Platform") + 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") + + // Check if this is a cloud endpoint or enterprise + 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) + throw new AbortOperationException("${e.message}") + } + } else { + // Enterprise endpoint - use PAT authentication + handleEnterpriseAuth(apiUrl) + } + } + + protected void performAuth0Login(String apiUrl, Map auth0Config) { + + // Start device authorization flow + final deviceAuth = requestDeviceAuthorization(auth0Config) + + println "" + println "Confirmation code: ${ColorUtil.colorize(deviceAuth.user_code as String, 'yellow')}" + final urlWithCode = "${deviceAuth.verification_uri}?user_code=${deviceAuth.user_code}" + 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 { + 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) + + 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 + 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 + final userInfo = callUserInfoApi(accessToken, apiUrl) + println "\n\n${ColorUtil.colorize('✔', 'green', true)} Authentication successful" + + // Generate PAT + final pat = generatePAT(accessToken, apiUrl) + + // Save to config + saveAuthToConfig(pat, apiUrl) + + // Automatically run configuration + try { + 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") + } + + } catch( InterruptedException e ) { + Thread.currentThread().interrupt() + throw new AbortOperationException("Authentication cancelled") + } 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 + if( Desktop.isDesktopSupported() ) { + final desktop = Desktop.getDesktop() + if( desktop.isSupported(Desktop.Action.BROWSE) ) { + desktop.browse(new URI(urlWithCode)) + browserOpened = true + } + } + + // Method 2: Platform-specific commands + if( !browserOpened ) { + browserOpened = runBrowserCommand(urlWithCode) + } + } catch( Exception e ) { + log.debug("Exception opening browser", e) + } + return browserOpened + } + + protected 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 e ) { + log.debug("Failed to open browser ${browser}: ${e.message}") + // Try next browser + } + } + } + + if( !browserOpened && command ) { + new ProcessBuilder(command as String[]).start() + browserOpened = true + } + return browserOpened + } + + private Map requestDeviceAuthorization(Map auth0Config) { + final 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) { + final tokenUrl = "https://${auth0Config.domain}/oauth/token" + def retryCount = 0 + + while( retryCount < AUTH_POLL_TIMEOUT_RETRIES ) { + final params = [ + 'grant_type' : 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': deviceCode, + 'client_id' : auth0Config.clientId + ] + + try { + final result = performAuth0Request(tokenUrl, params) + return result + } catch( RuntimeException e ) { + final 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.") + } + + + 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(getWebUrlFromApiEndpoint(apiUrl) + '/tokens', 'magenta')}" + println "" + + final pat = promptPAT() + + if( !pat ) { + throw new AbortOperationException("Personal Access Token is required for Seqera Platform Enterprise authentication") + } + + // Save to config + saveAuthToConfig(pat, apiUrl) + 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() + + 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) { + 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}" + + final requestBody = new JsonBuilder([name: tokenName]).toString() + + final client = createHttpClient(accessToken) + final request = HttpRequest.newBuilder() + .uri(URI.create(tokensUrl)) + .header('Content-Type', 'application/json') + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .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 generate PAT: ${error}") + } + + final json = new JsonSlurper().parseText(response.body()) as Map + return json.accessKey as String + } + + private String normalizeApiUrl(String url) { + if( !url ) { + // 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 + } + return url + } + + 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('&') + + 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() + + final response = client.send(request, HttpResponse.BodyHandlers.ofString()) + + if( response.statusCode() == 200 ) { + final json = new JsonSlurper().parseText(response.body()) + return json as Map + } else { + 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 ${response.statusCode()}") + } + } + } + + private void saveAuthToConfig(String accessToken, String apiUrl) { + final config = [:] + config['tower.accessToken'] = accessToken + config['tower.endpoint'] = apiUrl + config['tower.enabled'] = true + + writeConfig(config, null) + } + + @Override + void logout() { + // Check if seqera-auth.config file exists + final authFile = getAuthFile() + if( !Files.exists(authFile) ) { + println "No previous login found.\n" + return + } + // Read token from seqera-auth.config file + final authConfig = readAuthFile() + final existingToken = authConfig['tower.accessToken'] + // 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") + println "Removing file: ${ColorUtil.colorize(authFile.toString(), 'magenta')}" + removeAuthFromConfig() + return + } + + // Check if TOWER_ACCESS_TOKEN environment variable is set + final envToken = SysEnv.get('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 "" + } + + 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 + try { + final userInfo = callUserInfoApi(existingToken as String, apiUrl) + 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") + } + + // 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(authFile.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?', '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( shouldDeleteFromPlatform ) { + try { + final tokenId = decodeTokenId(existingToken as String) + deleteTokenViaApi(existingToken as String, apiUrl, tokenId) + } catch( Exception e ) { + ColorUtil.printColored("Error removing token: ${e.message}", "red") + } + } + + removeAuthFromConfig() + } + + private String decodeTokenId(String token) { + try { + // Decode base64 token + final decoded = new String(Base64.decoder.decode(token), "UTF-8") + + // Parse JSON to extract token ID + final json = new JsonSlurper().parseText(decoded) as Map + final 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) { + final client = createHttpClient(token) + final request = HttpRequest.newBuilder() + .uri(URI.create("${apiUrl}/tokens/${tokenId}")) + .DELETE() + .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 delete token: ${error}") + } + + println "\n${ColorUtil.colorize('✔', 'green', true)} Token successfully deleted from Seqera Platform." + } + + private void removeAuthFromConfig() { + final configFile = getConfigFile() + final authFile = getAuthFile() + + // Remove includeConfig line from main config file + if( Files.exists(configFile) ) { + final existingContent = Files.readString(configFile) + final updatedContent = removeIncludeConfigLine(existingContent) + Files.writeString(configFile, updatedContent.toString(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + } + + // Delete seqera-auth.config file + if( Files.exists(authFile) ) { + Files.delete(authFile) + } + + println "${ColorUtil.colorize('✔', 'green', true)} Authentication removed from Nextflow config." + } + + @Override + 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() + final config = configObject.flatten() + + // 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 = PlatformHelper.getEndpoint(towerConfig, SysEnv.get()) + + if( !existingToken ) { + println "No authentication found. Please run ${ColorUtil.colorize('nextflow auth login', 'cyan')} first." + return + } + + 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") + } + } + + try { + // Get user info to validate token and get user ID + final 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 + 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 + if( configChanged ) { + writeConfig(config, workspaceResult.metadata as Map) + 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) + 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}") + } + } + + private boolean configureEnabled(Map config) { + 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 "" + + 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 ) { + 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 + 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") + return [changed: false, metadata: null] + } + + // Get all workspaces for the user + final workspaces = getUserWorkspaces(accessToken, endpoint, userId) + + if( !workspaces ) { + println "\nNo workspaces found for your account." + return [changed: false, metadata: null] + } + + // Show current workspace setting + final currentWorkspaceId = config.get('tower.workspaceId') + + String currentSetting = getCurrentWorkspaceName(workspaces, config.get('tower.workspaceId')) + + println "\nDefault workspace. Current setting: ${ColorUtil.colorize(currentSetting, 'cyan', true)}" + ColorUtil.printColored(" Workflow runs use this workspace by default", "dim") + // Group by organization + final 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 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:" + 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}" + + // 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') : '' + 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)}${currentInd}" + } + + // Show current workspace and prepare prompt + 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-${sortedWorkspaces.size()}): ", 'bold', true), 0, sortedWorkspaces.size(), true) + + if( selection == null ) { + return [changed: false, metadata: null] + } + + if( selection == 0 ) { + final hadWorkspaceId = config.containsKey('tower.workspaceId') + config.remove('tower.workspaceId') + return [changed: hadWorkspaceId, metadata: null] + } else { + final selectedWorkspace = sortedWorkspaces[selection - 1] as Map + final selectedId = selectedWorkspace.workspaceId.toString() + final currentId = config.get('tower.workspaceId') + config['tower.workspaceId'] = selectedId + final metadata = [ + orgName : selectedWorkspace.orgName, + workspaceName : selectedWorkspace.workspaceName, + workspaceFullName: selectedWorkspace.workspaceFullName + ] + return [changed: currentId != selectedId, metadata: metadata] + } + } + + private Map selectWorkspaceByOrg(Map config, Map orgWorkspaces, final currentWorkspaceId) { + // Get current workspace info for prompts + final allWorkspaces = [] + orgWorkspaces.values().each { workspaceList -> + allWorkspaces.addAll(workspaceList as List) + } + final currentWorkspaceDisplay = getCurrentWorkspaceName(allWorkspaces, currentWorkspaceId) + + // First, select organization + 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') + + println "\nAvailable organizations:" + orgs.eachWithIndex { orgName, index -> + final displayName = orgName == 'Personal' ? 'None [Personal workspace]' : orgName + println " ${index + 1}. ${ColorUtil.colorize(displayName as String, 'cyan', 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] + + final selectedOrgName = orgs[orgSelection - 1] + + // If Personal was selected, remove workspace ID (use personal workspace) + if( selectedOrgName == 'Personal' ) { + final hadWorkspaceId = config.containsKey('tower.workspaceId') + config.remove('tower.workspaceId') + return [changed: hadWorkspaceId, metadata: null] + } + + 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}:" + + orgWorkspaceList.eachWithIndex { workspace, index -> + final ws = workspace as Map + 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() + 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() + final currentId = config.get('tower.workspaceId') + config['tower.workspaceId'] = selectedId + final 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) { + final client = createHttpClient(accessToken) + final request = HttpRequest.newBuilder() + .uri(URI.create("${endpoint}/user/${userId}/workspaces")) + .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 workspaces: ${error}") + } + + final json = new JsonSlurper().parseText(response.body()) as Map + final orgsAndWorkspaces = json.orgsAndWorkspaces as List + + 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:" + + // 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 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-${sortedComputeEnvs.size()}): ", 'bold', true), 1, sortedComputeEnvs.size(), true) + + if( selection == null ) { + return + } + + final selectedEnv = sortedComputeEnvs[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() + + if( input?.isEmpty() ) { + return defaultValue + } 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 ) { + final input = readUserInput(prompt) + + if( input?.isEmpty() && allowEmpty ) { + return null + } + + try { + final number = Integer.parseInt(input) + if( number >= min && number <= max ) { + return number + } + } catch( NumberFormatException ignored ) { + // Fall through to error message + } + ColorUtil.printColored("Invalid input. Please enter a number between ${min} and ${max}.", "red") + } + } + + @Override + void status() { + final config = readConfig() + printStatus(collectStatus(config)) + } + + private ConfigStatus collectStatus(Map config) { + // Collect all status information + final status = new ConfigStatus([], null, null) + + // 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') + status.table.add(['API endpoint', ColorUtil.colorize(endpoint, 'magenta'), (endpointInfo.source ?: 'default') as String]) + + // API connection check + final apiConnectionOk = checkApiConnection(endpoint) + final connectionColor = apiConnectionOk ? 'green' : 'red' + status.table.add(['API connection', ColorUtil.colorize(apiConnectionOk ? '✔ OK' : 'ERROR', connectionColor), '']) + + // Authentication check - use PlatformHelper + final String accessToken = PlatformHelper.getAccessToken(towerConfig, SysEnv.get()) + + // Determine source for display + final tokenInfo = getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN') + final String tokenSource = tokenInfo.source ?: 'not set' + + if( accessToken ) { + try { + 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]) + } catch( Exception e ) { + status.table.add(['Authentication', ColorUtil.colorize('✘ Connection check failed', 'red'), tokenSource]) + } + } else { + status.table.add(['Authentication', ColorUtil.colorize('Not set', 'red'), 'not set']) + } + + // Monitoring enabled + 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]) + + // 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, endpoint, workspaceId) + } + + if( workspaceDetails ) { + // Add workspace ID row and remember its index + status.workspaceRowIndex = status.table.size() + 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(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('N/A', 'dim', true), 'default']) + } + } + + // Primary compute environment and work directory + def primaryEnv = null + if( accessToken && workspaceId ) { + try { + 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'), '']) + 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 = (!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']) + } + + return status + } + private void printStatus(ConfigStatus status){ + println "" + + // 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 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() + 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 + + // Add table header + lines.add(ColorUtil.colorize("${'Setting'.padRight(col1Width)} ${'Value'.padRight(col2Width)} Source", "bold")) + lines.add("${'-' * col1Width} ${'-' * col2Width} ${'-' * col3Width}".toString()) + + // 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) + lines.add("${paddedCol1} ${paddedCol2} ${paddedCol3}".toString()) + } + + return lines + } + + private String stripAnsiCodes(String text) { + return text?.replaceAll(/\u001B\[[0-9;]*m/, '') ?: '' + } + + private String padStringWithAnsi(String text, int width) { + final plainText = stripAnsiCodes(text) + final padding = width - plainText.length() + return padding > 0 ? text + (' ' * padding) : text + } + + private String shortenPath(String path) { + final 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) { + //Checks where the config value came from + final configValue = config[configKey] + final envValue = envVarName ? SysEnv.get(envVarName) : null + final effectiveValue = configValue ?: envValue + + def source = null + if( configValue ) { + source = "nextflow config" + } else if( envValue ) { + source = "env var \$${envVarName}" + } + + return [ + value : effectiveValue, + source : source, + fromConfig: configValue != null, + fromEnv : envValue != null, + isDefault : !configValue && !envValue + ] + } + + protected boolean checkApiConnection(String endpoint) { + try { + 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 ) { + log.debug("Failed to connect to API endpoint ${endpoint}: ${e.message}", e) + return false + } + } + + protected static String readUserInput(String message = null) { + if (message) { + System.out.print(message) + System.out.flush() + } + final console = System.console() + final line = console != null + ? console.readLine() + : new BufferedReader(new InputStreamReader(System.in)).readLine() + return line?.trim() + } + + protected Map getWorkspaceDetailsFromApi(String accessToken, String endpoint, String workspaceId) { + try { + 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() + + final response = client.send(request, HttpResponse.BodyHandlers.ofString()) + + if (response.statusCode() != 200) { + return null + } + + final json = new JsonSlurper().parseText(response.body()) as Map + final orgsAndWorkspaces = json.orgsAndWorkspaces as List + + final workspace = orgsAndWorkspaces.find { ((Map)it).workspaceId?.toString() == workspaceId } + if (workspace) { + final ws = workspace as Map + return [ + orgName: ws.orgName, + workspaceName: ws.workspaceName, + workspaceFullName: ws.workspaceFullName + ] + } + + return null + } catch (Exception e) { + log.debug("Failed to get workspace details for workspace ${workspaceId}: ${e.message}", e) + return null + } + } + + private Map getCloudEndpointInfo(String apiUrl) { + // 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) + 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] + } + + private boolean isCloudEndpoint(String apiUrl) { + return getCloudEndpointInfo(apiUrl).isCloud + } + + protected Map callUserInfoApi(String accessToken, String apiUrl) { + 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 (response.statusCode() != 200) { + final error = response.body() ?: "HTTP ${response.statusCode()}" + throw new RuntimeException("Failed to get user info: ${error}") + } + + final json = new JsonSlurper().parseText(response.body()) as Map + return json.user as Map + } + + protected Path getConfigFile() { + return Const.APP_HOME_DIR.resolve('config') + } + + protected Path getAuthFile() { + return Const.APP_HOME_DIR.resolve('seqera-auth.config') + } + + private Map readAuthFile() { + final configFile = getAuthFile() + if (!Files.exists(configFile)) { + return [:] + } + + try { + 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}") + } + } + + private Map readConfig() { + 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) { + log.debug("Getting config ") + final configFile = getConfigFile() + final authFile = getAuthFile() + + // Create directory if it doesn't exist + if (!Files.exists(configFile.parent)) { + Files.createDirectories(configFile.parent) + } + + // Write tower config to seqera-auth.config file + final towerConfig = config.findAll { key, value -> + key.toString().startsWith('tower.') + } + + 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 + + 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}]" + } + authConfigText.append("${line}\n") + } else { + authConfigText.append(" ${configKey} = ${value}\n") + } + } + authConfigText.append("}\n") + + // 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 'seqera-auth.config'" + + 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 'seqera-auth.config' line + final lines = content.split('\n') + final filteredLines = lines.findAll { line -> + !line.trim().equals("includeConfig 'seqera-auth.config'") + } + return filteredLines.join('\n') + } + @Canonical + static class ConfigStatus { + List> table + Map workspaceInfo + Integer workspaceRowIndex // Track which row has the workspace so we can insert details after it + } +} + + 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..c6f1b4b9e2 --- /dev/null +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/cli/AuthCommandImplTest.groovy @@ -0,0 +1,1291 @@ +/* + * 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 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 + +/** + * 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').auth.domain == 'seqera.eu.auth0.com' + cmd.getCloudEndpointInfo('https://api.cloud.stage-seqera.io').isCloud == true + 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 + } + + 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 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 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 'seqera-auth.config' + +param2 = 'value2' +""" + + when: + def result = cmd.removeIncludeConfigLine(content) + + then: + !result.contains("includeConfig 'seqera-auth.config'") + 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 auth file path'() { + given: + def cmd = new AuthCommandImpl() + + when: + def authFile = cmd.getAuthFile() + + then: + authFile == Const.APP_HOME_DIR.resolve('seqera-auth.config') + } + + def 'should read empty auth file'() { + given: + def cmd = new AuthCommandImpl() + + when: + def config = cmd.readAuthFile() + + then: + config instanceof Map + config.isEmpty() || config.size() >= 0 + } + + def 'should write config to seqera-auth.config file'() { + given: + def cmd = Spy(AuthCommandImpl) + def authFile = tempDir.resolve('seqera-auth.config') + def configFile = tempDir.resolve('config') + + cmd.getAuthFile() >> authFile + 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(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 authFile = tempDir.resolve('seqera-auth.config') + def configFile = tempDir.resolve('config') + + cmd.getAuthFile() >> authFile + 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(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 authFile = tempDir.resolve('seqera-auth.config') + def configFile = tempDir.resolve('config') + + cmd.getAuthFile() >> authFile + 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 '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 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") + + def config = [ + 'tower.accessToken': 'test-token-123' + ] + + when: + cmd.writeConfig(config, null) + + then: + Files.exists(configFile) + def configContent = Files.readString(configFile) + configContent.count("includeConfig 'seqera-auth.config'") == 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 config'() { + given: + def cmd = new AuthCommandImpl() + def config = ['tower.accessToken': 'token-from-config'] + + when: + def result = cmd.getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN', null) + + then: + result.value == 'token-from-config' + result.source == 'nextflow config' + result.fromConfig == true + result.fromEnv == false + result.isDefault == false + } + + def 'should get config value from config for endpoint'() { + given: + def cmd = new AuthCommandImpl() + def config = ['tower.endpoint': 'https://example.com'] + + when: + def result = cmd.getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', null) + + then: + result.value == 'https://example.com' + result.source == 'nextflow config' + result.fromConfig == true + result.fromEnv == false + result.isDefault == false + } + + def 'should get config value from environment variable'() { + given: + def cmd = new AuthCommandImpl() + def config = [:] + + and: + SysEnv.push( ['TOWER_ACCESS_TOKEN': 'token-from-env']) + + when: + def result = cmd.getConfigValue(config, '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 = [:] + + when: + def result = cmd.getConfigValue(config, '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 config over env'() { + given: + def cmd = new AuthCommandImpl() + def config = ['tower.accessToken': 'token-from-config'] + + SysEnv.push(['TOWER_ACCESS_TOKEN': 'token-from-env']) + + when: + def result = cmd.getConfigValue(config, 'tower.accessToken', 'TOWER_ACCESS_TOKEN', 'default-token') + + then: + result.value == 'token-from-config' + result.source == 'nextflow config' + + cleanup: + SysEnv.pop() + } + + def 'should prioritize config over env for endpoint'() { + given: + def cmd = new AuthCommandImpl() + def config = ['tower.endpoint': 'https://config.example.com'] + + SysEnv.push(['TOWER_API_ENDPOINT': 'https://env.example.com']) + + when: + def result = cmd.getConfigValue(config, 'tower.endpoint', 'TOWER_API_ENDPOINT', 'https://default.example.com') + + then: + result.value == 'https://config.example.com' + result.source == 'nextflow config' + + cleanup: + SysEnv.pop() + } + + def 'should handle null environment variable name'() { + given: + def cmd = new AuthCommandImpl() + def config = ['tower.enabled': true] + + when: + def result = cmd.getConfigValue(config, '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: + def lines = cmd.generateStatusTableLines(rows) + def output = lines.join('\n') + + 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: + def lines = cmd.generateStatusTableLines(rows) + def output = lines.join('\n') + + 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: + def lines = cmd.generateStatusTableLines(rows) + + then: + lines.isEmpty() + } + + def 'should handle null status table'() { + given: + def cmd = new AuthCommandImpl() + + when: + def lines = cmd.generateStatusTableLines(null) + + then: + lines.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 result = cmd.stripAnsiCodes(null) + + then: + 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: + def lines = cmd.generateStatusTableLines(rows) + def output = lines.join('\n') + + 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 + 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: + def lines = cmd.generateStatusTableLines(rows) + def output = lines.join('\n') + + 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 + lines.size() >= 3 // Header + separator + data row + } + + def 'should collect status with valid authentication'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = [ + '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 + cmd.getComputeEnvironments(_, _, _) >> [] + + when: + def status = cmd.collectStatus(config) + + then: + status != null + 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') + // 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 = [:] + + cmd.checkApiConnection(_) >> true + + when: + def status = cmd.collectStatus(config) + + then: + status != null + status.table.size() == 7 // endpoint, connection, auth, monitoring, workspace, compute env, work dir + // Authentication should show not set + status.table[2][0] == 'Authentication' + status.table[2][1].contains('Not set') + status.table[2][2] == 'not set' + } + + def 'should collect status with failed API connection'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = ['tower.endpoint': 'https://unreachable.example.com'] + + cmd.checkApiConnection(_) >> false + + when: + def status = cmd.collectStatus(config) + + 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 = ['tower.accessToken': 'invalid-token'] + + cmd.checkApiConnection(_) >> true + cmd.callUserInfoApi(_, _) >> { throw new RuntimeException('Invalid token') } + + when: + def status = cmd.collectStatus(config) + + 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 = ['tower.enabled': true] + + cmd.checkApiConnection(_) >> true + + when: + def status = cmd.collectStatus(config) + + 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 = ['tower.enabled': false] + + cmd.checkApiConnection(_) >> true + + when: + def status = cmd.collectStatus(config) + + 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 = [ + '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) + + 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 = [ + '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) + + 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 = [:] + + 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': 'workflow-123', + 'TOWER_WORKSPACE_ID': 'ws-123'] ) + + when: + def status = cmd.collectStatus(config) + + + 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_WORKSPACE_ID') + + cleanup: + SysEnv.pop() + } + + def 'should collect status with default values'() { + given: + def cmd = Spy(AuthCommandImpl) + def config = [:] + + cmd.checkApiConnection(_) >> true + + when: + def status = cmd.collectStatus(config) + + 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 authFile = tempDir.resolve('seqera-auth.config') + def configFile = tempDir.resolve('config') + + cmd.getAuthFile() >> authFile + cmd.getConfigFile() >> configFile + + def config = [ + 'tower.enabled': true, + 'tower.accessToken': 'login-token' + ] + + cmd.checkApiConnection(_) >> true + cmd.callUserInfoApi(_, _) >> [userName: 'mixeduser', id: '789'] + SysEnv.push(['TOWER_WORKSPACE_ID': '99999']) + + when: + def status = cmd.collectStatus(config) + + + then: + status != null + // 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_WORKSPACE_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' + ], + 1 // workspaceRowIndex + ) + + 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, + null + ) + + when: + cmd.printStatus(status) + def output = capture.toString() + + then: + output.contains('API endpoint') + !output.contains('TestOrg') + } + + def 'should detect existing auth file and prevent duplicate login'() { + given: + def cmd = Spy(AuthCommandImpl) + def authFile = tempDir.resolve('seqera-auth.config') + Files.createFile(authFile) + + cmd.getAuthFile() >> authFile + + 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 authFile = tempDir.resolve('seqera-auth.config-not-exists') + + cmd.getAuthFile() >> authFile + 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 authFile = tempDir.resolve('seqera-auth.config-not-exists') + + cmd.getAuthFile() >> authFile + + 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 authFile = tempDir.resolve('seqera-auth.config-not-exists') + + cmd.getAuthFile() >> authFile + + 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 authFile = tempDir.resolve('seqera-auth.config-not-exists') + + cmd.getAuthFile() >> authFile + + 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 authFile = tempDir.resolve('seqera-auth.config') + def configFile = tempDir.resolve('config') + + cmd.getAuthFile() >> authFile + 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(authFile) + def content = Files.readString(authFile) + 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: + cmd.pollForDeviceToken('device-code-123', 1, auth0Config) + + then: + def ex = thrown(RuntimeException) + ex.message.contains('denied') + } +}