From e7b10823f5937cfcf32c76f165651300e6216166 Mon Sep 17 00:00:00 2001 From: Alberto Miranda Date: Wed, 18 Dec 2024 13:43:44 +0100 Subject: [PATCH] feat(nf-tower): Add `TowerFusionEnv` provider to set required env vars Signed-off-by: Alberto Miranda --- plugins/nf-tower/build.gradle | 2 + .../seqera/tower/plugin/TowerFusionEnv.groovy | 266 +++++++++++ .../exception/BadResponseException.groovy | 7 + .../exception/UnauthorizedException.groovy | 7 + .../exchange/LicenseTokenRequest.groovy | 22 + .../exchange/LicenseTokenResponse.groovy | 23 + .../src/resources/META-INF/extensions.idx | 1 + .../tower/plugin/TowerFusionEnvTest.groovy | 420 ++++++++++++++++++ 8 files changed, 748 insertions(+) create mode 100644 plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerFusionEnv.groovy create mode 100644 plugins/nf-tower/src/main/io/seqera/tower/plugin/exception/BadResponseException.groovy create mode 100644 plugins/nf-tower/src/main/io/seqera/tower/plugin/exception/UnauthorizedException.groovy create mode 100644 plugins/nf-tower/src/main/io/seqera/tower/plugin/exchange/LicenseTokenRequest.groovy create mode 100644 plugins/nf-tower/src/main/io/seqera/tower/plugin/exchange/LicenseTokenResponse.groovy create mode 100644 plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerFusionEnvTest.groovy diff --git a/plugins/nf-tower/build.gradle b/plugins/nf-tower/build.gradle index 347d127762..797e6fb538 100644 --- a/plugins/nf-tower/build.gradle +++ b/plugins/nf-tower/build.gradle @@ -39,4 +39,6 @@ dependencies { testImplementation(testFixtures(project(":nextflow"))) testImplementation "org.apache.groovy:groovy:4.0.24" testImplementation "org.apache.groovy:groovy-nio:4.0.24" + // wiremock required by TowerFusionEnvTest + testImplementation "org.wiremock:wiremock:3.5.4" } diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerFusionEnv.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerFusionEnv.groovy new file mode 100644 index 0000000000..4a36fda8b2 --- /dev/null +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/TowerFusionEnv.groovy @@ -0,0 +1,266 @@ +package io.seqera.tower.plugin + +import com.google.gson.Gson +import dev.failsafe.Failsafe +import dev.failsafe.RetryPolicy +import dev.failsafe.event.EventListener +import dev.failsafe.event.ExecutionAttemptedEvent +import dev.failsafe.function.CheckedSupplier +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import io.seqera.tower.plugin.exception.BadResponseException +import io.seqera.tower.plugin.exception.UnauthorizedException +import io.seqera.tower.plugin.exchange.LicenseTokenRequest +import io.seqera.tower.plugin.exchange.LicenseTokenResponse +import nextflow.Global +import nextflow.Session +import nextflow.SysEnv +import nextflow.exception.AbortOperationException +import nextflow.fusion.FusionConfig +import nextflow.fusion.FusionEnv +import nextflow.util.Threads +import org.pf4j.Extension + +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.time.temporal.ChronoUnit +import java.util.concurrent.Executors +import java.util.function.Predicate + +/** + * Environment provider for Platform-specific environment variables. + * + * @author Alberto Miranda + */ +@Slf4j +@Extension +@CompileStatic +class TowerFusionEnv implements FusionEnv { + + // The endpoint where license-scoped JWT tokens are obtained + private static final String LICENSE_TOKEN_ENDPOINT = 'license/token/' + + // Server errors that should trigger a retry + private static final List SERVER_ERRORS = [429, 500, 502, 503, 504] + + // Default connection timeout for HTTP requests + private static final Duration DEFAULT_CONNECTION_TIMEOUT = Duration.of(30, ChronoUnit.SECONDS) + + // Default retry policy settings for HTTP requests: delay, max delay, attempts, and jitter + private static final Duration DEFAULT_RETRY_POLICY_DELAY = Duration.of(450, ChronoUnit.MILLIS) + private static final Duration DEFAULT_RETRY_POLICY_MAX_DELAY = Duration.of(90, ChronoUnit.SECONDS) + private static final int DEFAULT_RETRY_POLICY_MAX_ATTEMPTS = 10 + private static final double DEFAULT_RETRY_POLICY_JITTER = 0.5 + + // The HttpClient instance used to send requests + private final HttpClient httpClient = newDefaultHttpClient() + + // The RetryPolicy instance used to retry requests + private final RetryPolicy retryPolicy = newDefaultRetryPolicy(SERVER_ERRORS) + + // Nextflow session + private final Session session + + // Platform endpoint to use for requests + private final String endpoint + + // Platform access token to use for requests + private final String accessToken + + /** + * Constructor for the class. It initializes the session, endpoint, and access token. + */ + TowerFusionEnv() { + this.session = Global.session as Session + final towerConfig = session.config.navigate('tower') as Map ?: [:] + final env = SysEnv.get() + this.endpoint = endpoint0(towerConfig, env) + this.accessToken = accessToken0(towerConfig, env) + } + + /** + * Return any environment variables relevant to Fusion execution. This method is called + * by {@link nextflow.fusion.FusionEnvProvider#getEnvironment} to determine which + * environment variables are needed for the current run. + * + * @param scheme The scheme for which the environment variables are needed (currently unused) + * @param config The Fusion configuration object + * @return A map of environment variables + */ + @Override + Map getEnvironment(String scheme, FusionConfig config) { + + // TODO(amiranda): Hardcoded for now. We need to find out how to obtain + // the concrete product SKU and version. Candidate: FusionConfig? + final product = 'fusion' + final version = '2.4' + + try { + final token = getLicenseToken(product, version) + return [ + 'FUSION_LICENSE_TOKEN': token, + ] + } catch (Exception e) { + log.warn("Error retrieving Fusion license information: ${e.message}") + return [:] + } + } + + /** + * Send a request to Platform to obtain a license-scoped JWT for Fusion. The request is authenticated using the + * Platform access token provided in the configuration of the current session. + * + * @throws AbortOperationException if a Platform access token cannot be found + * + * @return The signed JWT token + */ + protected String getLicenseToken(product, version) { + // FIXME(amiranda): Find out how to obtain the product and version + // Candidate: FusionConfig? + + if (accessToken == null) { + throw new AbortOperationException("Missing personal access token -- Make sure there's a variable TOWER_ACCESS_TOKEN in your environment") + } + + final req = HttpRequest.newBuilder() + .uri(URI.create("${endpoint}/${LICENSE_TOKEN_ENDPOINT}").normalize()) + .header('Content-Type', 'application/json') + .header('Authorization', "Bearer ${accessToken}") + .POST( + HttpRequest.BodyPublishers.ofString( + new Gson().toJson( + new LicenseTokenRequest( + product: product, + version: version + ), + LicenseTokenRequest.class + ), + ) + ) + .build() + + try { + final resp = safeHttpSend(req, retryPolicy) + + if (resp.statusCode() == 200) { + return new Gson().fromJson(resp.body(), LicenseTokenResponse.class).signedToken + } + + if (resp.statusCode() == 401) { + throw new UnauthorizedException("Unauthorized [401] - Verify you have provided a valid access token") + } + + throw new BadResponseException("Invalid response: ${req.method()} ${req.uri()} [${resp.statusCode()}] ${resp.body()}") + + } catch (IOException e) { + throw new IllegalStateException("Unable to send request to '${req.uri()}' : ${e.message}") + } + } + + /************************************************************************** + * Helper methods + *************************************************************************/ + + /** + * Get the configured Platform API endpoint: if the endpoint is not provided in the configuration, we fallback to the + * environment variable `TOWER_API_ENDPOINT`. If neither is provided, we fallback to the default endpoint. + * + * @param opts the configuration options for Platform + * @param env the applicable environment variables + * @return the Platform API endpoint + */ + protected static String endpoint0(Map opts, Map env) { + def result = opts.endpoint as String + if (!result || result == '-') { + result = env.get('TOWER_API_ENDPOINT') ?: TowerClient.DEF_ENDPOINT_URL + } + return result.stripEnd('/') + } + + /** + * Get the configured Platform access token: if `TOWER_WORKFLOW_ID` is provided in the environment, we are running + * in a Platform-made run and we should ONLY retrieve the token from the environment. Otherwise, check + * the configuration file or fallback to the environment. If no token is found, returns null. + * + * @param opts the configuration options for Platform + * @param env the applicable environment variables + * @return the Platform access token + */ + protected static String accessToken0(Map opts, Map env) { + def token = env.get('TOWER_WORKFLOW_ID') + ? env.get('TOWER_ACCESS_TOKEN') + : opts.containsKey('accessToken') ? opts.accessToken as String : env.get('TOWER_ACCESS_TOKEN') + return token + } + + /** + * Create a new HttpClient instance with default settings + * @return The new HttpClient instance + */ + private static HttpClient newDefaultHttpClient() { + final builder = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NEVER) + .cookieHandler(new CookieManager()) + .connectTimeout(DEFAULT_CONNECTION_TIMEOUT) + // use virtual threads executor if enabled + if ( Threads.useVirtual() ) { + builder.executor(Executors.newVirtualThreadPerTaskExecutor()) + } + // build and return the new client + return builder.build() + } + + /** + * Create a new RetryPolicy instance with default settings and the given list of retryable errors. With this policy, + * a request is retried on IOExceptions and any server errors defined in errorsToRetry. The number of retries, delay, + * max delay, and jitter are controlled by the corresponding values defined at class level. + * + * @return The new RetryPolicy instance + */ + private static RetryPolicy> newDefaultRetryPolicy(List errorsToRetry) { + + final retryOnException = (e -> e instanceof IOException) as Predicate + final retryOnStatusCode = ((HttpResponse resp) -> resp.statusCode() in errorsToRetry) as Predicate> + + final listener = new EventListener>>() { + @Override + void accept(ExecutionAttemptedEvent event) throws Throwable { + def msg = "connection failure - attempt: ${event.attemptCount}" + if (event.lastResult != null) + msg += "; response: ${event.lastResult}" + if (event.lastFailure != null) + msg += "; exception: [${event.lastFailure.class.name}] ${event.lastFailure.message}" + log.debug(msg) + } + } + return RetryPolicy.> builder() + .handleIf(retryOnException) + .handleResultIf(retryOnStatusCode) + .withBackoff(DEFAULT_RETRY_POLICY_DELAY.toMillis(), DEFAULT_RETRY_POLICY_MAX_DELAY.toMillis(), ChronoUnit.MILLIS) + .withMaxAttempts(DEFAULT_RETRY_POLICY_MAX_ATTEMPTS) + .withJitter(DEFAULT_RETRY_POLICY_JITTER) + .onRetry(listener) + .build() + } + + /** + * Send an HTTP request and return the response. This method automatically retries the request according to the + * given RetryPolicy. + * + * @param req The HttpRequest to send + * @return The HttpResponse received + */ + private HttpResponse safeHttpSend(HttpRequest req, RetryPolicy policy) { + return Failsafe.with(policy).get( + () -> { + log.debug "Request: method:=${req.method()}; uri:=${req.uri()}; request:=${req}" + final resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()) + log.debug "Response: statusCode:=${resp.statusCode()}; body:=${resp.body()}" + return resp + } as CheckedSupplier + ) as HttpResponse + } +} diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/exception/BadResponseException.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/exception/BadResponseException.groovy new file mode 100644 index 0000000000..4de9a30882 --- /dev/null +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/exception/BadResponseException.groovy @@ -0,0 +1,7 @@ +package io.seqera.tower.plugin.exception + +import groovy.transform.InheritConstructors + +@InheritConstructors +class BadResponseException extends RuntimeException{ +} diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/exception/UnauthorizedException.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/exception/UnauthorizedException.groovy new file mode 100644 index 0000000000..6269a825b5 --- /dev/null +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/exception/UnauthorizedException.groovy @@ -0,0 +1,7 @@ +package io.seqera.tower.plugin.exception + +import groovy.transform.InheritConstructors + +@InheritConstructors +class UnauthorizedException extends RuntimeException { +} diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/exchange/LicenseTokenRequest.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/exchange/LicenseTokenRequest.groovy new file mode 100644 index 0000000000..831b6641a8 --- /dev/null +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/exchange/LicenseTokenRequest.groovy @@ -0,0 +1,22 @@ +package io.seqera.tower.plugin.exchange + +import groovy.transform.CompileStatic +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString + +/** + * Models a REST request to obtain a license-scoped JWT token from Platform + * + * @author Alberto Miranda + */ +@EqualsAndHashCode +@ToString(includeNames = true, includePackage = false) +@CompileStatic +class LicenseTokenRequest { + + /** The product code */ + String product + + /** The product version */ + String version +} diff --git a/plugins/nf-tower/src/main/io/seqera/tower/plugin/exchange/LicenseTokenResponse.groovy b/plugins/nf-tower/src/main/io/seqera/tower/plugin/exchange/LicenseTokenResponse.groovy new file mode 100644 index 0000000000..a7c41454e9 --- /dev/null +++ b/plugins/nf-tower/src/main/io/seqera/tower/plugin/exchange/LicenseTokenResponse.groovy @@ -0,0 +1,23 @@ +package io.seqera.tower.plugin.exchange + +import groovy.transform.CompileStatic +import groovy.transform.ToString + +/** + * Models a REST response containing a license-scoped JWT token from Platform + * + * @author Alberto Miranda + */ +@CompileStatic +@ToString(includeNames = true, includePackage = false) +class LicenseTokenResponse { + /** + * The signed JWT token + */ + String signedToken + + /** + * The expiration date of the token + */ + Date expirationDate +} diff --git a/plugins/nf-tower/src/resources/META-INF/extensions.idx b/plugins/nf-tower/src/resources/META-INF/extensions.idx index 68286228bc..8a61ce9a9c 100644 --- a/plugins/nf-tower/src/resources/META-INF/extensions.idx +++ b/plugins/nf-tower/src/resources/META-INF/extensions.idx @@ -10,3 +10,4 @@ # io.seqera.tower.plugin.TowerFactory +io.seqera.tower.plugin.TowerFusionEnv diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerFusionEnvTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerFusionEnvTest.groovy new file mode 100644 index 0000000000..cdd35d49de --- /dev/null +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerFusionEnvTest.groovy @@ -0,0 +1,420 @@ +package io.seqera.tower.plugin + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import io.seqera.tower.plugin.exception.UnauthorizedException +import nextflow.Global +import nextflow.Session +import nextflow.SysEnv +import nextflow.exception.AbortOperationException +import nextflow.fusion.FusionConfig +import spock.lang.Shared +import spock.lang.Specification + +/** + * Test cases for the TowerFusionEnv class. + * + * @author Alberto Miranda + */ +class TowerFusionEnvTest extends Specification { + + @Shared + WireMockServer wireMockServer + + def setupSpec() { + wireMockServer = new WireMockServer(18080) + 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 + } + + + def 'should return the endpoint from the config'() { + given: 'a session' + Global.session = Mock(Session) { + config >> [ + tower: [ + endpoint: 'https://tower.nf' + ] + ] + } + + when: 'the provider is created' + def provider = new TowerFusionEnv() + + then: 'the endpoint has the expected value' + provider.endpoint == 'https://tower.nf' + } + + def 'should return the endpoint from the environment'() { + setup: + SysEnv.push(['TOWER_API_ENDPOINT': 'https://tower.nf']) + Global.session = Mock(Session) { + config >> [:] + } + + when: 'the provider is created' + def provider = new TowerFusionEnv() + + then: 'the endpoint has the expected value' + provider.endpoint == 'https://tower.nf' + + cleanup: + SysEnv.pop() + } + + def 'should return the default endpoint'() { + when: 'session config is empty' + Global.session = Mock(Session) { + config >> [ + tower: [:] + ] + } + def provider = new TowerFusionEnv() + + 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' + + when: 'session.config.tower.endpoint is empty' + Global.session = Mock(Session) { + config >> [ + tower: [ + endpoint: '' + ] + ] + } + + then: 'the endpoint has the expected value' + + when: 'session.config.tower.endpoint is defined as "-"' + Global.session = Mock(Session) { + config >> [ + tower: [ + endpoint: '-' + ] + ] + } + + then: 'the endpoint has the expected value' + } + + 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 TowerFusionEnv() + + 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 TowerFusionEnv() + + 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 TowerFusionEnv() + + 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 TowerFusionEnv() + + then: 'the access token has the expected value' + provider.accessToken == null + + 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 TowerFusionEnv() + + then: 'the access token has the expected value' + provider.accessToken == 'xyz789' + + cleanup: + SysEnv.pop() + } + + def 'should get a license token'() { + given: 'a TowerFusionEnv provider' + Global.session = Mock(Session) { + config >> [ + tower: [ + endpoint : 'http://localhost:18080', + accessToken: 'abc123' + ] + ] + } + def provider = new TowerFusionEnv() + + and: 'a mock endpoint returning a valid token' + wireMockServer.stubFor( + WireMock.post(WireMock.urlEqualTo("/license/token/")) + .withHeader('Authorization', WireMock.equalTo('Bearer abc123')) + .willReturn( + WireMock.aResponse() + .withStatus(200) + .withHeader('Content-Type', 'application/json') + .withBody('{"signedToken":"xyz789"}') + ) + ) + + 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 abc123'))) + + where: + PRODUCT | VERSION + 'some-product' | 'some-version' + 'some-product' | null + null | 'some-version' + null | null + } + + def 'should fail getting a token if the Platform configuration is missing'() { + given: 'a TowerFusionEnv provider' + Global.session = Mock(Session) { + config >> [:] + } + def provider = new TowerFusionEnv() + + when: 'a license token is requested' + provider.getLicenseToken('some-product', 'some-version') + + then: 'an exception is thrown' + final ex = thrown(AbortOperationException) + ex.message == 'Missing personal access token -- Make sure there\'s a variable TOWER_ACCESS_TOKEN in your environment' + } + + def 'should fail getting a token if the Platform configuration is empty'() { + given: 'a TowerFusionEnv provider' + Global.session = Mock(Session) { + config >> [ + tower: [:] + ] + } + def provider = new TowerFusionEnv() + + when: 'a license token is requested' + provider.getLicenseToken('some-product', 'some-version') + + then: 'an exception is thrown' + final ex = thrown(AbortOperationException) + ex.message == 'Missing personal access token -- Make sure there\'s a variable TOWER_ACCESS_TOKEN in your environment' + } + + def 'should fail getting a token if the Platform access token is missing'() { + given: 'a TowerFusionEnv provider' + Global.session = Mock(Session) { + config >> [ + tower: [ + endpoint: 'http://localhost:18080' + ] + ] + } + def provider = new TowerFusionEnv() + + when: 'a license token is requested' + provider.getLicenseToken('some-product', 'some-version') + + then: 'an exception is thrown' + final ex = thrown(AbortOperationException) + ex.message == 'Missing personal access token -- Make sure there\'s a variable TOWER_ACCESS_TOKEN in your environment' + } + + def 'should throw UnauthorizedException if getting a token fails with 401'() { + given: 'a TowerFusionEnv provider' + Global.session = Mock(Session) { + config >> [ + tower: [ + endpoint : 'http://localhost:18080', + accessToken: 'abc123' + ] + ] + } + def provider = new TowerFusionEnv() + + and: 'a mock endpoint returning an error' + wireMockServer.stubFor( + WireMock.post(WireMock.urlEqualTo("/license/token/")) + .withHeader('Authorization', WireMock.equalTo('Bearer abc123')) + .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 return a valid environment' () { + given: 'a TowerFusionEnv provider' + Global.session = Mock(Session) { + config >> [:] + } + def provider = Spy(TowerFusionEnv) + + when: 'the environment is requested' + def env = provider.getEnvironment('s3', Mock(FusionConfig)) + + then: 'the environment has the expected values' + 1 * provider.getLicenseToken(_, _) >> 'xyz789' + env == [FUSION_LICENSE_TOKEN: 'xyz789'] + } + + def 'should return an empty environment if no Platform config is available' () { + given: 'a session with no config for Platform' + Global.session = Mock(Session) { + config >> [:] + } + + when: 'the environment is requested' + def provider = new TowerFusionEnv() + def env = provider.getEnvironment('-', Mock(FusionConfig)) + + then: 'the environment is empty' + env == [:] + } + + def 'should return an empty environment if the license token cannot be obtained' () { + given: 'a TowerFusionEnv provider' + Global.session = Mock(Session) { + config >> [ + tower: [ + endpoint : 'http://localhost:18080', + accessToken: 'abc123' + ] + ] + } + def provider = Spy(TowerFusionEnv) + + when: 'the environment is requested' + def env = provider.getEnvironment('s3', Mock(FusionConfig)) + + then: 'the environment has the expected values' + 1 * provider.getLicenseToken(_, _) >> { + throw new Exception('error') + } + env == [:] + } +}