diff --git a/docs/reference/cli/auth.mdx b/docs/reference/cli/auth.mdx
index 713ee9cb7d..ea8aaaed99 100644
--- a/docs/reference/cli/auth.mdx
+++ b/docs/reference/cli/auth.mdx
@@ -43,7 +43,7 @@ Remove Seqera authentication and revoke the Seqera Cloud access token, if applic
##### `config`
-Set the Seqera primary compute environment, monitoring, and workspace.
+Set the Seqera primary compute environment, monitoring, and workspace. If you do not set a default workspace here, Nextflow uses the default workspace configured in your Seqera Platform account (if any), otherwise your personal workspace.
##### `status`
diff --git a/docs/reference/cli/launch.mdx b/docs/reference/cli/launch.mdx
index a6ccf73e73..c50441ecc3 100644
--- a/docs/reference/cli/launch.mdx
+++ b/docs/reference/cli/launch.mdx
@@ -83,6 +83,14 @@ The directory where intermediate result files are stored.
The Seqera Platform workspace name.
+The workspace is resolved in the following order:
+
+1. This `-workspace` option
+2. The `tower.workspaceId` configuration setting
+3. The `TOWER_WORKSPACE_ID` environment variable
+4. The default workspace configured in your Seqera Platform account
+5. Your personal workspace
+
##### `-workspace-secret`
diff --git a/docs/reference/config/tower.mdx b/docs/reference/config/tower.mdx
index c70ed3242b..f64a790bec 100644
--- a/docs/reference/config/tower.mdx
+++ b/docs/reference/config/tower.mdx
@@ -39,6 +39,6 @@ The HTTP read timeout for Seqera Platform API requests (default: `'60s'`).
##### `tower.workspaceId`
-The workspace ID in Seqera Platform in which to save the run (default: the launching user's personal workspace).
+The workspace ID in Seqera Platform in which to save the run (default: the user's default workspace configured in Seqera Platform, or the personal workspace if no default is set).
-The workspace ID can also be specified using the environment variable `TOWER_WORKSPACE_ID` (config file has priority over the environment variable).
+The workspace ID can also be specified using the environment variable `TOWER_WORKSPACE_ID` (config file has priority over the environment variable). When neither is set, Nextflow uses the default workspace configured in your Seqera Platform account, if any.
diff --git a/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy b/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy
index c0e46e45c3..a4cb30d0e6 100644
--- a/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/platform/PlatformHelper.groovy
@@ -16,6 +16,8 @@
package nextflow.platform
+import java.util.function.Supplier
+
import groovy.transform.CompileStatic
import nextflow.Global
import nextflow.Session
@@ -29,6 +31,52 @@ import nextflow.SysEnv
@CompileStatic
class PlatformHelper {
+ /**
+ * Extract the `tower` scope options from a flattened config map, stripping the
+ * `tower.` prefix so the result can be passed to the accessors in this class.
+ *
+ * @param flatConfig a flattened config map, e.g. `['tower.endpoint': '...']`
+ * @return the `tower` options keyed without the prefix, e.g. `[endpoint: '...']`
+ */
+ static Map towerOpts(Map flatConfig) {
+ return flatConfig
+ .findAll { it.key.toString().startsWith('tower.') }
+ .collectEntries { k, v -> [(k.toString().substring('tower.'.length())): v] }
+ }
+
+ /**
+ * A run made by Seqera Platform is signalled by the {@code TOWER_WORKFLOW_ID}
+ * environment variable. In that case the settings must be taken from the
+ * environment only, because Platform has already decided them for this run.
+ *
+ * @param env the applicable environment variables
+ * @return {@code true} when the current run was launched by Platform
+ */
+ static boolean isPlatformRun(Map env) {
+ return env.get('TOWER_WORKFLOW_ID') as boolean
+ }
+
+ /**
+ * Resolve the workspace effectively used for a run: the locally configured
+ * value wins, otherwise fall back to the user's server-side default workspace
+ * in Seqera Platform.
+ *
+ * The Platform lookup is supplied by the caller so that this class stays free
+ * of any I/O and of any dependency on the Platform API client.
+ *
+ * @param opts the configuration options for Platform (e.g. `session.config.navigate('tower')`)
+ * @param env the applicable environment variables
+ * @param platformDefault supplies the Platform default workspace ID, queried only when needed
+ * @return the workspace ID to use, or null to use the personal workspace
+ */
+ static String getEffectiveWorkspaceId(Map opts, Map env, Supplier platformDefault) {
+ final local = getWorkspaceId(opts, env)
+ // a local setting always wins; for a Platform-driven run the environment is authoritative
+ if( local || isPlatformRun(env) )
+ return local
+ return platformDefault.get()
+ }
+
/**
* Get the configured Platform API endpoint: if the endpoint is not provided in the configuration, we fallback to the
* environment variable `TOWER_API_ENDPOINT`. If neither is provided, we fallback to the default endpoint.
@@ -114,7 +162,7 @@ class PlatformHelper {
* @return the Platform access token
*/
static String getAccessToken(Map opts, Map env) {
- final token = env.get('TOWER_WORKFLOW_ID')
+ final token = isPlatformRun(env)
? env.get('TOWER_ACCESS_TOKEN')
: opts.containsKey('accessToken') ? opts.accessToken as String : env.get('TOWER_ACCESS_TOKEN')
return token
@@ -130,7 +178,7 @@ class PlatformHelper {
* @return the Platform refresh token
*/
static String getRefreshToken(Map opts, Map env) {
- final token = env.get('TOWER_WORKFLOW_ID')
+ final token = isPlatformRun(env)
? env.get('TOWER_REFRESH_TOKEN')
: opts.containsKey('refreshToken') ? opts.refreshToken as String : env.get('TOWER_REFRESH_TOKEN')
return token
@@ -140,13 +188,21 @@ class PlatformHelper {
* Return the Platform Workspace ID: 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 workspace ID from the environment. Otherwise, check the
* configuration or fallback to the environment. If no workspace ID is found, return null.
+ *
+ * Note for callers passing the `tower` scope of a live session config: when the user set no
+ * workspace of their own, the workspace configured as default in their Seqera Platform
+ * account is resolved during session init and written into that map, so this can return a
+ * value that appears nowhere in the user's configuration files. That is deliberate -- it is
+ * what keeps the run, Wave, the Fusion licence and the Seqera executor in the same
+ * workspace. See {@code TowerFactory.publishDefaultWorkspaceId}.
+ *
* @param opts
* @param env
* @return
*/
static String getWorkspaceId(Map opts, Map env) {
try {
- final workspaceId = env.get('TOWER_WORKFLOW_ID')
+ final workspaceId = isPlatformRun(env)
? env.get('TOWER_WORKSPACE_ID')
: opts.workspaceId as Long ?: env.get('TOWER_WORKSPACE_ID') as Long
return workspaceId
@@ -167,7 +223,7 @@ class PlatformHelper {
* @return the Platform compute environment ID, or null
*/
static String getComputeEnvId(Map opts, Map env) {
- final computeEnvId = env.get('TOWER_WORKFLOW_ID')
+ final computeEnvId = isPlatformRun(env)
? env.get('TOWER_COMPUTE_ENV_ID')
: opts.containsKey('computeEnvId') ? opts.computeEnvId as String : env.get('TOWER_COMPUTE_ENV_ID')
return computeEnvId
diff --git a/modules/nextflow/src/test/groovy/nextflow/platform/PlatformHelperTest.groovy b/modules/nextflow/src/test/groovy/nextflow/platform/PlatformHelperTest.groovy
index 7b8d792147..fddfef6627 100644
--- a/modules/nextflow/src/test/groovy/nextflow/platform/PlatformHelperTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/platform/PlatformHelperTest.groovy
@@ -18,6 +18,7 @@ package nextflow.platform
import nextflow.SysEnv
import spock.lang.Specification
+import spock.lang.Unroll
/**
* Test PlatformHelper functionality
@@ -123,4 +124,39 @@ class PlatformHelperTest extends Specification {
expect:
PlatformHelper.getComputeEnvId([:], [:]) == null
}
+
+ def 'should detect a Platform-driven run'() {
+ expect:
+ !PlatformHelper.isPlatformRun([:])
+ !PlatformHelper.isPlatformRun([TOWER_WORKSPACE_ID: '100'])
+ PlatformHelper.isPlatformRun([TOWER_WORKFLOW_ID: 'wf-1'])
+ }
+
+ @Unroll
+ def 'should resolve effective workspace id from #SOURCE'() {
+ expect:
+ PlatformHelper.getEffectiveWorkspaceId(OPTS, ENV, () -> DEFAULT) == EXPECTED
+
+ where:
+ SOURCE | OPTS | ENV | DEFAULT | EXPECTED
+ 'config' | [workspaceId: '200']| [:] | '999' | '200'
+ 'env var' | [:] | [TOWER_WORKSPACE_ID: '100'] | '999' | '100'
+ 'platform default' | [:] | [:] | '999' | '999'
+ 'nothing set' | [:] | [:] | null | null
+ // a Platform-driven run is authoritative: never override with the account default
+ 'platform run' | [:] | [TOWER_WORKFLOW_ID: 'wf-1'] | '999' | null
+ 'platform run env' | [:] | [TOWER_WORKFLOW_ID: 'wf-1', TOWER_WORKSPACE_ID: '100'] | '999' | '100'
+ }
+
+ def 'should not query the platform default when a local workspace is set'() {
+ given:
+ def queried = false
+
+ when:
+ final result = PlatformHelper.getEffectiveWorkspaceId([workspaceId: '200'], [:], () -> { queried = true; '999' })
+
+ then:
+ result == '200'
+ !queried
+ }
}
diff --git a/modules/nf-cli-v1/src/main/groovy/nextflow/cli/CmdAuth.groovy b/modules/nf-cli-v1/src/main/groovy/nextflow/cli/CmdAuth.groovy
index 84d7ca038f..c4f6f686df 100644
--- a/modules/nf-cli-v1/src/main/groovy/nextflow/cli/CmdAuth.groovy
+++ b/modules/nf-cli-v1/src/main/groovy/nextflow/cli/CmdAuth.groovy
@@ -243,8 +243,7 @@ class CmdAuth extends CmdBase implements UsageAware {
// Read config to get the actual resolved endpoint value
final builder = new ConfigCmdAdapter().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] }
+ final towerConfig = PlatformHelper.towerOpts(config)
def defaultEndpoint = PlatformHelper.getEndpoint(towerConfig, SysEnv.get())
result << 'Authenticate with Seqera Platform'
@@ -407,7 +406,7 @@ class CmdAuth extends CmdBase implements UsageAware {
*
*
Default workspace
*
Configured workspace ID and name
- *
nextflow config, env var $TOWER_WORKSPACE_ID, default (Personal)
+ *
nextflow config, env var $TOWER_WORKSPACE_ID, platform (account default), default (Personal)
*
*
*
Primary compute env
diff --git a/plugins/nf-seqera/src/main/io/seqera/executor/SeqeraExecutor.groovy b/plugins/nf-seqera/src/main/io/seqera/executor/SeqeraExecutor.groovy
index 6513c1f2b4..bcd1ce78c4 100644
--- a/plugins/nf-seqera/src/main/io/seqera/executor/SeqeraExecutor.groovy
+++ b/plugins/nf-seqera/src/main/io/seqera/executor/SeqeraExecutor.groovy
@@ -128,6 +128,8 @@ class SeqeraExecutor extends Executor implements ExtensionPoint {
final workflowId = session.workflowMetadata?.platform?.workflowId
this.workflowId = workflowId
final workflowUrl = session.workflowMetadata?.platform?.workflowUrl
+ // note: the workspace may have been resolved during session init -- see the
+ // PlatformHelper.getWorkspaceId javadoc
final workspaceId = PlatformHelper.getWorkspaceId(towerConfig, SysEnv.get()) as Long
final computeEnvId = PlatformHelper.getComputeEnvId(towerConfig, SysEnv.get()) ?: seqeraConfig.computeEnvId
diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerClient.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerClient.groovy
index b7e8828b17..b455881151 100644
--- a/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerClient.groovy
+++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerClient.groovy
@@ -24,6 +24,7 @@ import groovy.json.JsonGenerator
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import groovy.transform.CompileStatic
+import groovy.transform.Memoized
import groovy.transform.TupleConstructor
import groovy.util.logging.Slf4j
import io.seqera.http.HxClient
@@ -48,6 +49,9 @@ class TowerClient {
static final public String DEF_ENDPOINT_URL = 'https://api.cloud.seqera.io'
+ /** Seqera Platform documentation for workspace roles and permissions */
+ static final private String WORKSPACE_ROLES_URL = 'https://docs.seqera.io/platform-cloud/orgs-and-teams/roles'
+
static private final String TOKEN_PREFIX = '@token:'
@TupleConstructor
@@ -120,11 +124,11 @@ class TowerClient {
}
Map traceCreate(Map req, String workspaceId){
- return sendAndProcessRequest( getUrlTraceCreate(workspaceId), req, 'POST')
+ return sendAndProcessRequest( getUrlTraceCreate(workspaceId), req, 'POST', workspaceId)
}
Map traceBegin(Map req, String workspaceId, String workflowId){
- return sendAndProcessRequest( getUrlTraceBegin(workspaceId, workflowId), req, 'PUT')
+ return sendAndProcessRequest( getUrlTraceBegin(workspaceId, workflowId), req, 'PUT', workspaceId)
}
void traceComplete(Map req, String workspaceId, String workflowId) {
@@ -142,15 +146,8 @@ class TowerClient {
void traceProgress(Map req, String workspaceId, String workflowId) {
final url = getUrlTraceProgress( workspaceId, workflowId )
final resp = sendHttpMessage(url, req, 'PUT')
- if( resp.error ) {
- final message = """\
- Unexpected HTTP response
- - endpoint : $url
- - status code : $resp.code
- - response msg: $resp.message
- """.stripIndent(true)
- throw new AbortRunException(message)
- }
+ if( resp.error )
+ throw new AbortRunException(errorMessage(url, resp, workspaceId))
}
/**
@@ -165,20 +162,83 @@ class TowerClient {
return sendHttpMessage(url, req, 'PATCH')
}
- protected Map sendAndProcessRequest(String url, Map req, String method){
+ protected Map sendAndProcessRequest(String url, Map req, String method, String workspaceId=null){
final resp = sendHttpMessage(url, req, method)
- if( resp.error ) {
- final message = """\
- Unexpected HTTP response
- - endpoint : $url
- - status code : $resp.code
- - response msg: $resp.message
- """.stripIndent(true)
- throw new AbortRunException(message)
- }
+ if( resp.error )
+ throw new AbortRunException(errorMessage(url, resp, workspaceId))
return parseTowerResponse(resp)
}
+ /**
+ * Describe a failed request: a workspace-scoped call refused with an auth status is a
+ * common, well understood condition, so explain it rather than dumping the HTTP
+ * response. Everything else falls back to the raw response.
+ */
+ private String errorMessage(String url, Response resp, String workspaceId) {
+ final explained = workspaceId ? workspaceAccessError(workspaceId, resp.code) : null
+ return explained ?: """\
+ Unexpected HTTP response
+ - endpoint : $url
+ - status code : $resp.code
+ - response msg: $resp.message
+ """.stripIndent(true)
+ }
+
+ /**
+ * Explain why a workspace-scoped request was refused, when the reason can be
+ * determined with confidence, and say how to resolve it.
+ *
+ * Whether the account can see the workspace at all separates the two cases: a
+ * workspace that is listed for the user exists and is visible, so a refusal means
+ * the role is insufficient; one that is not listed means the ID does not identify a
+ * workspace this account can use. A 401 is deliberately not handled here -- the token
+ * itself is rejected, so nothing about the workspace can be established.
+ *
+ * @return the explanation, or null when the status is not one we can attribute
+ */
+ protected String workspaceAccessError(String workspaceId, int statusCode) {
+ if( statusCode != 403 && statusCode != 404 )
+ return null
+ // if the visibility lookup itself fails we cannot attribute the refusal with
+ // confidence, so say nothing and let the caller report the raw response
+ final List