From e9c0822b939185b8dc7af1dbce9e80dcc7cc982b Mon Sep 17 00:00:00 2001 From: Sergiy Getlin <91149690+sergiygetlin@users.noreply.github.com> Date: Wed, 15 Nov 2023 10:18:57 -0800 Subject: [PATCH] WOR-1141 Set up landing zone web endpoints (#313) * WOR-1141 Set up landing zone web endpoints * Isolate gradle scripts * Add version endpoint. * Exclude swagger generated code from spotless analysis * Add landing zone create endpoint with tests * Add endpoint to check result of landing zone create operation * Add all existing landing zone endpoints * Add unit tests for landing zone controller * Add security to swagger/config to serve static content * Fix artifactory conflict * PR comments: unauthenticated-> public controller. sh file permission. * Refactor test code. --- build.gradle | 3 + ...landingzone.java-common-conventions.gradle | 4 + ...andingzone.java-library-conventions.gradle | 4 + client/.swagger-codegen-ignore | 2 + client/artifactory.gradle | 48 ++ client/build.gradle | 18 + client/swagger.gradle | 44 ++ library/build.gradle | 2 +- service/build.gradle | 16 +- service/gradle/generators.gradle | 50 ++ service/{ => gradle}/publishing.gradle | 0 service/local-dev/local-postgres-init.sql | 4 + service/local-dev/run_postgres.sh | 50 ++ service/local-dev/sql_validate.sh | 6 + .../futureservice/LandingZoneApplication.java | 15 - .../app/LandingZoneApplication.java | 31 + .../futureservice/app/StartupInitializer.java | 14 + .../configuration/VersionConfiguration.java | 64 ++ .../app/configuration/spring/BeanConfig.java | 15 + .../controller/LandingZoneApiController.java | 125 ++++ .../app/controller/PublicApiController.java | 32 + .../app/controller/common/ResponseUtils.java | 20 + .../app/service/LandingZoneAppService.java | 303 ++++++++ .../LandingZoneInvalidInputException.java | 9 + ...andingZoneUnsupportedPurposeException.java | 9 + .../common/utils/MapperUtils.java | 66 ++ .../main/resources/api/service_openapi.yaml | 695 ++++++++++++++++++ .../src/main/resources/api/swagger-ui.html | 98 +++ service/src/main/resources/application.yml | 74 +- .../LandingZoneApplicationTests.java | 2 +- .../controller/GlobalExceptionHandler.java | 39 + .../LandingZoneApiControllerTest.java | 423 +++++++++++ .../common/iam/AuthenticatedUserRequest.java | 92 +++ .../fixture/AzureLandingZoneFixtures.java | 150 ++++ .../common/utils/MockMvcUtils.java | 19 + settings.gradle | 1 + 36 files changed, 2527 insertions(+), 20 deletions(-) create mode 100644 build.gradle create mode 100644 buildSrc/src/main/groovy/bio.terra.landingzone.java-library-conventions.gradle create mode 100644 client/.swagger-codegen-ignore create mode 100644 client/artifactory.gradle create mode 100644 client/build.gradle create mode 100644 client/swagger.gradle create mode 100644 service/gradle/generators.gradle rename service/{ => gradle}/publishing.gradle (100%) create mode 100644 service/local-dev/local-postgres-init.sql create mode 100644 service/local-dev/run_postgres.sh create mode 100644 service/local-dev/sql_validate.sh delete mode 100644 service/src/main/java/bio/terra/lz/futureservice/LandingZoneApplication.java create mode 100644 service/src/main/java/bio/terra/lz/futureservice/app/LandingZoneApplication.java create mode 100644 service/src/main/java/bio/terra/lz/futureservice/app/StartupInitializer.java create mode 100644 service/src/main/java/bio/terra/lz/futureservice/app/configuration/VersionConfiguration.java create mode 100644 service/src/main/java/bio/terra/lz/futureservice/app/configuration/spring/BeanConfig.java create mode 100644 service/src/main/java/bio/terra/lz/futureservice/app/controller/LandingZoneApiController.java create mode 100644 service/src/main/java/bio/terra/lz/futureservice/app/controller/PublicApiController.java create mode 100644 service/src/main/java/bio/terra/lz/futureservice/app/controller/common/ResponseUtils.java create mode 100644 service/src/main/java/bio/terra/lz/futureservice/app/service/LandingZoneAppService.java create mode 100644 service/src/main/java/bio/terra/lz/futureservice/app/service/exception/LandingZoneInvalidInputException.java create mode 100644 service/src/main/java/bio/terra/lz/futureservice/app/service/exception/LandingZoneUnsupportedPurposeException.java create mode 100644 service/src/main/java/bio/terra/lz/futureservice/common/utils/MapperUtils.java create mode 100644 service/src/main/resources/api/service_openapi.yaml create mode 100644 service/src/main/resources/api/swagger-ui.html rename service/src/test/java/bio/terra/lz/futureservice/{ => app}/LandingZoneApplicationTests.java (82%) create mode 100644 service/src/test/java/bio/terra/lz/futureservice/app/controller/GlobalExceptionHandler.java create mode 100644 service/src/test/java/bio/terra/lz/futureservice/app/controller/LandingZoneApiControllerTest.java create mode 100644 service/src/test/java/bio/terra/lz/futureservice/app/controller/common/iam/AuthenticatedUserRequest.java create mode 100644 service/src/test/java/bio/terra/lz/futureservice/common/fixture/AzureLandingZoneFixtures.java create mode 100644 service/src/test/java/bio/terra/lz/futureservice/common/utils/MockMvcUtils.java diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..41ed6fc04 --- /dev/null +++ b/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'com.jfrog.artifactory' version '5.1.10' apply false +} \ No newline at end of file diff --git a/buildSrc/src/main/groovy/bio.terra.landingzone.java-common-conventions.gradle b/buildSrc/src/main/groovy/bio.terra.landingzone.java-common-conventions.gradle index 303a43e23..6ddcf82b6 100644 --- a/buildSrc/src/main/groovy/bio.terra.landingzone.java-common-conventions.gradle +++ b/buildSrc/src/main/groovy/bio.terra.landingzone.java-common-conventions.gradle @@ -5,6 +5,7 @@ plugins { id 'com.diffplug.spotless' id 'com.github.spotbugs' + id 'org.hidetake.swagger.generator' } boolean isGithubAction = System.getenv().containsKey("GITHUB_ACTIONS") @@ -41,6 +42,8 @@ repositories { dependencies { compileOnly "com.google.code.findbugs:annotations:3.0.1" implementation 'org.slf4j:slf4j-api:1.7.35' + implementation 'io.swagger.core.v3:swagger-annotations:2.1.12' + swaggerCodegen 'io.swagger.codegen.v3:swagger-codegen-cli:3.0.31' testImplementation 'ch.qos.logback:logback-classic:1.2.10' testImplementation 'org.hamcrest:hamcrest:2.2' @@ -68,6 +71,7 @@ if (hasProperty("buildScan")) { spotless { java { targetExclude "${buildDir}/**" + targetExclude "**/swagger-code/**" googleJavaFormat() } } diff --git a/buildSrc/src/main/groovy/bio.terra.landingzone.java-library-conventions.gradle b/buildSrc/src/main/groovy/bio.terra.landingzone.java-library-conventions.gradle new file mode 100644 index 000000000..575aab17d --- /dev/null +++ b/buildSrc/src/main/groovy/bio.terra.landingzone.java-library-conventions.gradle @@ -0,0 +1,4 @@ +plugins { + id 'bio.terra.landingzone.java-common-conventions' + id 'java-library' +} diff --git a/client/.swagger-codegen-ignore b/client/.swagger-codegen-ignore new file mode 100644 index 000000000..40cc767de --- /dev/null +++ b/client/.swagger-codegen-ignore @@ -0,0 +1,2 @@ +** +!**/src/** diff --git a/client/artifactory.gradle b/client/artifactory.gradle new file mode 100644 index 000000000..be8838af0 --- /dev/null +++ b/client/artifactory.gradle @@ -0,0 +1,48 @@ +// This and the test below makes sure the build will fail reasonably if you try +// to publish without the environment variables defined. +def artifactory_username = System.getenv("ARTIFACTORY_USERNAME") +def artifactory_password = System.getenv("ARTIFACTORY_PASSWORD") + +gradle.taskGraph.whenReady { taskGraph -> + if (taskGraph.hasTask(artifactoryPublish) && + (artifactory_username == null || artifactory_password == null)) { + throw new GradleException("Set env vars ARTIFACTORY_USERNAME and ARTIFACTORY_PASSWORD to publish") + } +} + +java { + // Builds sources into the published package as part of the 'assemble' task. + withSourcesJar() +} + +publishing { + publications { + billingProfileManagerClientLibrary(MavenPublication) { + artifactId = "landing-zone-service-client" + from components.java + versionMapping { + usage("java-runtime") { + fromResolutionResult() + } + } + } + } +} + +artifactory { + publish { + contextUrl = "https://broadinstitute.jfrog.io/broadinstitute/" + repository { + repoKey = "libs-snapshot-local" // The Artifactory repository key to publish to + username = "${artifactory_username}" // The publisher user name + password = "${artifactory_password}" // The publisher password + } + defaults { + // This is how we tell the Artifactory Plugin which artifacts should be published to Artifactory. + // Reference to Gradle publications defined in the build script. + publications("landingZoneServiceClientLibrary") + publishArtifacts = true + publishPom = true + } + } +} diff --git a/client/build.gradle b/client/build.gradle new file mode 100644 index 000000000..b9aa6673b --- /dev/null +++ b/client/build.gradle @@ -0,0 +1,18 @@ +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + id 'bio.terra.landingzone.java-library-conventions' + id 'maven-publish' + id 'io.spring.dependency-management' + id 'com.jfrog.artifactory' + id 'org.hidetake.swagger.generator' +} + +dependencyManagement { + imports { + mavenBom(SpringBootPlugin.BOM_COORDINATES) + } +} + +apply from: 'artifactory.gradle' +apply from: 'swagger.gradle' \ No newline at end of file diff --git a/client/swagger.gradle b/client/swagger.gradle new file mode 100644 index 000000000..0d36e2c1f --- /dev/null +++ b/client/swagger.gradle @@ -0,0 +1,44 @@ +dependencies { + // Version controlled by dependency management plugin + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation 'org.glassfish.jersey.core:jersey-client' + implementation 'org.glassfish.jersey.media:jersey-media-json-jackson' + implementation 'org.glassfish.jersey.media:jersey-media-multipart' + + implementation 'io.swagger.core.v3:swagger-annotations' + swaggerCodegen 'io.swagger.codegen.v3:swagger-codegen-cli' +} + +def artifactGroup = "${group}.lz.futureservice" + +generateSwaggerCode { + inputFile = file('../service/src/main/resources/api/service_openapi.yaml') + language = 'java' + library = 'jersey2' + + // For Swagger Codegen v3 on Java 16+ + // See https://github.com/swagger-api/swagger-codegen/issues/10966 + jvmArgs = ['--add-opens=java.base/java.util=ALL-UNNAMED'] + + components = [ + apiDocs : false, apiTests: false, + modelDocs: false, modelTests: false + ] + + additionalProperties = [ + modelPackage : "${artifactGroup}.model", + apiPackage : "${artifactGroup}.api", + invokerPackage: "${artifactGroup}.client", + dateLibrary : 'java11', + java8 : true + ] + + rawOptions = ['--ignore-file-override', "${projectDir}/.swagger-codegen-ignore"] +} + +idea.module.generatedSourceDirs = [file("${generateSwaggerCode.outputDir}/src/main/java")] +sourceSets.main.java.srcDir "${generateSwaggerCode.outputDir}/src/main/java" +compileJava.dependsOn generateSwaggerCode +sourcesJar.dependsOn generateSwaggerCode +//why this is required? +spotlessJava.dependsOn generateSwaggerCode diff --git a/library/build.gradle b/library/build.gradle index 127e0c6e2..292680a14 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -4,7 +4,7 @@ plugins { id 'com.google.cloud.tools.jib' id 'com.srcclr.gradle' id 'org.sonarqube' - id 'com.jfrog.artifactory' version '5.1.10' + id 'com.jfrog.artifactory' id 'org.liquibase.gradle' version '2.2.0' } diff --git a/service/build.gradle b/service/build.gradle index b03afbddb..1f30f93f7 100644 --- a/service/build.gradle +++ b/service/build.gradle @@ -3,15 +3,27 @@ plugins { id 'de.undercouch.download' id 'com.google.cloud.tools.jib' id 'com.srcclr.gradle' + id 'com.gorylenko.gradle-git-properties' version '2.4.1' } -apply from: 'publishing.gradle' +project.ext { + includeDir = "$projectDir/gradle" + resourceDir = "${projectDir}/src/main/resources" +} + +apply(from: "$includeDir/generators.gradle") +apply(from: "$includeDir/publishing.gradle") dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' implementation project(':library') + implementation group: "org.springframework.boot", name: "spring-boot-starter-web" + implementation group: "org.springframework.boot", name: "spring-boot-starter-validation" + implementation group: "org.springframework.boot", name: "spring-boot-starter-data-jdbc" + + //implementation group: "org.liquibase", name: "liquibase-core", version: "4.8.0" testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation group: "org.hamcrest", name: "hamcrest", version: "2.2" } test { diff --git a/service/gradle/generators.gradle b/service/gradle/generators.gradle new file mode 100644 index 000000000..1e49391c8 --- /dev/null +++ b/service/gradle/generators.gradle @@ -0,0 +1,50 @@ +dependencies { + implementation 'io.swagger.core.v3:swagger-annotations' + runtimeOnly 'org.webjars.npm:swagger-ui-dist:4.5.0' + swaggerCodegen 'io.swagger.codegen.v3:swagger-codegen-cli' + + // Versioned by Spring: + implementation 'javax.validation:validation-api' + implementation 'org.webjars:webjars-locator-core' + + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' +} + +def artifactGroup = "${group}.lz.futureservice" + +generateSwaggerCode { + inputFile = file('src/main/resources/api/service_openapi.yaml') + language = 'spring' + components = ['models', 'apis'] + // For Swagger Codegen v3 on Java 16+ + // See https://github.com/swagger-api/swagger-codegen/issues/10966 + jvmArgs = ['--add-opens=java.base/java.util=ALL-UNNAMED'] + additionalProperties = [ + modelPackage : "${artifactGroup}.generated.model", + apiPackage : "${artifactGroup}.generated.api", + modelNamePrefix : "Api", + dateLibrary : 'java8', + java8 : true, + interfaceOnly : 'true', + useTags : 'true', + springBootVersion: dependencyManagement.managedVersions['org.springframework.boot:spring-boot'] + ] +} + +String swaggerOutputSrc = "${generateSwaggerCode.outputDir}/src/main/java" + +idea.module.generatedSourceDirs = [file(swaggerOutputSrc)] +sourceSets.main.java.srcDir swaggerOutputSrc +compileJava.dependsOn generateSwaggerCode +//why this is required? +spotlessJava.dependsOn generateSwaggerCode + +// see https://github.com/n0mer/gradle-git-properties +gitProperties { + keys = [] + gitPropertiesName = "version.properties" + customProperty('landingzone.version.gitTag', { it.describe(tags: true) }) + customProperty('landingzone.version.gitHash', { it.head().abbreviatedId }) + customProperty('landingzone.version.github', { "https://github.com/DataBiosphere/terra-landing-zone-service/tree/${it.describe(tags: true)}" }) + customProperty('landingzone.version.build', version) +} diff --git a/service/publishing.gradle b/service/gradle/publishing.gradle similarity index 100% rename from service/publishing.gradle rename to service/gradle/publishing.gradle diff --git a/service/local-dev/local-postgres-init.sql b/service/local-dev/local-postgres-init.sql new file mode 100644 index 000000000..0af1c7339 --- /dev/null +++ b/service/local-dev/local-postgres-init.sql @@ -0,0 +1,4 @@ +CREATE DATABASE landingzone_db; +CREATE ROLE landingzoneuser WITH LOGIN ENCRYPTED PASSWORD 'landingzonepwd'; +CREATE DATABASE landingzone_stairway_db; +CREATE ROLE landingzonestairwayuser WITH LOGIN ENCRYPTED PASSWORD 'landingzonestairwaypwd'; \ No newline at end of file diff --git a/service/local-dev/run_postgres.sh b/service/local-dev/run_postgres.sh new file mode 100644 index 000000000..f38191256 --- /dev/null +++ b/service/local-dev/run_postgres.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Start up a postgres container with initial user/database setup. +POSTGRES_VERSION=13.1 + +start() { + echo "attempting to remove old $CONTAINER container..." + docker rm -f $CONTAINER + + # start up postgres + echo "starting up postgres container..." + BASEDIR=$(dirname "$0") + docker create --name $CONTAINER --rm -e POSTGRES_PASSWORD=password -p "$POSTGRES_PORT:5432" postgres:$POSTGRES_VERSION + docker cp $BASEDIR/local-postgres-init.sql $CONTAINER:/docker-entrypoint-initdb.d/docker_postgres_init.sql + docker start $CONTAINER + + # validate postgres + echo "running postgres validation..." + docker exec $CONTAINER sh -c "$(cat $BASEDIR/sql_validate.sh)" + if [ 0 -eq $? ]; then + echo "postgres validation succeeded." + else + echo "postgres validation failed." + exit 1 + fi + +} + +stop() { + echo "Stopping docker $CONTAINER container..." + docker stop $CONTAINER || echo "postgres stop failed. container already stopped." + docker rm -v $CONTAINER + exit 0 +} + +CONTAINER=postgres_lz +COMMAND=$1 +POSTGRES_PORT=${2:-"5432"} + +if [ ${#@} == 0 ]; then + echo "Usage: $0 stop|start" + exit 1 +fi + +if [ $COMMAND = "start" ]; then + start +elif [ $COMMAND = "stop" ]; then + stop +else + exit 1 +fi diff --git a/service/local-dev/sql_validate.sh b/service/local-dev/sql_validate.sh new file mode 100644 index 000000000..384d97d71 --- /dev/null +++ b/service/local-dev/sql_validate.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +# validate postgres +echo "sleeping for 5 seconds during postgres boot..." +sleep 5 +PGPASSWORD=dbpwd psql --username landingzoneuser -d landingzone_db -c "SELECT VERSION();SELECT NOW()" diff --git a/service/src/main/java/bio/terra/lz/futureservice/LandingZoneApplication.java b/service/src/main/java/bio/terra/lz/futureservice/LandingZoneApplication.java deleted file mode 100644 index ac8a9a517..000000000 --- a/service/src/main/java/bio/terra/lz/futureservice/LandingZoneApplication.java +++ /dev/null @@ -1,15 +0,0 @@ -package bio.terra.lz.futureservice; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; - -// temporarily disable autoconfiguration since we don't have any related db yet. -// this prevents from running application -@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) -public class LandingZoneApplication { - - public static void main(String[] args) { - SpringApplication.run(LandingZoneApplication.class, args); - } -} diff --git a/service/src/main/java/bio/terra/lz/futureservice/app/LandingZoneApplication.java b/service/src/main/java/bio/terra/lz/futureservice/app/LandingZoneApplication.java new file mode 100644 index 000000000..bf749acc0 --- /dev/null +++ b/service/src/main/java/bio/terra/lz/futureservice/app/LandingZoneApplication.java @@ -0,0 +1,31 @@ +package bio.terra.lz.futureservice.app; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; + +// temporarily disable autoconfiguration since we don't have any related db yet. +// this prevents from running application +@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) +@ComponentScan( + basePackages = { + // Dependencies for Stairway + "bio.terra.common.kubernetes", + // Scan for iam token handling + "bio.terra.common.iam", + // Stairway initialization and status + "bio.terra.common.stairway", + // Scan all landing zone service packages; from 'library' module + "bio.terra.landingzone", + // Scan for Liquibase migration components & configs + "bio.terra.common.migrate", + // future lz service + "bio.terra.lz.futureservice" + }) +public class LandingZoneApplication { + + public static void main(String[] args) { + SpringApplication.run(LandingZoneApplication.class, args); + } +} diff --git a/service/src/main/java/bio/terra/lz/futureservice/app/StartupInitializer.java b/service/src/main/java/bio/terra/lz/futureservice/app/StartupInitializer.java new file mode 100644 index 000000000..187593ff5 --- /dev/null +++ b/service/src/main/java/bio/terra/lz/futureservice/app/StartupInitializer.java @@ -0,0 +1,14 @@ +package bio.terra.lz.futureservice.app; + +import bio.terra.common.migrate.LiquibaseMigrator; +import bio.terra.landingzone.library.LandingZoneMain; +import org.springframework.context.ApplicationContext; + +public class StartupInitializer { + public static void initialize(ApplicationContext applicationContext) { + LiquibaseMigrator migrateService = applicationContext.getBean(LiquibaseMigrator.class); + + // Initialize Terra Landing Zone library + LandingZoneMain.initialize(applicationContext, migrateService); + } +} diff --git a/service/src/main/java/bio/terra/lz/futureservice/app/configuration/VersionConfiguration.java b/service/src/main/java/bio/terra/lz/futureservice/app/configuration/VersionConfiguration.java new file mode 100644 index 000000000..be8e2d308 --- /dev/null +++ b/service/src/main/java/bio/terra/lz/futureservice/app/configuration/VersionConfiguration.java @@ -0,0 +1,64 @@ +package bio.terra.lz.futureservice.app.configuration; + +import java.util.Collections; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +/** Read from the version.properties file auto-generated at build time */ +@Configuration +@PropertySource("classpath:version.properties") +@ConfigurationProperties(prefix = "landingzone.version") +public class VersionConfiguration implements InitializingBean { + private String gitHash; + private String gitTag; + private String build; + + private final ConfigurableEnvironment configurableEnvironment; + + @Autowired + public VersionConfiguration(ConfigurableEnvironment configurableEnvironment) { + this.configurableEnvironment = configurableEnvironment; + } + + public String getGitHash() { + return gitHash; + } + + public void setGitHash(String gitHash) { + this.gitHash = gitHash; + } + + public String getGitTag() { + return gitTag; + } + + public void setGitTag(String gitTag) { + this.gitTag = gitTag; + } + + public String getBuild() { + return build; + } + + public void setBuild(String build) { + this.build = build; + } + + /** + * Copies the version.build property to spring.application.version, for consumption by the common + * logging module's JSON layout. + */ + @Override + public void afterPropertiesSet() { + configurableEnvironment + .getPropertySources() + .addFirst( + new MapPropertySource( + "version", Collections.singletonMap("spring.application.version", getBuild()))); + } +} diff --git a/service/src/main/java/bio/terra/lz/futureservice/app/configuration/spring/BeanConfig.java b/service/src/main/java/bio/terra/lz/futureservice/app/configuration/spring/BeanConfig.java new file mode 100644 index 000000000..8efeee778 --- /dev/null +++ b/service/src/main/java/bio/terra/lz/futureservice/app/configuration/spring/BeanConfig.java @@ -0,0 +1,15 @@ +package bio.terra.lz.futureservice.app.configuration.spring; + +import bio.terra.lz.futureservice.app.StartupInitializer; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class BeanConfig { + @Bean + public SmartInitializingSingleton postSetupInitialization(ApplicationContext applicationContext) { + return () -> StartupInitializer.initialize(applicationContext); + } +} diff --git a/service/src/main/java/bio/terra/lz/futureservice/app/controller/LandingZoneApiController.java b/service/src/main/java/bio/terra/lz/futureservice/app/controller/LandingZoneApiController.java new file mode 100644 index 000000000..3ba29efb5 --- /dev/null +++ b/service/src/main/java/bio/terra/lz/futureservice/app/controller/LandingZoneApiController.java @@ -0,0 +1,125 @@ +package bio.terra.lz.futureservice.app.controller; + +import static bio.terra.lz.futureservice.app.controller.common.ResponseUtils.getAsyncResponseCode; + +import bio.terra.common.iam.BearerTokenFactory; +import bio.terra.lz.futureservice.app.service.LandingZoneAppService; +import bio.terra.lz.futureservice.generated.api.LandingZonesApi; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZone; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneDefinitionList; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneList; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneResourcesList; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneResult; +import bio.terra.lz.futureservice.generated.model.ApiCreateAzureLandingZoneRequestBody; +import bio.terra.lz.futureservice.generated.model.ApiCreateLandingZoneResult; +import bio.terra.lz.futureservice.generated.model.ApiDeleteAzureLandingZoneJobResult; +import bio.terra.lz.futureservice.generated.model.ApiDeleteAzureLandingZoneRequestBody; +import bio.terra.lz.futureservice.generated.model.ApiDeleteAzureLandingZoneResult; +import bio.terra.lz.futureservice.generated.model.ApiResourceQuota; +import java.util.UUID; +import javax.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestBody; + +@Controller +public class LandingZoneApiController implements LandingZonesApi { + private final HttpServletRequest request; + private final BearerTokenFactory bearerTokenFactory; + private final LandingZoneAppService landingZoneAppService; + + @Autowired + public LandingZoneApiController( + HttpServletRequest request, + BearerTokenFactory bearerTokenFactory, + LandingZoneAppService landingZoneAppService) { + this.request = request; + this.bearerTokenFactory = bearerTokenFactory; + this.landingZoneAppService = landingZoneAppService; + } + + @Override + public ResponseEntity createAzureLandingZone( + @RequestBody ApiCreateAzureLandingZoneRequestBody body) { + String resultEndpoint = + String.format( + "%s/%s/%s", request.getServletPath(), "create-result", body.getJobControl().getId()); + ApiCreateLandingZoneResult result = + landingZoneAppService.createAzureLandingZone( + bearerTokenFactory.from(request), body, resultEndpoint); + + return new ResponseEntity<>(result, getAsyncResponseCode(result.getJobReport())); + } + + @Override + public ResponseEntity getCreateAzureLandingZoneResult(String jobId) { + ApiAzureLandingZoneResult result = + landingZoneAppService.getCreateAzureLandingZoneResult( + bearerTokenFactory.from(request), jobId); + return new ResponseEntity<>(result, getAsyncResponseCode(result.getJobReport())); + } + + @Override + public ResponseEntity listAzureLandingZones(UUID billingProfileId) { + ApiAzureLandingZoneList result = + landingZoneAppService.listAzureLandingZones( + bearerTokenFactory.from(request), billingProfileId); + return new ResponseEntity<>(result, HttpStatus.OK); + } + + @Override + public ResponseEntity getDeleteAzureLandingZoneResult( + UUID landingZoneId, String jobId) { + ApiDeleteAzureLandingZoneJobResult response = + landingZoneAppService.getDeleteAzureLandingZoneResult( + bearerTokenFactory.from(request), landingZoneId, jobId); + return new ResponseEntity<>(response, getAsyncResponseCode(response.getJobReport())); + } + + @Override + public ResponseEntity listAzureLandingZonesDefinitions() { + ApiAzureLandingZoneDefinitionList result = + landingZoneAppService.listAzureLandingZonesDefinitions(bearerTokenFactory.from(request)); + return new ResponseEntity<>(result, HttpStatus.OK); + } + + @Override + public ResponseEntity deleteAzureLandingZone( + UUID landingZoneId, ApiDeleteAzureLandingZoneRequestBody body) { + String resultEndpoint = + String.format( + "%s/%s/%s", request.getServletPath(), "delete-result", body.getJobControl().getId()); + ApiDeleteAzureLandingZoneResult result = + landingZoneAppService.deleteLandingZone( + bearerTokenFactory.from(request), landingZoneId, body, resultEndpoint); + return new ResponseEntity<>(result, getAsyncResponseCode(result.getJobReport())); + } + + @Override + public ResponseEntity getAzureLandingZone(UUID landingZoneId) { + ApiAzureLandingZone result = + landingZoneAppService.getAzureLandingZone(bearerTokenFactory.from(request), landingZoneId); + return new ResponseEntity<>(result, HttpStatus.OK); + } + + @Override + public ResponseEntity listAzureLandingZoneResources( + UUID landingZoneId) { + ApiAzureLandingZoneResourcesList result = + landingZoneAppService.listAzureLandingZoneResources( + bearerTokenFactory.from(request), landingZoneId); + return new ResponseEntity<>(result, HttpStatus.OK); + } + + @Override + public ResponseEntity getResourceQuotaResult( + UUID landingZoneId, String azureResourceId) { + ApiResourceQuota result = + landingZoneAppService.getResourceQuota( + bearerTokenFactory.from(request), landingZoneId, azureResourceId); + + return new ResponseEntity<>(result, HttpStatus.OK); + } +} diff --git a/service/src/main/java/bio/terra/lz/futureservice/app/controller/PublicApiController.java b/service/src/main/java/bio/terra/lz/futureservice/app/controller/PublicApiController.java new file mode 100644 index 000000000..1eb3799cb --- /dev/null +++ b/service/src/main/java/bio/terra/lz/futureservice/app/controller/PublicApiController.java @@ -0,0 +1,32 @@ +package bio.terra.lz.futureservice.app.controller; + +import bio.terra.lz.futureservice.app.configuration.VersionConfiguration; +import bio.terra.lz.futureservice.generated.api.PublicApi; +import bio.terra.lz.futureservice.generated.model.ApiSystemVersion; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; + +@Controller +public class PublicApiController implements PublicApi { + + private final ApiSystemVersion currentVersion; + + @Autowired + public PublicApiController(VersionConfiguration versionConfiguration) { + currentVersion = + new ApiSystemVersion() + .gitTag(versionConfiguration.getGitTag()) + .gitHash(versionConfiguration.getGitHash()) + .github( + "https://github.com/DataBiosphere/terra-landing-zone-service/commit/" + + versionConfiguration.getGitHash()) + .build(versionConfiguration.getBuild()); + } + + @Override + public ResponseEntity serviceVersion() { + return new ResponseEntity<>(currentVersion, HttpStatus.OK); + } +} diff --git a/service/src/main/java/bio/terra/lz/futureservice/app/controller/common/ResponseUtils.java b/service/src/main/java/bio/terra/lz/futureservice/app/controller/common/ResponseUtils.java new file mode 100644 index 000000000..1b8069f2c --- /dev/null +++ b/service/src/main/java/bio/terra/lz/futureservice/app/controller/common/ResponseUtils.java @@ -0,0 +1,20 @@ +package bio.terra.lz.futureservice.app.controller.common; + +import bio.terra.lz.futureservice.generated.model.ApiJobReport; +import org.springframework.http.HttpStatus; + +public class ResponseUtils { + private ResponseUtils() {} + + /** + * Return the appropriate response code for an endpoint, given an async job report. For a job + * that's still running, this is 202. For a job that's finished (either succeeded or failed), the + * endpoint should return 200. More informational status codes will be included in either the + * response or error report bodies. + */ + public static HttpStatus getAsyncResponseCode(ApiJobReport jobReport) { + return jobReport.getStatus() == ApiJobReport.StatusEnum.RUNNING + ? HttpStatus.ACCEPTED + : HttpStatus.OK; + } +} diff --git a/service/src/main/java/bio/terra/lz/futureservice/app/service/LandingZoneAppService.java b/service/src/main/java/bio/terra/lz/futureservice/app/service/LandingZoneAppService.java new file mode 100644 index 000000000..6114622c1 --- /dev/null +++ b/service/src/main/java/bio/terra/lz/futureservice/app/service/LandingZoneAppService.java @@ -0,0 +1,303 @@ +package bio.terra.lz.futureservice.app.service; + +import bio.terra.common.exception.ConflictException; +import bio.terra.common.iam.BearerToken; +import bio.terra.landingzone.job.LandingZoneJobService; +import bio.terra.landingzone.job.model.JobReport; +import bio.terra.landingzone.library.landingzones.deployment.LandingZonePurpose; +import bio.terra.landingzone.library.landingzones.deployment.ResourcePurpose; +import bio.terra.landingzone.library.landingzones.deployment.SubnetResourcePurpose; +import bio.terra.landingzone.library.landingzones.management.quotas.ResourceQuota; +import bio.terra.landingzone.service.landingzone.azure.LandingZoneService; +import bio.terra.landingzone.service.landingzone.azure.model.DeletedLandingZone; +import bio.terra.landingzone.service.landingzone.azure.model.DeployedLandingZone; +import bio.terra.landingzone.service.landingzone.azure.model.LandingZone; +import bio.terra.landingzone.service.landingzone.azure.model.LandingZoneDefinition; +import bio.terra.landingzone.service.landingzone.azure.model.LandingZoneRequest; +import bio.terra.landingzone.service.landingzone.azure.model.LandingZoneResource; +import bio.terra.landingzone.service.landingzone.azure.model.StartLandingZoneCreation; +import bio.terra.landingzone.service.landingzone.azure.model.StartLandingZoneDeletion; +import bio.terra.lz.futureservice.app.service.exception.LandingZoneInvalidInputException; +import bio.terra.lz.futureservice.app.service.exception.LandingZoneUnsupportedPurposeException; +import bio.terra.lz.futureservice.common.utils.MapperUtils; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZone; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneDefinition; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneDefinitionList; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneDeployedResource; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneDetails; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneList; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneResourcesList; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneResourcesPurposeGroup; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneResult; +import bio.terra.lz.futureservice.generated.model.ApiCreateAzureLandingZoneRequestBody; +import bio.terra.lz.futureservice.generated.model.ApiCreateLandingZoneResult; +import bio.terra.lz.futureservice.generated.model.ApiDeleteAzureLandingZoneJobResult; +import bio.terra.lz.futureservice.generated.model.ApiDeleteAzureLandingZoneRequestBody; +import bio.terra.lz.futureservice.generated.model.ApiDeleteAzureLandingZoneResult; +import bio.terra.lz.futureservice.generated.model.ApiResourceQuota; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +@Service +public class LandingZoneAppService { + private static final Logger logger = LoggerFactory.getLogger(LandingZoneAppService.class); + + // this service is from library module + private final LandingZoneService landingZoneService; + + public LandingZoneAppService(LandingZoneService landingZoneService) { + this.landingZoneService = landingZoneService; + } + + public ApiCreateLandingZoneResult createAzureLandingZone( + BearerToken bearerToken, + ApiCreateAzureLandingZoneRequestBody body, + String asyncResultEndpoint) { + logger.info( + "Requesting new Azure landing zone with definition='{}', version='{}'", + body.getDefinition(), + body.getVersion()); + + // Prevent deploying more than 1 landing zone per billing profile + verifyLandingZoneDoesNotExistForBillingProfile(bearerToken, body); + + LandingZoneRequest landingZoneRequest = + LandingZoneRequest.builder() + .landingZoneId(body.getLandingZoneId()) + .definition(body.getDefinition()) + .version(body.getVersion()) + .parameters( + MapperUtils.LandingZoneMapper.landingZoneParametersFrom(body.getParameters())) + .billingProfileId(body.getBillingProfileId()) + .build(); + return toApiCreateLandingZoneResult( + landingZoneService.startLandingZoneCreationJob( + bearerToken, body.getJobControl().getId(), landingZoneRequest, asyncResultEndpoint)); + } + + public ApiAzureLandingZoneResult getCreateAzureLandingZoneResult( + BearerToken bearerToken, String jobId) { + return toApiAzureLandingZoneResult(landingZoneService.getAsyncJobResult(bearerToken, jobId)); + } + + public ApiAzureLandingZoneList listAzureLandingZones( + BearerToken bearerToken, UUID billingProfileId) { + if (billingProfileId != null) { + return getAzureLandingZonesByBillingProfile(bearerToken, billingProfileId); + } + List landingZones = landingZoneService.listLandingZones(bearerToken); + return new ApiAzureLandingZoneList() + .landingzones( + landingZones.stream().map(this::toApiAzureLandingZone).collect(Collectors.toList())); + } + + public ApiDeleteAzureLandingZoneJobResult getDeleteAzureLandingZoneResult( + BearerToken token, UUID landingZoneId, String jobId) { + return toApiDeleteAzureLandingZoneJobResult( + landingZoneService.getAsyncDeletionJobResult(token, landingZoneId, jobId)); + } + + public ApiAzureLandingZoneDefinitionList listAzureLandingZonesDefinitions( + BearerToken bearerToken) { + List templates = + landingZoneService.listLandingZoneDefinitions(bearerToken); + + return new ApiAzureLandingZoneDefinitionList() + .landingzones( + templates.stream() + .map( + t -> + new ApiAzureLandingZoneDefinition() + .definition(t.definition()) + .name(t.name()) + .description(t.description()) + .version(t.version())) + .collect(Collectors.toList())); + } + + public ApiDeleteAzureLandingZoneResult deleteLandingZone( + BearerToken bearerToken, + UUID landingZoneId, + ApiDeleteAzureLandingZoneRequestBody body, + String resultEndpoint) { + return toApiDeleteAzureLandingZoneResult( + landingZoneService.startLandingZoneDeletionJob( + bearerToken, body.getJobControl().getId(), landingZoneId, resultEndpoint)); + } + + public ApiAzureLandingZone getAzureLandingZone(BearerToken bearerToken, UUID landingZoneId) { + LandingZone landingZoneRecord = landingZoneService.getLandingZone(bearerToken, landingZoneId); + return toApiAzureLandingZone(landingZoneRecord); + } + + public ApiAzureLandingZoneResourcesList listAzureLandingZoneResources( + BearerToken bearerToken, UUID landingZoneId) { + var result = new ApiAzureLandingZoneResourcesList().id(landingZoneId); + landingZoneService + .listResourcesWithPurposes(bearerToken, landingZoneId) + .deployedResources() + .forEach( + (p, dp) -> + result.addResourcesItem( + new ApiAzureLandingZoneResourcesPurposeGroup() + .purpose(p.toString()) + .deployedResources( + dp.stream() + .map(r -> toApiAzureLandingZoneDeployedResource(r, p)) + .toList()))); + return result; + } + + public ApiResourceQuota getResourceQuota( + BearerToken bearerToken, UUID landingZoneId, String azureResourceId) { + return toApiResourceQuota( + landingZoneId, + landingZoneService.getResourceQuota(bearerToken, landingZoneId, azureResourceId)); + } + + private void verifyLandingZoneDoesNotExistForBillingProfile( + BearerToken bearerToken, ApiCreateAzureLandingZoneRequestBody body) { + // TODO: Catching the exception is a temp solution. + // A better approach would be to return an empty list instead of throwing an exception + try { + landingZoneService + .getLandingZonesByBillingProfile(bearerToken, body.getBillingProfileId()) + .stream() + .findFirst() + .ifPresent( + t -> { + throw new LandingZoneInvalidInputException( + "A Landing Zone already exists in the requested billing profile"); + }); + } catch (bio.terra.landingzone.db.exception.LandingZoneNotFoundException ex) { + logger.info("The billing profile does not have a landing zone. ", ex); + } + } + + private ApiCreateLandingZoneResult toApiCreateLandingZoneResult( + LandingZoneJobService.AsyncJobResult jobResult) { + + return new ApiCreateLandingZoneResult() + .jobReport(MapperUtils.JobReportMapper.from(jobResult.getJobReport())) + .errorReport(MapperUtils.ErrorReportMapper.from(jobResult.getApiErrorReport())) + .landingZoneId(jobResult.getResult().landingZoneId()) + .definition(jobResult.getResult().definition()) + .version(jobResult.getResult().version()); + } + + private ApiAzureLandingZoneResult toApiAzureLandingZoneResult( + LandingZoneJobService.AsyncJobResult jobResult) { + ApiAzureLandingZoneDetails azureLandingZone = null; + if (jobResult.getJobReport().getStatus().equals(JobReport.StatusEnum.SUCCEEDED)) { + azureLandingZone = + Optional.ofNullable(jobResult.getResult()) + .map( + lz -> + new ApiAzureLandingZoneDetails() + .id(lz.id()) + .resources( + lz.deployedResources().stream() + .map( + resource -> + new ApiAzureLandingZoneDeployedResource() + .region(resource.region()) + .resourceType(resource.resourceType()) + .resourceId(resource.resourceId())) + .collect(Collectors.toList()))) + .orElse(null); + } + return new ApiAzureLandingZoneResult() + .jobReport(MapperUtils.JobReportMapper.from(jobResult.getJobReport())) + .errorReport(MapperUtils.ErrorReportMapper.from(jobResult.getApiErrorReport())) + .landingZone(azureLandingZone); + } + + private ApiAzureLandingZoneList getAzureLandingZonesByBillingProfile( + BearerToken bearerToken, UUID billingProfileId) { + ApiAzureLandingZoneList result = new ApiAzureLandingZoneList(); + List landingZones = + landingZoneService.getLandingZonesByBillingProfile(bearerToken, billingProfileId); + if (landingZones.size() > 0) { + // The enforced logic is 1:1 relation between Billing Profile and a Landing Zone. + // The landing zone service returns one record in the list if landing zone exists + // for a given billing profile. + if (landingZones.size() == 1) { + result.addLandingzonesItem(toApiAzureLandingZone(landingZones.get(0))); + } else { + throw new ConflictException( + String.format( + "There are more than one landing zone found for the given billing profile: '%s'. Please" + + " check the landing zone deployment is correct.", + billingProfileId)); + } + } + return result; + } + + private ApiAzureLandingZone toApiAzureLandingZone(LandingZone landingZone) { + return new ApiAzureLandingZone() + .billingProfileId(landingZone.billingProfileId()) + .landingZoneId(landingZone.landingZoneId()) + .definition(landingZone.definition()) + .version(landingZone.version()) + .createdDate(landingZone.createdDate()); + } + + private ApiDeleteAzureLandingZoneJobResult toApiDeleteAzureLandingZoneJobResult( + LandingZoneJobService.AsyncJobResult jobResult) { + var apiJobResult = + new ApiDeleteAzureLandingZoneJobResult() + .jobReport(MapperUtils.JobReportMapper.from(jobResult.getJobReport())) + .errorReport(MapperUtils.ErrorReportMapper.from(jobResult.getApiErrorReport())); + + if (jobResult.getJobReport().getStatus().equals(JobReport.StatusEnum.SUCCEEDED)) { + apiJobResult.landingZoneId(jobResult.getResult().landingZoneId()); + apiJobResult.resources(jobResult.getResult().deleteResources()); + } + return apiJobResult; + } + + private ApiDeleteAzureLandingZoneResult toApiDeleteAzureLandingZoneResult( + LandingZoneJobService.AsyncJobResult jobResult) { + return new ApiDeleteAzureLandingZoneResult() + .jobReport(MapperUtils.JobReportMapper.from(jobResult.getJobReport())) + .errorReport(MapperUtils.ErrorReportMapper.from(jobResult.getApiErrorReport())) + .landingZoneId(jobResult.getResult().landingZoneId()); + } + + private ApiAzureLandingZoneDeployedResource toApiAzureLandingZoneDeployedResource( + LandingZoneResource resource, LandingZonePurpose purpose) { + if (purpose.getClass().equals(ResourcePurpose.class)) { + return new ApiAzureLandingZoneDeployedResource() + .resourceId(resource.resourceId()) + .resourceType(resource.resourceType()) + .tags(resource.tags()) + .region(resource.region()); + } + if (purpose.getClass().equals(SubnetResourcePurpose.class)) { + return new ApiAzureLandingZoneDeployedResource() + .resourceParentId(resource.resourceParentId().orElse(null)) // Only available for subnets + .resourceName(resource.resourceName().orElse(null)) // Only available for subnets + .resourceType(resource.resourceType()) + .resourceId(resource.resourceId()) + .tags(resource.tags()) + .region(resource.region()); + } + throw new LandingZoneUnsupportedPurposeException( + String.format( + "Support for purpose type %s is not implemented.", purpose.getClass().getSimpleName())); + } + + private ApiResourceQuota toApiResourceQuota(UUID landingZoneId, ResourceQuota resourceQuota) { + return new ApiResourceQuota() + .landingZoneId(landingZoneId) + .azureResourceId(resourceQuota.resourceId()) + .resourceType(resourceQuota.resourceType()) + .quotaValues(resourceQuota.quota()); + } +} diff --git a/service/src/main/java/bio/terra/lz/futureservice/app/service/exception/LandingZoneInvalidInputException.java b/service/src/main/java/bio/terra/lz/futureservice/app/service/exception/LandingZoneInvalidInputException.java new file mode 100644 index 000000000..394423c1f --- /dev/null +++ b/service/src/main/java/bio/terra/lz/futureservice/app/service/exception/LandingZoneInvalidInputException.java @@ -0,0 +1,9 @@ +package bio.terra.lz.futureservice.app.service.exception; + +import bio.terra.common.exception.BadRequestException; + +public class LandingZoneInvalidInputException extends BadRequestException { + public LandingZoneInvalidInputException(String message) { + super(message); + } +} diff --git a/service/src/main/java/bio/terra/lz/futureservice/app/service/exception/LandingZoneUnsupportedPurposeException.java b/service/src/main/java/bio/terra/lz/futureservice/app/service/exception/LandingZoneUnsupportedPurposeException.java new file mode 100644 index 000000000..8d9255685 --- /dev/null +++ b/service/src/main/java/bio/terra/lz/futureservice/app/service/exception/LandingZoneUnsupportedPurposeException.java @@ -0,0 +1,9 @@ +package bio.terra.lz.futureservice.app.service.exception; + +import bio.terra.common.exception.NotImplementedException; + +public class LandingZoneUnsupportedPurposeException extends NotImplementedException { + public LandingZoneUnsupportedPurposeException(String message) { + super(message); + } +} diff --git a/service/src/main/java/bio/terra/lz/futureservice/common/utils/MapperUtils.java b/service/src/main/java/bio/terra/lz/futureservice/common/utils/MapperUtils.java new file mode 100644 index 000000000..32ff79cef --- /dev/null +++ b/service/src/main/java/bio/terra/lz/futureservice/common/utils/MapperUtils.java @@ -0,0 +1,66 @@ +package bio.terra.lz.futureservice.common.utils; + +import bio.terra.landingzone.job.model.ErrorReport; +import bio.terra.landingzone.job.model.JobReport; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneParameter; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class MapperUtils { + public static class LandingZoneMapper { + private LandingZoneMapper() {} + + public static HashMap landingZoneParametersFrom( + List parametersList) { + return nullSafeListToStream(parametersList) + .flatMap(Stream::ofNullable) + .collect( + Collectors.toMap( + ApiAzureLandingZoneParameter::getKey, + ApiAzureLandingZoneParameter::getValue, + (prev, next) -> prev, + HashMap::new)); + } + + private static Stream nullSafeListToStream(Collection collection) { + return Optional.ofNullable(collection).stream().flatMap(Collection::stream); + } + } + + public static class JobReportMapper { + private JobReportMapper() {} + + public static bio.terra.lz.futureservice.generated.model.ApiJobReport from( + JobReport jobReport) { + return new bio.terra.lz.futureservice.generated.model.ApiJobReport() + .id(jobReport.getId()) + .description(jobReport.getDescription()) + .status( + bio.terra.lz.futureservice.generated.model.ApiJobReport.StatusEnum.valueOf( + jobReport.getStatus().toString())) + .statusCode(jobReport.getStatusCode()) + .submitted(jobReport.getSubmitted()) + .completed(jobReport.getCompleted()) + .resultURL(jobReport.getResultURL()); + } + } + + public static class ErrorReportMapper { + private ErrorReportMapper() {} + + public static bio.terra.lz.futureservice.generated.model.ApiErrorReport from( + ErrorReport errorReport) { + if (errorReport == null) { + return null; + } + return new bio.terra.lz.futureservice.generated.model.ApiErrorReport() + .message(errorReport.getMessage()) + .statusCode(errorReport.getStatusCode()) + .causes(errorReport.getCauses()); + } + } +} diff --git a/service/src/main/resources/api/service_openapi.yaml b/service/src/main/resources/api/service_openapi.yaml new file mode 100644 index 000000000..b04bbb960 --- /dev/null +++ b/service/src/main/resources/api/service_openapi.yaml @@ -0,0 +1,695 @@ +openapi: 3.0.3 +info: + title: Terra Landing Zone Service + description: | + Manages landing zones + version: 0.1.0 + +security: + - bearerAuth: [] + +paths: +# /status: +# get: +# security: [] +# summary: Returns the operational status of the service. +# operationId: serviceStatus +# tags: [ public ] +# responses: +# '200': +# $ref: '#/components/responses/StatusResponse' +# '500': +# $ref: '#/components/responses/StatusResponse' + + /version: + get: + security: [ ] + summary: Returns the deployed version of the service. + operationId: serviceVersion + tags: [ public ] + responses: + '200': + description: System version response + content: + application/json: + schema: + $ref: '#/components/schemas/SystemVersion' + + /api/landingzones/v1/azure: + post: + summary: Starts an async job to create an Azure landing zone + operationId: createAzureLandingZone + tags: [ LandingZones ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAzureLandingZoneRequestBody' + responses: + '200': + $ref: '#/components/responses/CreateLandingZoneResponse' + '202': + $ref: '#/components/responses/CreateLandingZoneResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/PermissionDenied' + '500': + $ref: '#/components/responses/ServerError' + get: + parameters: + - $ref: '#/components/parameters/BillingProfileId' + summary: List Azure landing zones available to user + operationId: listAzureLandingZones + tags: [ LandingZones ] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AzureLandingZoneList' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/ServerError' + + /api/landingzones/v1/azure/create-result/{jobId}: + parameters: + - $ref: '#/components/parameters/JobId' + get: + summary: Get the status of a async job to create an Azure Landing Zone + operationId: getCreateAzureLandingZoneResult + tags: [ LandingZones ] + responses: + '200': + $ref: '#/components/responses/CreateLandingZoneJobResponse' + '202': + $ref: '#/components/responses/CreateLandingZoneJobResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/PermissionDenied' + '500': + $ref: '#/components/responses/ServerError' + + /api/landingzones/v1/azure/{landingZoneId}/delete-result/{jobId}: + parameters: + - $ref: '#/components/parameters/LandingZoneId' + - $ref: '#/components/parameters/JobId' + get: + summary: Get the result of a async job to delete the Azure Landing Zone + operationId: getDeleteAzureLandingZoneResult + tags: [ LandingZones ] + responses: + '200': + $ref: '#/components/responses/DeleteAzureLandingZoneJobResponse' + '202': + $ref: '#/components/responses/DeleteAzureLandingZoneJobResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/PermissionDenied' + '500': + $ref: '#/components/responses/ServerError' + + /api/landingzones/definitions/v1/azure: + get: + summary: List all Azure landing zones definitions + operationId: listAzureLandingZonesDefinitions + tags: [ LandingZones ] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AzureLandingZoneDefinitionList' + '500': + $ref: '#/components/responses/ServerError' + + /api/landingzones/v1/azure/{landingZoneId}: + parameters: + - $ref: '#/components/parameters/LandingZoneId' + post: + summary: | + Starts an async job to delete an existing Azure landing zone. OpenAPI + does not support request body in DELETE, but async state requires it. + Hence this is a POST. + operationId: deleteAzureLandingZone + tags: [ LandingZones ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteAzureLandingZoneRequestBody' + responses: + '200': + $ref: '#/components/responses/DeleteAzureLandingZoneResponse' + '202': + $ref: '#/components/responses/DeleteAzureLandingZoneResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/PermissionDenied' + '500': + $ref: '#/components/responses/ServerError' + get: + summary: Get Azure landing zone + operationId: getAzureLandingZone + tags: [ LandingZones ] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AzureLandingZone' + '403': + $ref: '#/components/responses/PermissionDenied' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + + /api/landingzones/v1/azure/{landingZoneId}/resources: + parameters: + - $ref: '#/components/parameters/LandingZoneId' + get: + deprecated: true + summary: | + List all Azure landing zones resources. + Deprecated in favor of the workspace-level landing zone resource endpoint. + operationId: listAzureLandingZoneResources + tags: [ LandingZones ] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AzureLandingZoneResourcesList' + '500': + $ref: '#/components/responses/ServerError' + + /api/landingzones/v1/azure/{landingZoneId}/resource-quota: + parameters: + - $ref: '#/components/parameters/LandingZoneId' + - $ref: '#/components/parameters/AzureResourceId' + get: + deprecated: true + summary: | + Get the quota information of a resource an Azure Landing Zone. + Deprecated in favor of the workspace-level landing zone resource endpoint. + operationId: getResourceQuotaResult + tags: [ LandingZones ] + responses: + '200': + $ref: '#/components/responses/ResourceQuotaResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/PermissionDenied' + '500': + $ref: '#/components/responses/ServerError' + +components: +# responses: +# StatusResponse: +# description: common status response +# content: +# application/json: +# schema: +# $ref: '#/components/schemas/SystemStatus' + parameters: + JobId: + name: jobId + in: path + description: A String ID to used to identify a job + required: true + schema: + type: string + + LandingZoneId: + name: landingZoneId + in: path + description: A string to identify an Azure landing zone. + required: true + schema: + type: string + format: uuid + + BillingProfileId: + name: billingProfileId + in: query + description: A string to identify an Azure billing profile. + required: false + schema: + type: string + format: uuid + + AzureResourceId: + name: azureResourceId + in: query + description: The fully qualified ID of the Azure resource, including the resource name and resource type. + Use the format, /subscriptions/{guid}/resourceGroups/{resource-group-name}/{resource-provider-namespace}/{resource-type}/{resource-name}. + required: true + schema: + type: string + + responses: + CreateLandingZoneResponse: + description: Response to starting an async job to create an Azure landing zone. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateLandingZoneResult' + + CreateLandingZoneJobResponse: + description: Response to get the status of an async job to create an Azure landing zone. + content: + application/json: + schema: + $ref: '#/components/schemas/AzureLandingZoneResult' + + DeleteAzureLandingZoneJobResponse: + description: Response to get the status of an async job to delete an Azure landing zone. + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteAzureLandingZoneJobResult' + + DeleteAzureLandingZoneResponse: + description: Response to starting an async job to delete an Azure landing zone. + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteAzureLandingZoneResult' + + ResourceQuotaResponse: + description: Response to get the quota information of an Azure landing zone resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ResourceQuota' + + BadRequest: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReport' + + Conflict: + description: Request conflicts with current state + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReport' + + PermissionDenied: + description: Permission denied + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReport' + + ServerError: + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReport' + + NotFound: + description: Not found (or unauthorized) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorReport' + + schemas: + SystemStatus: + type: object + properties: + ok: + type: boolean + description: status of this service + systems: + type: object + additionalProperties: + type: object + properties: + ok: + type: boolean + messages: + type: array + items: + type: string + + SystemVersion: + type: object + required: [ gitTag, gitHash, github, build ] + properties: + gitTag: + type: string + description: Git tag of currently deployed app. + gitHash: + type: string + description: Git hash of currently deployed app. + github: + type: string + description: Github link to currently deployed commit. + build: + type: string + description: Version of the currently deployed app declared in build.gradle. Client and server versions are linked. + + CreateAzureLandingZoneRequestBody: + description: Payload for requesting a new Azure landing zone. + type: object + required: [ definition, billingProfileId ] + properties: + landingZoneId: + description: The ID of the landing zone (optional). If omitted an ID will be auto-generated. + type: string + format: uuid + definition: + description: A definition to create an Azure landing zone from + type: string + version: + description: | + A version of the landing zone. If not set the most recent will be used. + If two versions available - 'v1' and 'v2' then 'v2' will be selected. + type: string + parameters: + description: List of Azure landing zone parameters + type: array + items: + description: | + Parameters to set user defined properties for resources in a landing zone. + The parameters vary per landing zone definition. + Here is a list of some parameters - POSTGRES_SERVER_SKU, POSTGRESQL_SUBNET, VNET_ADDRESS_SPACE. + These are example of assigned values - POSTGRES_SERVER_SKU=GP_Gen5_2, POSTGRESQL_SUBNET=10.1.0.16/29 + $ref: '#/components/schemas/AzureLandingZoneParameter' + billingProfileId: + description: Identifier for the billing profile to be used for this landing zone. + type: string + format: uuid + jobControl: + $ref: '#/components/schemas/JobControl' + + AzureLandingZoneParameter: + description: Parameters to set user defined properties for resources in a landing zone + type: object + required: [ key, value ] + properties: + key: + description: Name of the parameter + type: string + value: + description: Value of the parameter + type: string + + CreateLandingZoneResult: + description: Result of starting an async job to create an Azure landing zone + type: object + properties: + landingZoneId: + description: An identifier of the Azure landing zone. + type: string + format: uuid + definition: + description: Requested landing zone definition. + type: string + version: + description: Requested version of the landing zone definition. + type: string + jobReport: + $ref: '#/components/schemas/JobReport' + errorReport: + $ref: '#/components/schemas/ErrorReport' + + AzureLandingZoneResult: + description: Result of creating Azure landing zone + type: object + properties: + landingZone: + $ref: '#/components/schemas/AzureLandingZoneDetails' + jobReport: + $ref: '#/components/schemas/JobReport' + errorReport: + $ref: '#/components/schemas/ErrorReport' + + AzureLandingZoneDetails: + description: Created Azure Landing Zone details. + type: object + properties: + id: + description: An identifier of created Azure landing zone. + type: string + format: uuid + resources: + description: List of Azure landing zone deployed resources. + type: array + items: + $ref: '#/components/schemas/AzureLandingZoneDeployedResource' + + AzureLandingZoneDeployedResource: + description: Details of an Azure resource. + type: object + properties: + resourceId: + description: Unique Azure resource identifier. + type: string + resourceType: + description: | + The azure deployed resource type; e.g., 'Microsoft.Compute/virtualMachines'. + The deployed resource type definition is located in ARM template documentation, under the Reference node. + type: string + resourceName: + description: Azure resource name. Present for a subnet resource. + type: string + resourceParentId: + description: Azure resource Id of a resource parent. Present for a subnet resource. + type: string + region: + description: A region where an Azure resource deployed. + type: string + tags: + description: Tags for this Azure resource. + type: object + additionalProperties: + type: string + + AzureLandingZoneList: + type: object + required: [ landingzones ] + properties: + landingzones: + description: A list of landing zones. + type: array + items: + $ref: '#/components/schemas/AzureLandingZone' + + AzureLandingZone: + description: | + The landing zone identification information. + type: object + required: [ landingZoneId, billingProfileId ] + properties: + landingZoneId: + description: An identifier of a Azure landing zone. + type: string + format: uuid + billingProfileId: + description: Identifier for the billing profile used for the landing zone. + type: string + format: uuid + definition: + description: A definition to create an Azure landing zone from + type: string + version: + description: | + A version of the landing zone. If not set the most recent will be used. + If two versions available - 'v1' and 'v2' then 'v2' will be selected. + type: string + createdDate: + description: | + A string containing date and time of Landing Zone creation. + It is set by the Landing Zone service and cannot be updated. + type: string + format: date-time + + DeleteAzureLandingZoneJobResult: + description: Result of delete job for an Azure landing zone + type: object + properties: + landingZoneId: + type: string + format: uuid + resources: + description: A list of resource IDs of the deleted resources. + type: array + items: + type: string + jobReport: + $ref: '#/components/schemas/JobReport' + errorReport: + $ref: '#/components/schemas/ErrorReport' + + AzureLandingZoneDefinitionList: + type: object + required: [ landingzones ] + properties: + landingzones: + description: A list of Azure landing zones definitions + type: array + items: + $ref: '#/components/schemas/AzureLandingZoneDefinition' + + AzureLandingZoneDefinition: + type: object + required: [ definition, name, description, version ] + properties: + definition: + description: The name of the corresponding landing zone definition + type: string + name: + description: User friendly name of the definition + type: string + description: + description: Description of the definition + type: string + version: + description: The version of the definition + type: string + + DeleteAzureLandingZoneRequestBody: + description: Payload for deleting an Azure landing zone. + type: object + required: [ jobControl ] + properties: + jobControl: + $ref: '#/components/schemas/JobControl' + + DeleteAzureLandingZoneResult: + description: Result of starting a job to delete an Azure landing zone + type: object + properties: + landingZoneId: + type: string + format: uuid + jobReport: + $ref: '#/components/schemas/JobReport' + errorReport: + $ref: '#/components/schemas/ErrorReport' + + AzureLandingZoneResourcesList: + type: object + required: [ resources ] + properties: + id: + description: An identifier of a Azure landing zone. + type: string + format: uuid + resources: + description: A list of deployed resources in a landing zone, grouped by purpose. + type: array + items: + $ref: '#/components/schemas/AzureLandingZoneResourcesPurposeGroup' + + AzureLandingZoneResourcesPurposeGroup: + description: | + The structure contains one landing zone purpose and a list of Azure deployed resources that + are tagged with this purpose. + type: object + required: [ purpose,deployedResources ] + properties: + purpose: + description: Purpose tag value string. + type: string + deployedResources: + description: A list of Azure landing zones deployed resources. + type: array + items: + $ref: '#/components/schemas/AzureLandingZoneDeployedResource' + + ResourceQuota: + description: Resource quota information of an Azure landing zone resource. + type: object + properties: + landingZoneId: + description: An identifier of the Azure landing zone. + type: string + format: uuid + azureResourceId: + description: The fully qualified ID of the Azure resource. + type: string + resourceType: + description: Azure resource type. + type: string + quotaValues: + description: A key-value pair of quota information values for the resource. + type: object + additionalProperties: true + + JobControl: + type: object + required: [ id ] + properties: + id: + description: >- + Unique identifier for the job. Best practice is for job identifier to be a UUID, + a ShortUUID, or other globally unique identifier. + type: string + + JobReport: + type: object + required: [ id, status, statusCode, resultURL ] + properties: + id: + description: caller-provided unique identifier for the job + type: string + description: + description: caller-provided description of the job + type: string + status: + description: status of the job + type: string + enum: [ 'RUNNING', 'SUCCEEDED', 'FAILED' ] + statusCode: + description: HTTP code providing status of the job. + type: integer + submitted: + description: timestamp when the job was submitted; in ISO-8601 format + type: string + completed: + description: >- + timestamp when the job completed - in ISO-8601 format. Present if + status is SUCCEEDED or FAILED. + type: string + resultURL: + description: >- + URL where the result of the job can be retrieved. Equivalent to a + Location header in HTTP. + type: string + + ErrorReport: + type: object + required: [ message, statusCode, causes ] + properties: + message: + type: string + statusCode: + type: integer + causes: + type: array + items: + type: string + + securitySchemes: + bearerAuth: + type: http + scheme: bearer \ No newline at end of file diff --git a/service/src/main/resources/api/swagger-ui.html b/service/src/main/resources/api/swagger-ui.html new file mode 100644 index 000000000..991706f20 --- /dev/null +++ b/service/src/main/resources/api/swagger-ui.html @@ -0,0 +1,98 @@ + + + + + + Swagger UI + + + + + + + +
+ + + + + + diff --git a/service/src/main/resources/application.yml b/service/src/main/resources/application.yml index 30da9a3dc..cc46f1520 100644 --- a/service/src/main/resources/application.yml +++ b/service/src/main/resources/application.yml @@ -1,2 +1,74 @@ +env: + db: + host: jdbc:postgresql://${DATABASE_HOSTNAME:127.0.0.1}:5432 + init: ${INIT_DB:false} + landingzone: + name: ${LANDINGZONE_DATABASE_NAME:landingzone_db} + pass: ${LANDINGZONE_DATABASE_USER_PASSWORD:landingzonepwd} + user: ${LANDINGZONE_DATABASE_USER:landingzoneuser} + landingzonestairway: + name: ${LANDINGZONE_STAIRWAY_DATABASE_NAME:landingzone_stairway_db} + pass: ${LANDINGZONE_STAIRWAY_DATABASE_USER_PASSWORD:landingzonestairwaypwd} + user: ${LANDINGZONE_STAIRWAY_DATABASE_USER:landingzonestairwayuser} + urls: # While we've traditionally thought of these as env specific and ok to hardcode, with kubernetes they may change + sam: ${SAM_ADDRESS:https://sam.dsde-dev.broadinstitute.org/} + bpm: ${BPM_ADDRESS:https://bpm.dsde-dev.broadinstitute.org/} + spring: - application.name: landingzone \ No newline at end of file + application.name: landingzone + web: + resources: + cache: + cachecontrol: + max-age: 0 + must-revalidate: true + use-last-modified: false + static-locations: classpath:/api/ + +landingzone: + landingzone-database: + initialize-on-start: ${env.db.init} + password: ${env.db.landingzone.pass} + upgrade-on-start: true + uri: ${env.db.host}/${env.db.landingzone.name} + username: ${env.db.landingzone.user} + landingzone-stairway-database: + initialize-on-start: ${env.db.init} + password: ${env.db.landingzonestairway.pass} + upgrade-on-start: true + uri: ${env.db.host}/${env.db.landingzonestairway.name} + username: ${env.db.landingzonestairway.user} + stairway: + cluster-name-suffix: landingzone-stairway + force-clean-start: false # ${env.db.init} + max-parallel-flights: 50 + migrate-upgrade: true + quiet-down-timeout: 30s + terminate-timeout: 30s + tracing-enabled: true + retention-check-interval: 1d + completed-flight-retention: 90d + sam: + base-path: ${env.urls.sam} + landing-zone-resource-users: + - workspace-dev@broad-dsde-dev.iam.gserviceaccount.com + - leonardo-dev@broad-dsde-dev.iam.gserviceaccount.com + bpm.base-path: ${env.urls.bpm} + protected-data: + long-term-storage-table-names: + - Alert + - AlertEvidence + - AlertInfo + - Anomalies + - AppTraces + - CommonSecurityLog + - ContainerLog + - ContainerLogV2 + - ContainerNodeInventory + - ContainerServiceLog + - Operation + - SecurityAlert + - SecurityIncident + - SentinelHealth + - StorageBlobLogs + - Syslog \ No newline at end of file diff --git a/service/src/test/java/bio/terra/lz/futureservice/LandingZoneApplicationTests.java b/service/src/test/java/bio/terra/lz/futureservice/app/LandingZoneApplicationTests.java similarity index 82% rename from service/src/test/java/bio/terra/lz/futureservice/LandingZoneApplicationTests.java rename to service/src/test/java/bio/terra/lz/futureservice/app/LandingZoneApplicationTests.java index eb5684061..ac4544442 100644 --- a/service/src/test/java/bio/terra/lz/futureservice/LandingZoneApplicationTests.java +++ b/service/src/test/java/bio/terra/lz/futureservice/app/LandingZoneApplicationTests.java @@ -1,4 +1,4 @@ -package bio.terra.lz.futureservice; +package bio.terra.lz.futureservice.app; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; diff --git a/service/src/test/java/bio/terra/lz/futureservice/app/controller/GlobalExceptionHandler.java b/service/src/test/java/bio/terra/lz/futureservice/app/controller/GlobalExceptionHandler.java new file mode 100644 index 000000000..a7a427ada --- /dev/null +++ b/service/src/test/java/bio/terra/lz/futureservice/app/controller/GlobalExceptionHandler.java @@ -0,0 +1,39 @@ +package bio.terra.lz.futureservice.app.controller; + +import bio.terra.common.exception.AbstractGlobalExceptionHandler; +import bio.terra.lz.futureservice.generated.model.ApiErrorReport; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@RestControllerAdvice +public class GlobalExceptionHandler extends AbstractGlobalExceptionHandler { + @Override + public ApiErrorReport generateErrorReport( + Throwable ex, HttpStatus statusCode, List causes) { + return new ApiErrorReport() + .message(ex.getMessage()) + .statusCode(statusCode.value()) + .causes(causes); + } + + @Override + @ExceptionHandler({ + MethodArgumentNotValidException.class, + MethodArgumentTypeMismatchException.class, + MissingServletRequestParameterException.class + }) + public ResponseEntity validationExceptionHandler(Exception ex) { + var errorReport = + new ApiErrorReport() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .message("Invalid request " + ex.getClass().getSimpleName()); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorReport); + } +} diff --git a/service/src/test/java/bio/terra/lz/futureservice/app/controller/LandingZoneApiControllerTest.java b/service/src/test/java/bio/terra/lz/futureservice/app/controller/LandingZoneApiControllerTest.java new file mode 100644 index 000000000..c00e4f30e --- /dev/null +++ b/service/src/test/java/bio/terra/lz/futureservice/app/controller/LandingZoneApiControllerTest.java @@ -0,0 +1,423 @@ +package bio.terra.lz.futureservice.app.controller; + +import static bio.terra.lz.futureservice.common.utils.MockMvcUtils.USER_REQUEST; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import bio.terra.common.exception.ConflictException; +import bio.terra.common.exception.ForbiddenException; +import bio.terra.lz.futureservice.app.service.LandingZoneAppService; +import bio.terra.lz.futureservice.common.fixture.AzureLandingZoneFixtures; +import bio.terra.lz.futureservice.common.utils.MockMvcUtils; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZone; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneDefinition; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneDefinitionList; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneList; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneResourcesList; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneResult; +import bio.terra.lz.futureservice.generated.model.ApiCreateAzureLandingZoneRequestBody; +import bio.terra.lz.futureservice.generated.model.ApiCreateLandingZoneResult; +import bio.terra.lz.futureservice.generated.model.ApiDeleteAzureLandingZoneJobResult; +import bio.terra.lz.futureservice.generated.model.ApiDeleteAzureLandingZoneRequestBody; +import bio.terra.lz.futureservice.generated.model.ApiDeleteAzureLandingZoneResult; +import bio.terra.lz.futureservice.generated.model.ApiJobReport; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; +import org.apache.http.HttpStatus; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +@SpringBootTest +@AutoConfigureMockMvc +public class LandingZoneApiControllerTest { + private static final String AZURE_LANDING_ZONE_PATH = "/api/landingzones/v1/azure"; + private static final String GET_CREATE_AZURE_LANDING_ZONE_RESULT = + "/api/landingzones/v1/azure/create-result"; + private static final String LIST_AZURE_LANDING_ZONES_DEFINITIONS_PATH = + "/api/landingzones/definitions/v1/azure"; + private static final String JOB_ID = "newJobId"; + private static final UUID LANDING_ZONE_ID = UUID.randomUUID(); + private static final UUID BILLING_PROFILE_ID = UUID.randomUUID(); + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockBean LandingZoneAppService mockLandingZoneAppService; + + @Test + public void createAzureLandingZoneJobRunning() throws Exception { + ApiCreateLandingZoneResult asyncJobResult = + AzureLandingZoneFixtures.buildApiCreateLandingZoneSuccessResult(JOB_ID); + ApiCreateAzureLandingZoneRequestBody requestBody = + AzureLandingZoneFixtures.buildCreateAzureLandingZoneRequest(JOB_ID, BILLING_PROFILE_ID); + + when(mockLandingZoneAppService.createAzureLandingZone(any(), any(), any())) + .thenReturn(asyncJobResult); + + mockMvc + .perform( + MockMvcUtils.addAuth( + post(AZURE_LANDING_ZONE_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody)) + .characterEncoding("utf-8"), + USER_REQUEST)) + .andExpect(status().is(HttpStatus.SC_ACCEPTED)) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport.id").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport.id", Matchers.is(JOB_ID))) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingZone").doesNotExist()); + } + + @Test + public void createAzureLandingZoneWithoutDefinitionValidationFailed() throws Exception { + ApiCreateLandingZoneResult asyncJobResult = + AzureLandingZoneFixtures.buildApiCreateLandingZoneSuccessResult(JOB_ID); + ApiCreateAzureLandingZoneRequestBody requestBody = + AzureLandingZoneFixtures.buildCreateAzureLandingZoneRequestWithoutDefinition(JOB_ID); + + when(mockLandingZoneAppService.createAzureLandingZone(any(), any(), any())) + .thenReturn(asyncJobResult); + + mockMvc + .perform( + MockMvcUtils.addAuth( + post(AZURE_LANDING_ZONE_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody)) + .characterEncoding("utf-8"), + USER_REQUEST)) + .andExpect(status().is(HttpStatus.SC_BAD_REQUEST)); + } + + @Test + public void createAzureLandingZoneWithoutBillingProfileValidationFailed() throws Exception { + ApiCreateLandingZoneResult asyncJobResult = + AzureLandingZoneFixtures.buildApiCreateLandingZoneSuccessResult(JOB_ID); + ApiCreateAzureLandingZoneRequestBody requestBody = + AzureLandingZoneFixtures.buildCreateAzureLandingZoneRequestWithoutBillingProfile(JOB_ID); + + when(mockLandingZoneAppService.createAzureLandingZone(any(), any(), any())) + .thenReturn(asyncJobResult); + + mockMvc + .perform( + MockMvcUtils.addAuth( + post(AZURE_LANDING_ZONE_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody)) + .characterEncoding("utf-8"), + USER_REQUEST)) + .andExpect(status().is(HttpStatus.SC_BAD_REQUEST)); + } + + @Test + public void getCreateAzureLandingZoneResultJobRunning() throws Exception { + ApiAzureLandingZoneResult asyncJobResult = + AzureLandingZoneFixtures.buildApiAzureLandingZoneResult( + JOB_ID, ApiJobReport.StatusEnum.RUNNING); + + when(mockLandingZoneAppService.getCreateAzureLandingZoneResult(any(), any())) + .thenReturn(asyncJobResult); + + mockMvc + .perform( + MockMvcUtils.addAuth( + get(GET_CREATE_AZURE_LANDING_ZONE_RESULT + "/{jobId}", JOB_ID), USER_REQUEST)) + .andExpect(status().is(HttpStatus.SC_ACCEPTED)) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport.id").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport.id", Matchers.is(JOB_ID))) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingZone").doesNotExist()); + } + + @Test + public void getCreateAzureLandingZoneResultJobSucceeded() throws Exception { + ApiAzureLandingZoneResult asyncJobResult = + AzureLandingZoneFixtures.buildApiAzureLandingZoneResult( + JOB_ID, LANDING_ZONE_ID, ApiJobReport.StatusEnum.SUCCEEDED); + + when(mockLandingZoneAppService.getCreateAzureLandingZoneResult(any(), any())) + .thenReturn(asyncJobResult); + + mockMvc + .perform( + MockMvcUtils.addAuth( + get(GET_CREATE_AZURE_LANDING_ZONE_RESULT + "/{jobId}", JOB_ID), USER_REQUEST)) + .andExpect(status().is(HttpStatus.SC_OK)) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport.id").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingZone").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingZone.id").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport.id", Matchers.is(JOB_ID))) + .andExpect( + MockMvcResultMatchers.jsonPath( + "$.landingZone.id", Matchers.is(LANDING_ZONE_ID.toString()))); + } + + @Test + public void listAzureLandingZoneDefinitionsSuccess() throws Exception { + ApiAzureLandingZoneDefinitionList definitionList = + new ApiAzureLandingZoneDefinitionList() + .landingzones( + List.of( + new ApiAzureLandingZoneDefinition() + .definition("fooDefinition") + .name("fooName") + .description("fooDescription") + .version("v1"), + new ApiAzureLandingZoneDefinition() + .definition("fooDefinition") + .name("fooName") + .description("fooDescription") + .version("v2"), + new ApiAzureLandingZoneDefinition() + .definition("barDefinition") + .name("barName") + .description("barDescription") + .version("v1"))); + when(mockLandingZoneAppService.listAzureLandingZonesDefinitions(any())) + .thenReturn(definitionList); + + mockMvc + .perform(MockMvcUtils.addAuth(get(LIST_AZURE_LANDING_ZONES_DEFINITIONS_PATH), USER_REQUEST)) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingzones").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingzones").isArray()) + .andExpect( + MockMvcResultMatchers.jsonPath( + "$.landingzones", hasSize(definitionList.getLandingzones().size()))); + } + + @Test + public void deleteAzureLandingZoneSuccess() throws Exception { + ApiDeleteAzureLandingZoneRequestBody requestBody = + AzureLandingZoneFixtures.buildDeleteAzureLandingZoneRequest(JOB_ID); + + ApiDeleteAzureLandingZoneResult asyncJobResult = + AzureLandingZoneFixtures.buildApiDeleteAzureLandingZoneResult( + JOB_ID, ApiJobReport.StatusEnum.RUNNING, LANDING_ZONE_ID); + when(mockLandingZoneAppService.deleteLandingZone(any(), any(), any(), any())) + .thenReturn(asyncJobResult); + + mockMvc + .perform( + MockMvcUtils.addAuth( + post(AZURE_LANDING_ZONE_PATH + "/{landingZoneId}", LANDING_ZONE_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody)) + .characterEncoding("utf-8"), + USER_REQUEST)) + .andExpect(status().is(HttpStatus.SC_ACCEPTED)) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport.id").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingZoneId").exists()) + .andExpect( + MockMvcResultMatchers.jsonPath("$.landingZoneId", equalTo(LANDING_ZONE_ID.toString()))); + } + + @ParameterizedTest + @MethodSource("getDeleteAzureLandingZoneResultScenario") + public void getDeleteAzureLandingZoneResultSuccess( + ApiJobReport.StatusEnum jobStatus, + int expectedHttpStatus, + ResultMatcher landingZoneMatcher, + ResultMatcher resourcesMatcher) + throws Exception { + ApiDeleteAzureLandingZoneJobResult asyncJobResult = + AzureLandingZoneFixtures.buildApiDeleteAzureLandingZoneJobResult( + JOB_ID, LANDING_ZONE_ID, jobStatus); + when(mockLandingZoneAppService.getDeleteAzureLandingZoneResult(any(), any(), any())) + .thenReturn(asyncJobResult); + + mockMvc + .perform( + MockMvcUtils.addAuth( + get( + AZURE_LANDING_ZONE_PATH + "/{landingZoneId}/delete-result/{jobId}", + LANDING_ZONE_ID, + JOB_ID) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding("utf-8"), + USER_REQUEST)) + .andExpect(status().is(expectedHttpStatus)) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.jobReport.id").exists()) + .andExpect(landingZoneMatcher) + .andExpect(resourcesMatcher); + } + + @Test + void listAzureLandingZoneResourcesSuccess() throws Exception { + ApiAzureLandingZoneResourcesList groupedResources = + AzureLandingZoneFixtures.buildListLandingZoneResourcesByPurposeResult(LANDING_ZONE_ID); + when(mockLandingZoneAppService.listAzureLandingZoneResources(any(), any())) + .thenReturn(groupedResources); + mockMvc + .perform( + MockMvcUtils.addAuth( + get(AZURE_LANDING_ZONE_PATH + "/{landingZoneId}/resources", LANDING_ZONE_ID), + USER_REQUEST)) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.id").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.id", Matchers.is(LANDING_ZONE_ID.toString()))) + .andExpect(MockMvcResultMatchers.jsonPath("$.resources").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.resources").isArray()) + .andExpect( + MockMvcResultMatchers.jsonPath( + "$.resources[0].purpose", Matchers.in(List.of("sharedResources", "lzResources")))) + .andExpect(MockMvcResultMatchers.jsonPath("$.resources[0].deployedResources").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.resources[0].deployedResources").isArray()); + } + + @Test + void listAzureLandingZoneResourcesNoResourcesSuccess() throws Exception { + ApiAzureLandingZoneResourcesList groupedResources = + AzureLandingZoneFixtures.buildEmptyListLandingZoneResourcesByPurposeResult(LANDING_ZONE_ID); + + when(mockLandingZoneAppService.listAzureLandingZoneResources(any(), any())) + .thenReturn(groupedResources); + mockMvc + .perform( + MockMvcUtils.addAuth( + get(AZURE_LANDING_ZONE_PATH + "/{landingZoneId}/resources", LANDING_ZONE_ID), + USER_REQUEST)) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.id").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.resources").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.resources").isArray()); + } + + @Test + void getAzureLandingZoneByLandingZoneIdSuccess() throws Exception { + var lzCreateDate = Instant.now().atOffset(ZoneOffset.UTC); + ApiAzureLandingZone landingZone = + AzureLandingZoneFixtures.buildDefaultApiAzureLandingZone( + LANDING_ZONE_ID, BILLING_PROFILE_ID, "definition", "version", lzCreateDate); + when(mockLandingZoneAppService.getAzureLandingZone(any(), eq(LANDING_ZONE_ID))) + .thenReturn(landingZone); + mockMvc + .perform( + MockMvcUtils.addAuth( + get(AZURE_LANDING_ZONE_PATH + "/{landingZoneId}", LANDING_ZONE_ID), USER_REQUEST)) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingZoneId").exists()) + .andExpect( + MockMvcResultMatchers.jsonPath("$.landingZoneId", equalTo(LANDING_ZONE_ID.toString()))) + .andExpect(MockMvcResultMatchers.jsonPath("$.definition").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.definition", equalTo("definition"))) + .andExpect(MockMvcResultMatchers.jsonPath("$.version").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.version", equalTo("version"))) + .andExpect(MockMvcResultMatchers.jsonPath("$.billingProfileId").exists()) + .andExpect( + MockMvcResultMatchers.jsonPath( + "$.billingProfileId", equalTo(BILLING_PROFILE_ID.toString()))) + .andExpect(MockMvcResultMatchers.jsonPath("$.createdDate").exists()) + .andExpect( + MockMvcResultMatchers.jsonPath("$.createdDate", equalTo(lzCreateDate.toString()))); + } + + @Test + void listAzureLandingZoneByBillingProfileIdSuccess() throws Exception { + var lzCreateDate = Instant.now().atOffset(ZoneOffset.UTC); + var landingZone = + AzureLandingZoneFixtures.buildDefaultApiAzureLandingZone( + LANDING_ZONE_ID, BILLING_PROFILE_ID, "definition", "version", lzCreateDate); + ApiAzureLandingZoneList landingZoneList = + new ApiAzureLandingZoneList().landingzones(List.of(landingZone)); + + when(mockLandingZoneAppService.listAzureLandingZones(any(), eq(BILLING_PROFILE_ID))) + .thenReturn(landingZoneList); + mockMvc + .perform( + MockMvcUtils.addAuth( + get( + AZURE_LANDING_ZONE_PATH + "?billingProfileId={billingProfileId}", + BILLING_PROFILE_ID), + USER_REQUEST)) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingzones").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingzones").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingzones[0].landingZoneId").exists()) + .andExpect( + MockMvcResultMatchers.jsonPath( + "$.landingzones[0].landingZoneId", equalTo(LANDING_ZONE_ID.toString()))) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingzones[0].billingProfileId").exists()) + .andExpect( + MockMvcResultMatchers.jsonPath( + "$.landingzones[0].billingProfileId", equalTo(BILLING_PROFILE_ID.toString()))) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingzones[0].definition").exists()) + .andExpect( + MockMvcResultMatchers.jsonPath("$.landingzones[0].definition", equalTo("definition"))) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingzones[0].version").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingzones[0].version", equalTo("version"))) + .andExpect(MockMvcResultMatchers.jsonPath("$.landingzones[0].createdDate").exists()) + .andExpect( + MockMvcResultMatchers.jsonPath( + "$.landingzones[0].createdDate", equalTo(lzCreateDate.toString()))); + } + + @Test + void listAzureLandingZoneByBillingProfileIdConflictResponse() throws Exception { + doThrow(new ConflictException("moreThanOneLandingZoneAssociated")) + .when(mockLandingZoneAppService) + .listAzureLandingZones(any(), eq(BILLING_PROFILE_ID)); + mockMvc + .perform( + MockMvcUtils.addAuth( + get( + AZURE_LANDING_ZONE_PATH + "?billingProfileId={billingProfileId}", + BILLING_PROFILE_ID), + USER_REQUEST)) + .andExpect(status().isConflict()); + } + + @Test + void getAzureLandingZoneByLandingZoneIdUserNotAuthorizedFailed() throws Exception { + doThrow(new ForbiddenException("User is not authorized to read Landing Zone")) + .when(mockLandingZoneAppService) + .getAzureLandingZone(any(), eq(LANDING_ZONE_ID)); + + mockMvc + .perform( + MockMvcUtils.addAuth( + get(AZURE_LANDING_ZONE_PATH + "/{landingZoneId}", LANDING_ZONE_ID), USER_REQUEST)) + .andExpect(status().isForbidden()); + } + + private static Stream getDeleteAzureLandingZoneResultScenario() { + return Stream.of( + Arguments.of( + ApiJobReport.StatusEnum.SUCCEEDED, + HttpStatus.SC_OK, + MockMvcResultMatchers.jsonPath("$.landingZoneId").exists(), + MockMvcResultMatchers.jsonPath("$.resources").exists()), + Arguments.of( + ApiJobReport.StatusEnum.RUNNING, + HttpStatus.SC_ACCEPTED, + MockMvcResultMatchers.jsonPath("$.landingZoneId").doesNotExist(), + MockMvcResultMatchers.jsonPath("$.resources").doesNotExist())); + } +} diff --git a/service/src/test/java/bio/terra/lz/futureservice/app/controller/common/iam/AuthenticatedUserRequest.java b/service/src/test/java/bio/terra/lz/futureservice/app/controller/common/iam/AuthenticatedUserRequest.java new file mode 100644 index 000000000..2372dbc77 --- /dev/null +++ b/service/src/test/java/bio/terra/lz/futureservice/app/controller/common/iam/AuthenticatedUserRequest.java @@ -0,0 +1,92 @@ +package bio.terra.lz.futureservice.app.controller.common.iam; + +import bio.terra.common.exception.ApiException; +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.Optional; +import java.util.UUID; + +public class AuthenticatedUserRequest { + public enum AuthType { + OIDC, + BEARER, + BASIC, + NONE + } + + private String email; + private String subjectId; + private Optional token; + private UUID reqId; + private AuthType authType; + + public AuthenticatedUserRequest() { + this.reqId = UUID.randomUUID(); + this.token = Optional.empty(); + } + + public AuthenticatedUserRequest(String email, String subjectId, Optional token) { + this.email = email; + this.subjectId = subjectId; + this.token = token; + } + + public String getSubjectId() { + return subjectId; + } + + public AuthenticatedUserRequest subjectId(String subjectId) { + this.subjectId = subjectId; + return this; + } + + public String getEmail() { + return email; + } + + public AuthenticatedUserRequest email(String email) { + this.email = email; + return this; + } + + public Optional getToken() { + return token; + } + + public AuthenticatedUserRequest token(Optional token) { + this.token = token; + return this; + } + + @JsonIgnore + public String getRequiredToken() { + return token.orElseThrow(() -> new ApiException("Token required")); + } + + public UUID getReqId() { + return reqId; + } + + public AuthenticatedUserRequest reqId(UUID reqId) { + this.reqId = reqId; + return this; + } + + public AuthType getAuthType() { + return authType; + } + + public AuthenticatedUserRequest authType(AuthType authType) { + this.authType = authType; + return this; + } + + @Override + public String toString() { + return String.format( + "AuthenticatedUserRequest%n\tEmail: %s%n\tSubject ID: %s%n\tToken: %s%n\tRequest ID: %s%n", + Optional.ofNullable(getEmail()).orElse("null"), + Optional.ofNullable(getSubjectId()).orElse("null"), + getToken().map(t -> "REDACTED (" + t.length() + " chars)").orElse("null"), + Optional.ofNullable(reqId).map(UUID::toString).orElse("null")); + } +} diff --git a/service/src/test/java/bio/terra/lz/futureservice/common/fixture/AzureLandingZoneFixtures.java b/service/src/test/java/bio/terra/lz/futureservice/common/fixture/AzureLandingZoneFixtures.java new file mode 100644 index 000000000..12e3b07b9 --- /dev/null +++ b/service/src/test/java/bio/terra/lz/futureservice/common/fixture/AzureLandingZoneFixtures.java @@ -0,0 +1,150 @@ +package bio.terra.lz.futureservice.common.fixture; + +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZone; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneDeployedResource; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneDetails; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneResourcesList; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneResourcesPurposeGroup; +import bio.terra.lz.futureservice.generated.model.ApiAzureLandingZoneResult; +import bio.terra.lz.futureservice.generated.model.ApiCreateAzureLandingZoneRequestBody; +import bio.terra.lz.futureservice.generated.model.ApiCreateLandingZoneResult; +import bio.terra.lz.futureservice.generated.model.ApiDeleteAzureLandingZoneJobResult; +import bio.terra.lz.futureservice.generated.model.ApiDeleteAzureLandingZoneRequestBody; +import bio.terra.lz.futureservice.generated.model.ApiDeleteAzureLandingZoneResult; +import bio.terra.lz.futureservice.generated.model.ApiErrorReport; +import bio.terra.lz.futureservice.generated.model.ApiJobControl; +import bio.terra.lz.futureservice.generated.model.ApiJobReport; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public class AzureLandingZoneFixtures { + private AzureLandingZoneFixtures() {} + + public static ApiCreateAzureLandingZoneRequestBody buildCreateAzureLandingZoneRequest( + String jobId, UUID billingProfileId) { + return new ApiCreateAzureLandingZoneRequestBody() + .billingProfileId(billingProfileId) + .jobControl(new ApiJobControl().id(jobId)) + .definition("azureLandingZoneDefinition") + .version("v1"); + } + + public static ApiCreateAzureLandingZoneRequestBody + buildCreateAzureLandingZoneRequestWithoutDefinition(String jobId) { + return new ApiCreateAzureLandingZoneRequestBody() + .billingProfileId(UUID.randomUUID()) + .jobControl(new ApiJobControl().id(jobId)) + .version("v1"); + } + + public static ApiCreateAzureLandingZoneRequestBody + buildCreateAzureLandingZoneRequestWithoutBillingProfile(String jobId) { + return new ApiCreateAzureLandingZoneRequestBody() + .jobControl(new ApiJobControl().id(jobId)) + .version("v1"); + } + + public static ApiCreateLandingZoneResult buildApiCreateLandingZoneSuccessResult(String jobId) { + var jobReport = buildApiJobReport(jobId, ApiJobReport.StatusEnum.RUNNING); + return new ApiCreateLandingZoneResult() + .jobReport(jobReport) + .landingZoneId(UUID.randomUUID()) + .definition("lzDefinition") + .version("lzVersion"); + } + + public static ApiDeleteAzureLandingZoneResult buildApiDeleteAzureLandingZoneResult( + String jobId, ApiJobReport.StatusEnum jobStatus, UUID landingZoneId) { + var jobReport = buildApiJobReport(jobId, jobStatus); + return new ApiDeleteAzureLandingZoneResult().landingZoneId(landingZoneId).jobReport(jobReport); + } + + public static ApiDeleteAzureLandingZoneRequestBody buildDeleteAzureLandingZoneRequest( + String jobId) { + return new ApiDeleteAzureLandingZoneRequestBody().jobControl(new ApiJobControl().id(jobId)); + } + + public static ApiDeleteAzureLandingZoneJobResult buildApiDeleteAzureLandingZoneJobResult( + String jobId, UUID landingZoneId, ApiJobReport.StatusEnum jobStatus) { + var jobReport = buildApiJobReport(jobId, jobStatus); + return switch (jobStatus) { + case SUCCEEDED -> new ApiDeleteAzureLandingZoneJobResult() + .jobReport(jobReport) + .landingZoneId(landingZoneId) + .resources(List.of("resource/id1", "resource/id2")); + case RUNNING -> new ApiDeleteAzureLandingZoneJobResult().jobReport(jobReport); + case FAILED -> new ApiDeleteAzureLandingZoneJobResult() + .jobReport(jobReport) + .errorReport(buildApiErrorReport(500)); + }; + } + + public static ApiAzureLandingZoneResourcesList buildListLandingZoneResourcesByPurposeResult( + UUID landingZoneId) { + var resourcePurposeGroups = + List.of( + new ApiAzureLandingZoneResourcesPurposeGroup() + .purpose("sharedResources") + .deployedResources( + List.of( + new ApiAzureLandingZoneDeployedResource() + .resourceName("subnet") + .resourceType("azure/subnet") + .resourceId("subnet1"))), + new ApiAzureLandingZoneResourcesPurposeGroup() + .purpose("lzResources") + .deployedResources( + List.of( + new ApiAzureLandingZoneDeployedResource() + .resourceName("sentinel") + .resourceType("azure/solution") + .resourceId("sentinel1")))); + return new ApiAzureLandingZoneResourcesList() + .id(landingZoneId) + .resources(resourcePurposeGroups); + } + + public static ApiAzureLandingZoneResourcesList buildEmptyListLandingZoneResourcesByPurposeResult( + UUID landingZoneId) { + return new ApiAzureLandingZoneResourcesList().id(landingZoneId); + } + + public static ApiAzureLandingZoneResult buildApiAzureLandingZoneResult( + String jobId, ApiJobReport.StatusEnum jobStatus) { + return new ApiAzureLandingZoneResult().jobReport(buildApiJobReport(jobId, jobStatus)); + } + + public static ApiAzureLandingZoneResult buildApiAzureLandingZoneResult( + String jobId, UUID landingZoneId, ApiJobReport.StatusEnum jobStatus) { + return new ApiAzureLandingZoneResult() + .jobReport(buildApiJobReport(jobId, jobStatus)) + .landingZone(buildApiAzureLandingZoneDetails(landingZoneId)); + } + + public static ApiAzureLandingZone buildDefaultApiAzureLandingZone( + UUID landingZoneId, + UUID billingProfileId, + String definition, + String version, + OffsetDateTime createDate) { + return new ApiAzureLandingZone() + .landingZoneId(landingZoneId) + .billingProfileId(billingProfileId) + .definition(definition) + .version(version) + .createdDate(createDate); + } + + private static ApiJobReport buildApiJobReport(String jobId, ApiJobReport.StatusEnum status) { + return new ApiJobReport().description("LZ creation").status(status).id(jobId); + } + + private static ApiErrorReport buildApiErrorReport(int statusCode) { + return new ApiErrorReport().statusCode(statusCode); + } + + private static ApiAzureLandingZoneDetails buildApiAzureLandingZoneDetails(UUID landingZoneId) { + return new ApiAzureLandingZoneDetails().id(landingZoneId); + } +} diff --git a/service/src/test/java/bio/terra/lz/futureservice/common/utils/MockMvcUtils.java b/service/src/test/java/bio/terra/lz/futureservice/common/utils/MockMvcUtils.java new file mode 100644 index 000000000..9a71439c6 --- /dev/null +++ b/service/src/test/java/bio/terra/lz/futureservice/common/utils/MockMvcUtils.java @@ -0,0 +1,19 @@ +package bio.terra.lz.futureservice.common.utils; + +import bio.terra.lz.futureservice.app.controller.common.iam.AuthenticatedUserRequest; +import java.util.Optional; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +public class MockMvcUtils { + public static final String DEFAULT_USER_EMAIL = "fake@user.com"; + public static final String DEFAULT_USER_SUBJECT_ID = "subjectId123456"; + + public static final AuthenticatedUserRequest USER_REQUEST = + new AuthenticatedUserRequest( + DEFAULT_USER_EMAIL, DEFAULT_USER_SUBJECT_ID, Optional.of("ThisIsNotARealBearerToken")); + + public static MockHttpServletRequestBuilder addAuth( + MockHttpServletRequestBuilder request, AuthenticatedUserRequest userRequest) { + return request.header("Authorization", "Bearer " + userRequest.getRequiredToken()); + } +} diff --git a/settings.gradle b/settings.gradle index 922adcb62..fd5a0a2ca 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,4 +3,5 @@ include('service','library', 'scripts', 'testharness') gradle.ext.releaseVersion = "0.0.231-SNAPSHOT" include 'testharness' +include 'client'