add nextflow d30e48d
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.tower.plugin
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
|
||||
import nextflow.SysEnv
|
||||
import nextflow.exception.AbortOperationException
|
||||
import spock.lang.Specification
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class CacheManagerTest extends Specification {
|
||||
|
||||
def setupSpec() {
|
||||
SysEnv.push([:])
|
||||
}
|
||||
|
||||
def cleanupSpec() {
|
||||
SysEnv.pop()
|
||||
}
|
||||
|
||||
def 'should init empty files' () {
|
||||
when:
|
||||
new CacheManager([:])
|
||||
then:
|
||||
thrown(AbortOperationException)
|
||||
}
|
||||
|
||||
def 'should upload cache files' () {
|
||||
given:
|
||||
def folder = Files.createTempDirectory('test')
|
||||
def remote = folder.resolve('remote'); remote.mkdir()
|
||||
def local = folder.resolve('local'); local.mkdir()
|
||||
def outFile = local.resolve('nf-out.txt'); outFile.text = 'out file'
|
||||
def logFile = local.resolve('nf-log.txt'); logFile.text = 'log file'
|
||||
def tmlFile = local.resolve('nf-tml.txt'); tmlFile.text = 'tml file'
|
||||
def cfgFile = local.resolve('tw-config.txt'); cfgFile.text = 'config file'
|
||||
def repFile = local.resolve('tw-report.txt'); repFile.text = 'report file'
|
||||
and:
|
||||
def uuid = UUID.randomUUID().toString()
|
||||
and:
|
||||
def ENV = [
|
||||
NXF_UUID:uuid,
|
||||
NXF_WORK: remote.toString(),
|
||||
NXF_OUT_FILE: outFile.toString(),
|
||||
NXF_LOG_FILE: logFile.toString(),
|
||||
NXF_TML_FILE: tmlFile.toString(),
|
||||
TOWER_CONFIG_FILE: cfgFile.toString(),
|
||||
TOWER_REPORTS_FILE: repFile.toString(),
|
||||
]
|
||||
|
||||
when:
|
||||
def tower = new CacheManager(ENV)
|
||||
then:
|
||||
tower.sessionUuid == uuid
|
||||
tower.localCachePath == Paths.get(".nextflow/cache/$uuid")
|
||||
tower.localOutFile == outFile
|
||||
tower.localLogFile == logFile
|
||||
tower.localTimelineFile == tmlFile
|
||||
tower.localTowerConfig == cfgFile
|
||||
tower.localTowerReports == repFile
|
||||
and:
|
||||
tower.remoteWorkDir == remote
|
||||
and:
|
||||
tower.remoteCachePath == remote.resolve(".nextflow/cache/$uuid")
|
||||
tower.remoteOutFile == remote.resolve( outFile.name )
|
||||
tower.remoteLogFile == remote.resolve( logFile.name )
|
||||
tower.remoteTimelineFile == remote.resolve( tmlFile.name )
|
||||
tower.remoteTowerConfig == remote.resolve( cfgFile.name )
|
||||
tower.remoteTowerReports == remote.resolve( repFile.name )
|
||||
|
||||
when:
|
||||
// create local cache fake data
|
||||
tower.localCachePath = local.resolve(".nextflow/cache/$uuid");
|
||||
tower.localCachePath.mkdirs()
|
||||
tower.localCachePath.resolve('index-foo').text = 'index foo'
|
||||
tower.localCachePath.resolve('db').mkdir()
|
||||
tower.localCachePath.resolve('db/xxx').text = 'data xxx'
|
||||
tower.localCachePath.resolve('db/yyy').text = 'data yyy'
|
||||
and:
|
||||
tower.saveCacheFiles()
|
||||
then:
|
||||
tower.remoteCachePath.resolve('index-foo').text == 'index foo'
|
||||
tower.remoteCachePath.resolve('db/xxx').text == 'data xxx'
|
||||
tower.remoteCachePath.resolve('db/yyy').text == 'data yyy'
|
||||
and:
|
||||
tower.remoteOutFile.text == outFile.text
|
||||
tower.remoteLogFile.text == logFile.text
|
||||
tower.remoteTimelineFile.text == tmlFile.text
|
||||
tower.remoteTowerConfig.text == cfgFile.text
|
||||
tower.remoteTowerReports.text == repFile.text
|
||||
|
||||
// simulate a 2nd run with different data
|
||||
when:
|
||||
tower.localCachePath.deleteDir()
|
||||
tower.localCachePath.mkdirs()
|
||||
tower.localCachePath.resolve('index-bar').text = 'index bar'
|
||||
tower.localCachePath.resolve('db').mkdir()
|
||||
tower.localCachePath.resolve('db/alpha').text = 'data alpha'
|
||||
tower.localCachePath.resolve('db/delta').text = 'data delta'
|
||||
and:
|
||||
tower.saveCacheFiles()
|
||||
then:
|
||||
tower.remoteCachePath.resolve('index-bar').text == 'index bar'
|
||||
tower.remoteCachePath.resolve('db/alpha').text == 'data alpha'
|
||||
tower.remoteCachePath.resolve('db/delta').text == 'data delta'
|
||||
and:
|
||||
!tower.remoteCachePath.resolve('index-foo').exists()
|
||||
!tower.remoteCachePath.resolve('db/xxx').exists()
|
||||
!tower.remoteCachePath.resolve('db/yyy').exists()
|
||||
and:
|
||||
tower.remoteOutFile.text == outFile.text
|
||||
tower.remoteLogFile.text == logFile.text
|
||||
tower.remoteTimelineFile.text == tmlFile.text
|
||||
tower.remoteTowerConfig.text == cfgFile.text
|
||||
tower.remoteTowerReports.text == repFile.text
|
||||
|
||||
cleanup:
|
||||
folder?.deleteDir()
|
||||
}
|
||||
|
||||
def 'should upload log files even when local cache path does not exist' () {
|
||||
given:
|
||||
def folder = Files.createTempDirectory('test')
|
||||
def remote = folder.resolve('remote'); remote.mkdir()
|
||||
def local = folder.resolve('local'); local.mkdir()
|
||||
def outFile = local.resolve('nf-out.txt'); outFile.text = 'out file'
|
||||
def logFile = local.resolve('nf-log.txt'); logFile.text = 'log file'
|
||||
def tmlFile = local.resolve('nf-tml.txt'); tmlFile.text = 'tml file'
|
||||
def cfgFile = local.resolve('tw-config.txt'); cfgFile.text = 'config file'
|
||||
def repFile = local.resolve('tw-report.txt'); repFile.text = 'report file'
|
||||
and:
|
||||
def uuid = UUID.randomUUID().toString()
|
||||
and:
|
||||
def ENV = [
|
||||
NXF_UUID: uuid,
|
||||
NXF_WORK: remote.toString(),
|
||||
NXF_OUT_FILE: outFile.toString(),
|
||||
NXF_LOG_FILE: logFile.toString(),
|
||||
NXF_TML_FILE: tmlFile.toString(),
|
||||
TOWER_CONFIG_FILE: cfgFile.toString(),
|
||||
TOWER_REPORTS_FILE: repFile.toString(),
|
||||
]
|
||||
|
||||
when:
|
||||
def tower = new CacheManager(ENV)
|
||||
// do NOT create localCachePath — simulates K8s where cache may not exist locally
|
||||
tower.saveCacheFiles()
|
||||
then:
|
||||
// metadata cache is not copied
|
||||
!tower.remoteCachePath.exists()
|
||||
and:
|
||||
// log files are still copied
|
||||
tower.remoteOutFile.text == outFile.text
|
||||
tower.remoteLogFile.text == logFile.text
|
||||
tower.remoteTimelineFile.text == tmlFile.text
|
||||
tower.remoteTowerConfig.text == cfgFile.text
|
||||
tower.remoteTowerReports.text == repFile.text
|
||||
|
||||
cleanup:
|
||||
folder?.deleteDir()
|
||||
}
|
||||
|
||||
def 'should download cache files' () {
|
||||
given:
|
||||
def uuid = UUID.randomUUID().toString()
|
||||
def folder = Files.createTempDirectory('test')
|
||||
def local = folder.resolve('local'); local.mkdir()
|
||||
def outFile = local.resolve('nf-out.txt');
|
||||
def logFile = local.resolve('nf-log.txt')
|
||||
def tmlFile = local.resolve('nf-tml.txt')
|
||||
def cfgFile = local.resolve('tw-config.txt')
|
||||
def repFile = local.resolve('tw-report.txt')
|
||||
and:
|
||||
def remote = folder.resolve('remote'); remote.mkdir()
|
||||
remote.resolve('nf-out.txt').text = 'the out file'
|
||||
remote.resolve('nf-log.txt').text = 'the log file'
|
||||
remote.resolve('nf-tml.txt').text = 'the timeline file'
|
||||
remote.resolve('nf-config.txt').text = 'the config file'
|
||||
remote.resolve('nf-report.txt').text = 'the report file'
|
||||
and:
|
||||
remote.resolve(".nextflow/cache/$uuid").mkdirs()
|
||||
remote.resolve(".nextflow/cache/$uuid").resolve('index-bar').text = 'index bar'
|
||||
remote.resolve(".nextflow/cache/$uuid").resolve('db').mkdirs()
|
||||
remote.resolve(".nextflow/cache/$uuid").resolve('db/alpha').text = 'data alpha'
|
||||
remote.resolve(".nextflow/cache/$uuid").resolve('db/delta').text = 'data delta'
|
||||
and:
|
||||
def tower = new CacheManager([NXF_UUID: uuid, NXF_WORK: remote.toString()])
|
||||
|
||||
when:
|
||||
tower.restoreCacheFiles()
|
||||
then:
|
||||
tower.localCachePath.resolve('index-bar').text == 'index bar'
|
||||
tower.localCachePath.resolve('db/alpha').text == 'data alpha'
|
||||
tower.localCachePath.resolve('db/delta').text == 'data delta'
|
||||
|
||||
cleanup:
|
||||
folder?.deleteDir()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.tower.plugin
|
||||
|
||||
import nextflow.Session
|
||||
import nextflow.SysEnv
|
||||
import nextflow.util.Duration
|
||||
import spock.lang.Specification
|
||||
import test.TestHelper
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class LogsCheckpointTest extends Specification {
|
||||
|
||||
def 'should configure default delay' () {
|
||||
given:
|
||||
def session = Mock(Session) {
|
||||
getWorkDir() >> TestHelper.createInMemTempDir()
|
||||
getConfig() >> [:]
|
||||
}
|
||||
and:
|
||||
def checkpoint = new LogsCheckpoint()
|
||||
|
||||
when:
|
||||
checkpoint.onFlowCreate(session)
|
||||
then:
|
||||
checkpoint.@interval == Duration.of('90s')
|
||||
}
|
||||
|
||||
def 'should configure delay via env var' () {
|
||||
given:
|
||||
SysEnv.push(TOWER_LOGS_CHECKPOINT_INTERVAL: '200s')
|
||||
def session = Mock(Session) {
|
||||
getWorkDir() >> TestHelper.createInMemTempDir()
|
||||
getConfig() >> [:]
|
||||
}
|
||||
and:
|
||||
def checkpoint = new LogsCheckpoint()
|
||||
|
||||
when:
|
||||
checkpoint.onFlowCreate(session)
|
||||
then:
|
||||
checkpoint.@interval == Duration.of('200s')
|
||||
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
}
|
||||
|
||||
def 'should configure delay via config file' () {
|
||||
given:
|
||||
SysEnv.push(NXF_WORK: '/some/path', TOWER_LOGS_CHECKPOINT_INTERVAL: '200s')
|
||||
def session = Mock(Session) {
|
||||
getConfig()>>[tower:[logs:[checkpoint:[interval: '500s']]]]
|
||||
getWorkDir() >> TestHelper.createInMemTempDir()
|
||||
}
|
||||
and:
|
||||
def checkpoint = new LogsCheckpoint()
|
||||
|
||||
when:
|
||||
checkpoint.onFlowCreate(session)
|
||||
then:
|
||||
checkpoint.@interval == Duration.of('500s')
|
||||
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.tower.plugin
|
||||
|
||||
import java.nio.file.Files
|
||||
|
||||
import nextflow.Session
|
||||
import nextflow.exception.AbortOperationException
|
||||
import spock.lang.Specification
|
||||
import test.TestHelper
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class LogsHandlerTest extends Specification {
|
||||
|
||||
def 'should init empty files' () {
|
||||
when:
|
||||
new LogsHandler(Mock(Session), [:])
|
||||
then:
|
||||
thrown(AbortOperationException)
|
||||
}
|
||||
|
||||
def 'should upload cache files' () {
|
||||
given:
|
||||
def folder = Files.createTempDirectory('test')
|
||||
def remote = TestHelper.createInMemTempDir()
|
||||
def local = folder.resolve('local'); local.mkdir()
|
||||
def outFile = local.resolve('nf-out.txt'); outFile.text = 'out file'
|
||||
def logFile = local.resolve('nf-log.txt'); logFile.text = 'log file'
|
||||
def tmlFile = local.resolve('nf-tml.txt'); tmlFile.text = 'tml file'
|
||||
def cfgFile = local.resolve('tw-config.txt'); cfgFile.text = 'config file'
|
||||
def repFile = local.resolve('tw-report.txt'); repFile.text = 'report file'
|
||||
and:
|
||||
def uuid = UUID.randomUUID().toString()
|
||||
and:
|
||||
def session = Mock(Session) {getWorkDir() >> remote }
|
||||
def ENV = [
|
||||
NXF_UUID:uuid,
|
||||
NXF_OUT_FILE: outFile.toString(),
|
||||
NXF_LOG_FILE: logFile.toString(),
|
||||
NXF_TML_FILE: tmlFile.toString(),
|
||||
TOWER_CONFIG_FILE: cfgFile.toString(),
|
||||
TOWER_REPORTS_FILE: repFile.toString(),
|
||||
]
|
||||
|
||||
when:
|
||||
def tower = new LogsHandler(session, ENV)
|
||||
then:
|
||||
tower.localOutFile == outFile
|
||||
tower.localLogFile == logFile
|
||||
tower.localTimelineFile == tmlFile
|
||||
tower.localTowerConfig == cfgFile
|
||||
tower.localTowerReports == repFile
|
||||
and:
|
||||
tower.remoteWorkDir == remote
|
||||
and:
|
||||
tower.remoteOutFile == remote.resolve( outFile.name )
|
||||
tower.remoteLogFile == remote.resolve( logFile.name )
|
||||
tower.remoteTimelineFile == remote.resolve( tmlFile.name )
|
||||
tower.remoteTowerConfig == remote.resolve( cfgFile.name )
|
||||
tower.remoteTowerReports == remote.resolve( repFile.name )
|
||||
|
||||
when:
|
||||
// create local cache fake data
|
||||
tower.saveFiles()
|
||||
then:
|
||||
tower.remoteOutFile.text == outFile.text
|
||||
tower.remoteLogFile.text == logFile.text
|
||||
tower.remoteTimelineFile.text == tmlFile.text
|
||||
tower.remoteTowerConfig.text == cfgFile.text
|
||||
tower.remoteTowerReports.text == repFile.text
|
||||
|
||||
cleanup:
|
||||
folder?.deleteDir()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.tower.plugin
|
||||
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Instant
|
||||
|
||||
import com.github.tomakehurst.wiremock.WireMockServer
|
||||
import com.github.tomakehurst.wiremock.client.WireMock
|
||||
import io.seqera.http.HxClient
|
||||
import nextflow.exception.AbortRunException
|
||||
import nextflow.util.Duration
|
||||
import spock.lang.Specification
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class TowerClientTest extends Specification {
|
||||
|
||||
protected boolean aroundNow(value) {
|
||||
def now = Instant.now().toEpochMilli()
|
||||
value > now-1_000 && value <= now
|
||||
}
|
||||
|
||||
def 'should parse response' () {
|
||||
given:
|
||||
def tower = new TowerClient()
|
||||
|
||||
when:
|
||||
def resp = new TowerClient.Response(200, '{"status":"OK", "workflowId":"12345", "watchUrl": "http://foo.com/watch/12345"}')
|
||||
def result = tower.parseTowerResponse(resp)
|
||||
then:
|
||||
result.workflowId == '12345'
|
||||
result.watchUrl == 'http://foo.com/watch/12345'
|
||||
|
||||
when:
|
||||
resp = new TowerClient.Response(500, '{"status":"OK", "workflowId":"12345"}')
|
||||
tower.parseTowerResponse(resp)
|
||||
then:
|
||||
thrown(Exception)
|
||||
}
|
||||
|
||||
def 'should validate URL' () {
|
||||
given:
|
||||
def observer = new TowerClient()
|
||||
|
||||
expect:
|
||||
observer.checkUrl('http://localhost') == 'http://localhost'
|
||||
observer.checkUrl('http://google.com') == 'http://google.com'
|
||||
observer.checkUrl('https://google.com') == 'https://google.com'
|
||||
observer.checkUrl('http://google.com:8080') == 'http://google.com:8080'
|
||||
observer.checkUrl('http://google.com:8080/') == 'http://google.com:8080'
|
||||
observer.checkUrl('http://google.com:8080/foo/bar') == 'http://google.com:8080/foo/bar'
|
||||
observer.checkUrl('http://google.com:8080/foo/bar/') == 'http://google.com:8080/foo/bar'
|
||||
observer.checkUrl('http://google.com:8080/foo/bar///') == 'http://google.com:8080/foo/bar'
|
||||
|
||||
when:
|
||||
observer.checkUrl('ftp://localhost')
|
||||
then:
|
||||
def e = thrown(IllegalArgumentException)
|
||||
e.message == 'Only http and https are supported -- The given URL was: ftp://localhost'
|
||||
}
|
||||
|
||||
def 'should get watch url' () {
|
||||
given:
|
||||
def observer = new TowerClient()
|
||||
expect:
|
||||
observer.getHostUrl(STR) == EXPECTED
|
||||
where:
|
||||
STR | EXPECTED
|
||||
'http://foo.com' | 'http://foo.com'
|
||||
'http://foo.com:800/' | 'http://foo.com:800'
|
||||
'https://foo.com:800/' | 'https://foo.com:800'
|
||||
'http://foo.com:8000/this/that' | 'http://foo.com:8000'
|
||||
}
|
||||
|
||||
def 'should get access token' () {
|
||||
when:
|
||||
def config = new TowerConfig([accessToken: 'abc'], [TOWER_ACCESS_TOKEN: 'xyz'])
|
||||
def client = new TowerClient(config)
|
||||
then:
|
||||
// the token in the config overrides the one in the env
|
||||
client.getAccessToken() == 'abc'
|
||||
|
||||
when:
|
||||
config = new TowerConfig([accessToken: 'abc'], [TOWER_ACCESS_TOKEN: 'xyz', TOWER_WORKFLOW_ID: '111222333'])
|
||||
client = new TowerClient(config)
|
||||
then:
|
||||
// the token from the env is taken because is a tower launch aka TOWER_WORKFLOW_ID is set
|
||||
client.getAccessToken() == 'xyz'
|
||||
|
||||
when:
|
||||
config = new TowerConfig([:], [TOWER_ACCESS_TOKEN: 'xyz'])
|
||||
client = new TowerClient(config)
|
||||
then:
|
||||
client.getAccessToken() == 'xyz'
|
||||
|
||||
when:
|
||||
def c = new TowerClient()
|
||||
c.getAccessToken()
|
||||
then:
|
||||
thrown(AbortRunException)
|
||||
}
|
||||
|
||||
def 'should set the auth token' () {
|
||||
given:
|
||||
def http = Mock(HxClient.Builder)
|
||||
def client = new TowerClient()
|
||||
and:
|
||||
def SIMPLE = '4ffbf1009ebabea77db3d72efefa836dfbb71271'
|
||||
def BEARER = 'eyJ0aWQiOiA1fS5jZmM1YjVhOThjZjM2MTk1NjBjZWU1YmMwODUxYzA1ZjkzMDdmN2Iz'
|
||||
|
||||
when:
|
||||
client.setupClientAuth(http, SIMPLE)
|
||||
then:
|
||||
1 * http.basicAuth('@token:' + SIMPLE) >> http
|
||||
|
||||
when:
|
||||
client.setupClientAuth(http, SIMPLE)
|
||||
then:
|
||||
1 * http.basicAuth('@token:' + SIMPLE) >> http
|
||||
|
||||
when:
|
||||
client.setupClientAuth(http, BEARER)
|
||||
then:
|
||||
1 * http.bearerToken(BEARER) >> http
|
||||
1 * http.refreshToken(_) >> http
|
||||
1 * http.refreshTokenUrl(_) >> http
|
||||
}
|
||||
|
||||
def 'should get trace endpoint' () {
|
||||
given:
|
||||
def client = new TowerClient()
|
||||
client.@endpoint = TowerClient.DEF_ENDPOINT_URL
|
||||
|
||||
expect:
|
||||
client.getUrlTraceCreate(null) == 'https://api.cloud.seqera.io/trace/create'
|
||||
client.getUrlTraceBegin(null, '12345') == 'https://api.cloud.seqera.io/trace/12345/begin'
|
||||
client.getUrlTraceProgress(null, '12345') == 'https://api.cloud.seqera.io/trace/12345/progress'
|
||||
client.getUrlTraceHeartbeat(null, '12345') == 'https://api.cloud.seqera.io/trace/12345/heartbeat'
|
||||
client.getUrlTraceComplete(null, '12345') == 'https://api.cloud.seqera.io/trace/12345/complete'
|
||||
}
|
||||
|
||||
def 'should get trace endpoint with workspace' () {
|
||||
given:
|
||||
def client = new TowerClient()
|
||||
client.@endpoint = TowerClient.DEF_ENDPOINT_URL
|
||||
|
||||
expect:
|
||||
client.getUrlTraceCreate('300') == 'https://api.cloud.seqera.io/trace/create?workspaceId=300'
|
||||
client.getUrlTraceBegin('300', '12345') == 'https://api.cloud.seqera.io/trace/12345/begin?workspaceId=300'
|
||||
client.getUrlTraceProgress('300', '12345') == 'https://api.cloud.seqera.io/trace/12345/progress?workspaceId=300'
|
||||
client.getUrlTraceHeartbeat('300', '12345') == 'https://api.cloud.seqera.io/trace/12345/heartbeat?workspaceId=300'
|
||||
client.getUrlTraceComplete('300', '12345') == 'https://api.cloud.seqera.io/trace/12345/complete?workspaceId=300'
|
||||
}
|
||||
|
||||
def 'should load schema col len' () {
|
||||
given:
|
||||
def tower = new TowerClient()
|
||||
|
||||
when:
|
||||
def schema = tower.loadSchema()
|
||||
then:
|
||||
schema.get('workflow.start') == null
|
||||
schema.get('workflow.profile') == 100
|
||||
schema.get('workflow.projectDir') == 255
|
||||
}
|
||||
|
||||
def 'should handle HTTP request with content'() {
|
||||
given: 'a TowerClient'
|
||||
def tower = new TowerClient()
|
||||
def content = '{"test": "data"}'
|
||||
def request = tower.makeRequest('http://example.com/test', content, 'POST')
|
||||
|
||||
expect: 'the request should be created with the content'
|
||||
request != null
|
||||
request.method() == 'POST'
|
||||
request.uri().toString() == 'http://example.com/test'
|
||||
}
|
||||
|
||||
def 'should send http message' () {
|
||||
given:
|
||||
def client = Mock(HxClient)
|
||||
def tower = new TowerClient()
|
||||
tower.@httpClient = client
|
||||
|
||||
when:
|
||||
def resp = tower.sendHttpMessage('http://foo.com', [foo: 'bar'], 'POST')
|
||||
then:
|
||||
1 * client.sendAsString(_) >> Mock(HttpResponse) { statusCode() >> 200; body() >> '{}' }
|
||||
and:
|
||||
!resp.error
|
||||
resp.code == 200
|
||||
}
|
||||
|
||||
def 'should return error response on http request timeout' () {
|
||||
given: 'a WireMock server that hangs for 5 seconds'
|
||||
def wireMock = new WireMockServer(0)
|
||||
wireMock.start()
|
||||
wireMock.stubFor(
|
||||
WireMock.post(WireMock.anyUrl())
|
||||
.willReturn(WireMock.aResponse()
|
||||
.withFixedDelay(5_000)
|
||||
.withStatus(200)
|
||||
.withBody('{}'))
|
||||
)
|
||||
|
||||
and: 'a TowerClient whose requests carry a 200ms timeout'
|
||||
TowerConfig config = Mock(TowerConfig) {
|
||||
getHttpReadTimeout() >> Duration.of('200 ms')
|
||||
getHttpConnectTimeout() >> Duration.of('5 s')
|
||||
getEndpoint() >> wireMock.baseUrl()
|
||||
getAccessToken() >> 'token'
|
||||
}
|
||||
TowerClient client = new TowerClient(config)
|
||||
|
||||
when:
|
||||
def response = client.sendHttpMessage("${wireMock.baseUrl()}/trace/create", [runName: 'test'], 'POST')
|
||||
|
||||
then: 'a timeout produces an error response with code 0'
|
||||
response.code == 0
|
||||
response.message.contains('Unable to connect')
|
||||
|
||||
cleanup:
|
||||
wireMock.stop()
|
||||
}
|
||||
|
||||
def 'should build URL without query params'() {
|
||||
given:
|
||||
def client = new TowerClient()
|
||||
client.@endpoint = 'https://api.cloud.seqera.io'
|
||||
|
||||
when:
|
||||
def url = client.buildUrl( '/workflow/launch', [:])
|
||||
|
||||
then:
|
||||
url == 'https://api.cloud.seqera.io/workflow/launch'
|
||||
}
|
||||
|
||||
def 'should build URL with query params'() {
|
||||
given:
|
||||
def client = new TowerClient()
|
||||
client.@endpoint = 'https://api.cloud.seqera.io'
|
||||
|
||||
when:
|
||||
def url = client.buildUrl( '/workflow/launch', [workspaceId: '12345'])
|
||||
|
||||
then:
|
||||
url.contains('https://api.cloud.seqera.io/workflow/launch?')
|
||||
url.contains('workspaceId=12345')
|
||||
}
|
||||
|
||||
def 'should URL encode query params'() {
|
||||
given:
|
||||
def client = new TowerClient()
|
||||
client.@endpoint = 'https://api.cloud.seqera.io'
|
||||
|
||||
when:
|
||||
def url = client.buildUrl( '/workflow', [name: 'test workflow'])
|
||||
|
||||
then:
|
||||
url.contains('name=test+workflow')
|
||||
}
|
||||
|
||||
def 'should send AbortRunException in selected client calls'() {
|
||||
given:
|
||||
def client = Spy(new TowerClient(new TowerConfig([:], [TOWER_ACCESS_TOKEN: 'token']))){
|
||||
sendHttpMessage(_,_,_) >> new TowerClient.Response(401)
|
||||
}
|
||||
|
||||
when:
|
||||
client.traceCreate([:], '1234')
|
||||
then:
|
||||
thrown(AbortRunException)
|
||||
|
||||
when:
|
||||
client.traceBegin([:], '1234', '5678')
|
||||
then:
|
||||
thrown(AbortRunException)
|
||||
|
||||
when:
|
||||
client.traceProgress([:], '1234', '5678')
|
||||
then:
|
||||
thrown(AbortRunException)
|
||||
|
||||
when:
|
||||
client.traceComplete([:], '1234', '5678')
|
||||
then:
|
||||
notThrown(AbortRunException)
|
||||
|
||||
when:
|
||||
client.traceHeartbeat([:], '1234', '5678')
|
||||
then:
|
||||
notThrown(AbortRunException)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.tower.plugin
|
||||
|
||||
import nextflow.util.Duration
|
||||
import spock.lang.Specification
|
||||
|
||||
/**
|
||||
* Unit tests for TowerConfig
|
||||
*/
|
||||
class TowerConfigTest extends Specification {
|
||||
|
||||
def 'should use default endpoint when not specified'() {
|
||||
when:
|
||||
def config = new TowerConfig([:], [TOWER_API_ENDPOINT: 'https://example.com'])
|
||||
then:
|
||||
config.endpoint == 'https://example.com'
|
||||
|
||||
when:
|
||||
config = new TowerConfig([:], [:])
|
||||
then:
|
||||
config.endpoint == 'https://api.cloud.seqera.io'
|
||||
}
|
||||
|
||||
def 'should use default timeout values when not specified'() {
|
||||
when:
|
||||
def config = new TowerConfig([:], [:])
|
||||
|
||||
then:
|
||||
config.httpConnectTimeout == Duration.of('60s')
|
||||
config.httpReadTimeout == Duration.of('60s')
|
||||
}
|
||||
|
||||
def 'should use provided connect timeout when specified'() {
|
||||
when:
|
||||
def config = new TowerConfig([httpConnectTimeout: Duration.of('30s')], [:])
|
||||
|
||||
then:
|
||||
config.httpConnectTimeout == Duration.of('30s')
|
||||
config.httpReadTimeout == Duration.of('60s')
|
||||
}
|
||||
|
||||
def 'should use provided read timeout when specified'() {
|
||||
when:
|
||||
def config = new TowerConfig([httpReadTimeout: Duration.of('120s')], [:])
|
||||
|
||||
then:
|
||||
config.httpConnectTimeout == Duration.of('60s')
|
||||
config.httpReadTimeout == Duration.of('120s')
|
||||
}
|
||||
|
||||
def 'should parse timeout from string value'() {
|
||||
when:
|
||||
def config = new TowerConfig([httpConnectTimeout: '5s', httpReadTimeout: '2m'], [:])
|
||||
|
||||
then:
|
||||
config.httpConnectTimeout == Duration.of('5s')
|
||||
config.httpReadTimeout == Duration.of('2m')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.tower.plugin
|
||||
|
||||
import nextflow.Session
|
||||
import spock.lang.Specification
|
||||
import spock.lang.Unroll
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class TowerFactoryTest extends Specification {
|
||||
|
||||
def 'should create a tower observer' () {
|
||||
given:
|
||||
def factory = new TowerFactory(env: [TOWER_ACCESS_TOKEN: '123'])
|
||||
|
||||
when:
|
||||
def session = Mock(Session) { getConfig() >> [tower: [enabled: true]] }
|
||||
def observer = factory.create(session)[0] as TowerObserver
|
||||
then:
|
||||
observer.@client.endpoint == TowerClient.DEF_ENDPOINT_URL
|
||||
|
||||
when:
|
||||
session = Mock(Session) { getConfig() >> [tower: [enabled: true, endpoint:'http://foo.com/api', accessToken: 'xyz']] }
|
||||
observer = factory.create(session)[0] as TowerObserver
|
||||
then:
|
||||
observer.@client.endpoint == 'http://foo.com/api'
|
||||
}
|
||||
|
||||
def 'should not create a tower observer' () {
|
||||
given:
|
||||
def session = Mock(Session)
|
||||
def factory = new TowerFactory()
|
||||
|
||||
when:
|
||||
def result = factory.create(session)
|
||||
then:
|
||||
session.getConfig() >> [:]
|
||||
then:
|
||||
result == []
|
||||
}
|
||||
|
||||
def 'should create with workspace id'() {
|
||||
//
|
||||
// the workspace id is taken from the env
|
||||
//
|
||||
when:
|
||||
def session = Mock(Session) { getConfig() >> [tower: [enabled: true, accessToken: 'xyz']] }
|
||||
def factory = new TowerFactory(env: [TOWER_WORKSPACE_ID: '100'])
|
||||
def observer = (TowerObserver) factory.create(session)[0]
|
||||
then:
|
||||
observer.getWorkspaceId() == '100'
|
||||
|
||||
//
|
||||
// the workspace id is taken from the config
|
||||
//
|
||||
when:
|
||||
session = Mock(Session) { getConfig() >> [tower: [enabled: true, workspaceId: '200', accessToken: 'xyz']] }
|
||||
factory = new TowerFactory(env: [:])
|
||||
observer = (TowerObserver) factory.create(session)[0]
|
||||
then:
|
||||
observer.getWorkspaceId() == '200'
|
||||
|
||||
//
|
||||
// the workspace id is set both in the config and the env
|
||||
// the config has the priority
|
||||
//
|
||||
when:
|
||||
session = Mock(Session) { getConfig() >> [tower: [enabled: true, workspaceId: '200', accessToken: 'xyz']] }
|
||||
factory = new TowerFactory(env: [TOWER_WORKSPACE_ID: '100'])
|
||||
observer = (TowerObserver) factory.create(session)[0]
|
||||
then:
|
||||
observer.getWorkspaceId() == '200'
|
||||
|
||||
//
|
||||
// when TOWER_WORKFLOW_ID is set is a tower launch
|
||||
// then the workspace id is only taken from the env
|
||||
//
|
||||
when:
|
||||
session = Mock(Session) { getConfig() >> [tower: [enabled: true, workspaceId: '200', accessToken: 'xyz']] }
|
||||
factory = new TowerFactory(env: [TOWER_WORKSPACE_ID: '100', TOWER_WORKFLOW_ID: '111222333', TOWER_ACCESS_TOKEN: 'xyz'])
|
||||
observer = (TowerObserver) factory.create(session)[0]
|
||||
then:
|
||||
observer.getWorkspaceId() == '100'
|
||||
|
||||
//
|
||||
// when enabled is false but `TOWER_WORKFLOW_ID` is provided
|
||||
// then the observer should be created
|
||||
//
|
||||
when:
|
||||
session = Mock(Session) { getConfig() >> [tower: [enabled: false]]}
|
||||
factory = new TowerFactory(env: [TOWER_WORKSPACE_ID: '100', TOWER_WORKFLOW_ID: '111222333', TOWER_ACCESS_TOKEN: 'xyz'])
|
||||
observer = (TowerObserver) factory.create(session)[0]
|
||||
then:
|
||||
observer.getWorkspaceId() == '100'
|
||||
}
|
||||
|
||||
@Unroll
|
||||
def 'should create tower http auth provider' () {
|
||||
given:
|
||||
def factory = new TowerFactory()
|
||||
and:
|
||||
def provider = factory.provider('https://tower.nf', 'xyz123')
|
||||
and:
|
||||
def conn = Spy(HttpURLConnection) {
|
||||
getURL() >> new URL(URL_STR)
|
||||
}
|
||||
|
||||
expect:
|
||||
provider.authorize(conn) == EXPECTED
|
||||
and:
|
||||
conn.getRequestProperty('Authorization') == AUTH
|
||||
|
||||
where:
|
||||
URL_STR | EXPECTED | AUTH
|
||||
'http://foo.com' | false | null
|
||||
'https://tower.nf/' | true | 'Bearer xyz123'
|
||||
'https://tower.nf/this/that' | true | 'Bearer xyz123'
|
||||
'HTTPS://TOWER.NF/THIS/THAT' | true | 'Bearer xyz123'
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.tower.plugin
|
||||
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.*
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
import com.github.tomakehurst.wiremock.WireMockServer
|
||||
import com.github.tomakehurst.wiremock.client.WireMock
|
||||
import com.github.tomakehurst.wiremock.stubbing.Scenario
|
||||
import com.google.gson.GsonBuilder
|
||||
import io.seqera.tower.plugin.exception.UnauthorizedException
|
||||
import nextflow.Global
|
||||
import nextflow.Session
|
||||
import nextflow.SysEnv
|
||||
import nextflow.exception.AbortRunException
|
||||
import nextflow.script.WorkflowMetadata
|
||||
import nextflow.serde.gson.InstantAdapter
|
||||
import spock.lang.Shared
|
||||
import spock.lang.Specification
|
||||
/**
|
||||
* Test cases for the TowerFusionEnv class.
|
||||
*
|
||||
* @author Alberto Miranda <alberto.miranda@seqera.io>
|
||||
*/
|
||||
class TowerFusionEnvTest extends Specification {
|
||||
|
||||
@Shared
|
||||
WireMockServer wireMockServer
|
||||
|
||||
def setupSpec() {
|
||||
wireMockServer = new WireMockServer(0)
|
||||
wireMockServer.start()
|
||||
}
|
||||
|
||||
def cleanupSpec() {
|
||||
wireMockServer.stop()
|
||||
}
|
||||
|
||||
def setup() {
|
||||
wireMockServer.resetAll()
|
||||
SysEnv.push([:]) // <-- ensure the system host env does not interfere
|
||||
}
|
||||
|
||||
def cleanup() {
|
||||
SysEnv.pop() // <-- restore the system host env
|
||||
}
|
||||
|
||||
static String toJson(Object obj) {
|
||||
new GsonBuilder()
|
||||
.registerTypeAdapter(Instant, new InstantAdapter())
|
||||
.create()
|
||||
.toJson(obj)
|
||||
}
|
||||
|
||||
def 'should return the endpoint from the config'() {
|
||||
given: 'a session'
|
||||
SysEnv.push(['TOWER_API_ENDPOINT': 'https://tower.nf', 'TOWER_ACCESS_TOKEN': 'abc123'])
|
||||
Global.session = Mock(Session) {
|
||||
config >> [
|
||||
tower: [
|
||||
endpoint: 'https://tower.nf'
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
when: 'the provider is created'
|
||||
def provider = new TowerFusionToken()
|
||||
|
||||
then: 'the endpoint has the expected value'
|
||||
provider.endpoint == 'https://tower.nf'
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
}
|
||||
|
||||
def 'should return the endpoint from the environment'() {
|
||||
setup:
|
||||
SysEnv.push(['TOWER_API_ENDPOINT': 'https://tower.nf', 'TOWER_ACCESS_TOKEN': 'abc123'])
|
||||
Global.session = Mock(Session) {
|
||||
config >> [:]
|
||||
}
|
||||
|
||||
when: 'the provider is created'
|
||||
def provider = new TowerFusionToken()
|
||||
|
||||
then: 'the endpoint has the expected value'
|
||||
provider.endpoint == 'https://tower.nf'
|
||||
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
}
|
||||
|
||||
def 'should return the default endpoint'() {
|
||||
SysEnv.push(['TOWER_ACCESS_TOKEN': 'abc123'])
|
||||
when: 'session config is empty'
|
||||
Global.session = Mock(Session) {
|
||||
config >> [
|
||||
tower: [:]
|
||||
]
|
||||
}
|
||||
def provider = new TowerFusionToken()
|
||||
|
||||
then: 'the endpoint has the expected value'
|
||||
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
|
||||
|
||||
when: 'session config is null'
|
||||
Global.session = Mock(Session) {
|
||||
config >> null
|
||||
}
|
||||
|
||||
then: 'the endpoint has the expected value'
|
||||
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
|
||||
|
||||
when: 'session config is missing'
|
||||
Global.session = Mock(Session) {
|
||||
config >> [:]
|
||||
}
|
||||
|
||||
then: 'the endpoint has the expected value'
|
||||
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
|
||||
|
||||
when: 'session.config.tower.endpoint is not defined'
|
||||
Global.session = Mock(Session) {
|
||||
config >> [
|
||||
tower: [:]
|
||||
]
|
||||
}
|
||||
|
||||
then: 'the endpoint has the expected value'
|
||||
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
|
||||
|
||||
when: 'session.config.tower.endpoint is null'
|
||||
Global.session = Mock(Session) {
|
||||
config >> [
|
||||
tower: [
|
||||
endpoint: null
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
then: 'the endpoint has the expected value'
|
||||
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
|
||||
|
||||
when: 'session.config.tower.endpoint is empty'
|
||||
Global.session = Mock(Session) {
|
||||
config >> [
|
||||
tower: [
|
||||
endpoint: ''
|
||||
]
|
||||
]
|
||||
}
|
||||
provider = new TowerFusionToken()
|
||||
|
||||
then: 'the endpoint has the expected value'
|
||||
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
|
||||
|
||||
when: 'session.config.tower.endpoint is defined as "-"'
|
||||
Global.session = Mock(Session) {
|
||||
config >> [
|
||||
tower: [
|
||||
endpoint: '-'
|
||||
]
|
||||
]
|
||||
}
|
||||
provider = new TowerFusionToken()
|
||||
|
||||
then: 'the endpoint has the expected value'
|
||||
provider.endpoint == TowerClient.DEF_ENDPOINT_URL
|
||||
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
}
|
||||
|
||||
def 'should return the access token from the config'() {
|
||||
given: 'a session'
|
||||
Global.session = Mock(Session) {
|
||||
config >> [
|
||||
tower: [
|
||||
accessToken: 'abc123'
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
when: 'the provider is created'
|
||||
def provider = new TowerFusionToken()
|
||||
|
||||
then: 'the access token has the expected value'
|
||||
provider.accessToken == 'abc123'
|
||||
}
|
||||
|
||||
def 'should return the access token from the environment'() {
|
||||
setup:
|
||||
Global.session = Mock(Session) {
|
||||
config >> [:]
|
||||
}
|
||||
SysEnv.push(['TOWER_ACCESS_TOKEN': 'abc123'])
|
||||
|
||||
when: 'the provider is created'
|
||||
def provider = new TowerFusionToken()
|
||||
|
||||
then: 'the access token has the expected value'
|
||||
provider.accessToken == 'abc123'
|
||||
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
}
|
||||
|
||||
def 'should prefer the access token from the config'() {
|
||||
setup:
|
||||
Global.session = Mock(Session) {
|
||||
config >> [
|
||||
tower: [
|
||||
accessToken: 'abc123'
|
||||
]
|
||||
]
|
||||
}
|
||||
SysEnv.push(['TOWER_ACCESS_TOKEN': 'xyz789'])
|
||||
|
||||
when: 'the provider is created'
|
||||
def provider = new TowerFusionToken()
|
||||
|
||||
then: 'the access token has the expected value'
|
||||
provider.accessToken == 'abc123'
|
||||
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
}
|
||||
|
||||
def 'should prefer the access token from the config despite being null'() {
|
||||
setup:
|
||||
Global.session = Mock(Session) {
|
||||
config >> [
|
||||
tower: [
|
||||
accessToken: null
|
||||
]
|
||||
]
|
||||
}
|
||||
SysEnv.push(['TOWER_ACCESS_TOKEN': 'xyz789'])
|
||||
|
||||
when: 'the provider is created'
|
||||
def provider = new TowerFusionToken()
|
||||
|
||||
then: 'the access token has the expected value'
|
||||
def e = thrown(AbortRunException)
|
||||
e.message.contains("Missing Seqera Platform access token")
|
||||
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
}
|
||||
|
||||
def 'should prefer the access token from the environment if TOWER_WORKFLOW_ID is set'() {
|
||||
setup:
|
||||
Global.session = Mock(Session) {
|
||||
config >> [
|
||||
tower: [
|
||||
accessToken: 'abc123'
|
||||
]
|
||||
]
|
||||
}
|
||||
SysEnv.push(['TOWER_ACCESS_TOKEN' : 'xyz789', 'TOWER_WORKFLOW_ID': '123'])
|
||||
|
||||
when: 'the provider is created'
|
||||
def provider = new TowerFusionToken()
|
||||
|
||||
then: 'the access token has the expected value'
|
||||
provider.accessToken == 'xyz789'
|
||||
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
}
|
||||
|
||||
def 'should get a license token with config'() {
|
||||
given:
|
||||
def config = [
|
||||
enabled : true,
|
||||
endpoint : wireMockServer.baseUrl(),
|
||||
accessToken: 'eyJ0aWQiOiAxMTkxN30uNWQ5MGFmYWU2YjhhNmFmY2FlNjVkMTQ4ZDFhM2ZlNzlmMmNjN2I4Mw==',
|
||||
workspaceId: '67890'
|
||||
]
|
||||
def session = Mock(Session)
|
||||
def meta = new WorkflowMetadata(
|
||||
session: session,
|
||||
projectName: 'the-project-name',
|
||||
repository: 'git://repo.com/foo')
|
||||
session.getConfig() >> [ tower: config ]
|
||||
session.getUniqueId() >> UUID.randomUUID()
|
||||
session.getWorkflowMetadata() >> meta
|
||||
def PRODUCT = 'some-product'
|
||||
def VERSION = 'some-version'
|
||||
and:
|
||||
Global.session = session
|
||||
def provider = new TowerFusionToken()
|
||||
and: 'a mock endpoint at flow create'
|
||||
wireMockServer.stubFor(
|
||||
WireMock.post(urlEqualTo("/trace/create?workspaceId=${config.workspaceId}"))
|
||||
.willReturn(
|
||||
WireMock.aResponse()
|
||||
.withStatus(200)
|
||||
.withBody('{"message": "", "workflowId": "1234"}')
|
||||
)
|
||||
)
|
||||
and:
|
||||
def observer = new TowerFactory().create(session)[0]
|
||||
observer.onFlowCreate(session)
|
||||
|
||||
and: 'a mock endpoint returning a valid token'
|
||||
final now = Instant.now()
|
||||
final expirationDate = toJson(now.plus(1, ChronoUnit.DAYS))
|
||||
wireMockServer.stubFor(
|
||||
WireMock.post(urlEqualTo("/license/token/"))
|
||||
.withHeader('Authorization', equalTo("Bearer ${config.accessToken}"))
|
||||
.withRequestBody(matchingJsonPath('$.product', equalTo("some-product")))
|
||||
.withRequestBody(matchingJsonPath('$.version', equalTo("some-version")))
|
||||
.withRequestBody(matchingJsonPath('$.workspaceId', equalTo("67890")))
|
||||
.willReturn(
|
||||
WireMock.aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader('Content-Type', 'application/json')
|
||||
.withBody('{"signedToken":"xyz789", "expiresAt":' + expirationDate + '}')
|
||||
)
|
||||
)
|
||||
|
||||
when: 'a license token is requested'
|
||||
final token = provider.getLicenseToken(PRODUCT, VERSION)
|
||||
|
||||
then: 'the token has the expected value'
|
||||
token == 'xyz789'
|
||||
|
||||
and: 'the request is correct'
|
||||
wireMockServer.verify(1, WireMock.postRequestedFor(WireMock.urlEqualTo("/license/token/"))
|
||||
.withHeader('Authorization', WireMock.equalTo("Bearer ${config.accessToken}")))
|
||||
}
|
||||
|
||||
def 'should get a license token with environment'() {
|
||||
given:
|
||||
def accessToken = 'eyJ0aWQiOiAxMTkxN30uNWQ5MGFmYWU2YjhhNmFmY2FlNjVkMTQ4ZDFhM2ZlNzlmMmNjN2I4Mw=='
|
||||
def workspaceId = '67890'
|
||||
SysEnv.push([
|
||||
TOWER_WORKFLOW_ID: '12345',
|
||||
TOWER_ACCESS_TOKEN: accessToken,
|
||||
TOWER_WORKSPACE_ID: workspaceId,
|
||||
TOWER_API_ENDPOINT: wireMockServer.baseUrl()
|
||||
])
|
||||
def session = Mock(Session)
|
||||
def meta = new WorkflowMetadata(
|
||||
session: session,
|
||||
projectName: 'the-project-name',
|
||||
repository: 'git://repo.com/foo')
|
||||
session.getConfig() >> [:]
|
||||
session.getUniqueId() >> UUID.randomUUID()
|
||||
session.getWorkflowMetadata() >> meta
|
||||
def PRODUCT = 'some-product'
|
||||
def VERSION = 'some-version'
|
||||
and:
|
||||
Global.session = session
|
||||
def provider = new TowerFusionToken()
|
||||
and: 'a mock endpoint at flow create'
|
||||
wireMockServer.stubFor(
|
||||
WireMock.post(urlEqualTo("/trace/create?workspaceId=${workspaceId}"))
|
||||
.willReturn(
|
||||
WireMock.aResponse()
|
||||
.withStatus(200)
|
||||
.withBody('{"message": "", "workflowId": "1234"}')
|
||||
)
|
||||
)
|
||||
and:
|
||||
def client = new TowerFactory().create(session)[0]
|
||||
client.onFlowCreate(session)
|
||||
|
||||
and: 'a mock endpoint returning a valid token'
|
||||
final now = Instant.now()
|
||||
final expirationDate = toJson(now.plus(1, ChronoUnit.DAYS))
|
||||
wireMockServer.stubFor(
|
||||
WireMock.post(urlEqualTo("/license/token/"))
|
||||
.withHeader('Authorization', equalTo("Bearer $accessToken"))
|
||||
.withRequestBody(matchingJsonPath('$.product', equalTo("some-product")))
|
||||
.withRequestBody(matchingJsonPath('$.version', equalTo("some-version")))
|
||||
.withRequestBody(matchingJsonPath('$.workspaceId', equalTo("${workspaceId}")))
|
||||
.willReturn(
|
||||
WireMock.aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader('Content-Type', 'application/json')
|
||||
.withBody('{"signedToken":"xyz789", "expiresAt":' + expirationDate + '}')
|
||||
)
|
||||
)
|
||||
|
||||
when: 'a license token is requested'
|
||||
final token = provider.getLicenseToken(PRODUCT, VERSION)
|
||||
|
||||
then: 'the token has the expected value'
|
||||
token == 'xyz789'
|
||||
|
||||
and: 'the request is correct'
|
||||
wireMockServer.verify(1, WireMock.postRequestedFor(WireMock.urlEqualTo("/license/token/"))
|
||||
.withHeader('Authorization', WireMock.equalTo("Bearer ${accessToken}")))
|
||||
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
}
|
||||
|
||||
def 'should refresh the auth token on 401 and retry the request'() {
|
||||
given:
|
||||
def accessToken = 'eyJ0aWQiOiAxMTkxN30uNWQ5MGFmYWU2YjhhNmFmY2FlNjVkMTQ4ZDFhM2ZlNzlmMmNjN2I4Mw=='
|
||||
def workspaceId = '67890'
|
||||
SysEnv.push([
|
||||
TOWER_WORKFLOW_ID: '12345',
|
||||
TOWER_ACCESS_TOKEN: accessToken,
|
||||
TOWER_REFRESH_TOKEN: 'xyz-refresh',
|
||||
TOWER_WORKSPACE_ID: workspaceId,
|
||||
TOWER_API_ENDPOINT: wireMockServer.baseUrl()
|
||||
])
|
||||
def session = Mock(Session)
|
||||
def meta = new WorkflowMetadata(
|
||||
session: session,
|
||||
projectName: 'the-project-name',
|
||||
repository: 'git://repo.com/foo')
|
||||
session.getConfig() >> [:]
|
||||
session.getUniqueId() >> UUID.randomUUID()
|
||||
session.getWorkflowMetadata() >> meta
|
||||
def PRODUCT = 'some-product'
|
||||
def VERSION = 'some-version'
|
||||
and:
|
||||
Global.session = session
|
||||
def provider = new TowerFusionToken()
|
||||
and: 'a mock endpoint at flow create'
|
||||
wireMockServer.stubFor(
|
||||
WireMock.post(urlEqualTo("/trace/create?workspaceId=${workspaceId}"))
|
||||
.willReturn(
|
||||
WireMock.aResponse()
|
||||
.withStatus(200)
|
||||
.withBody('{"message": "", "workflowId": "1234"}')
|
||||
)
|
||||
)
|
||||
and:
|
||||
def client = new TowerFactory().create(session)[0]
|
||||
client.onFlowCreate(session)
|
||||
|
||||
and: 'prepare stubs'
|
||||
|
||||
final now = Instant.now()
|
||||
final expirationDate = toJson(now.plus(1, ChronoUnit.DAYS))
|
||||
|
||||
// 1️⃣ First attempt: /license/token/ fails with 401
|
||||
wireMockServer.stubFor(
|
||||
WireMock.post(urlEqualTo("/license/token/"))
|
||||
.withHeader('Authorization', equalTo("Bearer $accessToken"))
|
||||
.inScenario("Refresh flow")
|
||||
.whenScenarioStateIs(Scenario.STARTED)
|
||||
.willReturn(WireMock.aResponse().withStatus(401))
|
||||
.willSetStateTo("Token Refreshed")
|
||||
)
|
||||
|
||||
// 2️⃣ Refresh token call
|
||||
wireMockServer.stubFor(
|
||||
WireMock.post(urlEqualTo("/oauth/access_token"))
|
||||
.withHeader('Content-Type', equalTo('application/x-www-form-urlencoded'))
|
||||
.withRequestBody(containing('grant_type=refresh_token'))
|
||||
.withRequestBody(containing('refresh_token=xyz-refresh'))
|
||||
.willReturn(
|
||||
WireMock.aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader('Set-Cookie', 'JWT=new-abc-token; Path=/; HttpOnly')
|
||||
.withHeader('Set-Cookie', 'JWT_REFRESH_TOKEN=new-refresh-456; Path=/; HttpOnly')
|
||||
.withBody('{"token_type":"Bearer"}')
|
||||
)
|
||||
)
|
||||
|
||||
// 3️⃣ Retry: /license/token/ succeeds
|
||||
wireMockServer.stubFor(
|
||||
WireMock.post(urlEqualTo("/license/token/"))
|
||||
.withHeader('Authorization', equalTo('Bearer new-abc-token'))
|
||||
.inScenario("Refresh flow")
|
||||
.whenScenarioStateIs("Token Refreshed")
|
||||
.willReturn(
|
||||
WireMock.aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader('Content-Type', 'application/json')
|
||||
.withBody('{"signedToken":"xyz789", "expiresAt":' + expirationDate + '}')
|
||||
)
|
||||
)
|
||||
|
||||
when:
|
||||
final token = provider.getLicenseToken(PRODUCT, VERSION)
|
||||
|
||||
then:
|
||||
token == 'xyz789'
|
||||
|
||||
and: 'verify that refresh endpoint was called'
|
||||
wireMockServer.verify(1, WireMock.postRequestedFor(WireMock.urlEqualTo("/oauth/access_token")))
|
||||
|
||||
and: 'verify both requests to license endpoint'
|
||||
wireMockServer.verify(2, WireMock.postRequestedFor(urlEqualTo("/license/token/")))
|
||||
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
}
|
||||
|
||||
def 'should throw UnauthorizedException if getting a token fails with 401'() {
|
||||
given: 'a TowerFusionEnv provider'
|
||||
def config = [
|
||||
enabled : true,
|
||||
endpoint : wireMockServer.baseUrl(),
|
||||
accessToken: 'eyJ0aWQiOiAxMTkxN30uNWQ5MGFmYWU2YjhhNmFmY2FlNjVkMTQ4ZDFhM2ZlNzlmMmNjN2I4Mw==',
|
||||
workspaceId: '67890'
|
||||
]
|
||||
def session = Mock(Session)
|
||||
def meta = new WorkflowMetadata(
|
||||
session: session,
|
||||
projectName: 'the-project-name',
|
||||
repository: 'git://repo.com/foo')
|
||||
session.getConfig() >> [ tower: config ]
|
||||
session.getUniqueId() >> UUID.randomUUID()
|
||||
session.getWorkflowMetadata() >> meta
|
||||
and:
|
||||
Global.session = session
|
||||
def provider = new TowerFusionToken()
|
||||
and: 'a mock endpoint at flow create'
|
||||
wireMockServer.stubFor(
|
||||
WireMock.post(urlEqualTo("/trace/create?workspaceId=${config.workspaceId}"))
|
||||
.willReturn(
|
||||
WireMock.aResponse()
|
||||
.withStatus(200)
|
||||
.withBody('{"message": "", "workflowId": "1234"}')
|
||||
)
|
||||
)
|
||||
and:
|
||||
def client = new TowerFactory().create(session)[0]
|
||||
client.onFlowCreate(session)
|
||||
and: 'a mock endpoint returning an error'
|
||||
wireMockServer.stubFor(
|
||||
WireMock.post(WireMock.urlEqualTo("/license/token/"))
|
||||
.withHeader('Authorization', WireMock.equalTo("Bearer ${config.accessToken}"))
|
||||
.willReturn(
|
||||
WireMock.aResponse()
|
||||
.withStatus(401)
|
||||
.withHeader('Content-Type', 'application/json')
|
||||
.withBody('{"error":"Unauthorized"}')
|
||||
)
|
||||
)
|
||||
|
||||
when: 'a license token is requested'
|
||||
provider.getLicenseToken('some-product', 'some-version')
|
||||
|
||||
then: 'an exception is thrown'
|
||||
thrown(UnauthorizedException)
|
||||
}
|
||||
|
||||
def 'should deserialize response' () {
|
||||
given:
|
||||
def ts = Instant.ofEpochSecond(1738788914)
|
||||
def json = '{"signedToken":"foo","expiresAt":"2025-02-05T20:55:14Z"}'
|
||||
|
||||
when:
|
||||
def resp = TowerFusionToken.parseLicenseTokenResponse(json)
|
||||
then:
|
||||
resp.signedToken == 'foo'
|
||||
resp.expiresAt == ts
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.tower.plugin
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
import groovy.json.JsonGenerator
|
||||
import groovy.json.JsonSlurper
|
||||
import nextflow.container.resolver.ContainerMeta
|
||||
import nextflow.trace.ProgressRecord
|
||||
import nextflow.trace.WorkflowStats
|
||||
import spock.lang.Specification
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class TowerJsonGeneratorTest extends Specification {
|
||||
|
||||
def 'should chomp too long values' () {
|
||||
given:
|
||||
def scheme = [foo: 5, 'bar.one': 5]
|
||||
def gen = new TowerJsonGenerator(new JsonGenerator.Options(), scheme)
|
||||
|
||||
when:
|
||||
def x = gen.toJson( [foo: "Hola", bar: 'mundo'] )
|
||||
then:
|
||||
x == '{"foo":"Hola","bar":"mundo"}'
|
||||
|
||||
when:
|
||||
x = gen.toJson( [foo: "Hello world"] )
|
||||
then:
|
||||
x == '{"foo":"Hello"}'
|
||||
|
||||
when:
|
||||
x = gen.toJson( [bar: [one: "Hello world", two: "Hola mundo"]] )
|
||||
then:
|
||||
x == '{"bar":{"one":"Hello","two":"Hola mundo"}}'
|
||||
}
|
||||
|
||||
def 'should normalise gitmodules attribute' () {
|
||||
given:
|
||||
def scheme = ['workflow.manifest.gitmodules': 10]
|
||||
def gen = new TowerJsonGenerator(new JsonGenerator.Options(), scheme)
|
||||
|
||||
when:
|
||||
def json = gen.toJson( [workflow: [manifest: [gitmodules: ['a','b','c']]]] )
|
||||
then:
|
||||
json == '{"workflow":{"manifest":{"gitmodules":"a,b,c"}}}'
|
||||
|
||||
when:
|
||||
json = gen.toJson( [workflow: [manifest: [gitmodules: 'abc']]] )
|
||||
then:
|
||||
json == '{"workflow":{"manifest":{"gitmodules":"abc"}}}'
|
||||
|
||||
when:
|
||||
json = gen.toJson( [workflow: [manifest: [gitmodules: '123456789012345']]] )
|
||||
then:
|
||||
json == '{"workflow":{"manifest":{"gitmodules":"1234567890"}}}'
|
||||
}
|
||||
|
||||
def 'should serialise progress records' () {
|
||||
given:
|
||||
def gen = new TowerJsonGenerator(new JsonGenerator.Options(), [:])
|
||||
and:
|
||||
def rec1 = new ProgressRecord(1, 'foo')
|
||||
|
||||
and:
|
||||
def rec2 = new ProgressRecord(2, 'bar')
|
||||
rec2.pending = 1
|
||||
rec2.submitted = 2
|
||||
rec2.running = 3
|
||||
rec2.succeeded = 4
|
||||
rec2.failed = 5
|
||||
rec2.aborted = 6
|
||||
rec2.stored = 7
|
||||
rec2.ignored = 8
|
||||
rec2.retries = 9
|
||||
rec2.cached = 10
|
||||
rec2.loadCpus = 11
|
||||
rec2.loadMemory = 12
|
||||
rec2.peakRunning = 13
|
||||
rec2.peakCpus = 14
|
||||
rec2.peakMemory = 15
|
||||
rec2.terminated = true
|
||||
|
||||
when:
|
||||
def json = gen.toJson([progress: [rec1, rec2]])
|
||||
then:
|
||||
def copy = (Map)new JsonSlurper().parseText(json)
|
||||
copy.size() == 1
|
||||
|
||||
and:
|
||||
def progress = (List<Map>)copy.progress
|
||||
progress.size() == 2
|
||||
|
||||
and:
|
||||
progress.get(0) == [
|
||||
index:1,
|
||||
name: 'foo',
|
||||
workDir: null,
|
||||
pending: 0,
|
||||
submitted: 0,
|
||||
running: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
aborted: 0,
|
||||
stored: 0,
|
||||
ignored: 0,
|
||||
retries: 0,
|
||||
cached: 0,
|
||||
loadCpus: 0,
|
||||
loadMemory: 0,
|
||||
peakCpus: 0,
|
||||
peakMemory: 0,
|
||||
peakRunning: 0,
|
||||
terminated: false]
|
||||
and:
|
||||
progress[1] == [
|
||||
index:2,
|
||||
name: 'bar',
|
||||
workDir: null,
|
||||
pending: 1,
|
||||
submitted: 2,
|
||||
running: 3,
|
||||
succeeded: 4,
|
||||
failed: 5,
|
||||
aborted: 6,
|
||||
stored: 7,
|
||||
ignored: 8,
|
||||
retries: 9,
|
||||
cached: 10,
|
||||
loadCpus: 11,
|
||||
loadMemory: 12,
|
||||
peakRunning: 13,
|
||||
peakCpus: 14,
|
||||
peakMemory: 15,
|
||||
terminated: true]
|
||||
|
||||
}
|
||||
|
||||
|
||||
def 'should serialise workflow stats' () {
|
||||
given:
|
||||
def gen = new TowerJsonGenerator(new JsonGenerator.Options(), [:])
|
||||
and:
|
||||
def rec1 = new ProgressRecord(1, 'foo')
|
||||
def rec2 = new ProgressRecord(2, 'bar')
|
||||
rec2.pending = 1
|
||||
rec2.submitted = 2
|
||||
rec2.running = 3
|
||||
rec2.succeeded = 4
|
||||
rec2.failed = 5
|
||||
rec2.aborted = 6
|
||||
rec2.stored = 7
|
||||
rec2.ignored = 8
|
||||
rec2.retries = 9
|
||||
rec2.cached = 10
|
||||
rec2.loadCpus = 11
|
||||
rec2.loadMemory = 12
|
||||
rec2.peakRunning = 13
|
||||
rec2.peakCpus = 14
|
||||
rec2.peakMemory = 15
|
||||
rec2.terminated = true
|
||||
and:
|
||||
def stats = new WorkflowStats(
|
||||
succeededCount: 1,
|
||||
cachedCount: 2,
|
||||
failedCount: 3,
|
||||
ignoredCount: 4,
|
||||
pendingCount: 5,
|
||||
submittedCount: 6,
|
||||
runningCount: 7,
|
||||
retriesCount: 8,
|
||||
abortedCount: 9,
|
||||
records: [1:rec1, 2:rec2])
|
||||
|
||||
when:
|
||||
def json = gen.toJson(new WorkflowProgress(stats))
|
||||
then:
|
||||
def copy = (Map)new JsonSlurper().parseText(json)
|
||||
copy.succeeded == 1
|
||||
copy.cached == 2
|
||||
copy.failed == 3
|
||||
copy.ignored == 4
|
||||
copy.pending == 5
|
||||
copy.submitted == 6
|
||||
copy.running == 7
|
||||
copy.retries == 8
|
||||
copy.aborted == 9
|
||||
and:
|
||||
(copy.processes as List).size() == 2
|
||||
and:
|
||||
with(copy.processes[0] as Map) {
|
||||
index == 1
|
||||
name == 'foo'
|
||||
pending == 0
|
||||
submitted == 0
|
||||
running == 0
|
||||
succeeded == 0
|
||||
failed == 0
|
||||
aborted == 0
|
||||
stored == 0
|
||||
ignored == 0
|
||||
retries == 0
|
||||
cached == 0
|
||||
loadCpus == 0
|
||||
loadMemory == 0
|
||||
peakCpus == 0
|
||||
peakMemory == 0
|
||||
peakRunning == 0
|
||||
terminated == false
|
||||
}
|
||||
and:
|
||||
with(copy.processes[1] as Map) {
|
||||
index == 2
|
||||
name == 'bar'
|
||||
pending ==1
|
||||
submitted == 2
|
||||
running == 3
|
||||
succeeded == 4
|
||||
failed == 5
|
||||
aborted == 6
|
||||
stored == 7
|
||||
ignored == 8
|
||||
retries == 9
|
||||
cached == 10
|
||||
loadCpus == 11
|
||||
loadMemory == 12
|
||||
peakRunning == 13
|
||||
peakCpus == 14
|
||||
peakMemory == 15
|
||||
terminated == true
|
||||
}
|
||||
}
|
||||
|
||||
def 'should serialise container meta' () {
|
||||
given:
|
||||
def gen = TowerJsonGenerator.create([:])
|
||||
and:
|
||||
def ts = Instant.ofEpochSecond(1742421070).atOffset(ZoneOffset.ofHours(2))
|
||||
def c1 = new ContainerMeta(
|
||||
requestId:'r-1',
|
||||
requestTime:ts,
|
||||
buildId: 'bd-2',
|
||||
scanId: 'sc-3',
|
||||
mirrorId: 'mr-4',
|
||||
cached: false,
|
||||
freeze: true,
|
||||
sourceImage: 'debian:latest',
|
||||
targetImage: 'wave/debian')
|
||||
|
||||
when:
|
||||
def json = gen.toJson([containers: [c1]])
|
||||
then:
|
||||
json == '{"containers":[{"requestId":"r-1","sourceImage":"debian:latest","targetImage":"wave/debian","buildId":"bd-2","mirrorId":"mr-4","scanId":"sc-3","cached":false,"freeze":true,"requestTime":"2025-03-19T23:51:10+02:00"}]}'
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.tower.plugin
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
import nextflow.Session
|
||||
import nextflow.SysEnv
|
||||
import nextflow.cloud.types.CloudMachineInfo
|
||||
import nextflow.cloud.types.PriceModel
|
||||
import nextflow.container.DockerConfig
|
||||
import nextflow.container.resolver.ContainerMeta
|
||||
import nextflow.exception.AbortRunException
|
||||
import nextflow.script.PlatformMetadata
|
||||
import nextflow.script.ScriptBinding
|
||||
import nextflow.script.WorkflowMetadata
|
||||
import nextflow.trace.TraceRecord
|
||||
import nextflow.trace.WorkflowStats
|
||||
import nextflow.trace.WorkflowStatsObserver
|
||||
import nextflow.util.ProcessHelper
|
||||
import spock.lang.Specification
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class TowerObserverTest extends Specification {
|
||||
|
||||
protected boolean aroundNow(value) {
|
||||
def now = Instant.now().toEpochMilli()
|
||||
value > now-1_000 && value <= now
|
||||
}
|
||||
|
||||
private TowerObserver newObserver(Session session, Map env = [:]) {
|
||||
def client = Mock(TowerClient)
|
||||
def observer = new TowerObserver(session, client, null, env)
|
||||
observer.@reports = Mock(TowerReports)
|
||||
return observer
|
||||
}
|
||||
|
||||
def 'should create message map' () {
|
||||
given:
|
||||
def session = Mock(Session)
|
||||
def params = new ScriptBinding.ParamsMap(x: "hello")
|
||||
def meta = Mock(WorkflowMetadata)
|
||||
def observer = Spy(newObserver(session))
|
||||
observer.@workflowId = '12ef'
|
||||
|
||||
when:
|
||||
def map = observer.makeCompleteReq(session)
|
||||
then:
|
||||
1 * session.getWorkflowMetadata() >> meta
|
||||
1 * session.getParams() >> params
|
||||
1 * meta.toMap() >> [foo:1, bar:2, container: [p1: 'c1', p2: 'c2']]
|
||||
1 * observer.getMetricsList() >> [[process:'foo', cpu: [min: 1, max:5], time: [min: 6, max: 9]]]
|
||||
1 * observer.getWorkflowProgress(false) >> new WorkflowProgress()
|
||||
1 * observer.getOutFile() >> 'bar.out'
|
||||
1 * observer.getLogFile() >> 'foo.out'
|
||||
1 * observer.getOperationId() >> 'op-12345'
|
||||
then:
|
||||
map.workflow.foo == 1
|
||||
map.workflow.bar == 2
|
||||
map.workflow.id == '12ef'
|
||||
map.workflow.params == [x: 'hello']
|
||||
map.workflow.container == null
|
||||
map.metrics == [[process:'foo', cpu: [min: 1, max:5], time: [min: 6, max: 9]]]
|
||||
map.progress == new WorkflowProgress()
|
||||
and:
|
||||
aroundNow(map.instant)
|
||||
and:
|
||||
map.workflow.outFile == 'bar.out'
|
||||
map.workflow.logFile == 'foo.out'
|
||||
map.workflow.operationId == 'op-12345'
|
||||
}
|
||||
|
||||
def 'should capitalise underscores' () {
|
||||
given:
|
||||
def tower = new TowerObserver(Mock(Session), Mock(TowerClient), "ws1234", [:] )
|
||||
|
||||
expect:
|
||||
tower.underscoreToCamelCase(STR) == EXPECTED
|
||||
where:
|
||||
STR | EXPECTED
|
||||
'abc' | 'abc'
|
||||
'a_b_c' | 'aBC'
|
||||
'foo__bar' | 'fooBar'
|
||||
}
|
||||
|
||||
def 'should post task records' () {
|
||||
given:
|
||||
def session = Mock(Session)
|
||||
def PROGRESS = Mock(WorkflowProgress) { getRunning()>>1; getSucceeded()>>2; getFailed()>>3 }
|
||||
def observer = Spy(newObserver(session))
|
||||
observer.@workflowId = 'xyz-123'
|
||||
|
||||
def nowTs = System.currentTimeMillis()
|
||||
def submitTs = nowTs-2000
|
||||
def startTs = nowTs-1000
|
||||
|
||||
def trace = new TraceRecord([
|
||||
taskId: 10,
|
||||
process: 'foo',
|
||||
workdir: "/work/dir",
|
||||
cpus: 1,
|
||||
submit: submitTs,
|
||||
start: startTs,
|
||||
complete: nowTs ])
|
||||
trace.executorName= 'batch'
|
||||
trace.machineInfo = new CloudMachineInfo('m4.large', 'eu-west-1b', PriceModel.spot)
|
||||
trace.containerMeta = new ContainerMeta(requestId: '12345', sourceImage: 'ubuntu:latest', targetImage: 'wave.io/12345/ubuntu:latest')
|
||||
|
||||
when:
|
||||
def req = observer.makeTasksReq([trace])
|
||||
then:
|
||||
observer.getWorkflowProgress(true) >> PROGRESS
|
||||
and:
|
||||
req.tasks[0].taskId == 10
|
||||
req.tasks[0].process == 'foo'
|
||||
req.tasks[0].workdir == "/work/dir"
|
||||
req.tasks[0].cpus == 1
|
||||
req.tasks[0].submit == OffsetDateTime.ofInstant(Instant.ofEpochMilli(submitTs), ZoneId.systemDefault())
|
||||
req.tasks[0].start == OffsetDateTime.ofInstant(Instant.ofEpochMilli(startTs), ZoneId.systemDefault())
|
||||
req.tasks[0].executor == 'batch'
|
||||
req.tasks[0].machineType == 'm4.large'
|
||||
req.tasks[0].cloudZone == 'eu-west-1b'
|
||||
req.tasks[0].priceModel == 'spot'
|
||||
and:
|
||||
req.progress.running == 1
|
||||
req.progress.succeeded == 2
|
||||
req.progress.failed == 3
|
||||
and:
|
||||
req.containers[0].requestId == '12345'
|
||||
req.containers[0].sourceImage == 'ubuntu:latest'
|
||||
req.containers[0].targetImage == 'wave.io/12345/ubuntu:latest'
|
||||
and:
|
||||
aroundNow(req.instant)
|
||||
}
|
||||
|
||||
static now_millis = System.currentTimeMillis()
|
||||
static now_instant = OffsetDateTime.ofInstant(Instant.ofEpochMilli(now_millis), ZoneId.systemDefault())
|
||||
|
||||
def 'should fix field types' () {
|
||||
|
||||
expect:
|
||||
TowerObserver.fixTaskField(FIELD, VALUE) == EXPECTED
|
||||
|
||||
where:
|
||||
FIELD | VALUE | EXPECTED
|
||||
'foo' | 'hola' | 'hola'
|
||||
'submit' | now_millis | now_instant
|
||||
'start' | now_millis | now_instant
|
||||
'complete' | now_millis | now_instant
|
||||
'complete' | 0 | null
|
||||
}
|
||||
|
||||
def 'should create workflow json' () {
|
||||
|
||||
given:
|
||||
def sessionId = UUID.randomUUID()
|
||||
def dir = Files.createTempDirectory('test')
|
||||
def session = Mock(Session)
|
||||
session.getUniqueId() >> sessionId
|
||||
session.getRunName() >> 'foo'
|
||||
session.config >> [:]
|
||||
session.containerConfig >> new DockerConfig([:])
|
||||
session.getParams() >> new ScriptBinding.ParamsMap([foo:'Hello', bar:'World'])
|
||||
|
||||
def meta = new WorkflowMetadata(
|
||||
session: session,
|
||||
projectName: 'the-project-name',
|
||||
repository: 'git://repo.com/foo' )
|
||||
session.getWorkflowMetadata() >> meta
|
||||
session.getStatsObserver() >> Mock(WorkflowStatsObserver) { getStats() >> new WorkflowStats() }
|
||||
|
||||
def observer = Spy(newObserver(session, ENV))
|
||||
observer.getOperationId() >> 'op-112233'
|
||||
observer.getLogFile() >> 'log.file'
|
||||
observer.getOutFile() >> 'out.file'
|
||||
|
||||
when:
|
||||
def req1 = observer.makeCreateReq(session)
|
||||
then:
|
||||
req1.sessionId == sessionId.toString()
|
||||
req1.runName == 'foo'
|
||||
req1.projectName == 'the-project-name'
|
||||
req1.repository == 'git://repo.com/foo'
|
||||
req1.workflowId == WORKFLOW_ID
|
||||
and:
|
||||
aroundNow(req1.instant)
|
||||
|
||||
when:
|
||||
def req = observer.makeBeginReq(session)
|
||||
then:
|
||||
observer.getWorkflowId() >> '12345'
|
||||
and:
|
||||
req.workflow.id == '12345'
|
||||
req.workflow.params == [foo:'Hello', bar:'World']
|
||||
req.workflow.outFile == 'out.file'
|
||||
req.workflow.logFile == 'log.file'
|
||||
req.workflow.operationId == 'op-112233'
|
||||
and:
|
||||
req.towerLaunch == TOWER_LAUNCH
|
||||
and:
|
||||
aroundNow(req.instant)
|
||||
|
||||
cleanup:
|
||||
dir?.deleteDir()
|
||||
|
||||
where:
|
||||
ENV | WORKFLOW_ID | TOWER_LAUNCH
|
||||
[:] | null | false
|
||||
[TOWER_WORKFLOW_ID: '1234'] | '1234' | true
|
||||
|
||||
}
|
||||
|
||||
def 'should convert map' () {
|
||||
given:
|
||||
def tower = new TowerObserver(Mock(Session), Mock(TowerClient), "ws1234", [:] )
|
||||
|
||||
expect:
|
||||
tower.mapToString(null) == null
|
||||
tower.mapToString('ciao') == 'ciao'
|
||||
tower.mapToString([:]) == null
|
||||
tower.mapToString([p:'foo', q:'bar']) == null
|
||||
}
|
||||
|
||||
def 'should create init request' () {
|
||||
given:
|
||||
def uuid = UUID.randomUUID()
|
||||
def meta = Mock(WorkflowMetadata) {
|
||||
getProjectName() >> 'the-project-name'
|
||||
getRepository() >> 'git://repo.com/foo'
|
||||
}
|
||||
def session = Mock(Session) {
|
||||
getUniqueId() >> uuid
|
||||
getRunName() >> 'foo_bar'
|
||||
getWorkflowMetadata() >> meta
|
||||
}
|
||||
def observer = newObserver(session, [TOWER_WORKFLOW_ID: 'x123'])
|
||||
|
||||
when:
|
||||
def req = observer.makeCreateReq(session)
|
||||
then:
|
||||
req.sessionId == uuid.toString()
|
||||
req.runName == 'foo_bar'
|
||||
req.projectName == 'the-project-name'
|
||||
req.repository == 'git://repo.com/foo'
|
||||
req.workflowId == 'x123'
|
||||
and:
|
||||
aroundNow(req.instant)
|
||||
|
||||
and:
|
||||
observer.towerLaunch
|
||||
}
|
||||
|
||||
def 'should post create request' () {
|
||||
given:
|
||||
def uuid = UUID.randomUUID()
|
||||
def platform = new PlatformMetadata()
|
||||
def meta = Mock(WorkflowMetadata) {
|
||||
getProjectName() >> 'the-project-name'
|
||||
getRepository() >> 'git://repo.com/foo'
|
||||
getPlatform() >> platform
|
||||
}
|
||||
def session = Mock(Session) {
|
||||
getUniqueId() >> uuid
|
||||
getRunName() >> 'foo_bar'
|
||||
getWorkflowMetadata() >> meta
|
||||
}
|
||||
def towerClient = Mock(TowerClient)
|
||||
def observer = Spy(new TowerObserver(session, towerClient, null, [:]))
|
||||
observer.@reports = Mock(TowerReports)
|
||||
|
||||
when:
|
||||
observer.onFlowCreate(session)
|
||||
then:
|
||||
1 * observer.makeCreateReq(session) >> [runName: 'foo']
|
||||
1 * towerClient.traceCreate([runName: 'foo'], null) >> [workflowId: 'xyz123', watchUrl: 'https://cloud.seqera.io/watch/xyz123']
|
||||
and:
|
||||
observer.runName == 'foo_bar'
|
||||
observer.runId == uuid.toString()
|
||||
and:
|
||||
observer.workflowId == 'xyz123'
|
||||
observer.@watchUrl == 'https://cloud.seqera.io/watch/xyz123'
|
||||
!observer.towerLaunch
|
||||
and:
|
||||
platform.workflowId == 'xyz123'
|
||||
platform.workflowUrl == 'https://cloud.seqera.io/watch/xyz123'
|
||||
|
||||
}
|
||||
|
||||
def 'should set workflowUrl on platform metadata during onFlowBegin' () {
|
||||
given:
|
||||
def platform = new PlatformMetadata()
|
||||
def meta = Mock(WorkflowMetadata) {
|
||||
getPlatform() >> platform
|
||||
}
|
||||
def session = Mock(Session) {
|
||||
getWorkflowMetadata() >> meta
|
||||
}
|
||||
def towerClient = Mock(TowerClient)
|
||||
def observer = Spy(new TowerObserver(session, towerClient, null, [:]))
|
||||
observer.@reports = Mock(TowerReports)
|
||||
observer.@workflowId = 'abc123'
|
||||
|
||||
when:
|
||||
observer.onFlowBegin()
|
||||
then:
|
||||
1 * observer.makeBeginReq(session) >> [foo: 'bar']
|
||||
1 * towerClient.traceBegin([foo: 'bar'], null, 'abc123') >> [watchUrl: 'https://cloud.seqera.io/watch/abc123']
|
||||
and:
|
||||
observer.@watchUrl == 'https://cloud.seqera.io/watch/abc123'
|
||||
platform.workflowUrl == 'https://cloud.seqera.io/watch/abc123'
|
||||
|
||||
cleanup:
|
||||
observer.@sender?.interrupt()
|
||||
}
|
||||
|
||||
def 'should fetch workflow meta' () {
|
||||
given:
|
||||
def session = Mock(Session)
|
||||
def observer = newObserver(session, ENV)
|
||||
|
||||
expect:
|
||||
observer.getOperationId() == OP_ID
|
||||
observer.getLogFile() == LOG_FILE
|
||||
observer.getOutFile() == OUT_FILE
|
||||
|
||||
where:
|
||||
OP_ID | OUT_FILE | LOG_FILE | ENV
|
||||
null | null | null | [:]
|
||||
"local-platform::${ProcessHelper.selfPid()}" | null | null | [TOWER_ALLOW_NEXTFLOW_LOGS:'true']
|
||||
'aws-batch::1234z' | 'xyz.out' | 'hola.log' | [TOWER_ALLOW_NEXTFLOW_LOGS:'true', AWS_BATCH_JOB_ID: '1234z', NXF_OUT_FILE: 'xyz.out', NXF_LOG_FILE: 'hola.log']
|
||||
}
|
||||
|
||||
def 'should deduplicate containers' () {
|
||||
given:
|
||||
def session = Mock(Session)
|
||||
def observer = newObserver(session)
|
||||
and:
|
||||
def c1 = new ContainerMeta(requestId: '12345', sourceImage: 'ubuntu:latest', targetImage: 'wave.io/12345/ubuntu:latest')
|
||||
def c2 = new ContainerMeta(requestId: '54321', sourceImage: 'ubuntu:latest', targetImage: 'wave.io/54321/ubuntu:latest')
|
||||
and:
|
||||
def trace1 = new TraceRecord(
|
||||
taskId: 1,
|
||||
process: 'foo',
|
||||
workdir: "/work/dir",
|
||||
cpus: 1,
|
||||
submit: System.currentTimeMillis(),
|
||||
start: System.currentTimeMillis(),
|
||||
complete: System.currentTimeMillis())
|
||||
trace1.containerMeta = c1
|
||||
and:
|
||||
def trace2 = new TraceRecord(
|
||||
taskId: 2,
|
||||
process: 'foo',
|
||||
workdir: "/work/dir",
|
||||
cpus: 1,
|
||||
submit: System.currentTimeMillis(),
|
||||
start: System.currentTimeMillis(),
|
||||
complete: System.currentTimeMillis())
|
||||
trace2.containerMeta = c2
|
||||
and:
|
||||
def trace3 = new TraceRecord(
|
||||
taskId: 3,
|
||||
process: 'foo',
|
||||
workdir: "/work/dir",
|
||||
cpus: 1,
|
||||
submit: System.currentTimeMillis(),
|
||||
start: System.currentTimeMillis(),
|
||||
complete: System.currentTimeMillis())
|
||||
trace3.containerMeta = c2
|
||||
|
||||
expect:
|
||||
observer.getNewContainers([trace1]) == [c1]
|
||||
and:
|
||||
observer.getNewContainers([trace1]) == []
|
||||
and:
|
||||
observer.getNewContainers([trace1, trace2, trace3]) == [c2]
|
||||
}
|
||||
|
||||
def 'should not send complete request when onFlowBegin was not invoked' () {
|
||||
given:
|
||||
def session = Mock(Session)
|
||||
def towerClient = Mock(TowerClient)
|
||||
def observer = Spy(new TowerObserver(session, towerClient, null, [:]))
|
||||
def reports = Mock(TowerReports)
|
||||
observer.@reports = reports
|
||||
observer.@workflowId = 'xyz-123'
|
||||
observer.@sender = null
|
||||
|
||||
when:
|
||||
observer.onFlowComplete()
|
||||
|
||||
then:
|
||||
1 * reports.publishRuntimeReports()
|
||||
1 * reports.flowComplete()
|
||||
0 * towerClient.traceComplete(_, _, _)
|
||||
}
|
||||
|
||||
def 'should apply platform metadata from trace create response'() {
|
||||
given:
|
||||
def metadata = new WorkflowMetadata()
|
||||
def session = Mock(Session) {
|
||||
getWorkflowMetadata() >> metadata
|
||||
}
|
||||
def observer = new TowerObserver(session, Mock(TowerClient), '1234', SysEnv.get())
|
||||
|
||||
def responseMetadata = [
|
||||
userId: 39,
|
||||
userName: 'user',
|
||||
userOrganization: 'ACME Inc.',
|
||||
workspaceId: 1234,
|
||||
workspaceName: 'Workspace-Name',
|
||||
workspaceFullName: 'Full Workspace Name',
|
||||
orgName: 'ACME Inc.',
|
||||
computeEnvId: 'ce1234',
|
||||
computeEnvName: 'ce-test',
|
||||
computeEnvPlatform: 'aws-batch',
|
||||
pipelineName: 'test-pipeline',
|
||||
pipelineId: 'pipe1234',
|
||||
revision: 'v1.1',
|
||||
commitId: 'abcd12345'
|
||||
]
|
||||
|
||||
when:
|
||||
observer.applyPlatformMetadata(responseMetadata)
|
||||
|
||||
then:
|
||||
metadata.platform.user.id == '39'
|
||||
metadata.platform.user.userName == 'user'
|
||||
metadata.platform.user.organization == 'ACME Inc.'
|
||||
metadata.platform.workspace.id == '1234'
|
||||
metadata.platform.workspace.name == 'Workspace-Name'
|
||||
metadata.platform.workspace.fullName == 'Full Workspace Name'
|
||||
metadata.platform.workspace.organization == 'ACME Inc.'
|
||||
metadata.platform.computeEnv.id == 'ce1234'
|
||||
metadata.platform.computeEnv.name == 'ce-test'
|
||||
metadata.platform.computeEnv.platform == 'aws-batch'
|
||||
metadata.platform.pipeline.id == 'pipe1234'
|
||||
metadata.platform.pipeline.name == 'test-pipeline'
|
||||
metadata.platform.pipeline.revision == 'v1.1'
|
||||
metadata.platform.pipeline.commitId == 'abcd12345'
|
||||
}
|
||||
|
||||
def 'should include numSpotInterruptions in task map'() {
|
||||
given:
|
||||
def session = Mock(Session)
|
||||
def observer = Spy(newObserver(session))
|
||||
observer.getWorkflowProgress(true) >> new WorkflowProgress()
|
||||
|
||||
def now = System.currentTimeMillis()
|
||||
def trace = new TraceRecord([
|
||||
taskId: 42,
|
||||
process: 'foo',
|
||||
workdir: "/work/dir",
|
||||
cpus: 1,
|
||||
submit: now-2000,
|
||||
start: now-1000,
|
||||
complete: now
|
||||
])
|
||||
trace.setNumSpotInterruptions(3)
|
||||
|
||||
when:
|
||||
def req = observer.makeTasksReq([trace])
|
||||
|
||||
then:
|
||||
req.tasks.size() == 1
|
||||
req.tasks[0].numSpotInterruptions == 3
|
||||
}
|
||||
|
||||
def 'should include logStreamId in task map'() {
|
||||
given:
|
||||
def session = Mock(Session)
|
||||
def observer = Spy(newObserver(session))
|
||||
observer.getWorkflowProgress(true) >> new WorkflowProgress()
|
||||
|
||||
def now = System.currentTimeMillis()
|
||||
def trace = new TraceRecord([
|
||||
taskId: 42,
|
||||
process: 'foo',
|
||||
workdir: "/work/dir",
|
||||
cpus: 1,
|
||||
submit: now-2000,
|
||||
start: now-1000,
|
||||
complete: now
|
||||
])
|
||||
trace.setLogStreamId('arn:aws:logs:us-east-1:123456789:log-group:/ecs/task:log-stream:abc123')
|
||||
|
||||
when:
|
||||
def req = observer.makeTasksReq([trace])
|
||||
|
||||
then:
|
||||
req.tasks.size() == 1
|
||||
req.tasks[0].logStreamId == 'arn:aws:logs:us-east-1:123456789:log-group:/ecs/task:log-stream:abc123'
|
||||
}
|
||||
|
||||
def 'should include resourceAllocation in task map'() {
|
||||
given:
|
||||
def session = Mock(Session)
|
||||
def observer = Spy(newObserver(session))
|
||||
observer.getWorkflowProgress(true) >> new WorkflowProgress()
|
||||
|
||||
def now = System.currentTimeMillis()
|
||||
def trace = new TraceRecord([
|
||||
taskId: 42,
|
||||
process: 'foo',
|
||||
workdir: "/work/dir",
|
||||
cpus: 1,
|
||||
submit: now-2000,
|
||||
start: now-1000,
|
||||
complete: now,
|
||||
accelerator: 2,
|
||||
acceleratorType: 'v100'
|
||||
])
|
||||
trace.setResourceAllocation([cpuShares: 2048, memoryMiB: 4096, time: '1h'])
|
||||
|
||||
when:
|
||||
def req = observer.makeTasksReq([trace])
|
||||
|
||||
then:
|
||||
req.tasks.size() == 1
|
||||
req.tasks[0].accelerator == 2
|
||||
req.tasks[0].acceleratorType == 'v100'
|
||||
req.tasks[0].resourceAllocation == [cpuShares: 2048, memoryMiB: 4096, time: '1h']
|
||||
}
|
||||
|
||||
def 'should include gpuMetrics in task map'() {
|
||||
given:
|
||||
def session = Mock(Session)
|
||||
def observer = Spy(newObserver(session))
|
||||
observer.getWorkflowProgress(true) >> new WorkflowProgress()
|
||||
|
||||
def now = System.currentTimeMillis()
|
||||
def trace = new TraceRecord([
|
||||
taskId: 42,
|
||||
process: 'foo',
|
||||
workdir: "/work/dir",
|
||||
cpus: 1,
|
||||
submit: now-2000,
|
||||
start: now-1000,
|
||||
complete: now
|
||||
])
|
||||
trace.setGpuMetrics([name: 'Tesla T4', mem: 15360, driver: '580.126.09', active_time: 651030, pct: 75, peak: 100])
|
||||
|
||||
when:
|
||||
def req = observer.makeTasksReq([trace])
|
||||
|
||||
then:
|
||||
req.tasks.size() == 1
|
||||
req.tasks[0].gpuMetrics.name == 'Tesla T4'
|
||||
req.tasks[0].gpuMetrics.mem == 15360
|
||||
req.tasks[0].gpuMetrics.pct == 75
|
||||
req.tasks[0].gpuMetrics.peak == 100
|
||||
}
|
||||
|
||||
def 'should throw AbortRunException if workflow id is not found'() {
|
||||
given:
|
||||
def session = Mock(Session){
|
||||
getUniqueId() >> UUID.randomUUID()
|
||||
getWorkflowMetadata() >> Mock(WorkflowMetadata)
|
||||
}
|
||||
def client = Mock(TowerClient){
|
||||
traceCreate(_,_) >> [:]
|
||||
}
|
||||
def observer = new TowerObserver(session, client, null, [:])
|
||||
|
||||
when:
|
||||
observer.onFlowCreate(session)
|
||||
then:
|
||||
thrown(AbortRunException)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.tower.plugin
|
||||
|
||||
import nextflow.Session
|
||||
import spock.lang.Specification
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Jordi Deu-Pons <jordi@seqera.io>
|
||||
*/
|
||||
class TowerReportsTest extends Specification {
|
||||
|
||||
def 'should convert to glob pattern'() {
|
||||
|
||||
expect:
|
||||
TowerReports.convertToGlobPattern(KEY) == EXPECTED
|
||||
|
||||
where:
|
||||
KEY | EXPECTED
|
||||
"multiqc.html" | "glob:**/multiqc.html"
|
||||
"**/multiqc.html" | "glob:**/multiqc.html"
|
||||
"*/multiqc.html" | "glob:**/*/multiqc.html"
|
||||
"reports/*.html" | "glob:**/reports/*.html"
|
||||
|
||||
}
|
||||
|
||||
def 'should load reports from tower yml file'() {
|
||||
|
||||
given:
|
||||
def launchDir = File.createTempDir()
|
||||
def workflowId = "1khBUM1SUioskd"
|
||||
def config = new File(launchDir, "nf-${workflowId}-tower.yml")
|
||||
config.text = """
|
||||
reports:
|
||||
multiqc_report.html:
|
||||
display: "MultiQC HTML report"
|
||||
deseq2.plots.pdf:
|
||||
display: "All samples STAR Salmon DESeq2 QC PDF plots"
|
||||
salmon.merged.gene_counts.tsv:
|
||||
display: "All samples STAR Salmon merged gene raw counts"
|
||||
"*.merged.gene_counts.tsv":
|
||||
display: "All samples STAR Salmon merged gene raw counts"
|
||||
""".stripIndent()
|
||||
|
||||
when:
|
||||
def reports = new TowerReports(Mock(Session))
|
||||
def entries = reports.parseReportEntries(launchDir.toPath(), workflowId)
|
||||
|
||||
then:
|
||||
entries == [
|
||||
"multiqc_report.html" : ["display": "MultiQC HTML report"],
|
||||
"deseq2.plots.pdf" : ["display": "All samples STAR Salmon DESeq2 QC PDF plots"],
|
||||
"salmon.merged.gene_counts.tsv": ["display": "All samples STAR Salmon merged gene raw counts"],
|
||||
"*.merged.gene_counts.tsv" : ["display": "All samples STAR Salmon merged gene raw counts"]
|
||||
].collect()
|
||||
|
||||
|
||||
}
|
||||
|
||||
def 'should generate reports file'() {
|
||||
|
||||
given: 'a launch directory with a tower yaml file'
|
||||
def launchDir = File.createTempDir()
|
||||
def workflowId = "1khBUM1SUioskd"
|
||||
def config = new File(launchDir, "nf-${workflowId}-tower.yml")
|
||||
config.text = """
|
||||
reports:
|
||||
multiqc_report.html:
|
||||
display: "MultiQC HTML report"
|
||||
deseq2.plots.pdf:
|
||||
display: "All samples STAR Salmon DESeq2 QC PDF plots"
|
||||
mimeType: "application/pdf"
|
||||
"*.merged.gene_counts.tsv":
|
||||
display: "All samples STAR Salmon merged gene raw counts"
|
||||
""".stripIndent()
|
||||
|
||||
and: 'a tower reports instance'
|
||||
def session = new Session()
|
||||
TowerReports reports = Spy(TowerReports, constructorArgs: [session])
|
||||
reports.launchDir >> launchDir.toPath()
|
||||
|
||||
and: 'some reports'
|
||||
def repo1 = new File(launchDir, "multiqc_report.html")
|
||||
repo1.text = "html"
|
||||
def repo2 = new File(launchDir, "deseq2.plots.pdf")
|
||||
repo2.text = "pdf"
|
||||
def repo3 = new File(launchDir, "salmon.merged.gene_counts.tsv")
|
||||
repo3.text = "tsv"
|
||||
|
||||
when: 'a workflow runs'
|
||||
reports.flowCreate(workflowId)
|
||||
reports.filePublish(repo1.toPath())
|
||||
reports.filePublish(repo2.toPath())
|
||||
reports.filePublish(repo3.toPath())
|
||||
reports.flowComplete()
|
||||
|
||||
then:
|
||||
def result = new File(launchDir, "nf-${workflowId}-reports.tsv")
|
||||
result.text == "key\tpath\tsize\tdisplay\tmime_type\n" +
|
||||
"multiqc_report.html\t${repo1.toPath().toUriString()}\t4\tMultiQC HTML report\t\n" +
|
||||
"deseq2.plots.pdf\t${repo2.toPath().toUriString()}\t3\tAll samples STAR Salmon DESeq2 QC PDF plots\tapplication/pdf\n" +
|
||||
"*.merged.gene_counts.tsv\t${repo3.toPath().toUriString()}\t3\tAll samples STAR Salmon merged gene raw counts\t\n"
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.tower.plugin
|
||||
|
||||
import nextflow.util.Duration
|
||||
import nextflow.util.RetryConfig
|
||||
import spock.lang.Specification
|
||||
|
||||
/**
|
||||
* Unit tests for TowerRetryPolicy
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class TowerRetryPolicyTest extends Specification {
|
||||
|
||||
def 'should validate default values of tower retry policy'() {
|
||||
when:
|
||||
def policy = new TowerRetryPolicy([:])
|
||||
|
||||
then:
|
||||
policy.delay == RetryConfig.DEFAULT_DELAY
|
||||
policy.maxDelay == RetryConfig.DEFAULT_MAX_DELAY
|
||||
policy.maxAttempts == RetryConfig.DEFAULT_MAX_ATTEMPTS
|
||||
policy.jitter == RetryConfig.DEFAULT_JITTER
|
||||
policy.multiplier == RetryConfig.DEFAULT_MULTIPLIER
|
||||
}
|
||||
|
||||
def 'should use provided values when specified'() {
|
||||
when:
|
||||
def customOptions = [
|
||||
delay: '1s' as nextflow.util.Duration,
|
||||
maxDelay: '60s' as nextflow.util.Duration,
|
||||
maxAttempts: 3,
|
||||
jitter: 0.5,
|
||||
multiplier: 1.5
|
||||
]
|
||||
def policy = new TowerRetryPolicy(customOptions)
|
||||
|
||||
then:
|
||||
policy.delay == customOptions.delay
|
||||
policy.maxDelay == customOptions.maxDelay
|
||||
policy.maxAttempts == 3
|
||||
policy.jitter == 0.5d
|
||||
policy.multiplier == 1.5d
|
||||
}
|
||||
|
||||
def 'should use provided values when specified'() {
|
||||
when:
|
||||
def policy = new TowerRetryPolicy([:], [backOffDelay: 500, maxRetries: 100, backOffBase: 5])
|
||||
|
||||
then:
|
||||
policy.delay == Duration.of('500ms')
|
||||
policy.maxAttempts == 100
|
||||
policy.multiplier == 5
|
||||
and:
|
||||
policy.maxDelay == RetryConfig.DEFAULT_MAX_DELAY
|
||||
policy.jitter == RetryConfig.DEFAULT_JITTER
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright 2013-2026, 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.dataset
|
||||
|
||||
import java.nio.file.AccessDeniedException
|
||||
import java.nio.file.NoSuchFileException
|
||||
|
||||
import groovy.json.JsonOutput
|
||||
import io.seqera.tower.plugin.TowerClient
|
||||
import io.seqera.tower.plugin.exception.ForbiddenException
|
||||
import io.seqera.tower.plugin.exception.NotFoundException
|
||||
import io.seqera.tower.plugin.exception.UnauthorizedException
|
||||
import nextflow.exception.AbortOperationException
|
||||
import spock.lang.Specification
|
||||
|
||||
/**
|
||||
* Tests for {@link SeqeraDatasetClient} using a mock {@link TowerClient}.
|
||||
*/
|
||||
class SeqeraDatasetClientTest extends Specification {
|
||||
|
||||
private TowerClient mockTower(String endpoint = 'https://api.example.com') {
|
||||
def tc = Mock(TowerClient)
|
||||
tc.endpoint >> endpoint
|
||||
return tc
|
||||
}
|
||||
private TowerClient spyTower(String endpoint = 'https://api.example.com') {
|
||||
def tc = Spy(TowerClient)
|
||||
tc.@endpoint = endpoint
|
||||
return tc
|
||||
}
|
||||
|
||||
private static TowerClient.Response ok(String body) {
|
||||
new TowerClient.Response(200, body)
|
||||
}
|
||||
|
||||
private static TowerClient.Response error(int code) {
|
||||
new TowerClient.Response(code, "error $code")
|
||||
}
|
||||
|
||||
// ---- listUserWorkspacesAndOrgs ----
|
||||
|
||||
def "listUserWorkspacesAndOrgs returns parsed DTOs"() {
|
||||
given:
|
||||
def body = JsonOutput.toJson([orgsAndWorkspaces: [
|
||||
[orgId: 1, orgName: 'acme', workspaceId: 10, workspaceName: 'research', workspaceFullName: 'acme/research']
|
||||
]])
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest('https://api.example.com/user/42/workspaces') >> ok(body)
|
||||
def client = new SeqeraDatasetClient(tc)
|
||||
|
||||
when:
|
||||
def list = client.listUserWorkspacesAndOrgs(42L)
|
||||
|
||||
then:
|
||||
list.size() == 1
|
||||
list[0].orgName == 'acme'
|
||||
list[0].workspaceId == 10L
|
||||
list[0].workspaceName == 'research'
|
||||
}
|
||||
|
||||
// ---- listDatasets ----
|
||||
|
||||
def "listDatasets returns parsed DatasetDto list"() {
|
||||
given:
|
||||
def body = JsonOutput.toJson([datasets: [
|
||||
[id: 'ds-1', name: 'samples', version: 2, mediaType: 'text/csv',
|
||||
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z']
|
||||
], totalSize: 1])
|
||||
def tc = mockTower()
|
||||
tc.sendApiRequest('https://api.example.com/datasets?workspaceId=99') >> ok(body)
|
||||
def client = new SeqeraDatasetClient(tc)
|
||||
|
||||
when:
|
||||
def list = client.listDatasets(99L)
|
||||
|
||||
then:
|
||||
list.size() == 1
|
||||
list[0].id == 'ds-1'
|
||||
list[0].name == 'samples'
|
||||
list[0].version == 2L
|
||||
}
|
||||
|
||||
def "listDatasets returns empty list when no datasets"() {
|
||||
given:
|
||||
def tc = mockTower()
|
||||
tc.sendApiRequest('https://api.example.com/datasets?workspaceId=99') >>
|
||||
ok(JsonOutput.toJson([datasets: [], totalSize: 0]))
|
||||
def client = new SeqeraDatasetClient(tc)
|
||||
|
||||
when:
|
||||
def list = client.listDatasets(99L)
|
||||
|
||||
then:
|
||||
list.isEmpty()
|
||||
}
|
||||
|
||||
// ---- listVersions ----
|
||||
|
||||
def "listVersions returns parsed DatasetVersionDto list"() {
|
||||
given:
|
||||
def body = JsonOutput.toJson([versions: [
|
||||
[datasetId: 'ds-1', version: 1, fileName: 'samples.csv',
|
||||
mediaType: 'text/csv', hasHeader: true, dateCreated: '2024-01-01T00:00:00Z', disabled: false]
|
||||
]])
|
||||
def tc = mockTower()
|
||||
tc.sendApiRequest('https://api.example.com/datasets/ds-1/versions?workspaceId=1234') >> ok(body)
|
||||
def client = new SeqeraDatasetClient(tc)
|
||||
|
||||
when:
|
||||
def list = client.listVersions('ds-1', 1234)
|
||||
|
||||
then:
|
||||
list.size() == 1
|
||||
list[0].version == 1L
|
||||
list[0].fileName == 'samples.csv'
|
||||
list[0].hasHeader
|
||||
!list[0].disabled
|
||||
}
|
||||
|
||||
// ---- downloadDataset ----
|
||||
|
||||
def "downloadDataset returns InputStream with correct content"() {
|
||||
given:
|
||||
def content = 'col1,col2\n1,2\n'
|
||||
def tc = mockTower()
|
||||
tc.sendStreamingRequest('https://api.example.com/datasets/ds-1/v/1/n/samples.csv?workspaceId=1234') >> new ByteArrayInputStream(content.getBytes('UTF-8'))
|
||||
def client = new SeqeraDatasetClient(tc)
|
||||
|
||||
when:
|
||||
def stream = client.downloadDataset('ds-1', '1', 'samples.csv', 1234)
|
||||
|
||||
then:
|
||||
stream.text == content
|
||||
}
|
||||
|
||||
def "downloadDataset URL-encodes the filename"() {
|
||||
given:
|
||||
def tc = mockTower()
|
||||
def client = new SeqeraDatasetClient(tc)
|
||||
|
||||
when:
|
||||
client.downloadDataset('ds-1', '1', 'my file.csv',1234)
|
||||
|
||||
then:
|
||||
1 * tc.sendStreamingRequest('https://api.example.com/datasets/ds-1/v/1/n/my%20file.csv?workspaceId=1234') >> new ByteArrayInputStream('data'.getBytes('UTF-8'))
|
||||
}
|
||||
|
||||
def "downloadDataset throws NoSuchFileException on 404"() {
|
||||
given:
|
||||
def tc = mockTower()
|
||||
tc.sendStreamingRequest(_) >> { throw new NotFoundException("not found") }
|
||||
def client = new SeqeraDatasetClient(tc)
|
||||
|
||||
when:
|
||||
client.downloadDataset('ds-missing', '1', 'file.csv', 1234)
|
||||
|
||||
then:
|
||||
thrown(NoSuchFileException)
|
||||
}
|
||||
|
||||
def "downloadDataset throws AccessDeniedException on 403"() {
|
||||
given:
|
||||
def tc = mockTower()
|
||||
tc.sendStreamingRequest(_) >> { throw new ForbiddenException("forbidden") }
|
||||
def client = new SeqeraDatasetClient(tc)
|
||||
|
||||
when:
|
||||
client.downloadDataset('ds-1', '1', 'file.csv', 1234)
|
||||
|
||||
then:
|
||||
thrown(AccessDeniedException)
|
||||
}
|
||||
|
||||
def "downloadDataset throws AbortOperationException on 401"() {
|
||||
given:
|
||||
def tc = mockTower()
|
||||
tc.sendStreamingRequest(_) >> { throw new UnauthorizedException("unauthorized") }
|
||||
def client = new SeqeraDatasetClient(tc)
|
||||
|
||||
when:
|
||||
client.downloadDataset('ds-1', '1', 'file.csv', 1234)
|
||||
|
||||
then:
|
||||
thrown(AbortOperationException)
|
||||
}
|
||||
|
||||
// ---- createDataset ----
|
||||
|
||||
def "createDataset posts and returns created dataset"() {
|
||||
given:
|
||||
def responseBody = JsonOutput.toJson([dataset: [id: 'ds-new', name: 'results']])
|
||||
def tc = mockTower()
|
||||
tc.sendApiRequest('https://api.example.com/datasets?workspaceId=10', [name: 'results'], 'POST') >> ok(responseBody)
|
||||
def client = new SeqeraDatasetClient(tc)
|
||||
|
||||
when:
|
||||
def dto = client.createDataset(10L, 'results')
|
||||
|
||||
then:
|
||||
dto.id == 'ds-new'
|
||||
dto.name == 'results'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright 2013-2026, 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.fs
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
import spock.lang.Specification
|
||||
|
||||
class DatasetInputStreamTest extends Specification {
|
||||
|
||||
def 'should read bytes into buffer'() {
|
||||
given:
|
||||
def data = 'hello world'.bytes
|
||||
def channel = new DatasetInputStream(new ByteArrayInputStream(data))
|
||||
def buf = ByteBuffer.allocate(data.length)
|
||||
|
||||
when:
|
||||
def n = channel.read(buf)
|
||||
|
||||
then:
|
||||
n == data.length
|
||||
buf.array() == data
|
||||
}
|
||||
|
||||
def 'should advance position after read'() {
|
||||
given:
|
||||
def data = 'abcdef'.bytes
|
||||
def channel = new DatasetInputStream(new ByteArrayInputStream(data))
|
||||
|
||||
when:
|
||||
channel.read(ByteBuffer.allocate(3))
|
||||
|
||||
then:
|
||||
channel.position() == 3
|
||||
|
||||
when:
|
||||
channel.read(ByteBuffer.allocate(3))
|
||||
|
||||
then:
|
||||
channel.position() == 6
|
||||
}
|
||||
|
||||
def 'should return -1 at end of stream'() {
|
||||
given:
|
||||
def channel = new DatasetInputStream(new ByteArrayInputStream(new byte[0]))
|
||||
|
||||
when:
|
||||
def n = channel.read(ByteBuffer.allocate(8))
|
||||
|
||||
then:
|
||||
n == -1
|
||||
channel.position() == 0
|
||||
}
|
||||
|
||||
def 'should read partial buffer when stream has fewer bytes'() {
|
||||
given:
|
||||
def data = 'hi'.bytes
|
||||
def channel = new DatasetInputStream(new ByteArrayInputStream(data))
|
||||
def buf = ByteBuffer.allocate(100)
|
||||
|
||||
when:
|
||||
def n = channel.read(buf)
|
||||
|
||||
then:
|
||||
n == 2
|
||||
channel.position() == 2
|
||||
}
|
||||
|
||||
def 'should be open initially and closed after close()'() {
|
||||
given:
|
||||
def channel = new DatasetInputStream(new ByteArrayInputStream(new byte[0]))
|
||||
|
||||
expect:
|
||||
channel.isOpen()
|
||||
|
||||
when:
|
||||
channel.close()
|
||||
|
||||
then:
|
||||
!channel.isOpen()
|
||||
}
|
||||
|
||||
def 'should close underlying stream on close()'() {
|
||||
given:
|
||||
def stream = Mock(InputStream)
|
||||
def channel = new DatasetInputStream(stream)
|
||||
|
||||
when:
|
||||
channel.close()
|
||||
|
||||
then:
|
||||
1 * stream.close()
|
||||
!channel.isOpen()
|
||||
}
|
||||
|
||||
def 'should throw on size'() {
|
||||
when:
|
||||
new DatasetInputStream(new ByteArrayInputStream(new byte[0])).size()
|
||||
|
||||
then:
|
||||
thrown(UnsupportedOperationException)
|
||||
}
|
||||
|
||||
def 'should throw on write'() {
|
||||
when:
|
||||
new DatasetInputStream(new ByteArrayInputStream(new byte[0])).write(ByteBuffer.allocate(1))
|
||||
|
||||
then:
|
||||
thrown(UnsupportedOperationException)
|
||||
}
|
||||
|
||||
def 'should throw on seek'() {
|
||||
when:
|
||||
new DatasetInputStream(new ByteArrayInputStream(new byte[0])).position(0L)
|
||||
|
||||
then:
|
||||
thrown(UnsupportedOperationException)
|
||||
}
|
||||
|
||||
def 'should throw on truncate'() {
|
||||
when:
|
||||
new DatasetInputStream(new ByteArrayInputStream(new byte[0])).truncate(0L)
|
||||
|
||||
then:
|
||||
thrown(UnsupportedOperationException)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
* Copyright 2013-2026, 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.fs
|
||||
|
||||
import java.nio.file.AccessDeniedException
|
||||
import java.nio.file.DirectoryStream
|
||||
import java.nio.file.FileSystemAlreadyExistsException
|
||||
import java.nio.file.InvalidPathException
|
||||
import java.nio.file.NoSuchFileException
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
|
||||
import groovy.json.JsonOutput
|
||||
import io.seqera.tower.plugin.TowerClient
|
||||
import io.seqera.tower.plugin.dataset.SeqeraDatasetClient
|
||||
import nextflow.exception.AbortOperationException
|
||||
import spock.lang.Specification
|
||||
|
||||
/**
|
||||
* Tests for {@link SeqeraFileSystemProvider} using a mock {@link TowerClient}.
|
||||
*/
|
||||
class SeqeraFileSystemProviderTest extends Specification {
|
||||
|
||||
private static final String ENDPOINT = 'https://api.example.com'
|
||||
|
||||
private TowerClient spyTower() {
|
||||
def tc = Spy(TowerClient)
|
||||
tc.@endpoint = ENDPOINT
|
||||
return tc
|
||||
}
|
||||
|
||||
private static TowerClient.Response ok(String body) {
|
||||
new TowerClient.Response(200, body)
|
||||
}
|
||||
|
||||
private static TowerClient.Response error(int code) {
|
||||
new TowerClient.Response(code, "error $code")
|
||||
}
|
||||
|
||||
private SeqeraFileSystem buildFs(TowerClient tc) {
|
||||
final client = new SeqeraDatasetClient(tc)
|
||||
final provider = new SeqeraFileSystemProvider()
|
||||
return new SeqeraFileSystem(provider, client)
|
||||
}
|
||||
|
||||
private static String userInfoJson() {
|
||||
JsonOutput.toJson([user: [id: 42L, userName: 'testuser']])
|
||||
}
|
||||
|
||||
private static String workspacesJson() {
|
||||
JsonOutput.toJson([orgsAndWorkspaces: [
|
||||
[orgId: 1L, orgName: 'acme', workspaceId: 10L, workspaceName: 'research', workspaceFullName: 'acme/research']
|
||||
]])
|
||||
}
|
||||
|
||||
private static String datasetsJson() {
|
||||
JsonOutput.toJson([datasets: [
|
||||
[id: 'ds-1', name: 'samples', version: 2L, mediaType: 'text/csv',
|
||||
workspaceId: 10L,
|
||||
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z']
|
||||
], totalSize: 1])
|
||||
}
|
||||
|
||||
private static String versionsJson() {
|
||||
JsonOutput.toJson([versions: [
|
||||
[datasetId: 'ds-1', version: 1L, fileName: 'samples.csv',
|
||||
mediaType: 'text/csv', hasHeader: true, dateCreated: '2024-01-01T00:00:00Z', disabled: false],
|
||||
[datasetId: 'ds-1', version: 2L, fileName: 'samples_v2.csv',
|
||||
mediaType: 'text/csv', hasHeader: true, dateCreated: '2024-01-02T00:00:00Z', disabled: false]
|
||||
]])
|
||||
}
|
||||
|
||||
// ---- newInputStream - latest version ----
|
||||
|
||||
def "newInputStream resolves latest version and downloads correct content"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets/ds-1/versions?workspaceId=10") >> ok(versionsJson())
|
||||
final csvContent = 'col1,col2\n1,2\n3,4\n'
|
||||
tc.sendStreamingRequest("${ENDPOINT}/datasets/ds-1/v/2/n/samples_v2.csv?workspaceId=10") >> new ByteArrayInputStream(csvContent.getBytes('UTF-8'))
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
when:
|
||||
final text = fs.provider().newInputStream(path).text
|
||||
|
||||
then:
|
||||
text == csvContent
|
||||
}
|
||||
|
||||
// ---- newInputStream - pinned version ----
|
||||
|
||||
def "newInputStream uses pinned version when @ver suffix given"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets/ds-1/versions?workspaceId=10") >> ok(versionsJson())
|
||||
final csvContent = 'col1,col2\n1,2\n'
|
||||
tc.sendStreamingRequest("${ENDPOINT}/datasets/ds-1/v/1/n/samples.csv?workspaceId=10") >> new ByteArrayInputStream(csvContent.getBytes('UTF-8'))
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples@1')
|
||||
|
||||
when:
|
||||
final text = fs.provider().newInputStream(path).text
|
||||
|
||||
then:
|
||||
text == csvContent
|
||||
}
|
||||
|
||||
// ---- newInputStream - missing dataset ----
|
||||
|
||||
def "newInputStream throws NoSuchFileException for unknown dataset"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >>
|
||||
ok(JsonOutput.toJson([datasets: [], totalSize: 0]))
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/missing-dataset')
|
||||
|
||||
when:
|
||||
fs.provider().newInputStream(path)
|
||||
|
||||
then:
|
||||
thrown(NoSuchFileException)
|
||||
}
|
||||
|
||||
// ---- newInputStream - pinned version not found ----
|
||||
|
||||
def "newInputStream throws NoSuchFileException for unknown pinned version"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets/ds-1/versions?workspaceId=10") >> ok(versionsJson())
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples@99')
|
||||
|
||||
when:
|
||||
fs.provider().newInputStream(path)
|
||||
|
||||
then:
|
||||
thrown(NoSuchFileException)
|
||||
}
|
||||
|
||||
// ---- readAttributes ----
|
||||
|
||||
def "readAttributes returns directory attributes for depth < 4"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final path = new SeqeraPath(fs, 'seqera://acme/research')
|
||||
|
||||
when:
|
||||
final attrs = fs.provider().readAttributes(path, java.nio.file.attribute.BasicFileAttributes)
|
||||
|
||||
then:
|
||||
attrs.isDirectory()
|
||||
!attrs.isRegularFile()
|
||||
}
|
||||
|
||||
def "readAttributes returns file attributes for dataset path"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson())
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
when:
|
||||
final attrs = fs.provider().readAttributes(path, BasicFileAttributes)
|
||||
|
||||
then:
|
||||
!attrs.isDirectory()
|
||||
attrs.isRegularFile()
|
||||
}
|
||||
|
||||
// ---- newDirectoryStream (T023) ----
|
||||
|
||||
def "newDirectoryStream on root returns org names"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final root = new SeqeraPath(fs, 'seqera://')
|
||||
|
||||
when:
|
||||
def entries = fs.provider().newDirectoryStream(root, null).toList()
|
||||
|
||||
then:
|
||||
entries.size() == 1
|
||||
entries[0].toString() == 'seqera://acme'
|
||||
}
|
||||
|
||||
def "newDirectoryStream on org returns workspace names"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final orgPath = new SeqeraPath(fs, 'seqera://acme')
|
||||
|
||||
when:
|
||||
def entries = fs.provider().newDirectoryStream(orgPath, null).toList()
|
||||
|
||||
then:
|
||||
entries.size() == 1
|
||||
entries[0].toString() == 'seqera://acme/research'
|
||||
}
|
||||
|
||||
def "newDirectoryStream on workspace returns datasets resource type"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
final fs = buildFs(tc)
|
||||
final wsPath = new SeqeraPath(fs, 'seqera://acme/research')
|
||||
|
||||
when:
|
||||
def entries = fs.provider().newDirectoryStream(wsPath, null).toList()
|
||||
|
||||
then:
|
||||
entries.size() == 1
|
||||
entries[0].toString() == 'seqera://acme/research/datasets'
|
||||
}
|
||||
|
||||
def "newDirectoryStream on datasets dir returns dataset names"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson())
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final dsDir = new SeqeraPath(fs, 'seqera://acme/research/datasets')
|
||||
|
||||
when:
|
||||
def entries = fs.provider().newDirectoryStream(dsDir, null).toList()
|
||||
|
||||
then:
|
||||
entries.size() == 1
|
||||
entries[0].toString() == 'seqera://acme/research/datasets/samples'
|
||||
}
|
||||
|
||||
def "newDirectoryStream on datasets dir with empty workspace returns empty stream"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >>
|
||||
ok(JsonOutput.toJson([datasets: [], totalSize: 0]))
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final dsDir = new SeqeraPath(fs, 'seqera://acme/research/datasets')
|
||||
|
||||
when:
|
||||
def entries = fs.provider().newDirectoryStream(dsDir, null).toList()
|
||||
|
||||
then:
|
||||
entries.isEmpty()
|
||||
}
|
||||
|
||||
def "newDirectoryStream filter is applied to entries"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(JsonOutput.toJson([datasets: [
|
||||
[id: 'ds-1', name: 'samples', version: 1L, mediaType: 'text/csv', workspaceId: 10L,
|
||||
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z'],
|
||||
[id: 'ds-2', name: 'results', version: 1L, mediaType: 'text/csv', workspaceId: 10L,
|
||||
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z']
|
||||
], totalSize: 2]))
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final dsDir = new SeqeraPath(fs, 'seqera://acme/research/datasets')
|
||||
final filter = { java.nio.file.Path p -> p.toString().contains('results') } as DirectoryStream.Filter
|
||||
|
||||
when:
|
||||
def entries = fs.provider().newDirectoryStream(dsDir, filter).toList()
|
||||
|
||||
then:
|
||||
entries.size() == 1
|
||||
entries[0].toString() == 'seqera://acme/research/datasets/results'
|
||||
}
|
||||
|
||||
// ---- error scenarios (T028) ----
|
||||
|
||||
def "readAttributes throws NoSuchFileException for unknown org"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final path = new SeqeraPath(fs, 'seqera://unknown-org/research')
|
||||
|
||||
when:
|
||||
fs.provider().readAttributes(path, BasicFileAttributes)
|
||||
|
||||
then:
|
||||
thrown(NoSuchFileException)
|
||||
}
|
||||
|
||||
def "newInputStream throws NoSuchFileException containing dataset name for missing dataset"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >>
|
||||
ok(JsonOutput.toJson([datasets: [], totalSize: 0]))
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/missing-dataset')
|
||||
|
||||
when:
|
||||
fs.provider().newInputStream(path)
|
||||
|
||||
then:
|
||||
def e = thrown(NoSuchFileException)
|
||||
e.file?.contains('missing-dataset')
|
||||
}
|
||||
|
||||
def "getUserInfo 401 propagates as AbortOperationException"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> new TowerClient.Response(401, 'Unauthorized')
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
when:
|
||||
fs.provider().newInputStream(path)
|
||||
|
||||
then:
|
||||
thrown(AbortOperationException)
|
||||
}
|
||||
|
||||
def "getUserInfo 403 propagates as AccessDeniedException"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> new TowerClient.Response(403, 'Forbidden')
|
||||
|
||||
final fs = buildFs(tc)
|
||||
final path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
when:
|
||||
fs.provider().newInputStream(path)
|
||||
|
||||
then:
|
||||
thrown(AccessDeniedException)
|
||||
}
|
||||
|
||||
def "SeqeraPath constructor throws InvalidPathException for path with empty workspace segment"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
final fs = buildFs(tc)
|
||||
|
||||
when:
|
||||
new SeqeraPath(fs, 'seqera://acme//datasets/samples')
|
||||
|
||||
then:
|
||||
thrown(InvalidPathException)
|
||||
}
|
||||
|
||||
// ---- newFileSystem contract ----
|
||||
|
||||
def "newFileSystem throws FileSystemAlreadyExistsException when filesystem exists"() {
|
||||
given: 'a provider with an existing filesystem'
|
||||
def tc = spyTower()
|
||||
def provider = new SeqeraFileSystemProvider()
|
||||
def fs = new SeqeraFileSystem(provider, new SeqeraDatasetClient(tc))
|
||||
provider.@fileSystem = fs
|
||||
|
||||
when:
|
||||
provider.newFileSystem(new URI('seqera://test'), [:])
|
||||
|
||||
then:
|
||||
thrown(FileSystemAlreadyExistsException)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright 2013-2026, 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.fs
|
||||
|
||||
import java.nio.file.NoSuchFileException
|
||||
|
||||
import groovy.json.JsonOutput
|
||||
import io.seqera.tower.plugin.TowerClient
|
||||
import io.seqera.tower.plugin.dataset.SeqeraDatasetClient
|
||||
import spock.lang.Specification
|
||||
|
||||
/**
|
||||
* Tests for {@link SeqeraFileSystem} caching and workspace resolution using a mock {@link TowerClient}.
|
||||
*/
|
||||
class SeqeraFileSystemTest extends Specification {
|
||||
|
||||
private static final String ENDPOINT = 'https://api.example.com'
|
||||
|
||||
private TowerClient spyTower() {
|
||||
def tc = Spy(TowerClient)
|
||||
tc.@endpoint = ENDPOINT
|
||||
return tc
|
||||
}
|
||||
|
||||
private static TowerClient.Response ok(String body) {
|
||||
new TowerClient.Response(200, body)
|
||||
}
|
||||
|
||||
private static String userInfoJson() {
|
||||
JsonOutput.toJson([user: [id: 42L, userName: 'testuser']])
|
||||
}
|
||||
|
||||
private static String workspacesJson() {
|
||||
JsonOutput.toJson([orgsAndWorkspaces: [
|
||||
[orgId: 1L, orgName: 'acme', workspaceId: 10L, workspaceName: 'research', workspaceFullName: 'acme/research'],
|
||||
[orgId: 1L, orgName: 'acme', workspaceId: 20L, workspaceName: 'dev', workspaceFullName: 'acme/dev'],
|
||||
[orgId: 2L, orgName: 'other', workspaceId: 30L, workspaceName: 'ws', workspaceFullName: 'other/ws']
|
||||
]])
|
||||
}
|
||||
|
||||
private SeqeraFileSystem buildFs(TowerClient tc) {
|
||||
new SeqeraFileSystem(new SeqeraFileSystemProvider(), new SeqeraDatasetClient(tc))
|
||||
}
|
||||
|
||||
// ---- cache loading ----
|
||||
|
||||
def "loadOrgWorkspaceCache is called only once across multiple invocations"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
final fs = buildFs(tc)
|
||||
|
||||
when:
|
||||
fs.loadOrgWorkspaceCache()
|
||||
fs.loadOrgWorkspaceCache()
|
||||
|
||||
then:
|
||||
1 * tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
1 * tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
}
|
||||
|
||||
def "listOrgNames returns distinct org names from cache"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
final fs = buildFs(tc)
|
||||
|
||||
when:
|
||||
def orgs = fs.listOrgNames()
|
||||
|
||||
then:
|
||||
orgs.size() == 2
|
||||
orgs.contains('acme')
|
||||
orgs.contains('other')
|
||||
}
|
||||
|
||||
def "listWorkspaceNames returns workspace names for the given org"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
final fs = buildFs(tc)
|
||||
|
||||
when:
|
||||
def names = fs.listWorkspaceNames('acme')
|
||||
|
||||
then:
|
||||
names.size() == 2
|
||||
names.containsAll(['research', 'dev'])
|
||||
}
|
||||
|
||||
// ---- resolveWorkspaceId ----
|
||||
|
||||
def "resolveWorkspaceId returns correct ID for known org and workspace"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
final fs = buildFs(tc)
|
||||
|
||||
when:
|
||||
def id = fs.resolveWorkspaceId('acme', 'research')
|
||||
|
||||
then:
|
||||
id == 10L
|
||||
}
|
||||
|
||||
def "resolveWorkspaceId throws NoSuchFileException for unknown org"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
final fs = buildFs(tc)
|
||||
|
||||
when:
|
||||
fs.resolveWorkspaceId('unknown-org', 'research')
|
||||
|
||||
then:
|
||||
thrown(NoSuchFileException)
|
||||
}
|
||||
|
||||
def "resolveWorkspaceId throws NoSuchFileException for unknown workspace within known org"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/user-info") >> ok(userInfoJson())
|
||||
tc.sendApiRequest("${ENDPOINT}/user/42/workspaces") >> ok(workspacesJson())
|
||||
final fs = buildFs(tc)
|
||||
|
||||
when:
|
||||
fs.resolveWorkspaceId('acme', 'no-such-ws')
|
||||
|
||||
then:
|
||||
thrown(NoSuchFileException)
|
||||
}
|
||||
|
||||
// ---- dataset cache ----
|
||||
|
||||
def "resolveDatasets populates cache and returns datasets"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >>
|
||||
ok(JsonOutput.toJson([datasets: [
|
||||
[id: 'ds-1', name: 'samples', version: 1L, mediaType: 'text/csv',
|
||||
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z']
|
||||
], totalSize: 1]))
|
||||
final fs = buildFs(tc)
|
||||
|
||||
when:
|
||||
def datasets = fs.resolveDatasets(10L)
|
||||
|
||||
then:
|
||||
datasets.size() == 1
|
||||
datasets[0].name == 'samples'
|
||||
}
|
||||
|
||||
def "resolveDatasets returns cached result on second call without extra API request"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
final datasetsJson = JsonOutput.toJson([datasets: [
|
||||
[id: 'ds-1', name: 'samples', version: 1L, mediaType: 'text/csv',
|
||||
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z']
|
||||
], totalSize: 1])
|
||||
final fs = buildFs(tc)
|
||||
|
||||
when:
|
||||
fs.resolveDatasets(10L)
|
||||
fs.resolveDatasets(10L)
|
||||
|
||||
then:
|
||||
1 * tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson)
|
||||
}
|
||||
|
||||
def "invalidateDatasetCache forces re-fetch on next resolveDatasets call"() {
|
||||
given:
|
||||
def tc = spyTower()
|
||||
final datasetsJson = JsonOutput.toJson([datasets: [
|
||||
[id: 'ds-1', name: 'samples', version: 1L, mediaType: 'text/csv',
|
||||
dateCreated: '2024-01-01T00:00:00Z', lastUpdated: '2024-01-02T00:00:00Z']
|
||||
], totalSize: 1])
|
||||
final fs = buildFs(tc)
|
||||
|
||||
when:
|
||||
fs.resolveDatasets(10L)
|
||||
fs.invalidateDatasetCache(10L)
|
||||
fs.resolveDatasets(10L)
|
||||
|
||||
then:
|
||||
2 * tc.sendApiRequest("${ENDPOINT}/datasets?workspaceId=10") >> ok(datasetsJson)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
/*
|
||||
* Copyright 2013-2026, 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.fs
|
||||
|
||||
import io.seqera.tower.plugin.dataset.SeqeraDatasetClient
|
||||
import spock.lang.Specification
|
||||
|
||||
/**
|
||||
* Unit tests for {@link SeqeraPath}.
|
||||
*/
|
||||
class SeqeraPathTest extends Specification {
|
||||
|
||||
private SeqeraFileSystem mockFs() {
|
||||
def provider = new SeqeraFileSystemProvider()
|
||||
def client = Mock(SeqeraDatasetClient)
|
||||
return new SeqeraFileSystem(provider, client)
|
||||
}
|
||||
|
||||
def "depth 0 - root path"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://')
|
||||
|
||||
expect:
|
||||
path.depth() == 0
|
||||
path.isDirectory()
|
||||
!path.isRegularFile()
|
||||
path.org == null
|
||||
path.workspace == null
|
||||
}
|
||||
|
||||
def "depth 1 - org path"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme')
|
||||
|
||||
expect:
|
||||
path.depth() == 1
|
||||
path.isDirectory()
|
||||
!path.isRegularFile()
|
||||
path.org == 'acme'
|
||||
path.workspace == null
|
||||
}
|
||||
|
||||
def "depth 2 - workspace path"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research')
|
||||
|
||||
expect:
|
||||
path.depth() == 2
|
||||
path.isDirectory()
|
||||
path.org == 'acme'
|
||||
path.workspace == 'research'
|
||||
path.resourceType == null
|
||||
}
|
||||
|
||||
def "depth 3 - resource type path"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets')
|
||||
|
||||
expect:
|
||||
path.depth() == 3
|
||||
path.isDirectory()
|
||||
path.org == 'acme'
|
||||
path.workspace == 'research'
|
||||
path.resourceType == 'datasets'
|
||||
path.datasetName == null
|
||||
}
|
||||
|
||||
def "depth 4 - dataset file path"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
expect:
|
||||
path.depth() == 4
|
||||
!path.isDirectory()
|
||||
path.isRegularFile()
|
||||
path.org == 'acme'
|
||||
path.workspace == 'research'
|
||||
path.resourceType == 'datasets'
|
||||
path.datasetName == 'samples'
|
||||
path.version == null
|
||||
}
|
||||
|
||||
def "depth 4 - dataset with pinned version"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples@2')
|
||||
|
||||
expect:
|
||||
path.depth() == 4
|
||||
path.datasetName == 'samples'
|
||||
path.version == '2'
|
||||
}
|
||||
|
||||
def "toUri round-trip - no version"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def uri = 'seqera://acme/research/datasets/samples'
|
||||
def path = new SeqeraPath(fs, uri)
|
||||
|
||||
expect:
|
||||
path.toUri().toString() == uri
|
||||
path.toString() == uri
|
||||
}
|
||||
|
||||
def "toUri round-trip - with version"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def uri = 'seqera://acme/research/datasets/samples@2'
|
||||
def path = new SeqeraPath(fs, uri)
|
||||
|
||||
expect:
|
||||
path.toUri().toString() == uri
|
||||
}
|
||||
|
||||
def "getParent - depth 4 returns depth 3"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
when:
|
||||
def parent = path.getParent()
|
||||
|
||||
then:
|
||||
parent.toString() == 'seqera://acme/research/datasets'
|
||||
(parent as SeqeraPath).depth() == 3
|
||||
}
|
||||
|
||||
def "getParent - depth 3 returns depth 2"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets')
|
||||
|
||||
expect:
|
||||
path.getParent().toString() == 'seqera://acme/research'
|
||||
}
|
||||
|
||||
def "getParent - depth 1 returns depth 0 root"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme')
|
||||
|
||||
expect:
|
||||
path.getParent().toString() == 'seqera://'
|
||||
}
|
||||
|
||||
def "getParent - root returns null"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://')
|
||||
|
||||
expect:
|
||||
path.getParent() == null
|
||||
}
|
||||
|
||||
def "resolve - appends segment to workspace"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research')
|
||||
|
||||
when:
|
||||
def resolved = path.resolve('datasets')
|
||||
|
||||
then:
|
||||
resolved.toString() == 'seqera://acme/research/datasets'
|
||||
(resolved as SeqeraPath).depth() == 3
|
||||
}
|
||||
|
||||
def "resolve - appends dataset name to resource type"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets')
|
||||
|
||||
when:
|
||||
def resolved = path.resolve('my-dataset')
|
||||
|
||||
then:
|
||||
resolved.toString() == 'seqera://acme/research/datasets/my-dataset'
|
||||
}
|
||||
|
||||
def "resolve - dataset name with version"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets')
|
||||
|
||||
when:
|
||||
def resolved = path.resolve('samples@3')
|
||||
|
||||
then:
|
||||
(resolved as SeqeraPath).datasetName == 'samples'
|
||||
(resolved as SeqeraPath).version == '3'
|
||||
}
|
||||
|
||||
def "equality and hashCode"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def p1 = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
def p2 = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
def p3 = new SeqeraPath(fs, 'seqera://acme/research/datasets/other')
|
||||
|
||||
expect:
|
||||
p1 == p2
|
||||
p1.hashCode() == p2.hashCode()
|
||||
p1 != p3
|
||||
}
|
||||
|
||||
def "isAbsolute always true"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
|
||||
expect:
|
||||
new SeqeraPath(fs, 'seqera://acme').isAbsolute()
|
||||
new SeqeraPath(fs, 'seqera://').isAbsolute()
|
||||
}
|
||||
|
||||
def "getNameCount equals depth"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
|
||||
expect:
|
||||
new SeqeraPath(fs, 'seqera://').nameCount == 0
|
||||
new SeqeraPath(fs, 'seqera://acme').nameCount == 1
|
||||
new SeqeraPath(fs, 'seqera://acme/research/datasets/samples').nameCount == 4
|
||||
}
|
||||
|
||||
// ---- relativize ----
|
||||
|
||||
def "relativize returns correct relative path string"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
|
||||
expect:
|
||||
new SeqeraPath(fs, base).relativize(new SeqeraPath(fs, other)).toString() == expected
|
||||
|
||||
where:
|
||||
base | other | expected
|
||||
'seqera://acme' | 'seqera://acme/research' | 'research'
|
||||
'seqera://acme/research' | 'seqera://acme/research/datasets' | 'datasets'
|
||||
'seqera://acme/research' | 'seqera://acme/research/datasets/samples' | 'datasets/samples'
|
||||
'seqera://acme/research/datasets' | 'seqera://acme/research/datasets/samples' | 'samples'
|
||||
'seqera://acme/research/datasets/samples' | 'seqera://acme/research/datasets/samples' | ''
|
||||
}
|
||||
|
||||
def "relativize result round-trips through resolve"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def base = new SeqeraPath(fs, 'seqera://acme/research')
|
||||
def target = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
when:
|
||||
def rel = base.relativize(target)
|
||||
def restored = base.resolve(rel)
|
||||
|
||||
then:
|
||||
rel.toString() == 'datasets/samples'
|
||||
!rel.isAbsolute()
|
||||
restored == target
|
||||
}
|
||||
|
||||
def "relativize produces '..' segments for upward traversal"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
|
||||
expect:
|
||||
new SeqeraPath(fs, base).relativize(new SeqeraPath(fs, other)).toString() == expected
|
||||
|
||||
where:
|
||||
base | other | expected
|
||||
'seqera://acme/research' | 'seqera://acme/dev' | '../dev'
|
||||
'seqera://acme/research/datasets' | 'seqera://acme/dev' | '../../dev'
|
||||
'seqera://acme' | 'seqera://other' | '../other'
|
||||
'seqera://acme/ws1' | 'seqera://acme/ws2' | '../ws2'
|
||||
'seqera://acme/research/datasets/samples' | 'seqera://acme/research/datasets/other' | '../other'
|
||||
}
|
||||
|
||||
// ---- multi-segment resolve ----
|
||||
|
||||
def "resolve with multi-segment string builds correct path"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def base = new SeqeraPath(fs, 'seqera://acme/research')
|
||||
|
||||
expect:
|
||||
base.resolve('datasets/samples').toString() == 'seqera://acme/research/datasets/samples'
|
||||
base.resolve('datasets').toString() == 'seqera://acme/research/datasets'
|
||||
}
|
||||
|
||||
def "resolve with absolute seqera URI returns that URI"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def base = new SeqeraPath(fs, 'seqera://acme/research')
|
||||
def absolute = 'seqera://other/ws/datasets/report'
|
||||
|
||||
expect:
|
||||
base.resolve(absolute).toString() == absolute
|
||||
}
|
||||
|
||||
def "isAbsolute is false for relative paths produced by relativize"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def rel = new SeqeraPath(fs, 'seqera://acme').relativize(new SeqeraPath(fs, 'seqera://acme/research'))
|
||||
|
||||
expect:
|
||||
!rel.isAbsolute()
|
||||
rel.toString() == 'research'
|
||||
}
|
||||
|
||||
// ---- getFileName ----
|
||||
|
||||
def "getFileName returns relative path for each depth"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
|
||||
expect:
|
||||
new SeqeraPath(fs, 'seqera://').getFileName() == null
|
||||
new SeqeraPath(fs, 'seqera://acme').getFileName().toString() == 'acme'
|
||||
!new SeqeraPath(fs, 'seqera://acme').getFileName().isAbsolute()
|
||||
new SeqeraPath(fs, 'seqera://acme/research').getFileName().toString() == 'research'
|
||||
new SeqeraPath(fs, 'seqera://acme/research/datasets').getFileName().toString() == 'datasets'
|
||||
new SeqeraPath(fs, 'seqera://acme/research/datasets/samples').getFileName().toString() == 'samples'
|
||||
new SeqeraPath(fs, 'seqera://acme/research/datasets/samples@2').getFileName().toString() == 'samples@2'
|
||||
}
|
||||
|
||||
def "getFileName is not absolute (uses relative constructor)"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def name = new SeqeraPath(fs, 'seqera://acme/research').getFileName()
|
||||
|
||||
expect:
|
||||
!name.isAbsolute()
|
||||
name.toString() == 'research'
|
||||
name.getFileSystem() == null
|
||||
}
|
||||
|
||||
// ---- asUri ----
|
||||
|
||||
def "asUri - valid full path round-trips"() {
|
||||
expect:
|
||||
SeqeraPath.asUri('seqera://acme/research/datasets/samples').toString() == 'seqera://acme/research/datasets/samples'
|
||||
SeqeraPath.asUri('seqera://acme/research').toString() == 'seqera://acme/research'
|
||||
}
|
||||
|
||||
def "asUri - empty path returns root URI"() {
|
||||
expect:
|
||||
SeqeraPath.asUri('seqera://').toString() == 'seqera:///'
|
||||
}
|
||||
|
||||
def "asUri - path starting with dot has dot stripped"() {
|
||||
expect:
|
||||
// seqera://. → strips dot → seqera:// → hits empty-path case → seqera:///
|
||||
SeqeraPath.asUri('seqera://.').toString() == 'seqera:///'
|
||||
// seqera://./foo/bar → strips dot only (substring from index 10) → seqera:///foo/bar
|
||||
SeqeraPath.asUri('seqera://./foo/bar').toString() == 'seqera://foo/bar'
|
||||
}
|
||||
|
||||
def "asUri - triple slash path throws IllegalArgumentException"() {
|
||||
when:
|
||||
SeqeraPath.asUri('seqera:///something')
|
||||
|
||||
then:
|
||||
thrown(IllegalArgumentException)
|
||||
}
|
||||
|
||||
def "asUri - missing protocol prefix throws IllegalArgumentException"() {
|
||||
when:
|
||||
SeqeraPath.asUri('s3://bucket/key')
|
||||
|
||||
then:
|
||||
thrown(IllegalArgumentException)
|
||||
}
|
||||
|
||||
def "asUri - null or empty throws IllegalArgumentException"() {
|
||||
when:
|
||||
SeqeraPath.asUri(null)
|
||||
|
||||
then:
|
||||
thrown(IllegalArgumentException)
|
||||
|
||||
when:
|
||||
SeqeraPath.asUri('')
|
||||
|
||||
then:
|
||||
thrown(IllegalArgumentException)
|
||||
}
|
||||
|
||||
// ---- startsWith ----
|
||||
|
||||
def "startsWith - same path returns true"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
expect:
|
||||
path.startsWith(new SeqeraPath(fs, 'seqera://acme/research/datasets/samples'))
|
||||
}
|
||||
|
||||
def "startsWith - prefix path returns true"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
expect:
|
||||
path.startsWith(new SeqeraPath(fs, 'seqera://acme'))
|
||||
path.startsWith(new SeqeraPath(fs, 'seqera://acme/research'))
|
||||
path.startsWith(new SeqeraPath(fs, 'seqera://'))
|
||||
}
|
||||
|
||||
def "startsWith - component-wise not substring"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme-corp/research/datasets/samples')
|
||||
|
||||
expect: 'acme is a substring prefix of acme-corp but not a component prefix'
|
||||
!path.startsWith(new SeqeraPath(fs, 'seqera://acme'))
|
||||
}
|
||||
|
||||
def "startsWith - longer path returns false"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme')
|
||||
|
||||
expect:
|
||||
!path.startsWith(new SeqeraPath(fs, 'seqera://acme/research'))
|
||||
}
|
||||
|
||||
def "startsWith with string"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
expect:
|
||||
path.startsWith('seqera://acme')
|
||||
!path.startsWith('seqera://acm')
|
||||
}
|
||||
|
||||
// ---- endsWith ----
|
||||
|
||||
def "endsWith - absolute path requires exact match"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
expect:
|
||||
path.endsWith(new SeqeraPath(fs, 'seqera://acme/research/datasets/samples'))
|
||||
!path.endsWith(new SeqeraPath(fs, 'seqera://acme/research'))
|
||||
}
|
||||
|
||||
def "endsWith - relative path matches trailing components"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
expect:
|
||||
path.endsWith(new SeqeraPath('samples'))
|
||||
path.endsWith(new SeqeraPath('datasets/samples'))
|
||||
!path.endsWith(new SeqeraPath('other'))
|
||||
}
|
||||
|
||||
def "endsWith - component-wise not substring"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/my-samples')
|
||||
|
||||
expect: 'samples is a substring suffix of my-samples but not a component match'
|
||||
!path.endsWith(new SeqeraPath('samples'))
|
||||
}
|
||||
|
||||
// ---- iterator ----
|
||||
|
||||
def "iterator returns relative name components"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme/research/datasets/samples')
|
||||
|
||||
when:
|
||||
def parts = path.iterator().collect { it.toString() }
|
||||
|
||||
then:
|
||||
parts == ['acme', 'research', 'datasets', 'samples']
|
||||
path.iterator().every { !it.isAbsolute() }
|
||||
}
|
||||
|
||||
def "iterator on root returns empty"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://')
|
||||
|
||||
expect:
|
||||
!path.iterator().hasNext()
|
||||
}
|
||||
|
||||
def "iterator on org returns single element"() {
|
||||
given:
|
||||
def fs = mockFs()
|
||||
def path = new SeqeraPath(fs, 'seqera://acme')
|
||||
|
||||
when:
|
||||
def parts = path.iterator().collect { it.toString() }
|
||||
|
||||
then:
|
||||
parts == ['acme']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
/*
|
||||
* Copyright 2013-2026, 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.launch
|
||||
|
||||
import io.seqera.http.HxClient
|
||||
import io.seqera.tower.plugin.TowerClient
|
||||
import nextflow.cli.CmdLaunch
|
||||
import nextflow.exception.AbortOperationException
|
||||
import org.junit.Rule
|
||||
import spock.lang.Specification
|
||||
import spock.lang.TempDir
|
||||
import test.OutputCapture
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Test LaunchCommandImpl functionality
|
||||
*
|
||||
* @author Jorge Ejarque <jorge.ejarque@seqera.io>
|
||||
*/
|
||||
class LaunchCommandImplTest extends Specification {
|
||||
|
||||
@Rule
|
||||
OutputCapture capture = new OutputCapture()
|
||||
|
||||
@TempDir
|
||||
Path tempDir
|
||||
|
||||
// ===== Pipeline Validation Tests =====
|
||||
|
||||
def 'should reject local file path'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
when:
|
||||
cmd.validateAndResolvePipeline('/path/to/local/workflow')
|
||||
|
||||
then:
|
||||
def ex = thrown(AbortOperationException)
|
||||
ex.message.contains('Local file paths are not supported')
|
||||
}
|
||||
|
||||
def 'should reject relative file path with ./'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
when:
|
||||
cmd.validateAndResolvePipeline('./local/workflow')
|
||||
|
||||
then:
|
||||
def ex = thrown(AbortOperationException)
|
||||
ex.message.contains('Local file paths are not supported')
|
||||
}
|
||||
|
||||
def 'should reject relative file path with ../'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
when:
|
||||
cmd.validateAndResolvePipeline('../local/workflow')
|
||||
|
||||
then:
|
||||
def ex = thrown(AbortOperationException)
|
||||
ex.message.contains('Local file paths are not supported')
|
||||
}
|
||||
|
||||
def 'should accept remote repository URL'() {
|
||||
given:
|
||||
def cmd = Spy(LaunchCommandImpl)
|
||||
|
||||
and:
|
||||
cmd.resolvePipelineUrl(_) >> 'https://github.com/org/repo'
|
||||
|
||||
when:
|
||||
def result = cmd.validateAndResolvePipeline('https://github.com/org/repo')
|
||||
|
||||
then:
|
||||
result == 'https://github.com/org/repo'
|
||||
}
|
||||
|
||||
def 'should accept github short name'() {
|
||||
given:
|
||||
def cmd = Spy(LaunchCommandImpl)
|
||||
|
||||
and:
|
||||
cmd.resolvePipelineUrl(_) >> 'https://github.com/nf-core/rnaseq'
|
||||
|
||||
when:
|
||||
def result = cmd.validateAndResolvePipeline('nf-core/rnaseq')
|
||||
|
||||
then:
|
||||
result == 'https://github.com/nf-core/rnaseq'
|
||||
}
|
||||
|
||||
def 'should identify local path correctly'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
expect:
|
||||
cmd.isLocalPath('/absolute/path') == true
|
||||
cmd.isLocalPath('./relative/path') == true
|
||||
cmd.isLocalPath('../parent/path') == true
|
||||
cmd.isLocalPath('C:\\windows\\path') == true
|
||||
cmd.isLocalPath('remote/repo') == false
|
||||
cmd.isLocalPath('https://github.com/org/repo') == false
|
||||
}
|
||||
|
||||
// ===== Parameter Parsing Tests =====
|
||||
|
||||
def 'should parse simple parameters'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def params = ['input': 'data.csv', 'output': 'results/']
|
||||
|
||||
when:
|
||||
def paramsText = cmd.buildParamsText(params, null)
|
||||
|
||||
then:
|
||||
paramsText != null
|
||||
paramsText.contains('"input"')
|
||||
paramsText.contains('data.csv')
|
||||
paramsText.contains('"output"')
|
||||
paramsText.contains('results/')
|
||||
}
|
||||
|
||||
def 'should parse nested parameters with dot notation'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def params = ['genome.fasta': 'hg38.fa', 'genome.index': 'hg38.idx']
|
||||
|
||||
when:
|
||||
def paramsText = cmd.buildParamsText(params, null)
|
||||
|
||||
then:
|
||||
paramsText != null
|
||||
paramsText.contains('"genome"')
|
||||
paramsText.contains('"fasta"')
|
||||
paramsText.contains('hg38.fa')
|
||||
paramsText.contains('"index"')
|
||||
paramsText.contains('hg38.idx')
|
||||
}
|
||||
|
||||
def 'should parse boolean parameters'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def params = ['verbose': 'true', 'skip': 'false']
|
||||
|
||||
when:
|
||||
def paramsText = cmd.buildParamsText(params, null)
|
||||
|
||||
then:
|
||||
paramsText != null
|
||||
paramsText.contains('"verbose":true')
|
||||
paramsText.contains('"skip":false')
|
||||
}
|
||||
|
||||
def 'should parse numeric parameters'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def params = ['threads': '8', 'memory': '16.5', 'size': '1000000']
|
||||
|
||||
when:
|
||||
def paramsText = cmd.buildParamsText(params, null)
|
||||
|
||||
then:
|
||||
paramsText != null
|
||||
paramsText.contains('"threads":8')
|
||||
paramsText.contains('"memory":16.5')
|
||||
paramsText.contains('"size":1000000')
|
||||
}
|
||||
|
||||
def 'should parse parameters from JSON file'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def paramsFile = tempDir.resolve('params.json')
|
||||
Files.writeString(paramsFile, '{"input": "data.csv", "output": "results/"}')
|
||||
|
||||
when:
|
||||
def paramsText = cmd.buildParamsText([:], paramsFile.toString())
|
||||
|
||||
then:
|
||||
paramsText != null
|
||||
paramsText.contains('"input"')
|
||||
paramsText.contains('data.csv')
|
||||
}
|
||||
|
||||
def 'should parse parameters from YAML file'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def paramsFile = tempDir.resolve('params.yml')
|
||||
Files.writeString(paramsFile, 'input: data.csv\noutput: results/')
|
||||
|
||||
when:
|
||||
def paramsText = cmd.buildParamsText([:], paramsFile.toString())
|
||||
|
||||
then:
|
||||
paramsText != null
|
||||
paramsText.contains('"input"')
|
||||
paramsText.contains('data.csv')
|
||||
}
|
||||
|
||||
def 'should merge CLI params with params file, CLI taking precedence'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def paramsFile = tempDir.resolve('params.json')
|
||||
Files.writeString(paramsFile, '{"input": "file.csv", "output": "old/"}')
|
||||
def params = ['output': 'new/']
|
||||
|
||||
when:
|
||||
def paramsText = cmd.buildParamsText(params, paramsFile.toString())
|
||||
|
||||
then:
|
||||
paramsText != null
|
||||
paramsText.contains('"input"')
|
||||
paramsText.contains('file.csv')
|
||||
paramsText.contains('"output"')
|
||||
paramsText.contains('new/')
|
||||
}
|
||||
|
||||
def 'should reject invalid params file extension'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def paramsFile = tempDir.resolve('params.txt')
|
||||
Files.writeString(paramsFile, 'input: data.csv')
|
||||
|
||||
when:
|
||||
cmd.buildParamsText([:], paramsFile.toString())
|
||||
|
||||
then:
|
||||
thrown(AbortOperationException)
|
||||
}
|
||||
|
||||
def 'should handle missing params file'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
when:
|
||||
cmd.buildParamsText([:], '/nonexistent/params.json')
|
||||
|
||||
then:
|
||||
thrown(AbortOperationException)
|
||||
}
|
||||
|
||||
def 'should return null when no parameters provided'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
when:
|
||||
def paramsText = cmd.buildParamsText([:], null)
|
||||
|
||||
then:
|
||||
paramsText == null
|
||||
}
|
||||
|
||||
def 'should convert kebab-case to camelCase in parameter names'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def params = ['max-memory': '16GB', 'output-dir': 'results/']
|
||||
|
||||
when:
|
||||
def paramsText = cmd.buildParamsText(params, null)
|
||||
|
||||
then:
|
||||
paramsText != null
|
||||
paramsText.contains('"maxMemory"')
|
||||
paramsText.contains('"outputDir"')
|
||||
}
|
||||
|
||||
def 'should handle escaped dots in parameter names'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def params = ['file\\.name': 'test.csv']
|
||||
|
||||
when:
|
||||
def paramsText = cmd.buildParamsText(params, null)
|
||||
|
||||
then:
|
||||
paramsText != null
|
||||
paramsText.contains('"file.name"')
|
||||
}
|
||||
|
||||
// ===== Config File Tests =====
|
||||
|
||||
def 'should read config file content'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def configFile = tempDir.resolve('test.config')
|
||||
Files.writeString(configFile, 'process.cpus = 8\nprocess.memory = "16 GB"')
|
||||
|
||||
when:
|
||||
def configText = cmd.buildConfigText([configFile.toString()])
|
||||
|
||||
then:
|
||||
configText != null
|
||||
configText.contains('process.cpus = 8')
|
||||
configText.contains('process.memory = "16 GB"')
|
||||
}
|
||||
|
||||
def 'should concatenate multiple config files'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def config1 = tempDir.resolve('config1.config')
|
||||
def config2 = tempDir.resolve('config2.config')
|
||||
Files.writeString(config1, 'process.cpus = 8')
|
||||
Files.writeString(config2, 'process.memory = "16 GB"')
|
||||
|
||||
when:
|
||||
def configText = cmd.buildConfigText([config1.toString(), config2.toString()])
|
||||
|
||||
then:
|
||||
configText != null
|
||||
configText.contains('process.cpus = 8')
|
||||
configText.contains('process.memory = "16 GB"')
|
||||
}
|
||||
|
||||
def 'should return null when no config files provided'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
when:
|
||||
def configText = cmd.buildConfigText(null)
|
||||
|
||||
then:
|
||||
configText == null
|
||||
|
||||
when:
|
||||
configText = cmd.buildConfigText([])
|
||||
|
||||
then:
|
||||
configText == null
|
||||
}
|
||||
|
||||
def 'should throw exception for missing config file'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
when:
|
||||
cmd.buildConfigText(['/nonexistent/config.config'])
|
||||
|
||||
then:
|
||||
thrown(AbortOperationException)
|
||||
}
|
||||
|
||||
// ===== Launch Context Tests =====
|
||||
|
||||
def 'should throw error when no access token configured'() {
|
||||
given:
|
||||
def cmd = Spy(LaunchCommandImpl)
|
||||
cmd.readConfig() >> [:]
|
||||
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
|
||||
|
||||
when:
|
||||
cmd.initializeLaunchContext(options)
|
||||
|
||||
then:
|
||||
def ex = thrown(AbortOperationException)
|
||||
ex.message.contains('No authentication found')
|
||||
ex.message.contains('nextflow auth login')
|
||||
}
|
||||
|
||||
def 'should initialize context with valid config'() {
|
||||
given:
|
||||
def config = [
|
||||
'tower.accessToken': 'test-token',
|
||||
'tower.endpoint': 'https://api.cloud.seqera.io'
|
||||
]
|
||||
def client = Mock(TowerClient){
|
||||
getUserInfo() >> [name: 'testuser', id: '123']
|
||||
getUserWorkspaceDetails(_,_) >> null
|
||||
}
|
||||
def cmd = Spy(new LaunchCommandImpl())
|
||||
cmd.createTowerClient(_,_) >> client
|
||||
cmd.readConfig() >> config
|
||||
cmd.resolveWorkspaceId(_, _, _, _) >> null
|
||||
cmd.resolveComputeEnvironment(_,_, _, _, _) >> [id: 'ce-123', name: 'test-ce', workDir: 's3://bucket/work']
|
||||
|
||||
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
|
||||
|
||||
when:
|
||||
def context = cmd.initializeLaunchContext(options)
|
||||
|
||||
then:
|
||||
context.accessToken == 'test-token'
|
||||
context.apiEndpoint == 'https://api.cloud.seqera.io'
|
||||
context.userName == 'testuser'
|
||||
context.computeEnvId == 'ce-123'
|
||||
context.computeEnvName == 'test-ce'
|
||||
context.workDir == 's3://bucket/work'
|
||||
}
|
||||
|
||||
def 'should use default endpoint when not configured'() {
|
||||
given:
|
||||
def config = ['tower.accessToken': 'test-token']
|
||||
def client = Mock(TowerClient){
|
||||
getUserInfo() >> [name: 'testuser', id: '123']
|
||||
getUserWorkspaceDetails(_, _) >> null
|
||||
}
|
||||
def cmd = Spy(new LaunchCommandImpl())
|
||||
cmd.createTowerClient(_,_) >> client
|
||||
cmd.readConfig() >> config
|
||||
cmd.resolveWorkspaceId(_, _, _, _) >> null
|
||||
cmd.resolveComputeEnvironment(_,_, _, _, _) >> [id: 'ce-123', name: 'test-ce', workDir: 's3://bucket/work']
|
||||
|
||||
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
|
||||
|
||||
when:
|
||||
def context = cmd.initializeLaunchContext(options)
|
||||
|
||||
then:
|
||||
context.apiEndpoint == 'https://api.cloud.seqera.io'
|
||||
}
|
||||
|
||||
def 'should resolve workspace details when workspace ID provided'() {
|
||||
given:
|
||||
|
||||
def config = ['tower.accessToken': 'test-token', 'tower.workspaceId': 12345]
|
||||
def client = Mock(TowerClient){
|
||||
getUserInfo() >> [name: 'testuser', id: '123']
|
||||
getUserWorkspaceDetails(_, _) >> [orgName: 'TestOrg', workspaceName: 'TestWS']
|
||||
}
|
||||
def cmd = Spy(new LaunchCommandImpl())
|
||||
cmd.createTowerClient(_,_) >> client
|
||||
cmd.readConfig() >> config
|
||||
cmd.resolveWorkspaceId(_, _, _, _) >> 12345L
|
||||
cmd.resolveComputeEnvironment(_, _, _, _, _) >> [id: 'ce-123', name: 'test-ce', workDir: 's3://bucket/work']
|
||||
|
||||
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
|
||||
|
||||
when:
|
||||
def context = cmd.initializeLaunchContext(options)
|
||||
|
||||
then:
|
||||
context.workspaceId == 12345L
|
||||
context.orgName == 'TestOrg'
|
||||
context.workspaceName == 'TestWS'
|
||||
}
|
||||
|
||||
// ===== Compute Environment Tests =====
|
||||
|
||||
def 'should find compute environment by name'() {
|
||||
given:
|
||||
def cmd = Spy(LaunchCommandImpl)
|
||||
def computeEnvs = [
|
||||
[id: 'ce-1', name: 'primary-ce', primary: true],
|
||||
[id: 'ce-2', name: 'secondary-ce', primary: false]
|
||||
]
|
||||
cmd.listComputeEnvironments(_, _) >> computeEnvs
|
||||
|
||||
when:
|
||||
def result = cmd.findComputeEnv(Mock(TowerClient), 'secondary-ce', null)
|
||||
|
||||
then:
|
||||
result.id == 'ce-2'
|
||||
result.name == 'secondary-ce'
|
||||
}
|
||||
|
||||
def 'should find primary compute environment when name not provided'() {
|
||||
given:
|
||||
def cmd = Spy(LaunchCommandImpl)
|
||||
def computeEnvs = [
|
||||
[id: 'ce-1', name: 'primary-ce', primary: true],
|
||||
[id: 'ce-2', name: 'secondary-ce', primary: false]
|
||||
]
|
||||
cmd.listComputeEnvironments(_, _) >> computeEnvs
|
||||
|
||||
when:
|
||||
def result = cmd.findComputeEnv( Mock(TowerClient) ,null, null)
|
||||
|
||||
then:
|
||||
result.id == 'ce-1'
|
||||
result.name == 'primary-ce'
|
||||
result.primary == true
|
||||
}
|
||||
|
||||
def 'should return null when compute environment not found'() {
|
||||
given:
|
||||
def cmd = Spy(LaunchCommandImpl)
|
||||
cmd.listComputeEnvironments(_, _) >> []
|
||||
|
||||
when:
|
||||
def result = cmd.findComputeEnv(Mock(TowerClient), 'nonexistent', null)
|
||||
|
||||
then:
|
||||
result == null
|
||||
}
|
||||
|
||||
def 'should throw error when compute environment not found'() {
|
||||
given:
|
||||
def cmd = Spy(LaunchCommandImpl)
|
||||
// Mock findComputeEnv to return null (not found)
|
||||
cmd.findComputeEnv(_,'nonexistent', null) >> null
|
||||
|
||||
when:
|
||||
cmd.resolveComputeEnvironment(null, 'nonexistent', null, 'token', 'https://api.cloud.seqera.io')
|
||||
|
||||
then:
|
||||
def ex = thrown(AbortOperationException)
|
||||
ex.message.contains('Compute environment \'nonexistent\' not found')
|
||||
}
|
||||
|
||||
def 'should throw error when no primary compute environment'() {
|
||||
given:
|
||||
def cmd = Spy(LaunchCommandImpl)
|
||||
// Mock findComputeEnv to return null (no primary found)
|
||||
cmd.createTowerClient(_,_) >> Mock(TowerClient)
|
||||
cmd.findComputeEnv(_ , null, null) >> null
|
||||
|
||||
when:
|
||||
cmd.resolveComputeEnvironment(null, null, null, 'token', 'https://api.cloud.seqera.io')
|
||||
|
||||
then:
|
||||
def ex = thrown(AbortOperationException)
|
||||
ex.message.contains('No primary compute environment found')
|
||||
}
|
||||
|
||||
// ===== Work Directory Tests =====
|
||||
|
||||
def 'should use CLI work dir when provided'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def computeEnvInfo = [workDir: 's3://default/work']
|
||||
|
||||
when:
|
||||
def workDir = cmd.resolveWorkDirectory('s3://custom/work', computeEnvInfo)
|
||||
|
||||
then:
|
||||
workDir == 's3://custom/work'
|
||||
}
|
||||
|
||||
def 'should use compute environment work dir when CLI not provided'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def computeEnvInfo = [workDir: 's3://default/work']
|
||||
|
||||
when:
|
||||
def workDir = cmd.resolveWorkDirectory(null, computeEnvInfo)
|
||||
|
||||
then:
|
||||
workDir == 's3://default/work'
|
||||
}
|
||||
|
||||
def 'should throw error when no work dir available'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def computeEnvInfo = [:]
|
||||
|
||||
when:
|
||||
cmd.resolveWorkDirectory(null, computeEnvInfo)
|
||||
|
||||
then:
|
||||
def ex = thrown(AbortOperationException)
|
||||
ex.message.contains('Work directory is required')
|
||||
}
|
||||
|
||||
// ===== Launch Request Building Tests =====
|
||||
|
||||
def 'should build basic launch request'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
|
||||
def context = new LaunchCommandImpl.LaunchContext(
|
||||
computeEnvId: 'ce-123',
|
||||
workDir: 's3://bucket/work'
|
||||
)
|
||||
|
||||
when:
|
||||
def request = cmd.buildLaunchRequestPayload(options, context, 'https://github.com/nf-core/rnaseq', null, null)
|
||||
|
||||
then:
|
||||
request.launch.computeEnvId == 'ce-123'
|
||||
request.launch.workDir == 's3://bucket/work'
|
||||
request.launch.pipeline == 'https://github.com/nf-core/rnaseq'
|
||||
request.launch.resume == false
|
||||
request.launch.pullLatest == false
|
||||
request.launch.stubRun == false
|
||||
}
|
||||
|
||||
def 'should include optional parameters in launch request'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def options = new CmdLaunch.LaunchOptions(
|
||||
pipeline: 'nf-core/rnaseq',
|
||||
runName: 'test-run',
|
||||
revision: 'main',
|
||||
profile: 'test',
|
||||
resume: 'session-id',
|
||||
latest: true,
|
||||
stubRun: true,
|
||||
mainScript: 'main.nf',
|
||||
entryName: 'workflow1'
|
||||
)
|
||||
def context = new LaunchCommandImpl.LaunchContext(
|
||||
computeEnvId: 'ce-123',
|
||||
workDir: 's3://bucket/work'
|
||||
)
|
||||
|
||||
when:
|
||||
def request = cmd.buildLaunchRequestPayload(options, context, 'https://github.com/nf-core/rnaseq',
|
||||
'{"input":"data.csv"}', 'process.cpus = 8')
|
||||
|
||||
then:
|
||||
request.launch.runName == 'test-run'
|
||||
request.launch.revision == 'main'
|
||||
request.launch.configProfiles == 'test'
|
||||
request.launch.resume == true
|
||||
request.launch.pullLatest == true
|
||||
request.launch.stubRun == true
|
||||
request.launch.mainScript == 'main.nf'
|
||||
request.launch.entryName == 'workflow1'
|
||||
request.launch.paramsText == '{"input":"data.csv"}'
|
||||
request.launch.configText == 'process.cpus = 8'
|
||||
}
|
||||
|
||||
def 'should include workspace and user secrets in launch request'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def options = new CmdLaunch.LaunchOptions(
|
||||
pipeline: 'nf-core/rnaseq',
|
||||
userSecrets: ['MY_USER_SECRET'],
|
||||
workspaceSecrets: ['DRAGEN_USERNAME', 'DRAGEN_PASSWORD']
|
||||
)
|
||||
def context = new LaunchCommandImpl.LaunchContext(
|
||||
computeEnvId: 'ce-123',
|
||||
workDir: 's3://bucket/work'
|
||||
)
|
||||
|
||||
when:
|
||||
def request = cmd.buildLaunchRequestPayload(options, context, 'https://github.com/nf-core/rnaseq', null, null)
|
||||
|
||||
then:
|
||||
request.launch.userSecrets == ['MY_USER_SECRET'] as Set
|
||||
request.launch.workspaceSecrets == ['DRAGEN_USERNAME', 'DRAGEN_PASSWORD'] as Set
|
||||
}
|
||||
|
||||
def 'should not include secrets in launch request when none provided'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
|
||||
def context = new LaunchCommandImpl.LaunchContext(
|
||||
computeEnvId: 'ce-123',
|
||||
workDir: 's3://bucket/work'
|
||||
)
|
||||
|
||||
when:
|
||||
def request = cmd.buildLaunchRequestPayload(options, context, 'https://github.com/nf-core/rnaseq', null, null)
|
||||
|
||||
then:
|
||||
!request.launch.containsKey('userSecrets')
|
||||
!request.launch.containsKey('workspaceSecrets')
|
||||
}
|
||||
|
||||
// ===== Workflow Status Tests =====
|
||||
|
||||
def 'should get color for workflow status'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
expect:
|
||||
cmd.getColorForStatus('PENDING') == 'yellow'
|
||||
cmd.getColorForStatus('SUBMITTED') == 'yellow'
|
||||
cmd.getColorForStatus('RUNNING') == 'blue'
|
||||
cmd.getColorForStatus('SUCCEEDED') == 'green'
|
||||
cmd.getColorForStatus('FAILED') == 'red'
|
||||
cmd.getColorForStatus('CANCELLED') == 'red'
|
||||
cmd.getColorForStatus('ABORTED') == 'red'
|
||||
cmd.getColorForStatus(null) == 'cyan'
|
||||
}
|
||||
|
||||
def 'should get spinner mode for workflow status'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
expect:
|
||||
cmd.getSpinnerMode('PENDING', false) == 'waiting'
|
||||
cmd.getSpinnerMode('SUBMITTED', false) == 'waiting'
|
||||
cmd.getSpinnerMode('RUNNING', false) == 'running'
|
||||
cmd.getSpinnerMode('SUCCEEDED', false) == 'succeeded'
|
||||
cmd.getSpinnerMode('FAILED', false) == 'failed'
|
||||
cmd.getSpinnerMode('CANCELLED', false) == 'failed'
|
||||
cmd.getSpinnerMode('ABORTED', false) == 'failed'
|
||||
cmd.getSpinnerMode(null, false) == 'waiting'
|
||||
}
|
||||
|
||||
def 'should format workflow status'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
when:
|
||||
def formatted = cmd.formatWorkflowStatus('RUNNING')
|
||||
|
||||
then:
|
||||
formatted.contains('Workflow status:')
|
||||
formatted.contains('RUNNING')
|
||||
}
|
||||
|
||||
// ===== Workspace Resolution Tests =====
|
||||
|
||||
def 'should use workspace ID from config'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def config = ['tower.workspaceId': 12345L]
|
||||
|
||||
when:
|
||||
def workspaceId = cmd.resolveWorkspaceId(config, null, 'token', 'endpoint')
|
||||
|
||||
then:
|
||||
workspaceId == 12345L
|
||||
}
|
||||
|
||||
def 'should lookup workspace by name'() {
|
||||
given:
|
||||
def config = [:]
|
||||
def workspaces = [
|
||||
[workspaceId: 111, workspaceName: 'ws1'],
|
||||
[workspaceId: 222, workspaceName: 'ws2']
|
||||
]
|
||||
def client = Mock(TowerClient) {
|
||||
getUserInfo() >> [id: 'user-123']
|
||||
}
|
||||
def cmd = Spy(new LaunchCommandImpl())
|
||||
cmd.createTowerClient(_,_) >> client
|
||||
cmd.listUserWorkspaces(_, _) >> workspaces
|
||||
|
||||
when:
|
||||
def workspaceId = cmd.resolveWorkspaceId(config, 'ws2', 'token', 'endpoint')
|
||||
|
||||
then:
|
||||
workspaceId == 222
|
||||
}
|
||||
|
||||
def 'should throw error when workspace not found by name'() {
|
||||
given:
|
||||
def config = [:]
|
||||
def client = Mock(TowerClient) {
|
||||
getUserInfo() >> [id: 'user-123']
|
||||
}
|
||||
def cmd = Spy(new LaunchCommandImpl())
|
||||
cmd.createTowerClient(_,_) >> client
|
||||
cmd.listUserWorkspaces(_, _, _) >> []
|
||||
|
||||
when:
|
||||
cmd.resolveWorkspaceId(config, 'nonexistent', 'token', 'endpoint')
|
||||
|
||||
then:
|
||||
def ex = thrown(AbortOperationException)
|
||||
ex.message.contains('Workspace \'nonexistent\' not found')
|
||||
}
|
||||
|
||||
def 'should return null when no workspace specified'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def config = [:]
|
||||
|
||||
when:
|
||||
def workspaceId = cmd.resolveWorkspaceId(config, null, 'token', 'endpoint')
|
||||
|
||||
then:
|
||||
workspaceId == null
|
||||
}
|
||||
|
||||
// ===== Launch Result Tests =====
|
||||
|
||||
def 'should extract launch result with workflow details'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def response = [workflowId: 'wf-123']
|
||||
def workflowDetails = [
|
||||
workflow: [
|
||||
runName: 'test-run',
|
||||
commitId: 'abc123',
|
||||
revision: 'main'
|
||||
]
|
||||
]
|
||||
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
|
||||
def context = new LaunchCommandImpl.LaunchContext(
|
||||
apiEndpoint: 'https://api.cloud.seqera.io',
|
||||
userName: 'testuser'
|
||||
)
|
||||
|
||||
when:
|
||||
def result = cmd.extractLaunchResult(response, workflowDetails, options, 'https://github.com/nf-core/rnaseq', context)
|
||||
|
||||
then:
|
||||
result.workflowId == 'wf-123'
|
||||
result.runName == 'test-run'
|
||||
result.commitId == 'abc123'
|
||||
result.revision == 'main'
|
||||
result.repository == 'https://github.com/nf-core/rnaseq'
|
||||
result.trackingUrl.contains('/user/testuser/watch/wf-123/')
|
||||
}
|
||||
|
||||
def 'should extract launch result without workflow details'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def response = [workflowId: 'wf-123']
|
||||
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq', runName: 'custom-run')
|
||||
def context = new LaunchCommandImpl.LaunchContext(
|
||||
apiEndpoint: 'https://api.cloud.seqera.io',
|
||||
userName: 'testuser'
|
||||
)
|
||||
|
||||
when:
|
||||
def result = cmd.extractLaunchResult(response, null, options, 'https://github.com/nf-core/rnaseq', context)
|
||||
|
||||
then:
|
||||
result.workflowId == 'wf-123'
|
||||
result.runName == 'custom-run'
|
||||
result.commitId == 'unknown'
|
||||
}
|
||||
|
||||
def 'should build tracking URL for organization workspace'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
def response = [workflowId: 'wf-123']
|
||||
def options = new CmdLaunch.LaunchOptions(pipeline: 'nf-core/rnaseq')
|
||||
def context = new LaunchCommandImpl.LaunchContext(
|
||||
apiEndpoint: 'https://api.cloud.seqera.io',
|
||||
userName: 'testuser',
|
||||
orgName: 'TestOrg',
|
||||
workspaceName: 'TestWS'
|
||||
)
|
||||
|
||||
when:
|
||||
def result = cmd.extractLaunchResult(response, null, options, 'https://github.com/nf-core/rnaseq', context)
|
||||
|
||||
then:
|
||||
result.trackingUrl == 'https://cloud.seqera.io/orgs/TestOrg/workspaces/TestWS/watch/wf-123/'
|
||||
}
|
||||
|
||||
// ===== Parameter Value Parsing Tests =====
|
||||
|
||||
def 'should parse parameter values correctly'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
expect:
|
||||
cmd.parseParamValue('true') == Boolean.TRUE
|
||||
cmd.parseParamValue('false') == Boolean.FALSE
|
||||
cmd.parseParamValue('TRUE') == Boolean.TRUE
|
||||
cmd.parseParamValue('FALSE') == Boolean.FALSE
|
||||
cmd.parseParamValue('42') == 42
|
||||
cmd.parseParamValue('3.14') == 3.14
|
||||
cmd.parseParamValue('text') == 'text'
|
||||
cmd.parseParamValue(null) == null
|
||||
}
|
||||
|
||||
def 'should convert kebab case to camel case'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
expect:
|
||||
cmd.kebabToCamelCase('max-memory') == 'maxMemory'
|
||||
cmd.kebabToCamelCase('output-dir') == 'outputDir'
|
||||
cmd.kebabToCamelCase('simple') == 'simple'
|
||||
cmd.kebabToCamelCase('very-long-param-name') == 'veryLongParamName'
|
||||
}
|
||||
|
||||
// ===== Utility Tests =====
|
||||
|
||||
def 'should get web URL from API endpoint'() {
|
||||
given:
|
||||
def cmd = new LaunchCommandImpl()
|
||||
|
||||
expect:
|
||||
cmd.getWebUrlFromApiEndpoint('https://api.cloud.seqera.io') == 'https://cloud.seqera.io'
|
||||
cmd.getWebUrlFromApiEndpoint('https://cloud.seqera.io/api') == 'https://cloud.seqera.io'
|
||||
cmd.getWebUrlFromApiEndpoint('https://custom.example.com') == 'https://custom.example.com'
|
||||
}
|
||||
|
||||
// ===== Data Class Tests =====
|
||||
|
||||
def 'should create LaunchContext with all fields'() {
|
||||
when:
|
||||
def context = new LaunchCommandImpl.LaunchContext(
|
||||
accessToken: 'token',
|
||||
apiEndpoint: 'https://api.cloud.seqera.io',
|
||||
userName: 'testuser',
|
||||
workspaceId: 12345L,
|
||||
orgName: 'TestOrg',
|
||||
workspaceName: 'TestWS',
|
||||
computeEnvId: 'ce-123',
|
||||
computeEnvName: 'test-ce',
|
||||
workDir: 's3://bucket/work'
|
||||
)
|
||||
|
||||
then:
|
||||
context.accessToken == 'token'
|
||||
context.apiEndpoint == 'https://api.cloud.seqera.io'
|
||||
context.userName == 'testuser'
|
||||
context.workspaceId == 12345L
|
||||
context.orgName == 'TestOrg'
|
||||
context.workspaceName == 'TestWS'
|
||||
context.computeEnvId == 'ce-123'
|
||||
context.computeEnvName == 'test-ce'
|
||||
context.workDir == 's3://bucket/work'
|
||||
}
|
||||
|
||||
def 'should create WorkflowLaunchResult with all fields'() {
|
||||
when:
|
||||
def result = new LaunchCommandImpl.WorkflowLaunchResult(
|
||||
workflowId: 'wf-123',
|
||||
runName: 'test-run',
|
||||
commitId: 'abc123',
|
||||
revision: 'main',
|
||||
repository: 'https://github.com/org/repo',
|
||||
trackingUrl: 'https://cloud.seqera.io/watch/wf-123'
|
||||
)
|
||||
|
||||
then:
|
||||
result.workflowId == 'wf-123'
|
||||
result.runName == 'test-run'
|
||||
result.commitId == 'abc123'
|
||||
result.revision == 'main'
|
||||
result.repository == 'https://github.com/org/repo'
|
||||
result.trackingUrl == 'https://cloud.seqera.io/watch/wf-123'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user