From c1801f11b9ba822e51afc4336e9cfae60e6167a9 Mon Sep 17 00:00:00 2001 From: jsotobroad Date: Wed, 15 Nov 2023 14:52:45 -0500 Subject: [PATCH] TSPS-86 - connecting to leonardo client (#40) * add logic to connect to Leonardo java client * reorganize app and test folders * add PR readme template --------- Co-authored-by: Jose Soto --- README.md | 2 +- pull_request_template.md | 5 + service/build.gradle | 4 +- .../main/java/bio/terra/pipelines/App.java | 12 - .../pipelines/app/StartupInitializer.java | 2 +- .../external/LeonardoServerConfiguration.java | 44 ++ .../external}/SamConfiguration.java | 2 +- .../internal/BeanConfiguration.java} | 32 +- .../internal/ImputationConfiguration.java | 7 + .../PipelinesSpringConfiguration.java | 2 +- .../internal/RetryConfiguration.java | 51 ++ .../internal}/StatusCheckConfiguration.java | 2 +- .../TspsDatabaseConfiguration.java | 2 +- .../internal}/VersionConfiguration.java | 2 +- .../app/controller/JobsApiController.java | 2 +- .../dependencies/common/HealthCheck.java | 8 + .../dependencies/leonardo/AppUtils.java | 199 ++++++ .../DependencyNotAvailableException.java | 13 + .../dependencies/leonardo/LeonardoClient.java | 27 + .../leonardo/LeonardoService.java | 77 +++ .../leonardo/LeonardoServiceApiException.java | 10 + .../leonardo/LeonardoServiceException.java | 11 + .../{iam => dependencies/sam}/SamClient.java | 4 +- .../{iam => dependencies/sam}/SamService.java | 32 +- .../pipelines/retry/RetryLoggingListener.java | 42 ++ .../pipelines/service/BaseStatusService.java | 2 +- .../pipelines/service/StatusService.java | 6 +- service/src/main/resources/application.yml | 22 +- .../terra/pipelines/BaseSpringBootTest.java | 8 - .../LeonardoServerConfigurationTest.java | 26 + .../internal/BeanConfigurationTest.java | 33 + .../TspsDatabaseConfigurationTest.java | 19 + .../controller/JobsApiControllerTest.java | 6 +- .../PipelinesApiControllerTest.java | 4 +- .../dependencies/leonardo/AppUtilsTest.java | 594 ++++++++++++++++++ .../leonardo/LeonardoClientTest.java | 41 ++ .../leonardo/LeonardoServiceTest.java | 156 +++++ .../dependencies/sam/SamClientTest.java | 34 + .../dependencies/sam/SamServiceTest.java | 73 +++ .../service/JobsServiceMockTest.java | 4 +- .../pipelines/service/JobsServiceTest.java | 8 +- .../src/test/resources/application-test.yml | 16 + 42 files changed, 1565 insertions(+), 81 deletions(-) create mode 100644 pull_request_template.md create mode 100644 service/src/main/java/bio/terra/pipelines/app/configuration/external/LeonardoServerConfiguration.java rename service/src/main/java/bio/terra/pipelines/{config => app/configuration/external}/SamConfiguration.java (76%) rename service/src/main/java/bio/terra/pipelines/{config/BeanConfig.java => app/configuration/internal/BeanConfiguration.java} (54%) create mode 100644 service/src/main/java/bio/terra/pipelines/app/configuration/internal/ImputationConfiguration.java rename service/src/main/java/bio/terra/pipelines/app/configuration/{ => internal}/PipelinesSpringConfiguration.java (98%) create mode 100644 service/src/main/java/bio/terra/pipelines/app/configuration/internal/RetryConfiguration.java rename service/src/main/java/bio/terra/pipelines/{config => app/configuration/internal}/StatusCheckConfiguration.java (84%) rename service/src/main/java/bio/terra/pipelines/app/configuration/{ => internal}/TspsDatabaseConfiguration.java (96%) rename service/src/main/java/bio/terra/pipelines/{config => app/configuration/internal}/VersionConfiguration.java (83%) create mode 100644 service/src/main/java/bio/terra/pipelines/dependencies/common/HealthCheck.java create mode 100644 service/src/main/java/bio/terra/pipelines/dependencies/leonardo/AppUtils.java create mode 100644 service/src/main/java/bio/terra/pipelines/dependencies/leonardo/DependencyNotAvailableException.java create mode 100644 service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoClient.java create mode 100644 service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoService.java create mode 100644 service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoServiceApiException.java create mode 100644 service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoServiceException.java rename service/src/main/java/bio/terra/pipelines/{iam => dependencies/sam}/SamClient.java (92%) rename service/src/main/java/bio/terra/pipelines/{iam => dependencies/sam}/SamService.java (66%) create mode 100644 service/src/main/java/bio/terra/pipelines/retry/RetryLoggingListener.java delete mode 100644 service/src/test/java/bio/terra/pipelines/BaseSpringBootTest.java create mode 100644 service/src/test/java/bio/terra/pipelines/configuration/external/LeonardoServerConfigurationTest.java create mode 100644 service/src/test/java/bio/terra/pipelines/configuration/internal/BeanConfigurationTest.java create mode 100644 service/src/test/java/bio/terra/pipelines/configuration/internal/TspsDatabaseConfigurationTest.java create mode 100644 service/src/test/java/bio/terra/pipelines/dependencies/leonardo/AppUtilsTest.java create mode 100644 service/src/test/java/bio/terra/pipelines/dependencies/leonardo/LeonardoClientTest.java create mode 100644 service/src/test/java/bio/terra/pipelines/dependencies/leonardo/LeonardoServiceTest.java create mode 100644 service/src/test/java/bio/terra/pipelines/dependencies/sam/SamClientTest.java create mode 100644 service/src/test/java/bio/terra/pipelines/dependencies/sam/SamServiceTest.java diff --git a/README.md b/README.md index aecb04eb..8f9dfc08 100644 --- a/README.md +++ b/README.md @@ -104,4 +104,4 @@ The two tasks `report-to-sherlock` and `set-version-in-dev` will prompt Sherlock You can check the status of the deployment in [Beehive](https://beehive.dsp-devops.broadinstitute.org/apps/tsps) and in [ArgoCD](https://ap-argocd.dsp-devops.broadinstitute.org/applications/ap-argocd/tsps-dev). -For more information about deployment to dev, check out DevOps' [excellent documentation](https://docs.google.com/document/d/1lkUkN2KOpHKWufaqw_RIE7EN3vN4G2xMnYBU83gi8VA/). \ No newline at end of file +For more information about deployment to dev, check out DevOps' [excellent documentation](https://docs.google.com/document/d/1lkUkN2KOpHKWufaqw_RIE7EN3vN4G2xMnYBU83gi8VA/). diff --git a/pull_request_template.md b/pull_request_template.md new file mode 100644 index 00000000..921749e5 --- /dev/null +++ b/pull_request_template.md @@ -0,0 +1,5 @@ +### Description + +_Please replace this description with a concise description of this Pull Request._ + +### Jira Ticket diff --git a/service/build.gradle b/service/build.gradle index fc798b6a..1a357cde 100644 --- a/service/build.gradle +++ b/service/build.gradle @@ -35,9 +35,10 @@ dependencies { implementation 'org.apache.commons:commons-dbcp2:2.9.0' implementation 'org.springframework.boot:spring-boot-starter-data-jpa:2.7.0' implementation 'org.springframework.boot:spring-boot-starter-web:2.7.0' - implementation 'org.springframework.retry:spring-retry:1.3.3' + implementation 'org.springframework.retry:spring-retry:1.3.4' implementation 'org.broadinstitute.dsde.workbench:sam-client_2.12:0.1-61135c7' + implementation "org.broadinstitute.dsde.workbench:leonardo-client_2.13:1.3.6-66d9fcf" implementation 'javax.ws.rs:javax.ws.rs-api:2.1.1' implementation 'org.postgresql:postgresql:42.3.3' implementation 'javax.servlet:jstl:1.2' @@ -65,6 +66,7 @@ dependencies { exclude group: 'com.vaadin.external.google', module: 'android-json' } testImplementation 'org.mockito:mockito-inline' + testImplementation 'org.apache.commons:commons-text:1.10.0' // dependencies for test containers testImplementation 'org.testcontainers:testcontainers:1.17.3' diff --git a/service/src/main/java/bio/terra/pipelines/App.java b/service/src/main/java/bio/terra/pipelines/App.java index abaf2ee2..393c8359 100644 --- a/service/src/main/java/bio/terra/pipelines/App.java +++ b/service/src/main/java/bio/terra/pipelines/App.java @@ -1,10 +1,6 @@ package bio.terra.pipelines; import bio.terra.common.logging.LoggingInitializer; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; import javax.sql.DataSource; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.builder.SpringApplicationBuilder; @@ -46,14 +42,6 @@ public App(DataSource dataSource) { this.dataSource = dataSource; } - @Bean("objectMapper") - public ObjectMapper objectMapper() { - return new ObjectMapper() - .registerModule(new ParameterNamesModule()) - .registerModule(new Jdk8Module()) - .registerModule(new JavaTimeModule()); - } - // This bean plus the @EnableTransactionManagement annotation above enables the use of the // @Transaction annotation to control the transaction properties of the data source. @Bean("transactionManager") diff --git a/service/src/main/java/bio/terra/pipelines/app/StartupInitializer.java b/service/src/main/java/bio/terra/pipelines/app/StartupInitializer.java index c1b4e63b..2c66e8f7 100644 --- a/service/src/main/java/bio/terra/pipelines/app/StartupInitializer.java +++ b/service/src/main/java/bio/terra/pipelines/app/StartupInitializer.java @@ -1,7 +1,7 @@ package bio.terra.pipelines.app; import bio.terra.common.migrate.LiquibaseMigrator; -import bio.terra.pipelines.app.configuration.TspsDatabaseConfiguration; +import bio.terra.pipelines.app.configuration.internal.TspsDatabaseConfiguration; import org.springframework.context.ApplicationContext; public final class StartupInitializer { diff --git a/service/src/main/java/bio/terra/pipelines/app/configuration/external/LeonardoServerConfiguration.java b/service/src/main/java/bio/terra/pipelines/app/configuration/external/LeonardoServerConfiguration.java new file mode 100644 index 00000000..73929c3a --- /dev/null +++ b/service/src/main/java/bio/terra/pipelines/app/configuration/external/LeonardoServerConfiguration.java @@ -0,0 +1,44 @@ +package bio.terra.pipelines.app.configuration.external; + +import java.time.Duration; +import java.util.List; +import org.broadinstitute.dsde.workbench.client.leonardo.model.AppType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; + +/** + * @param baseUri - Leonardo URI to send requests to + * @param wdsAppTypeNames - names used to signify what is the WDS app we want to use + * @param cbasAppTypeNames - names used to signify what is the cbas app we want to use + * @param dependencyUrlCacheTtl - how long (in seconds) to keep items in the cache + * @param debugApiLogging + */ +@ConfigurationProperties(prefix = "leonardo") +public record LeonardoServerConfiguration( + String baseUri, + List wdsAppTypeNames, + List cbasAppTypeNames, + Duration dependencyUrlCacheTtl, + Boolean debugApiLogging) { + + private static final Logger log = LoggerFactory.getLogger(LeonardoServerConfiguration.class); + + @ConstructorBinding + public LeonardoServerConfiguration( + String baseUri, + List wdsAppTypeNames, + List cbasAppTypeNames, + long dependencyUrlCacheTtlSeconds, + Boolean debugApiLogging) { + this( + baseUri, + wdsAppTypeNames.stream().map(AppType::fromValue).toList(), + cbasAppTypeNames.stream().map(AppType::fromValue).toList(), + Duration.ofSeconds(dependencyUrlCacheTtlSeconds), + debugApiLogging); + log.info("Setting wdsAppTypes={}", wdsAppTypeNames); + log.info("Setting cbasAppTypeNames={}", cbasAppTypeNames); + } +} diff --git a/service/src/main/java/bio/terra/pipelines/config/SamConfiguration.java b/service/src/main/java/bio/terra/pipelines/app/configuration/external/SamConfiguration.java similarity index 76% rename from service/src/main/java/bio/terra/pipelines/config/SamConfiguration.java rename to service/src/main/java/bio/terra/pipelines/app/configuration/external/SamConfiguration.java index 34492a55..d1fb4ae2 100644 --- a/service/src/main/java/bio/terra/pipelines/config/SamConfiguration.java +++ b/service/src/main/java/bio/terra/pipelines/app/configuration/external/SamConfiguration.java @@ -1,4 +1,4 @@ -package bio.terra.pipelines.config; +package bio.terra.pipelines.app.configuration.external; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/service/src/main/java/bio/terra/pipelines/config/BeanConfig.java b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/BeanConfiguration.java similarity index 54% rename from service/src/main/java/bio/terra/pipelines/config/BeanConfig.java rename to service/src/main/java/bio/terra/pipelines/app/configuration/internal/BeanConfiguration.java index 54247646..398856db 100644 --- a/service/src/main/java/bio/terra/pipelines/config/BeanConfig.java +++ b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/BeanConfiguration.java @@ -1,10 +1,8 @@ -package bio.terra.pipelines.config; +package bio.terra.pipelines.app.configuration.internal; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import bio.terra.common.iam.BearerToken; +import bio.terra.common.iam.BearerTokenFactory; +import javax.servlet.http.HttpServletRequest; import javax.sql.DataSource; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -13,26 +11,30 @@ import org.springframework.retry.annotation.EnableRetry; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.web.context.annotation.RequestScope; @Configuration @EnableRetry @EnableTransactionManagement @EnableConfigurationProperties -public class BeanConfig { +public class BeanConfiguration { private final DataSource dataSource; - public BeanConfig(DataSource dataSource) { + public BeanConfiguration(DataSource dataSource) { this.dataSource = dataSource; } - @Bean("objectMapper") - public ObjectMapper objectMapper() { - return new ObjectMapper() - .registerModule(new ParameterNamesModule()) - .registerModule(new Jdk8Module()) - .registerModule(new JavaTimeModule()) - .setDefaultPropertyInclusion(JsonInclude.Include.NON_ABSENT); + /** + * Taken from Terra + * Data Catalog Lasts for the duration of one request, and is injected into dependent beans, + * even singletons + */ + @Bean("bearerToken") + @RequestScope + public BearerToken bearerToken(HttpServletRequest request) { + return new BearerTokenFactory().from(request); } // This bean plus the @EnableTransactionManagement annotation above enables the use of the diff --git a/service/src/main/java/bio/terra/pipelines/app/configuration/internal/ImputationConfiguration.java b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/ImputationConfiguration.java new file mode 100644 index 00000000..9480ffe4 --- /dev/null +++ b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/ImputationConfiguration.java @@ -0,0 +1,7 @@ +package bio.terra.pipelines.app.configuration.internal; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** configuration for all properties related to imputation */ +@ConfigurationProperties(prefix = "imputation") +public record ImputationConfiguration(String workspaceId) {} diff --git a/service/src/main/java/bio/terra/pipelines/app/configuration/PipelinesSpringConfiguration.java b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/PipelinesSpringConfiguration.java similarity index 98% rename from service/src/main/java/bio/terra/pipelines/app/configuration/PipelinesSpringConfiguration.java rename to service/src/main/java/bio/terra/pipelines/app/configuration/internal/PipelinesSpringConfiguration.java index 54437f2f..75f64347 100644 --- a/service/src/main/java/bio/terra/pipelines/app/configuration/PipelinesSpringConfiguration.java +++ b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/PipelinesSpringConfiguration.java @@ -1,4 +1,4 @@ -package bio.terra.pipelines.app.configuration; +package bio.terra.pipelines.app.configuration.internal; import bio.terra.pipelines.app.StartupInitializer; import bio.terra.pipelines.generated.model.ApiVersionProperties; diff --git a/service/src/main/java/bio/terra/pipelines/app/configuration/internal/RetryConfiguration.java b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/RetryConfiguration.java new file mode 100644 index 00000000..7fecc572 --- /dev/null +++ b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/RetryConfiguration.java @@ -0,0 +1,51 @@ +package bio.terra.pipelines.app.configuration.internal; + +import bio.terra.pipelines.retry.RetryLoggingListener; +import java.net.SocketTimeoutException; +import javax.ws.rs.ProcessingException; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.RetryListener; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.retry.backoff.FixedBackOffPolicy; +import org.springframework.retry.policy.ExceptionClassifierRetryPolicy; +import org.springframework.retry.policy.NeverRetryPolicy; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; + +/** bean used to retry requests made by the system */ +@EnableRetry +@Configuration +public class RetryConfiguration { + + @Bean(name = "listenerResetRetryTemplate") + public RetryTemplate listenerResetRetryTemplate() { + RetryTemplate retryTemplate = new RetryTemplate(); + + // Fixed delay of 1 second between retries + FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy(); + fixedBackOffPolicy.setBackOffPeriod(1000L); + + // Inner retry (assumping the classifier hits): up to 3 times + SimpleRetryPolicy srp = new SimpleRetryPolicy(); + srp.setMaxAttempts(3); + + ExceptionClassifierRetryPolicy ecrp = new ExceptionClassifierRetryPolicy(); + ecrp.setExceptionClassifier( + exception -> { + if (exception instanceof ProcessingException + || exception instanceof SocketTimeoutException) { + return srp; + } else { + return new NeverRetryPolicy(); + } + }); + + retryTemplate.setBackOffPolicy(fixedBackOffPolicy); + retryTemplate.setRetryPolicy(ecrp); + retryTemplate.setThrowLastExceptionOnExhausted(true); + retryTemplate.setListeners(new RetryListener[] {new RetryLoggingListener()}); + + return retryTemplate; + } +} diff --git a/service/src/main/java/bio/terra/pipelines/config/StatusCheckConfiguration.java b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/StatusCheckConfiguration.java similarity index 84% rename from service/src/main/java/bio/terra/pipelines/config/StatusCheckConfiguration.java rename to service/src/main/java/bio/terra/pipelines/app/configuration/internal/StatusCheckConfiguration.java index daef1d9c..1cfc17d3 100644 --- a/service/src/main/java/bio/terra/pipelines/config/StatusCheckConfiguration.java +++ b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/StatusCheckConfiguration.java @@ -1,4 +1,4 @@ -package bio.terra.pipelines.config; +package bio.terra.pipelines.app.configuration.internal; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/service/src/main/java/bio/terra/pipelines/app/configuration/TspsDatabaseConfiguration.java b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/TspsDatabaseConfiguration.java similarity index 96% rename from service/src/main/java/bio/terra/pipelines/app/configuration/TspsDatabaseConfiguration.java rename to service/src/main/java/bio/terra/pipelines/app/configuration/internal/TspsDatabaseConfiguration.java index ebd4d789..b928ea76 100644 --- a/service/src/main/java/bio/terra/pipelines/app/configuration/TspsDatabaseConfiguration.java +++ b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/TspsDatabaseConfiguration.java @@ -1,4 +1,4 @@ -package bio.terra.pipelines.app.configuration; +package bio.terra.pipelines.app.configuration.internal; import bio.terra.common.db.BaseDatabaseProperties; import bio.terra.common.db.DataSourceInitializer; diff --git a/service/src/main/java/bio/terra/pipelines/config/VersionConfiguration.java b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/VersionConfiguration.java similarity index 83% rename from service/src/main/java/bio/terra/pipelines/config/VersionConfiguration.java rename to service/src/main/java/bio/terra/pipelines/app/configuration/internal/VersionConfiguration.java index 43e60eef..4f5f448e 100644 --- a/service/src/main/java/bio/terra/pipelines/config/VersionConfiguration.java +++ b/service/src/main/java/bio/terra/pipelines/app/configuration/internal/VersionConfiguration.java @@ -1,4 +1,4 @@ -package bio.terra.pipelines.config; +package bio.terra.pipelines.app.configuration.internal; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/service/src/main/java/bio/terra/pipelines/app/controller/JobsApiController.java b/service/src/main/java/bio/terra/pipelines/app/controller/JobsApiController.java index 7e660f9c..4fc3608b 100644 --- a/service/src/main/java/bio/terra/pipelines/app/controller/JobsApiController.java +++ b/service/src/main/java/bio/terra/pipelines/app/controller/JobsApiController.java @@ -3,7 +3,7 @@ import bio.terra.common.exception.ApiException; import bio.terra.common.iam.SamUser; import bio.terra.common.iam.SamUserFactory; -import bio.terra.pipelines.config.SamConfiguration; +import bio.terra.pipelines.app.configuration.external.SamConfiguration; import bio.terra.pipelines.db.entities.Job; import bio.terra.pipelines.db.exception.PipelineNotFoundException; import bio.terra.pipelines.generated.api.JobsApi; diff --git a/service/src/main/java/bio/terra/pipelines/dependencies/common/HealthCheck.java b/service/src/main/java/bio/terra/pipelines/dependencies/common/HealthCheck.java new file mode 100644 index 00000000..0cbc8e9e --- /dev/null +++ b/service/src/main/java/bio/terra/pipelines/dependencies/common/HealthCheck.java @@ -0,0 +1,8 @@ +package bio.terra.pipelines.dependencies.common; + +public interface HealthCheck { + + record Result(boolean isOk, String message) {} + + Result checkHealth(); +} diff --git a/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/AppUtils.java b/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/AppUtils.java new file mode 100644 index 00000000..7e565f24 --- /dev/null +++ b/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/AppUtils.java @@ -0,0 +1,199 @@ +package bio.terra.pipelines.dependencies.leonardo; + +import bio.terra.pipelines.app.configuration.external.LeonardoServerConfiguration; +import java.time.OffsetDateTime; +import java.util.*; +import java.util.stream.Collectors; +import org.broadinstitute.dsde.workbench.client.leonardo.model.AppStatus; +import org.broadinstitute.dsde.workbench.client.leonardo.model.AppType; +import org.broadinstitute.dsde.workbench.client.leonardo.model.ListAppResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class AppUtils { + + private final LeonardoServerConfiguration leonardoServerConfiguration; + + private static final Logger logger = LoggerFactory.getLogger(AppUtils.class); + + public AppUtils(LeonardoServerConfiguration leonardoServerConfiguration) { + this.leonardoServerConfiguration = leonardoServerConfiguration; + } + + int appComparisonFunction( + ListAppResponse appToCompareA, + ListAppResponse appToCompareB, + List appTypeList, + String workspaceId) { + // First criteria: Prefer apps with the expected app type. + // NB: Negative because lower index is better + int appTypeScoreA = -appTypeList.indexOf(appToCompareA.getAppType()); + int appTypeScoreB = -appTypeList.indexOf(appToCompareB.getAppType()); + if (appTypeScoreA != appTypeScoreB) { + return appTypeScoreA - appTypeScoreB; + } + // If there is a WDS app type present, do this check; does not apply to cromwell app-types + if (appToCompareA.getAppType() == AppType.WDS || appToCompareB.getAppType() == AppType.WDS) { + // Second criteria: Prefer apps with the expected app type name + int nameScoreA = + Objects.equals(appToCompareA.getAppName(), "wds-%s".formatted(workspaceId)) ? 1 : 0; + int nameScoreB = + Objects.equals(appToCompareB.getAppName(), "wds-%s".formatted(workspaceId)) ? 1 : 0; + if (nameScoreA != nameScoreB) { + return nameScoreA - nameScoreB; + } + } + + // Third criteria: tie-break on whichever is older + return OffsetDateTime.parse(appToCompareA.getAuditInfo().getCreatedDate()) + .compareTo(OffsetDateTime.parse(appToCompareB.getAuditInfo().getCreatedDate())); + } + + /** + * Invokes logic to determine the appropriate app for WDS and CROMWELL. If app is not running, a + * URL will not be present, in this case we return empty string Note: This logic is similar to how + * DataTable finds WDS app in Terra UI + * + *

(...) + */ + ListAppResponse findBestAppForAppType( + List apps, AppType appType, String workspaceId) + throws DependencyNotAvailableException { + // WDS and Cromwell apps look for Kubernetes deployment statuses (such as RUNNING or + // PROVISIONING), expressed by + // Leo + // See here for specific enumerations -- + // https://github.com/DataBiosphere/leonardo/blob/develop/core/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/kubernetesModels.scala + // look explicitly for a RUNNING app named 'wds-${app.workspaceId}' or + // 'terra-app-' -- if app is healthy and + // running, there should only be one app RUNNING + // an app may be in the 'PROVISIONING', 'STOPPED', 'STOPPING', which can still be deemed as an + // OK state for Leonardo apps + List appTypeList; + + // WDS and CROMWELL apps will get their proxy urls from a different group of app types (located + // in application.yml). Because of this, we need to identify the app type we need a url for, and + // get the url from the correct group of relevant apps. + if (appType.equals(AppType.WDS)) { + appTypeList = leonardoServerConfiguration.wdsAppTypeNames(); + } else { + appTypeList = leonardoServerConfiguration.cbasAppTypeNames(); + } + + Set healthyStates = + EnumSet.of( + AppStatus.RUNNING, + AppStatus.PROVISIONING, + AppStatus.STARTING, + AppStatus.STOPPED, + AppStatus.STOPPING); + + List suitableApps = + apps.stream() + .filter( + app -> passesSuitableFilter(app, healthyStates, appType, appTypeList, workspaceId)) + .toList(); + + return suitableApps.stream() + .max( + (appToCompareA, appToCompareB) -> + this.appComparisonFunction(appToCompareA, appToCompareB, appTypeList, workspaceId)) + .orElseThrow( + () -> + new DependencyNotAvailableException( + "%s".formatted(appType.toString()), + "No suitable, healthy app found for %s (out of %s total apps in this workspace)" + .formatted(appType.toString(), apps.size()))); + } + + boolean passesSuitableFilter( + ListAppResponse app, + Set healthyStates, + AppType appType, + List appTypeList, + String workspaceId) { + var appMatchesWorkspaceId = Objects.equals(app.getWorkspaceId(), workspaceId); + if (!appMatchesWorkspaceId) { + logger.info( + "Not using app {} for {} because it is in workspace {}, not {}", + app.getAppName(), + appType, + app.getWorkspaceId(), + workspaceId); + } + var appTypeListContainsApp = appTypeList.contains(app.getAppType()); + if (!appTypeListContainsApp) { + logger.info( + "Not using app {} for {} because it is of type {}, not one of {}", + app.getAppName(), + appType, + app.getAppType(), + appTypeList); + } + var isAppHealthy = healthyStates.contains(app.getStatus()); + if (!isAppHealthy) { + logger.info( + "Not using app {} for {} because it is in state {}, not one of {}", + app.getAppName(), + appType, + app.getStatus(), + healthyStates); + } + + return appMatchesWorkspaceId && appTypeListContainsApp && isAppHealthy; + } + + public String findUrlForWds(List apps, String workspaceId) + throws DependencyNotAvailableException { + ListAppResponse foundApp = findBestAppForAppType(apps, AppType.WDS, workspaceId); + @SuppressWarnings("unchecked") + Map proxyUrls = (foundApp.getProxyUrls()); + if (proxyUrls != null && foundApp.getStatus() == AppStatus.RUNNING) { + return Optional.ofNullable(proxyUrls.get("wds")) + .orElseThrow( + () -> + new DependencyNotAvailableException( + "WDS", + "WDS proxy URL not found in %s app (available proxy URLs: %s)" + .formatted( + foundApp.getAppName(), + proxyUrls.keySet().stream() + .sorted() + .collect(Collectors.joining(", "))))); + } + + throw new DependencyNotAvailableException( + "WDS", + "WDS in %s app not ready (%s)".formatted(foundApp.getAppName(), foundApp.getStatus())); + } + + public String findUrlForCbas(List apps, String workspaceId) + throws DependencyNotAvailableException { + ListAppResponse foundApp = findBestAppForAppType(apps, AppType.CROMWELL, workspaceId); + + String proxyUrl = "cbas"; + // find proper proxy for cromwell app type + @SuppressWarnings("unchecked") + Map proxyUrls = foundApp.getProxyUrls(); + if (proxyUrls != null && foundApp.getStatus() == AppStatus.RUNNING) { + return Optional.ofNullable(proxyUrls.get(proxyUrl)) + .orElseThrow( + () -> + new DependencyNotAvailableException( + "CBAS", + "CBAS proxy URL not found in %s app (available proxy URLs: %s)" + .formatted( + foundApp.getAppName(), + proxyUrls.keySet().stream() + .sorted() + .collect(Collectors.joining(", "))))); + } + + throw new DependencyNotAvailableException( + "CBAS", + "CBAS in %s app not ready (%s)".formatted(foundApp.getAppName(), foundApp.getStatus())); + } +} diff --git a/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/DependencyNotAvailableException.java b/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/DependencyNotAvailableException.java new file mode 100644 index 00000000..e872af98 --- /dev/null +++ b/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/DependencyNotAvailableException.java @@ -0,0 +1,13 @@ +package bio.terra.pipelines.dependencies.leonardo; + +import bio.terra.common.exception.ErrorReportException; + +public class DependencyNotAvailableException extends ErrorReportException { + public DependencyNotAvailableException(String dependency, String context) { + super("Dependency not available: %s. %s".formatted(dependency, context)); + } + + public DependencyNotAvailableException(String dependency, String context, Throwable reason) { + super("Dependency not available: %s. %s".formatted(dependency, context), reason); + } +} diff --git a/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoClient.java b/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoClient.java new file mode 100644 index 00000000..88f6f0eb --- /dev/null +++ b/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoClient.java @@ -0,0 +1,27 @@ +package bio.terra.pipelines.dependencies.leonardo; + +import bio.terra.pipelines.app.configuration.external.LeonardoServerConfiguration; +import org.broadinstitute.dsde.workbench.client.leonardo.ApiClient; +import org.springframework.stereotype.Component; + +@Component +public class LeonardoClient { + + private final LeonardoServerConfiguration leonardoServerConfiguration; + + public LeonardoClient(LeonardoServerConfiguration leonardoServerConfiguration) { + this.leonardoServerConfiguration = leonardoServerConfiguration; + } + + public ApiClient getUnauthorizedApiClient() { + var apiClient = new ApiClient().setBasePath(leonardoServerConfiguration.baseUri()); + apiClient.setDebugging(leonardoServerConfiguration.debugApiLogging()); + return apiClient; + } + + public ApiClient getApiClient(String accessToken) { + ApiClient apiClient = getUnauthorizedApiClient(); + apiClient.setAccessToken(accessToken); + return apiClient; + } +} diff --git a/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoService.java b/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoService.java new file mode 100644 index 00000000..18b466fe --- /dev/null +++ b/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoService.java @@ -0,0 +1,77 @@ +package bio.terra.pipelines.dependencies.leonardo; + +import bio.terra.common.iam.BearerToken; +import bio.terra.pipelines.dependencies.common.HealthCheck; +import java.util.List; +import org.broadinstitute.dsde.workbench.client.leonardo.ApiException; +import org.broadinstitute.dsde.workbench.client.leonardo.api.AppsApi; +import org.broadinstitute.dsde.workbench.client.leonardo.api.ServiceInfoApi; +import org.broadinstitute.dsde.workbench.client.leonardo.model.ListAppResponse; +import org.broadinstitute.dsde.workbench.client.leonardo.model.SystemStatus; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Service; + +@Service +public class LeonardoService implements HealthCheck { + + private final LeonardoClient leonardoClient; + private final BearerToken bearerToken; + private final RetryTemplate listenerResetRetryTemplate; + + @Autowired + public LeonardoService( + LeonardoClient leonardoClient, + RetryTemplate listenerResetRetryTemplate, + BearerToken bearerToken) { + this.leonardoClient = leonardoClient; + this.listenerResetRetryTemplate = listenerResetRetryTemplate; + this.bearerToken = bearerToken; + } + + // this will need to be reworked to use the service account credentials instead of the user who + // made the request + AppsApi getAppsApi() { + return new AppsApi(leonardoClient.getApiClient(bearerToken.getToken())); + } + + ServiceInfoApi getServiceInfoApi() { + return new ServiceInfoApi(leonardoClient.getUnauthorizedApiClient()); + } + + /** grab app information for a workspace id */ + public List getApps(String workspaceId, boolean creatorOnly) + throws LeonardoServiceException { + String creatorRoleSpecifier = creatorOnly ? "creator" : null; + return executionWithRetryTemplate( + listenerResetRetryTemplate, + () -> getAppsApi().listAppsV2(workspaceId, null, null, null, creatorRoleSpecifier)); + } + + @Override + public Result checkHealth() { + try { + SystemStatus result = getServiceInfoApi().getSystemStatus(); + return new Result(result.getOk(), result.toString()); + } catch (ApiException e) { + return new Result(false, e.getMessage()); + } + } + + interface LeonardoAction { + T execute() throws ApiException; + } + + static T executionWithRetryTemplate(RetryTemplate retryTemplate, LeonardoAction action) + throws LeonardoServiceException { + + return retryTemplate.execute( + context -> { + try { + return action.execute(); + } catch (ApiException e) { + throw new LeonardoServiceApiException(e); + } + }); + } +} diff --git a/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoServiceApiException.java b/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoServiceApiException.java new file mode 100644 index 00000000..c9d529dc --- /dev/null +++ b/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoServiceApiException.java @@ -0,0 +1,10 @@ +package bio.terra.pipelines.dependencies.leonardo; + +import org.broadinstitute.dsde.workbench.client.leonardo.ApiException; + +public class LeonardoServiceApiException extends LeonardoServiceException { + + public LeonardoServiceApiException(ApiException exception) { + super("Leonardo returned an unsuccessful status code", exception); + } +} diff --git a/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoServiceException.java b/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoServiceException.java new file mode 100644 index 00000000..6f2ba071 --- /dev/null +++ b/service/src/main/java/bio/terra/pipelines/dependencies/leonardo/LeonardoServiceException.java @@ -0,0 +1,11 @@ +package bio.terra.pipelines.dependencies.leonardo; + +import bio.terra.common.exception.ErrorReportException; +import java.util.ArrayList; +import org.springframework.http.HttpStatus; + +public abstract class LeonardoServiceException extends ErrorReportException { + protected LeonardoServiceException(String message, Throwable cause) { + super(message, cause, new ArrayList<>(), HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/service/src/main/java/bio/terra/pipelines/iam/SamClient.java b/service/src/main/java/bio/terra/pipelines/dependencies/sam/SamClient.java similarity index 92% rename from service/src/main/java/bio/terra/pipelines/iam/SamClient.java rename to service/src/main/java/bio/terra/pipelines/dependencies/sam/SamClient.java index c732545a..e56a083a 100644 --- a/service/src/main/java/bio/terra/pipelines/iam/SamClient.java +++ b/service/src/main/java/bio/terra/pipelines/dependencies/sam/SamClient.java @@ -1,7 +1,7 @@ -package bio.terra.pipelines.iam; +package bio.terra.pipelines.dependencies.sam; import bio.terra.common.tracing.OkHttpClientTracingInterceptor; -import bio.terra.pipelines.config.SamConfiguration; +import bio.terra.pipelines.app.configuration.external.SamConfiguration; import io.opencensus.trace.Tracing; import okhttp3.OkHttpClient; import org.broadinstitute.dsde.workbench.client.sam.ApiClient; diff --git a/service/src/main/java/bio/terra/pipelines/iam/SamService.java b/service/src/main/java/bio/terra/pipelines/dependencies/sam/SamService.java similarity index 66% rename from service/src/main/java/bio/terra/pipelines/iam/SamService.java rename to service/src/main/java/bio/terra/pipelines/dependencies/sam/SamService.java index b0a9b12c..80ebbd17 100644 --- a/service/src/main/java/bio/terra/pipelines/iam/SamService.java +++ b/service/src/main/java/bio/terra/pipelines/dependencies/sam/SamService.java @@ -1,10 +1,10 @@ -package bio.terra.pipelines.iam; +package bio.terra.pipelines.dependencies.sam; import bio.terra.common.iam.BearerToken; import bio.terra.common.sam.SamRetry; import bio.terra.common.sam.exception.SamExceptionFactory; +import bio.terra.pipelines.dependencies.common.HealthCheck; import bio.terra.pipelines.generated.model.ApiSystemStatusSystems; -import java.util.List; import org.broadinstitute.dsde.workbench.client.sam.ApiException; import org.broadinstitute.dsde.workbench.client.sam.model.SystemStatus; import org.slf4j.Logger; @@ -13,7 +13,7 @@ import org.springframework.stereotype.Component; @Component -public class SamService { +public class SamService implements HealthCheck { private static final Logger logger = LoggerFactory.getLogger(SamService.class); private final SamClient samClient; @@ -38,24 +38,22 @@ public boolean getAction( } } - public ApiSystemStatusSystems status() { + public Result checkHealth() { // No access token needed since this is an unauthenticated API. try { - // Don't retry status check - SystemStatus samStatus = samClient.statusApi().getSystemStatus(); - var result = new ApiSystemStatusSystems().ok(samStatus.getOk()); - var samSystems = samStatus.getSystems(); - // Populate error message if Sam status is non-ok - if (result.isOk() == null || !result.isOk()) { - String errorMsg = "Sam status check failed. Messages = " + samSystems; - logger.error(errorMsg); - result.addMessagesItem(errorMsg); - } - return result; - } catch (Exception e) { + SystemStatus result = samClient.statusApi().getSystemStatus(); + return new Result(result.getOk(), result.toString()); + } catch (ApiException e) { String errorMsg = "Sam status check failed"; logger.error(errorMsg, e); - return new ApiSystemStatusSystems().ok(false).messages(List.of(errorMsg)); + return new Result(false, e.getMessage()); } } + + public ApiSystemStatusSystems checkHealthApiSystemStatus() { + Result healthResult = checkHealth(); + return new ApiSystemStatusSystems() + .ok(healthResult.isOk()) + .addMessagesItem(healthResult.message()); + } } diff --git a/service/src/main/java/bio/terra/pipelines/retry/RetryLoggingListener.java b/service/src/main/java/bio/terra/pipelines/retry/RetryLoggingListener.java new file mode 100644 index 00000000..ee4cc99d --- /dev/null +++ b/service/src/main/java/bio/terra/pipelines/retry/RetryLoggingListener.java @@ -0,0 +1,42 @@ +package bio.terra.pipelines.retry; + +import bio.terra.pipelines.app.configuration.internal.RetryConfiguration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.RetryListener; +import org.springframework.stereotype.Component; + +/** + * Listener used to log messages on requests that are retried through {@link + * RetryConfiguration#listenerResetRetryTemplate} + */ +@Component("retryLoggingListener") +public class RetryLoggingListener implements RetryListener { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @Override + public void close( + RetryContext context, RetryCallback callback, Throwable throwable) { + if (context.getRetryCount() > 1) { + logger.debug("Retryable method closing ({}th retry)", context.getRetryCount()); + } + } + + @Override + public boolean open(RetryContext context, RetryCallback callback) { + if (context.getRetryCount() > 1) { + logger.debug("Retryable method opening (retry count: {})", context.getRetryCount()); + } + return true; + } + + @Override + public void onError( + RetryContext context, RetryCallback callback, Throwable throwable) { + logger.warn( + "Retryable method threw exception (retry count: {})", context.getRetryCount(), throwable); + } +} diff --git a/service/src/main/java/bio/terra/pipelines/service/BaseStatusService.java b/service/src/main/java/bio/terra/pipelines/service/BaseStatusService.java index ca16b3bf..c895df76 100644 --- a/service/src/main/java/bio/terra/pipelines/service/BaseStatusService.java +++ b/service/src/main/java/bio/terra/pipelines/service/BaseStatusService.java @@ -1,6 +1,6 @@ package bio.terra.pipelines.service; -import bio.terra.pipelines.config.StatusCheckConfiguration; +import bio.terra.pipelines.app.configuration.internal.StatusCheckConfiguration; import bio.terra.pipelines.generated.model.ApiSystemStatus; import bio.terra.pipelines.generated.model.ApiSystemStatusSystems; import com.google.common.annotations.VisibleForTesting; diff --git a/service/src/main/java/bio/terra/pipelines/service/StatusService.java b/service/src/main/java/bio/terra/pipelines/service/StatusService.java index 3c849133..6edebb6a 100644 --- a/service/src/main/java/bio/terra/pipelines/service/StatusService.java +++ b/service/src/main/java/bio/terra/pipelines/service/StatusService.java @@ -1,8 +1,8 @@ package bio.terra.pipelines.service; -import bio.terra.pipelines.config.StatusCheckConfiguration; +import bio.terra.pipelines.app.configuration.internal.StatusCheckConfiguration; +import bio.terra.pipelines.dependencies.sam.SamService; import bio.terra.pipelines.generated.model.ApiSystemStatusSystems; -import bio.terra.pipelines.iam.SamService; import java.sql.Connection; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,7 +24,7 @@ public StatusService( super(configuration); this.jdbcTemplate = jdbcTemplate; registerStatusCheck("CloudSQL", this::databaseStatus); - registerStatusCheck("Sam", samService::status); + registerStatusCheck("Sam", samService::checkHealthApiSystemStatus); } private ApiSystemStatusSystems databaseStatus() { diff --git a/service/src/main/resources/application.yml b/service/src/main/resources/application.yml index adb20833..bc0c1058 100644 --- a/service/src/main/resources/application.yml +++ b/service/src/main/resources/application.yml @@ -11,8 +11,9 @@ env: tracing: exportEnabled: ${CLOUD_TRACE_ENABLED:false} samplingRate: ${SAMPLING_PROBABILITY:0} - sam: - basePath: ${SAM_ADDRESS:https://sam.dsde-dev.broadinstitute.org} + urls: + sam: ${SAM_ADDRESS:https://sam.dsde-dev.broadinstitute.org} + leonardo: ${LEONARDO_ADDRESS:https://leonardo.dsde-dev.broadinstitute.org} # Below here is non-deployment-specific @@ -80,4 +81,19 @@ pipelines: stalenessThresholdSeconds: 125 sam: - basePath: ${env.sam.basePath} \ No newline at end of file + basePath: ${env.urls.sam} + +imputation: + # workspace id for the imputation workspace, currently hardcoded to this workspace in dev + # https://bvdp-saturn-dev.appspot.com/#workspaces/tsps_dev_BP/Imputation_Control_Workspace_test + # needs to be updated in helmfile when other imputation workspaces are created in different environments + workspaceId: a2e9cfbb-e4f0-4788-8aa1-de23533205fc + +leonardo: + baseUri: ${env.urls.leonardo} + # The below appTypeNames are in priority order for the named app type. This order is necessary for + # the appComparisonFunction() located in AppUtils.java for selecting the best app for each app type. + wdsAppTypeNames: ['WDS'] + cbasAppTypeNames: ['CROMWELL'] + dependencyUrlCacheTtlSeconds: 300 # Refresh every 5 minutes + debugApiLogging: false diff --git a/service/src/test/java/bio/terra/pipelines/BaseSpringBootTest.java b/service/src/test/java/bio/terra/pipelines/BaseSpringBootTest.java deleted file mode 100644 index 172aa2fb..00000000 --- a/service/src/test/java/bio/terra/pipelines/BaseSpringBootTest.java +++ /dev/null @@ -1,8 +0,0 @@ -package bio.terra.pipelines; - -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest -@ActiveProfiles({"test", "human-readable-logging"}) -public abstract class BaseSpringBootTest {} diff --git a/service/src/test/java/bio/terra/pipelines/configuration/external/LeonardoServerConfigurationTest.java b/service/src/test/java/bio/terra/pipelines/configuration/external/LeonardoServerConfigurationTest.java new file mode 100644 index 00000000..44e4bc8f --- /dev/null +++ b/service/src/test/java/bio/terra/pipelines/configuration/external/LeonardoServerConfigurationTest.java @@ -0,0 +1,26 @@ +package bio.terra.pipelines.configuration.external; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import bio.terra.pipelines.app.configuration.external.LeonardoServerConfiguration; +import bio.terra.pipelines.testutils.BaseContainerTest; +import java.time.Duration; +import java.util.List; +import org.broadinstitute.dsde.workbench.client.leonardo.model.AppType; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class LeonardoServerConfigurationTest extends BaseContainerTest { + @Autowired LeonardoServerConfiguration leonardoServerConfiguration; + + /** test reading leonardo server config from application yml */ + @Test + void verifyLeonardoServerConfig() { + assertEquals("https://test_leonardo_url/", leonardoServerConfiguration.baseUri()); + assertEquals(List.of(AppType.CROMWELL), leonardoServerConfiguration.cbasAppTypeNames()); + assertEquals(List.of(AppType.WDS), leonardoServerConfiguration.wdsAppTypeNames()); + assertEquals(Duration.ofSeconds(300), leonardoServerConfiguration.dependencyUrlCacheTtl()); + assertFalse(leonardoServerConfiguration.debugApiLogging()); + } +} diff --git a/service/src/test/java/bio/terra/pipelines/configuration/internal/BeanConfigurationTest.java b/service/src/test/java/bio/terra/pipelines/configuration/internal/BeanConfigurationTest.java new file mode 100644 index 00000000..11c2e34e --- /dev/null +++ b/service/src/test/java/bio/terra/pipelines/configuration/internal/BeanConfigurationTest.java @@ -0,0 +1,33 @@ +package bio.terra.pipelines.configuration.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import bio.terra.common.exception.UnauthorizedException; +import bio.terra.pipelines.app.configuration.internal.BeanConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; + +class BeanConfigurationTest { + private final BeanConfiguration beanConfiguration = new BeanConfiguration(null); + private MockHttpServletRequest request; + + @BeforeEach + void init() { + request = new MockHttpServletRequest(); + } + + @Test + void bearerTokenSuccess() { + String token = "token"; + request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + assertEquals(token, beanConfiguration.bearerToken(request).getToken()); + } + + @Test + void noBearerToken() { + assertThrows(UnauthorizedException.class, () -> beanConfiguration.bearerToken(request)); + } +} diff --git a/service/src/test/java/bio/terra/pipelines/configuration/internal/TspsDatabaseConfigurationTest.java b/service/src/test/java/bio/terra/pipelines/configuration/internal/TspsDatabaseConfigurationTest.java new file mode 100644 index 00000000..35a2266e --- /dev/null +++ b/service/src/test/java/bio/terra/pipelines/configuration/internal/TspsDatabaseConfigurationTest.java @@ -0,0 +1,19 @@ +package bio.terra.pipelines.configuration.internal; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +import bio.terra.pipelines.app.configuration.internal.TspsDatabaseConfiguration; +import bio.terra.pipelines.testutils.BaseContainerTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class TspsDatabaseConfigurationTest extends BaseContainerTest { + + @Autowired TspsDatabaseConfiguration tspsDatabaseConfiguration; + + @Test + void verifyTspsDatabaseConfiguration() { + assertFalse(tspsDatabaseConfiguration.isInitializeOnStart()); + assertFalse(tspsDatabaseConfiguration.isUpgradeOnStart()); + } +} diff --git a/service/src/test/java/bio/terra/pipelines/controller/JobsApiControllerTest.java b/service/src/test/java/bio/terra/pipelines/controller/JobsApiControllerTest.java index 46cdd3a7..5f81f6eb 100644 --- a/service/src/test/java/bio/terra/pipelines/controller/JobsApiControllerTest.java +++ b/service/src/test/java/bio/terra/pipelines/controller/JobsApiControllerTest.java @@ -13,16 +13,16 @@ import bio.terra.common.iam.BearerTokenFactory; import bio.terra.common.iam.SamUser; import bio.terra.common.iam.SamUserFactory; +import bio.terra.pipelines.app.configuration.external.SamConfiguration; import bio.terra.pipelines.app.controller.GlobalExceptionHandler; import bio.terra.pipelines.app.controller.JobsApiController; -import bio.terra.pipelines.config.SamConfiguration; import bio.terra.pipelines.db.entities.Job; import bio.terra.pipelines.db.exception.JobNotFoundException; import bio.terra.pipelines.db.exception.PipelineNotFoundException; +import bio.terra.pipelines.dependencies.sam.SamService; import bio.terra.pipelines.generated.model.ApiGetJobResponse; import bio.terra.pipelines.generated.model.ApiGetJobsResponse; import bio.terra.pipelines.generated.model.ApiPostJobRequestBody; -import bio.terra.pipelines.iam.SamService; import bio.terra.pipelines.service.JobsService; import bio.terra.pipelines.service.PipelinesService; import bio.terra.pipelines.testutils.MockMvcUtils; @@ -101,7 +101,7 @@ void testGetJobOk() throws Exception { new ObjectMapper() .readValue(result.getResponse().getContentAsString(), ApiGetJobResponse.class); // you could compare other fields here too beyond the id, if wanted - assertEquals(response.getJobId(), jobIdOkDone.toString()); + assertEquals(jobIdOkDone.toString(), response.getJobId()); } @Test diff --git a/service/src/test/java/bio/terra/pipelines/controller/PipelinesApiControllerTest.java b/service/src/test/java/bio/terra/pipelines/controller/PipelinesApiControllerTest.java index 23786b61..42e619d3 100644 --- a/service/src/test/java/bio/terra/pipelines/controller/PipelinesApiControllerTest.java +++ b/service/src/test/java/bio/terra/pipelines/controller/PipelinesApiControllerTest.java @@ -10,11 +10,11 @@ import bio.terra.common.iam.BearerTokenFactory; import bio.terra.common.iam.SamUser; import bio.terra.common.iam.SamUserFactory; +import bio.terra.pipelines.app.configuration.external.SamConfiguration; import bio.terra.pipelines.app.controller.PipelinesApiController; -import bio.terra.pipelines.config.SamConfiguration; import bio.terra.pipelines.db.entities.Pipeline; +import bio.terra.pipelines.dependencies.sam.SamService; import bio.terra.pipelines.generated.model.ApiPipelinesGetResult; -import bio.terra.pipelines.iam.SamService; import bio.terra.pipelines.service.PipelinesService; import bio.terra.pipelines.testutils.MockMvcUtils; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/service/src/test/java/bio/terra/pipelines/dependencies/leonardo/AppUtilsTest.java b/service/src/test/java/bio/terra/pipelines/dependencies/leonardo/AppUtilsTest.java new file mode 100644 index 00000000..1a131893 --- /dev/null +++ b/service/src/test/java/bio/terra/pipelines/dependencies/leonardo/AppUtilsTest.java @@ -0,0 +1,594 @@ +package bio.terra.pipelines.dependencies.leonardo; + +import static org.junit.jupiter.api.Assertions.*; + +import bio.terra.pipelines.app.configuration.external.LeonardoServerConfiguration; +import com.google.gson.Gson; +import java.io.IOException; +import java.util.*; +import org.apache.commons.text.StringSubstitutor; +import org.broadinstitute.dsde.workbench.client.leonardo.model.AppStatus; +import org.broadinstitute.dsde.workbench.client.leonardo.model.AppType; +import org.broadinstitute.dsde.workbench.client.leonardo.model.ListAppResponse; +import org.junit.jupiter.api.Test; + +class AppUtilsTest { + private final String workspaceId = UUID.randomUUID().toString(); + + private final LeonardoServerConfiguration leonardoServerConfiguration = + new LeonardoServerConfiguration( + "baseuri", + List.of("WDS", "CROMWELL"), + List.of("CROMWELL_RUNNER_APP", "CROMWELL"), + 0, + false); + + private final ListAppResponse separatedWdsAppWdsAppName; + // does not have "wds-" app name and does not have anticipated wds url + private final ListAppResponse separatedWdsAppNotWdsAppName; + private final ListAppResponse cromwellListAppResponse; + private final ListAppResponse cromwellListAppResponseProvisioning; + private final ListAppResponse cromwellListAppResponseNoProxyUrlDeleted; + private final ListAppResponse combinedWdsInCromwellApp; + private final ListAppResponse otherNamedCromwellAppOlder; + private final ListAppResponse galaxyApp; + private final ListAppResponse otherNamedCromwellApp; + private final ListAppResponse otherNamedCromwellAppProvisioning; + private final ListAppResponse separatedWorkflowsApp; + + private final AppUtils au = new AppUtils(leonardoServerConfiguration); + + @Test + void findWdsUrlInCombinedApp() { + List apps = List.of(combinedWdsInCromwellApp); + + assertEquals(anticipatedWdsUrl("wds"), au.findUrlForWds(apps, workspaceId)); + } + + @Test + void findCbasUrlInTerraApp() { + List apps = List.of(cromwellListAppResponse); + + assertEquals(anticipatedCbasUrl("terra-app"), au.findUrlForCbas(apps, workspaceId)); + } + + @Test + void findWdsUrlInWdsApp() { + List apps = List.of(separatedWdsAppWdsAppName); + + assertEquals(anticipatedWdsUrl("wds"), au.findUrlForWds(apps, workspaceId)); + } + + @Test + void findWdsAndCbasInCombinedAppResponse() { + List apps = List.of(separatedWdsAppWdsAppName, cromwellListAppResponse); + + assertEquals(anticipatedWdsUrl("wds"), au.findUrlForWds(apps, workspaceId)); + assertEquals(anticipatedCbasUrl("terra-app"), au.findUrlForCbas(apps, workspaceId)); + } + + @Test + void preferSpecificallyNamedApp() { + List apps = + new java.util.ArrayList<>(List.of(separatedWdsAppWdsAppName, separatedWdsAppNotWdsAppName)); + permuteAndTest(apps, anticipatedWdsUrl("wds")); + } + + @Test + void notChooseGalaxyApp() { + List apps = + new java.util.ArrayList<>(List.of(otherNamedCromwellApp, galaxyApp)); + permuteAndTest(apps, anticipatedWdsUrl("app1")); + } + + @Test + void preferNewerCreatedApp() { + List apps = + new java.util.ArrayList<>(List.of(otherNamedCromwellApp, otherNamedCromwellAppOlder)); + permuteAndTest(apps, anticipatedWdsUrl("app1")); + } + + @Test + void preferWdsAppOverCromwell() { + List apps = + new java.util.ArrayList<>(List.of(separatedWdsAppWdsAppName, separatedWorkflowsApp)); + + permuteAndTest(apps, anticipatedWdsUrl("wds")); + } + + @Test + void throwIfBestAppNotReady() { + List apps = + new java.util.ArrayList<>( + List.of(otherNamedCromwellAppOlder, otherNamedCromwellAppProvisioning)); + + assertThrows(DependencyNotAvailableException.class, () -> au.findUrlForWds(apps, workspaceId)); + } + + @Test + void throwIfBestAppHasNoWds() { + List apps = new java.util.ArrayList<>(List.of(separatedWorkflowsApp)); + + assertThrows(DependencyNotAvailableException.class, () -> au.findUrlForWds(apps, workspaceId)); + } + + @Test + void throwIfBestAppHasNoCbas() { + List apps = + new java.util.ArrayList<>(List.of(cromwellListAppResponseNoProxyUrlDeleted)); + + assertThrows(DependencyNotAvailableException.class, () -> au.findUrlForCbas(apps, workspaceId)); + } + + @Test + void throwIfBestAppHasNoRunningCbas() { + List apps = + new java.util.ArrayList<>(List.of(cromwellListAppResponseProvisioning)); + + assertThrows(DependencyNotAvailableException.class, () -> au.findUrlForCbas(apps, workspaceId)); + } + + @Test + void findCbasUrlInCombinedWdsApp() { + List apps = List.of(cromwellListAppResponse, separatedWdsAppWdsAppName); + + assertEquals(anticipatedCbasUrl("terra-app"), au.findUrlForCbas(apps, workspaceId)); + } + + @Test + void testFilterSuitableApps() { + Set healthyStates = EnumSet.of(AppStatus.RUNNING, AppStatus.STOPPED); + List appTypeList = List.of(AppType.CROMWELL, AppType.CROMWELL_RUNNER_APP); + AppType appTypeToFilterOn = AppType.CROMWELL; + + // test an app that should pass the filter + ListAppResponse goodApp = getPassingListAppResponse(); + assertTrue( + au.passesSuitableFilter( + goodApp, healthyStates, appTypeToFilterOn, appTypeList, workspaceId)); + + // test an app that should fail the filter due to bad workspace id + ListAppResponse badAppWorkspaceId = + getPassingListAppResponse().workspaceId(UUID.randomUUID().toString()); + assertFalse( + au.passesSuitableFilter( + badAppWorkspaceId, healthyStates, appTypeToFilterOn, appTypeList, workspaceId)); + + // test an app that should fail the filter due to app type + ListAppResponse badAppAppType = getPassingListAppResponse().appType(AppType.WDS); + assertFalse( + au.passesSuitableFilter( + badAppAppType, healthyStates, appTypeToFilterOn, appTypeList, workspaceId)); + + // test an app that should fail the filter due to status + ListAppResponse badAppStatus = getPassingListAppResponse().status(AppStatus.DELETED); + assertFalse( + au.passesSuitableFilter( + badAppStatus, healthyStates, appTypeToFilterOn, appTypeList, workspaceId)); + } + + private ListAppResponse getPassingListAppResponse() { + return new ListAppResponse() + .workspaceId(workspaceId) + .appType(AppType.CROMWELL) + .status(AppStatus.RUNNING); + } + + private void permuteAndTest(List apps, String expectedUrl) { + int permutation = 0; + do { + Collections.rotate(apps, 1); + assertEquals(expectedUrl, au.findUrlForWds(apps, workspaceId)); + } while (permutation++ < apps.size()); + } + + private String anticipatedWdsUrl(String appName) { + return StringSubstitutor.replace( + "https://lzblahblahblah.servicebus.windows.net/${appName}-${workspaceId}/wds", + Map.of("workspaceId", workspaceId, "appName", appName)); + } + + private String anticipatedCbasUrl(String appName) { + return StringSubstitutor.replace( + "https://lzblahblahblah.servicebus.windows.net/${appName}-${workspaceId}/cbas", + Map.of("workspaceId", workspaceId, "appName", appName)); + } + + public AppUtilsTest() throws IOException { + // Use GSON instead of objectMapper because we want to simulate the JSON that comes back from + // Leonardo. + org.broadinstitute.dsde.workbench.client.leonardo.JSON.setGson(new Gson()); + combinedWdsInCromwellApp = + ListAppResponse.fromJson( + StringSubstitutor.replace( + """ + { + "workspaceId": "${workspaceId}", + "cloudContext": { + "cloudProvider": "AZURE", + "cloudResource": "blah-blah-blah" + }, + "kubernetesRuntimeConfig": { + "numNodes": 1, + "machineType": "Standard_A2_v2", + "autoscalingEnabled": false + }, + "errors": [], + "status": "RUNNING", + "proxyUrls": { + "cbas": "https://lzblahblahblah.servicebus.windows.net/wds-${workspaceId}/cbas", + "cbas-ui": "https://lzblahblahblah.servicebus.windows.net/wds-${workspaceId}/", + "cromwell": "https://lzblahblahblah.servicebus.windows.net/wds-${workspaceId}/cromwell", + "wds": "https://lzblahblahblah.servicebus.windows.net/wds-${workspaceId}/wds" + }, + "appName": "wds-${workspaceId}", + "appType": "CROMWELL", + "diskName": null, + "auditInfo": { + "creator": "me@broadinstitute.org", + "createdDate": "2023-02-09T16:01:36.660590Z", + "destroyedDate": null, + "dateAccessed": "2023-02-09T16:01:36.660590Z" + }, + "accessScope": null, + "labels": {} + }""", + Map.of("workspaceId", workspaceId))); + + separatedWdsAppWdsAppName = + ListAppResponse.fromJson( + StringSubstitutor.replace( + """ + { + "workspaceId": "${workspaceId}", + "cloudContext": { + "cloudProvider": "AZURE", + "cloudResource": "blah-blah-blah" + }, + "kubernetesRuntimeConfig": { + "numNodes": 1, + "machineType": "Standard_A2_v2", + "autoscalingEnabled": false + }, + "errors": [], + "status": "RUNNING", + "proxyUrls": { + "wds": "https://lzblahblahblah.servicebus.windows.net/wds-${workspaceId}/wds" + }, + "appName": "wds-${workspaceId}", + "appType": "WDS", + "diskName": null, + "auditInfo": { + "creator": "me@broadinstitute.org", + "createdDate": "2022-10-10T16:01:36.660590Z", + "destroyedDate": null, + "dateAccessed": "2023-02-09T16:01:36.660590Z" + }, + "accessScope": null, + "labels": {} + }""", + Map.of("workspaceId", workspaceId))); + + separatedWdsAppNotWdsAppName = + ListAppResponse.fromJson( + StringSubstitutor.replace( + """ + { + "workspaceId": "${workspaceId}", + "cloudContext": { + "cloudProvider": "AZURE", + "cloudResource": "blah-blah-blah" + }, + "kubernetesRuntimeConfig": { + "numNodes": 1, + "machineType": "Standard_A2_v2", + "autoscalingEnabled": false + }, + "errors": [], + "status": "RUNNING", + "proxyUrls": { + "wds": "https://lzblahblahblah.shouldneverwork.servicebus.windows.net/wds-${workspaceId}/wds" + }, + "appName": "notwds-${workspaceId}", + "appType": "WDS", + "diskName": null, + "auditInfo": { + "creator": "me@broadinstitute.org", + "createdDate": "2022-10-10T16:01:36.660590Z", + "destroyedDate": null, + "dateAccessed": "2023-02-09T16:01:36.660590Z" + }, + "accessScope": null, + "labels": {} + }""", + Map.of("workspaceId", workspaceId))); + + cromwellListAppResponse = + ListAppResponse.fromJson( + StringSubstitutor.replace( + """ + { + "workspaceId": "${workspaceId}", + "cloudContext": { + "cloudProvider": "AZURE", + "cloudResource": "blah-blah-blah" + }, + "kubernetesRuntimeConfig": { + "numNodes": 1, + "machineType": "Standard_A2_v2", + "autoscalingEnabled": false + }, + "errors": [], + "status": "RUNNING", + "proxyUrls": { + "cbas": "https://lzblahblahblah.servicebus.windows.net/terra-app-${workspaceId}/cbas", + "cbas-ui": "https://lzblahblahblah.servicebus.windows.net/terra-app-${workspaceId}/", + "cromwell": "https://lzblahblahblah.servicebus.windows.net/terra-app-${workspaceId}/cromwell" + }, + "appName": "terra-app-${workspaceId}", + "appType": "CROMWELL", + "diskName": null, + "auditInfo": { + "creator": "me@broadinstitute.org", + "createdDate": "2023-02-09T16:01:36.660590Z", + "destroyedDate": null, + "dateAccessed": "2023-02-09T16:01:36.660590Z" + }, + "accessScope": null, + "labels": {} + }""", + Map.of("workspaceId", workspaceId))); + + cromwellListAppResponseNoProxyUrlDeleted = + ListAppResponse.fromJson( + StringSubstitutor.replace( + """ + { + "workspaceId": "${workspaceId}", + "cloudContext": { + "cloudProvider": "AZURE", + "cloudResource": "blah-blah-blah" + }, + "kubernetesRuntimeConfig": { + "numNodes": 1, + "machineType": "Standard_A2_v2", + "autoscalingEnabled": false + }, + "errors": [], + "status": "DELETED", + "proxyUrls": { + "cbas": "https://lzblahblahblah.servicebus.windows.net/terra-app-${workspaceId}/cbas", + "cbas-ui": "https://lzblahblahblah.servicebus.windows.net/terra-app-${workspaceId}/" + }, + "appName": "terra-app-${workspaceId}", + "appType": "CROMWELL", + "diskName": null, + "auditInfo": { + "creator": "me@broadinstitute.org", + "createdDate": "2023-02-09T16:01:36.660590Z", + "destroyedDate": null, + "dateAccessed": "2023-02-09T16:01:36.660590Z" + }, + "accessScope": null, + "labels": {} + }""", + Map.of("workspaceId", workspaceId))); + + cromwellListAppResponseProvisioning = + ListAppResponse.fromJson( + StringSubstitutor.replace( + """ + { + "workspaceId": "${workspaceId}", + "cloudContext": { + "cloudProvider": "AZURE", + "cloudResource": "blah-blah-blah" + }, + "kubernetesRuntimeConfig": { + "numNodes": 1, + "machineType": "Standard_A2_v2", + "autoscalingEnabled": false + }, + "errors": [], + "status": "PROVISIONING", + "proxyUrls": { + "cbas": "https://lzblahblahblah.servicebus.windows.net/terra-app-${workspaceId}/cbas", + "cbas-ui": "https://lzblahblahblah.servicebus.windows.net/terra-app-${workspaceId}/", + "cromwell": "https://lzblahblahblah.servicebus.windows.net/terra-app-${workspaceId}/cromwell" + }, + "appName": "terra-app-${workspaceId}", + "appType": "CROMWELL", + "diskName": null, + "auditInfo": { + "creator": "me@broadinstitute.org", + "createdDate": "2023-02-09T16:01:36.660590Z", + "destroyedDate": null, + "dateAccessed": "2023-02-09T16:01:36.660590Z" + }, + "accessScope": null, + "labels": {} + }""", + Map.of("workspaceId", workspaceId))); + + otherNamedCromwellAppOlder = + ListAppResponse.fromJson( + StringSubstitutor.replace( + """ + { + "workspaceId": "${workspaceId}", + "cloudContext": { + "cloudProvider": "AZURE", + "cloudResource": "blah-blah-blah" + }, + "kubernetesRuntimeConfig": { + "numNodes": 1, + "machineType": "Standard_A2_v2", + "autoscalingEnabled": false + }, + "errors": [], + "status": "RUNNING", + "proxyUrls": { + "cbas": "https://lzblahblahblah.servicebus.windows.net/app2-${workspaceId}/cbas", + "cbas-ui": "https://lzblahblahblah.servicebus.windows.net/app2-${workspaceId}/", + "cromwell": "https://lzblahblahblah.servicebus.windows.net/app2-${workspaceId}/cromwell", + "wds": "https://lzblahblahblah.servicebus.windows.net/app2-${workspaceId}/wds" + }, + "appName": "app2-${workspaceId}", + "appType": "CROMWELL", + "diskName": null, + "auditInfo": { + "creator": "me@broadinstitute.org", + "createdDate": "2022-10-10T16:01:36.660590Z", + "destroyedDate": null, + "dateAccessed": "2023-02-09T16:01:36.660590Z" + }, + "accessScope": null, + "labels": {} + }""", + Map.of("workspaceId", workspaceId))); + + galaxyApp = + ListAppResponse.fromJson( + StringSubstitutor.replace( + """ + { + "workspaceId": "${workspaceId}", + "cloudContext": { + "cloudProvider": "AZURE", + "cloudResource": "blah-blah-blah" + }, + "kubernetesRuntimeConfig": { + "numNodes": 1, + "machineType": "Standard_A2_v2", + "autoscalingEnabled": false + }, + "errors": [], + "status": "RUNNING", + "proxyUrls": { + "blah": "blah blah" + }, + "appName": "galaxy-${workspaceId}", + "appType": "GALAXY", + "diskName": null, + "auditInfo": { + "creator": "me@broadinstitute.org", + "createdDate": "2023-02-09T16:01:36.660590Z", + "destroyedDate": null, + "dateAccessed": "2023-02-09T16:01:36.660590Z" + }, + "accessScope": null, + "labels": {} + }""", + Map.of("workspaceId", workspaceId))); + + otherNamedCromwellApp = + ListAppResponse.fromJson( + StringSubstitutor.replace( + """ + { + "workspaceId": "${workspaceId}", + "cloudContext": { + "cloudProvider": "AZURE", + "cloudResource": "blah-blah-blah" + }, + "kubernetesRuntimeConfig": { + "numNodes": 1, + "machineType": "Standard_A2_v2", + "autoscalingEnabled": false + }, + "errors": [], + "status": "RUNNING", + "proxyUrls": { + "cbas": "https://lzblahblahblah.servicebus.windows.net/app1-${workspaceId}/cbas", + "cbas-ui": "https://lzblahblahblah.servicebus.windows.net/app1-${workspaceId}/", + "cromwell": "https://lzblahblahblah.servicebus.windows.net/app1-${workspaceId}/cromwell", + "wds": "https://lzblahblahblah.servicebus.windows.net/app1-${workspaceId}/wds" + }, + "appName": "app1-${workspaceId}", + "appType": "CROMWELL", + "diskName": null, + "auditInfo": { + "creator": "me@broadinstitute.org", + "createdDate": "2023-02-09T16:01:36.660590Z", + "destroyedDate": null, + "dateAccessed": "2023-02-09T16:01:36.660590Z" + }, + "accessScope": null, + "labels": {} + }""", + Map.of("workspaceId", workspaceId))); + + otherNamedCromwellAppProvisioning = + ListAppResponse.fromJson( + StringSubstitutor.replace( + """ + { + "workspaceId": "${workspaceId}", + "cloudContext": { + "cloudProvider": "AZURE", + "cloudResource": "blah-blah-blah" + }, + "kubernetesRuntimeConfig": { + "numNodes": 1, + "machineType": "Standard_A2_v2", + "autoscalingEnabled": false + }, + "errors": [], + "status": "PROVISIONING", + "proxyUrls": { + "cbas": "https://lzblahblahblah.servicebus.windows.net/app1-${workspaceId}/cbas", + "cbas-ui": "https://lzblahblahblah.servicebus.windows.net/app1-${workspaceId}/", + "cromwell": "https://lzblahblahblah.servicebus.windows.net/app1-${workspaceId}/cromwell", + "wds": "https://lzblahblahblah.servicebus.windows.net/app1-${workspaceId}/wds" + }, + "appName": "app1-${workspaceId}", + "appType": "CROMWELL", + "diskName": null, + "auditInfo": { + "creator": "me@broadinstitute.org", + "createdDate": "2023-02-09T16:01:36.660590Z", + "destroyedDate": null, + "dateAccessed": "2023-02-09T16:01:36.660590Z" + }, + "accessScope": null, + "labels": {} + }""", + Map.of("workspaceId", workspaceId))); + + separatedWorkflowsApp = + ListAppResponse.fromJson( + StringSubstitutor.replace( + """ + { + "workspaceId": "${workspaceId}", + "cloudContext": { + "cloudProvider": "AZURE", + "cloudResource": "blah-blah-blah" + }, + "kubernetesRuntimeConfig": { + "numNodes": 1, + "machineType": "Standard_A2_v2", + "autoscalingEnabled": false + }, + "errors": [], + "status": "RUNNING", + "proxyUrls": { + "cbas": "https://lzblahblahblah.servicebus.windows.net/workflows-${workspaceId}/cbas", + "cbas-ui": "https://lzblahblahblah.servicebus.windows.net/workflows-${workspaceId}/", + "cromwell": "https://lzblahblahblah.servicebus.windows.net/workflows-${workspaceId}/cromwell" + }, + "appName": "workflows-${workspaceId}", + "appType": "CROMWELL", + "diskName": null, + "auditInfo": { + "creator": "me@broadinstitute.org", + "createdDate": "2023-02-09T16:01:36.660590Z", + "destroyedDate": null, + "dateAccessed": "2023-02-09T16:01:36.660590Z" + }, + "accessScope": null, + "labels": {} + }""", + Map.of("workspaceId", workspaceId))); + } +} diff --git a/service/src/test/java/bio/terra/pipelines/dependencies/leonardo/LeonardoClientTest.java b/service/src/test/java/bio/terra/pipelines/dependencies/leonardo/LeonardoClientTest.java new file mode 100644 index 00000000..71f133a6 --- /dev/null +++ b/service/src/test/java/bio/terra/pipelines/dependencies/leonardo/LeonardoClientTest.java @@ -0,0 +1,41 @@ +package bio.terra.pipelines.dependencies.leonardo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +import bio.terra.pipelines.app.configuration.external.LeonardoServerConfiguration; +import java.time.Duration; +import java.util.List; +import org.broadinstitute.dsde.workbench.client.leonardo.ApiClient; +import org.broadinstitute.dsde.workbench.client.leonardo.auth.Authentication; +import org.broadinstitute.dsde.workbench.client.leonardo.auth.OAuth; +import org.junit.jupiter.api.Test; + +class LeonardoClientTest { + LeonardoClient leonardoClient; + + String expectedBaseUri = "baseuri"; + LeonardoServerConfiguration leonardoServerConfiguration = + new LeonardoServerConfiguration( + expectedBaseUri, List.of(), List.of(), Duration.ofMinutes(10), true); + + @Test + void TestLeonardoUnauthorizedClient() { + leonardoClient = new LeonardoClient(leonardoServerConfiguration); + + ApiClient apiClient = leonardoClient.getUnauthorizedApiClient(); + + assertEquals(expectedBaseUri, apiClient.getBasePath()); + assertTrue(apiClient.isDebugging()); + + String expectedToken = "expected_token"; + apiClient = leonardoClient.getApiClient(expectedToken); + for (Authentication auth : apiClient.getAuthentications().values()) { + if (auth instanceof OAuth) { + String actual_token = ((OAuth) auth).getAccessToken(); + assertEquals(expectedToken, actual_token); + } + } + } +} diff --git a/service/src/test/java/bio/terra/pipelines/dependencies/leonardo/LeonardoServiceTest.java b/service/src/test/java/bio/terra/pipelines/dependencies/leonardo/LeonardoServiceTest.java new file mode 100644 index 00000000..394fba63 --- /dev/null +++ b/service/src/test/java/bio/terra/pipelines/dependencies/leonardo/LeonardoServiceTest.java @@ -0,0 +1,156 @@ +package bio.terra.pipelines.dependencies.leonardo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +import bio.terra.common.iam.BearerToken; +import bio.terra.pipelines.app.configuration.internal.RetryConfiguration; +import bio.terra.pipelines.dependencies.common.HealthCheck; +import java.net.SocketTimeoutException; +import java.util.List; +import java.util.UUID; +import org.broadinstitute.dsde.workbench.client.leonardo.ApiException; +import org.broadinstitute.dsde.workbench.client.leonardo.api.AppsApi; +import org.broadinstitute.dsde.workbench.client.leonardo.api.ServiceInfoApi; +import org.broadinstitute.dsde.workbench.client.leonardo.model.ListAppResponse; +import org.broadinstitute.dsde.workbench.client.leonardo.model.SystemStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; +import org.springframework.retry.backoff.FixedBackOffPolicy; +import org.springframework.retry.support.RetryTemplate; + +@ExtendWith(MockitoExtension.class) +class LeonardoServiceTest { + final String workspaceId = UUID.randomUUID().toString(); + final RetryConfiguration retryConfig = new RetryConfiguration(); + RetryTemplate template = retryConfig.listenerResetRetryTemplate(); + + final BearerToken bearerToken = new BearerToken(""); + + final Answer errorAnswer = + invocation -> { + throw new SocketTimeoutException("Timeout"); + }; + + @BeforeEach + void init() { + FixedBackOffPolicy smallerBackoff = new FixedBackOffPolicy(); + smallerBackoff.setBackOffPeriod(5L); // 5 ms + template.setBackOffPolicy(smallerBackoff); + } + + @Test + void socketExceptionRetriesEventuallySucceed() throws Exception { + List expectedResponse = List.of(new ListAppResponse()); + + LeonardoClient leonardoClient = mock(LeonardoClient.class); + AppsApi appsApi = mock(AppsApi.class); + when(appsApi.listAppsV2(workspaceId, null, null, null, null)) + .thenAnswer(errorAnswer) + .thenReturn(expectedResponse); + + LeonardoService leonardoService = + spy(new LeonardoService(leonardoClient, template, bearerToken)); + + doReturn(appsApi).when(leonardoService).getAppsApi(); + + assertEquals(expectedResponse, leonardoService.getApps(workspaceId, false)); + } + + @Test + void socketExceptionRetriesEventuallyFail() throws Exception { + List expectedResponse = List.of(new ListAppResponse()); + + LeonardoClient leonardoClient = mock(LeonardoClient.class); + AppsApi appsApi = mock(AppsApi.class); + when(appsApi.listAppsV2(workspaceId, null, null, null, null)) + .thenAnswer(errorAnswer) + .thenAnswer(errorAnswer) + .thenAnswer(errorAnswer) + .thenReturn(expectedResponse); + + LeonardoService leonardoService = + spy(new LeonardoService(leonardoClient, template, bearerToken)); + + doReturn(appsApi).when(leonardoService).getAppsApi(); + + assertThrows( + SocketTimeoutException.class, + () -> { + leonardoService.getApps(workspaceId, false); + }); + } + + @Test + void apiExceptionsDoNotRetry() throws Exception { + List expectedResponse = List.of(new ListAppResponse()); + + ApiException expectedException = new ApiException(400, "Bad Leonardo"); + + LeonardoClient leonardoClient = mock(LeonardoClient.class); + AppsApi appsApi = mock(AppsApi.class); + when(appsApi.listAppsV2(workspaceId, null, null, null, null)) + .thenThrow(expectedException) + .thenReturn(expectedResponse); + + LeonardoService leonardoService = + spy(new LeonardoService(leonardoClient, template, bearerToken)); + + doReturn(appsApi).when(leonardoService).getAppsApi(); + + LeonardoServiceApiException thrown = + assertThrows( + LeonardoServiceApiException.class, + () -> { + leonardoService.getApps(workspaceId, false); + }); + assertEquals(expectedException, thrown.getCause()); + } + + @Test + void checkHealth() throws ApiException { + LeonardoClient leonardoClient = mock(LeonardoClient.class); + ServiceInfoApi serviceInfoApi = mock(ServiceInfoApi.class); + + SystemStatus systemStatus = new SystemStatus(); + systemStatus.setOk(true); + + when(serviceInfoApi.getSystemStatus()).thenReturn(systemStatus); + + LeonardoService leonardoService = + spy(new LeonardoService(leonardoClient, template, bearerToken)); + + doReturn(serviceInfoApi).when(leonardoService).getServiceInfoApi(); + HealthCheck.Result actualResult = leonardoService.checkHealth(); + + assertEquals( + new HealthCheck.Result(systemStatus.getOk(), systemStatus.toString()), actualResult); + } + + @Test + void checkHealthWithException() throws ApiException { + LeonardoClient leonardoClient = mock(LeonardoClient.class); + ServiceInfoApi serviceInfoApi = mock(ServiceInfoApi.class); + + SystemStatus systemStatus = new SystemStatus(); + systemStatus.setOk(true); + + String exceptionMessage = "this is my exception message"; + ApiException apiException = new ApiException(exceptionMessage); + when(serviceInfoApi.getSystemStatus()).thenThrow(apiException); + + HealthCheck.Result expectedResultOnFail = + new HealthCheck.Result(false, apiException.getMessage()); + LeonardoService leonardoService = + spy(new LeonardoService(leonardoClient, template, bearerToken)); + + doReturn(serviceInfoApi).when(leonardoService).getServiceInfoApi(); + HealthCheck.Result actualResult = leonardoService.checkHealth(); + + assertEquals(expectedResultOnFail, actualResult); + } +} diff --git a/service/src/test/java/bio/terra/pipelines/dependencies/sam/SamClientTest.java b/service/src/test/java/bio/terra/pipelines/dependencies/sam/SamClientTest.java new file mode 100644 index 00000000..d2f81d03 --- /dev/null +++ b/service/src/test/java/bio/terra/pipelines/dependencies/sam/SamClientTest.java @@ -0,0 +1,34 @@ +package bio.terra.pipelines.dependencies.sam; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import bio.terra.pipelines.app.configuration.external.SamConfiguration; +import org.broadinstitute.dsde.workbench.client.sam.api.StatusApi; +import org.broadinstitute.dsde.workbench.client.sam.api.UsersApi; +import org.broadinstitute.dsde.workbench.client.sam.auth.Authentication; +import org.broadinstitute.dsde.workbench.client.sam.auth.OAuth; +import org.junit.jupiter.api.Test; + +class SamClientTest { + SamClient samClient; + + String expectedBaseUri = "baseuri"; + SamConfiguration samConfiguration = new SamConfiguration(expectedBaseUri); + + @Test + void TestSamUnauthorizedClient() { + samClient = new SamClient(samConfiguration); + + StatusApi statusApi = samClient.statusApi(); + assertEquals(expectedBaseUri, statusApi.getApiClient().getBasePath()); + + String expectedToken = "expected_token"; + UsersApi usersApi = samClient.usersApi(expectedToken); + for (Authentication auth : usersApi.getApiClient().getAuthentications().values()) { + if (auth instanceof OAuth) { + String actual_token = ((OAuth) auth).getAccessToken(); + assertEquals(expectedToken, actual_token); + } + } + } +} diff --git a/service/src/test/java/bio/terra/pipelines/dependencies/sam/SamServiceTest.java b/service/src/test/java/bio/terra/pipelines/dependencies/sam/SamServiceTest.java new file mode 100644 index 00000000..c45b1826 --- /dev/null +++ b/service/src/test/java/bio/terra/pipelines/dependencies/sam/SamServiceTest.java @@ -0,0 +1,73 @@ +package bio.terra.pipelines.dependencies.sam; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +import bio.terra.pipelines.dependencies.common.HealthCheck; +import bio.terra.pipelines.generated.model.ApiSystemStatusSystems; +import org.broadinstitute.dsde.workbench.client.sam.ApiException; +import org.broadinstitute.dsde.workbench.client.sam.api.StatusApi; +import org.broadinstitute.dsde.workbench.client.sam.model.SystemStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SamServiceTest { + + @Test + void checkHealth() throws ApiException { + SamClient samClient = mock(SamClient.class); + StatusApi statusApi = mock(StatusApi.class); + + SystemStatus expectedSystemStatus = new SystemStatus(); + expectedSystemStatus.setOk(true); + + when(samClient.statusApi()).thenReturn(statusApi); + when(statusApi.getSystemStatus()).thenReturn(expectedSystemStatus); + + SamService samService = spy(new SamService(samClient)); + + HealthCheck.Result actualResult = samService.checkHealth(); + + assertEquals( + new HealthCheck.Result(expectedSystemStatus.getOk(), expectedSystemStatus.toString()), + actualResult); + + ApiSystemStatusSystems apiSystemStatusSystems = samService.checkHealthApiSystemStatus(); + assertEquals( + new ApiSystemStatusSystems() + .ok(expectedSystemStatus.getOk()) + .addMessagesItem(expectedSystemStatus.toString()), + apiSystemStatusSystems); + } + + @Test + void checkHealthWithException() throws ApiException { + SamClient samClient = mock(SamClient.class); + StatusApi statusApi = mock(StatusApi.class); + + SystemStatus systemStatus = new SystemStatus(); + systemStatus.setOk(true); + + String exceptionMessage = "this is my exception message"; + ApiException apiException = new ApiException(exceptionMessage); + when(samClient.statusApi()).thenReturn(statusApi); + when(statusApi.getSystemStatus()).thenThrow(apiException); + + HealthCheck.Result expectedResultOnFail = + new HealthCheck.Result(false, apiException.getMessage()); + SamService samService = spy(new SamService(samClient)); + + HealthCheck.Result actualResult = samService.checkHealth(); + + assertEquals(expectedResultOnFail, actualResult); + + ApiSystemStatusSystems apiSystemStatusSystems = samService.checkHealthApiSystemStatus(); + assertEquals( + new ApiSystemStatusSystems() + .ok(expectedResultOnFail.isOk()) + .addMessagesItem(expectedResultOnFail.message()), + apiSystemStatusSystems); + } +} diff --git a/service/src/test/java/bio/terra/pipelines/service/JobsServiceMockTest.java b/service/src/test/java/bio/terra/pipelines/service/JobsServiceMockTest.java index dcba9436..4b7fd9a2 100644 --- a/service/src/test/java/bio/terra/pipelines/service/JobsServiceMockTest.java +++ b/service/src/test/java/bio/terra/pipelines/service/JobsServiceMockTest.java @@ -68,7 +68,7 @@ void testCreateJob_successfulWriteUUIDsMatch() { UUID writtenUUID = jobServiceSpy.createJob( testUserId, testGoodPipelineId, testPipelineVersion, testPipelineInputs); - assertEquals(writtenUUID, testGoodUUID); + assertEquals(testGoodUUID, writtenUUID); } @Test @@ -90,6 +90,6 @@ void testCreateJob_unsuccessfulWriteDaoReturnsNullThenSucceeds() { jobServiceSpy.createJob( testUserId, testGoodPipelineId, testPipelineVersion, testPipelineInputs); - assertEquals(returnedUUID, testGoodUUID); + assertEquals(testGoodUUID, returnedUUID); } } diff --git a/service/src/test/java/bio/terra/pipelines/service/JobsServiceTest.java b/service/src/test/java/bio/terra/pipelines/service/JobsServiceTest.java index 2266a08c..240f4c54 100644 --- a/service/src/test/java/bio/terra/pipelines/service/JobsServiceTest.java +++ b/service/src/test/java/bio/terra/pipelines/service/JobsServiceTest.java @@ -49,10 +49,10 @@ void testWriteValidJob() { assertEquals(2, jobsAfterSave.size()); Job savedJob = jobsService.getJob(testUserId, testPipelineId, savedUUID); - assertEquals(savedJob.getJobId(), savedUUID); - assertEquals(savedJob.getPipelineId(), testPipelineId); - assertEquals(savedJob.getPipelineVersion(), testPipelineVersion); - assertEquals(savedJob.getUserId(), testUserId); + assertEquals(savedUUID, savedJob.getJobId()); + assertEquals(testPipelineId, savedJob.getPipelineId()); + assertEquals(testPipelineVersion, savedJob.getPipelineVersion()); + assertEquals(testUserId, savedJob.getUserId()); Optional pipelineInput = pipelineInputsRepository.findById(savedJob.getId()); assertTrue(pipelineInput.isPresent()); diff --git a/service/src/test/resources/application-test.yml b/service/src/test/resources/application-test.yml index 3e3ed465..6006068c 100644 --- a/service/src/test/resources/application-test.yml +++ b/service/src/test/resources/application-test.yml @@ -11,7 +11,23 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} driverClassName: org.postgresql.Driver + initializeOnStart: false + upgradeOnStart: false # this will cause the test row insertion to run liquibase: contexts: test + + +imputation: + # workspace id for the imputation workspace + workspaceId: workspace_uuid + +leonardo: + baseUri: "https://test_leonardo_url/" + # The below appTypeNames are in priority order for the named app type. This order is necessary for + # the appComparisonFunction() located in AppUtils.java for selecting the best app for each app type. + wdsAppTypeNames: ['WDS'] + cbasAppTypeNames: ['CROMWELL'] + dependencyUrlCacheTtlSeconds: 300 # Refresh every 5 minutes + debugApiLogging: false