diff --git a/.changes/1.17.json b/.changes/1.17.json new file mode 100644 index 0000000000..9f6b4d4a30 --- /dev/null +++ b/.changes/1.17.json @@ -0,0 +1,35 @@ +{ + "date" : "2020-07-16", + "version" : "1.17", + "entries" : [ { + "type" : "feature", + "description" : "Wrap logstream entries when they are selected (#1863)" + }, { + "type" : "feature", + "description" : "Adding 'Outputs' tab to the CloudFormation Stack Viewer" + }, { + "type" : "feature", + "description" : "Support for SAM CLI version 1.x" + }, { + "type" : "feature", + "description" : "Add support for 2020.2" + }, { + "type" : "feature", + "description" : "Add word wrap to CloudFormation status reasons on selection (#1858)" + }, { + "type" : "bugfix", + "description" : "Fix CloudWatch Logs logstream scrolling up automatically in certain circumstances" + }, { + "type" : "bugfix", + "description" : "Change the way we stop SAM CLI processes when debugging to allow docker container to shut down" + }, { + "type" : "bugfix", + "description" : "Fix double clicking Cloud Formation node not opening the stack viewer" + }, { + "type" : "bugfix", + "description" : "Fix Cloud Formation event viewer not expanding as the window expands" + }, { + "type" : "bugfix", + "description" : "The project SDK is now passed as JAVA_HOME to SAM when building Java functions when not using the build in container option" + } ] +} \ No newline at end of file diff --git a/.changes/next-release/bugfix-a462f2ce-093b-44ad-bf57-d506e715121b.json b/.changes/next-release/bugfix-a462f2ce-093b-44ad-bf57-d506e715121b.json deleted file mode 100644 index 5c46c4d56c..0000000000 --- a/.changes/next-release/bugfix-a462f2ce-093b-44ad-bf57-d506e715121b.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "bugfix", - "description" : "Fix double clicking Cloud Formation node not opening the stack viewer" -} diff --git a/.changes/next-release/bugfix-cf15b891-593c-4493-b594-8fda9f30d031.json b/.changes/next-release/bugfix-cf15b891-593c-4493-b594-8fda9f30d031.json new file mode 100644 index 0000000000..21a0fdcbec --- /dev/null +++ b/.changes/next-release/bugfix-cf15b891-593c-4493-b594-8fda9f30d031.json @@ -0,0 +1,4 @@ +{ + "type" : "bugfix", + "description" : "Fix Rider building Lambda into incorrect folders" +} \ No newline at end of file diff --git a/.changes/next-release/bugfix-d2b78feb-6a6d-4e7f-81dc-af756d3d92a0.json b/.changes/next-release/bugfix-d2b78feb-6a6d-4e7f-81dc-af756d3d92a0.json new file mode 100644 index 0000000000..66996ce4b4 --- /dev/null +++ b/.changes/next-release/bugfix-d2b78feb-6a6d-4e7f-81dc-af756d3d92a0.json @@ -0,0 +1,4 @@ +{ + "type" : "bugfix", + "description" : "Fix several uncaught exceptions caused by plugins being installed but not enabled" +} \ No newline at end of file diff --git a/.changes/next-release/bugfix-eaed8849-efa2-4aa5-b236-57ce48eb595c.json b/.changes/next-release/bugfix-eaed8849-efa2-4aa5-b236-57ce48eb595c.json new file mode 100644 index 0000000000..4a17f11343 --- /dev/null +++ b/.changes/next-release/bugfix-eaed8849-efa2-4aa5-b236-57ce48eb595c.json @@ -0,0 +1,4 @@ +{ + "type" : "bugfix", + "description" : "Improve watching of the AWS profile files to incorporate changes made to the files outisde of the IDE" +} \ No newline at end of file diff --git a/.changes/next-release/feature-12cdfa65-4d7a-4c1e-9f61-04b388fce392.json b/.changes/next-release/feature-12cdfa65-4d7a-4c1e-9f61-04b388fce392.json new file mode 100644 index 0000000000..a30810e4f4 --- /dev/null +++ b/.changes/next-release/feature-12cdfa65-4d7a-4c1e-9f61-04b388fce392.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "Support colons (`:`) in credential profile names" +} \ No newline at end of file diff --git a/.changes/next-release/feature-201f0e9b-dfe2-4a97-ae98-87bfbbcede3d.json b/.changes/next-release/feature-201f0e9b-dfe2-4a97-ae98-87bfbbcede3d.json deleted file mode 100644 index 0ce807f1b8..0000000000 --- a/.changes/next-release/feature-201f0e9b-dfe2-4a97-ae98-87bfbbcede3d.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "feature", - "description" : "Adding 'Outputs' tab to the CloudFormation Stack Viewer" -} diff --git a/.changes/next-release/feature-f6a70036-5fd8-46f5-b70f-3841a04ca0c1.json b/.changes/next-release/feature-f6a70036-5fd8-46f5-b70f-3841a04ca0c1.json new file mode 100644 index 0000000000..b92cd98f57 --- /dev/null +++ b/.changes/next-release/feature-f6a70036-5fd8-46f5-b70f-3841a04ca0c1.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "Several enhancements to the UX around connecting to AWS including:\n- Making connection settings more visible (now visible in the AWS Explorer)\n- Automatically selecting 'default' profile if it exists\n- Better visibility of connection validation workflow (more information when unable to connect)\n- Handling of default regions on credential profile\n- Better UX around partitions\n- Adding ability to refresh connection from the UI" +} diff --git a/.run/Run IDE - Core [2020.2].run.xml b/.run/Run IDE - Core [2020.2].run.xml new file mode 100644 index 0000000000..cb5849e5be --- /dev/null +++ b/.run/Run IDE - Core [2020.2].run.xml @@ -0,0 +1,27 @@ + + + + + + + + + false + + + \ No newline at end of file diff --git a/.run/Run IDE - Rider [2020.2].run.xml b/.run/Run IDE - Rider [2020.2].run.xml new file mode 100644 index 0000000000..f39b43810c --- /dev/null +++ b/.run/Run IDE - Rider [2020.2].run.xml @@ -0,0 +1,27 @@ + + + + + + + + + false + + + \ No newline at end of file diff --git a/.run/Run IDE - Ultimate [2020.2].run.xml b/.run/Run IDE - Ultimate [2020.2].run.xml new file mode 100644 index 0000000000..b887740f2e --- /dev/null +++ b/.run/Run IDE - Ultimate [2020.2].run.xml @@ -0,0 +1,27 @@ + + + + + + + + + false + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b77a414c4..0dddcf3fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# _1.17_ (2020-07-16) +- **(Feature)** Wrap logstream entries when they are selected ([#1863](https://github.com/aws/aws-toolkit-jetbrains/issues/1863)) +- **(Feature)** Adding 'Outputs' tab to the CloudFormation Stack Viewer +- **(Feature)** Support for SAM CLI version 1.x +- **(Feature)** Add support for 2020.2 +- **(Feature)** Add word wrap to CloudFormation status reasons on selection ([#1858](https://github.com/aws/aws-toolkit-jetbrains/issues/1858)) +- **(Bug Fix)** Fix CloudWatch Logs logstream scrolling up automatically in certain circumstances +- **(Bug Fix)** Change the way we stop SAM CLI processes when debugging to allow docker container to shut down +- **(Bug Fix)** Fix double clicking Cloud Formation node not opening the stack viewer +- **(Bug Fix)** Fix Cloud Formation event viewer not expanding as the window expands +- **(Bug Fix)** The project SDK is now passed as JAVA_HOME to SAM when building Java functions when not using the build in container option + # _1.16_ (2020-05-27) - **(Breaking Change)** The toolkit now requires 2019.3 or newer - **(Feature)** Add support for GoLand, CLion, RubyMine, and PhpStorm diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f9e5793bb..e5d30349cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,13 +24,11 @@ reported the issue. Please try to include as much information as you can. Detail * [Java 11](https://docs.aws.amazon.com/corretto/latest/corretto-11-ug/downloads-list.html) * [Git](https://git-scm.com/) -* Dotnet Framework (Windows) or Mono (Linux, macOS) +* Dotnet Framework (Windows) * macOS steps: ``` - brew install mono brew cask install dotnet-sdk ``` - * Note: You can skip this if you do not want to build Rider support by adding `-PskipRider` to any Gradle command. ### Instructions @@ -104,11 +102,12 @@ To test your changes locally, you can run the project from IntelliJ or gradle. - Requires [`cfn-lint`](https://github.com/aws-cloudformation/cfn-python-lint/) CLI to be on your `$PATH`. - To run **GUI tests**: ``` - ./gradlew guiTest + ./gradlew uiTestCore ``` - To debug GUI tests, - 1. Set `runIde.debugOptions.enabled=true` in the gradle file. - 2. When prompted, attach your (IntelliJ) debugger to port 5005. + 1. Start the IDE that will be debugged `./gradlew :jetbrains-core:runIdeForUiTests --debug-jvm` + 2. In your running Intellij instance `Run -> Attach to process` attach to the ide test debug process. + 4. Run `./gradlew uiTestCore`. This will attach to the running debug IDE instance and run tests. ### Logging diff --git a/README.md b/README.md index 9f5ea37d8a..de7266e4e2 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Gitter](https://badges.gitter.im/aws/aws-toolkit-jetbrains.svg)](https://gitter.im/aws/aws-toolkit-jetbrains?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Downloads](https://img.shields.io/jetbrains/plugin/d/11349-aws-toolkit.svg)](https://plugins.jetbrains.com/plugin/11349-aws-toolkit) [![Version](https://img.shields.io/jetbrains/plugin/v/11349.svg?label=version)](https://plugins.jetbrains.com/plugin/11349-aws-toolkit) -[![LGTM Grade](https://img.shields.io/lgtm/grade/java/github/aws/aws-toolkit-jetbrains)](https://lgtm.com/projects/g/aws/aws-toolkit-jetbrains/) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=aws_aws-toolkit-jetbrains&metric=alert_status)](https://sonarcloud.io/dashboard?id=aws_aws-toolkit-jetbrains) # AWS Toolkit for JetBrains diff --git a/build.gradle b/build.gradle index 53ee4eebfc..b54eebb066 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import toolkits.gradle.changelog.tasks.GenerateGithubChangeLog import java.nio.file.Files import java.nio.file.Paths @@ -18,6 +19,10 @@ buildscript { } } +plugins { + id "de.undercouch.download" version "4.1.1" apply false +} + apply from: 'intellijJVersions.gradle' def ideVersion = shortenVersion(resolveIdeProfileName()) @@ -26,7 +31,6 @@ group 'software.aws.toolkits' // please check changelog generation logic if this format is changed version "$toolkitVersion-$ideVersion".toString() - repositories { maven { url "https://www.jetbrains.com/intellij-repository/snapshots/" } } @@ -52,11 +56,11 @@ allprojects { tasks.withType(org.jetbrains.intellij.tasks.RunIdeTask) { intellij { if (System.env.ALTERNATIVE_IDE) { - if(file(System.env.ALTERNATIVE_IDE).exists()) { + if (file(System.env.ALTERNATIVE_IDE).exists()) { alternativeIdePath = System.env.ALTERNATIVE_IDE } else { throw new GradleException("ALTERNATIVE_IDE path not found" - + (System.env.ALTERNATIVE_IDE ==~ /.*[\/\\] *$/ + + (System.env.ALTERNATIVE_IDE ==~ /.*[\/\\] *$/ ? " (HINT: remove trailing slash '/')" : ": ${System.env.ALTERNATIVE_IDE}")) } @@ -68,6 +72,7 @@ allprojects { runtimeClasspath.exclude group: "org.slf4j" runtimeClasspath.exclude group: "org.jetbrains.kotlin" runtimeClasspath.exclude group: "org.jetbrains.kotlinx" + runtimeClasspath.exclude group: "software.amazon.awssdk", module: "netty-nio-client" } } @@ -88,25 +93,17 @@ subprojects { apply plugin: 'java' apply plugin: 'idea' - apply plugin: 'signing' - - def isReleaseVersion = !version.endsWith("SNAPSHOT") - - signing { - required { isReleaseVersion && gradle.startParameter.taskNames.contains("publishPlugin") } - sign configurations.archives - } sourceSets { - main.java.srcDirs = ['src', "src-$ideVersion"] - main.resources.srcDirs = ['resources', "resources-$ideVersion"] - test.java.srcDirs = ['tst', "tst-$ideVersion"] - test.resources.srcDirs = ['tst-resources', "tst-resources-$ideVersion"] + main.java.srcDirs = SourceUtils.findFolders(project, "src", ideVersion) + main.resources.srcDirs = SourceUtils.findFolders(project, "resources", ideVersion) + test.java.srcDirs = SourceUtils.findFolders(project, "tst", ideVersion) + test.resources.srcDirs = SourceUtils.findFolders(project, "tst-resources", ideVersion) integrationTest { compileClasspath += main.output + test.output runtimeClasspath += main.output + test.output - java.srcDirs = ['it', "it-$ideVersion"] - resources.srcDirs = ['it-resources', "it-resources-$ideVersion"] + java.srcDirs = SourceUtils.findFolders(project, "it", ideVersion) + resources.srcDirs = SourceUtils.findFolders(project, "it-resources", ideVersion) } } @@ -121,6 +118,7 @@ subprojects { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockitoKotlinVersion" + testImplementation "org.mockito:mockito-core:$mockitoVersion" testImplementation "org.assertj:assertj-core:$assertjVersion" testImplementation "junit:junit:$junitVersion" } @@ -148,8 +146,8 @@ subprojects { idea { module { - sourceDirs += file("src-$ideVersion") - resourceDirs += file("resources-$ideVersion") + sourceDirs += sourceSets.main.java.srcDirs + resourceDirs += sourceSets.main.resources.srcDirs testSourceDirs += file("tst-$ideVersion") testResourceDirs += file("tst-resources-$ideVersion") @@ -182,6 +180,23 @@ subprojects { mustRunAfter tasks.test } + project.plugins.withId("org.jetbrains.intellij") { + downloadRobotServerPlugin.version = remoteRobotVersion + + tasks.withType(org.jetbrains.intellij.tasks.RunIdeForUiTestTask).all { + systemProperty "robot-server.port", remoteRobotPort + systemProperty "ide.mac.file.chooser.native", "false" + systemProperty "jb.consents.confirmation.enabled", "false" + // This does some magic in EndUserAgreement.java to make it not show the privacy policy + systemProperty "jb.privacy.policy.text", "" + if (System.getenv("CI") != null) { + systemProperty("aws.sharedCredentialsFile", "/tmp/.aws/credentials") + } + } + + jacoco.applyTo(runIdeForUiTests) + } + tasks.withType(KotlinCompile).all { kotlinOptions.jvmTarget = "1.8" } @@ -189,7 +204,7 @@ subprojects { // Force us to compile the integration tests even during check even though we don't run them check.dependsOn(integrationTestClasses) - task testJar (type: Jar) { + task testJar(type: Jar) { baseName = "${project.name}-test" from sourceSets.test.output from sourceSets.integrationTest.output @@ -201,12 +216,9 @@ subprojects { // Remove the tasks added in by gradle-intellij-plugin so that we don't publish/verify multiple times project.afterEvaluate { - removeTask(tasks, 'publishPlugin') - removeTask(tasks, 'verifyPlugin') - removeTask(tasks, 'buildSearchableOptions') - if (!isReleaseVersion) { - removeTask(tasks, "signArchives") - } + removeTask(tasks, org.jetbrains.intellij.tasks.PublishTask) + removeTask(tasks, org.jetbrains.intellij.tasks.VerifyPluginTask) + removeTask(tasks, org.jetbrains.intellij.tasks.BuildSearchableOptionsTask) } } @@ -214,16 +226,14 @@ configurations { ktlint } -def removeTask(tasks, taskName) { - def task = tasks.findByName("$taskName") - - if (task != null) { - task.setEnabled(false) +def removeTask(TaskContainer tasks, Class takeType) { + tasks.withType(takeType).configureEach { + setEnabled(false) } } apply plugin: 'org.jetbrains.intellij' -apply plugin: ChangeLogPlugin +apply plugin: 'toolkit-change-log' intellij { version ideSdkVersion("IC") @@ -233,21 +243,18 @@ intellij { } prepareSandbox { - tasks.findByPath(":jetbrains-rider:buildReSharperPlugin")?.collect { - from(it, { - into("${intellij.pluginName}/dotnet") - }) + tasks.findByPath(":jetbrains-rider:prepareSandbox")?.collect { + from(it) } } publishPlugin { token publishToken - channels publishChannel ? publishChannel.split(',').collect{ it.trim() } : [] + channels publishChannel ? publishChannel.split(',').collect { it.trim() } : [] } -generateChangeLog { - generateJetbrains = false - generateGithub = true +tasks.register('generateChangeLog', GenerateGithubChangeLog) { + changeLogFile = project.file("CHANGELOG.md") } task ktlint(type: JavaExec, group: "verification") { @@ -328,26 +335,15 @@ if (gradle.startParameter.taskNames.contains("runIde")) { println("Top level runIde selected, excluding sub-projects' runIde") gradle.taskGraph.whenReady { graph -> graph.allTasks.forEach { - if (gradle.startParameter.systemPropertiesArgs.getOrDefault("exec.args", "").contains("guitest")) { - if (it.name == "runIde" && - it.project != project(':jetbrains-core-gui')) { - it.enabled = false - } - } else { - if (it.name == "runIde" && - it.project != project(':jetbrains-core')) { - it.enabled = false - } + if (it.name == "runIde" && + it.project != project(':jetbrains-core')) { + it.enabled = false } } } } } -task guiTest(type: Test) { - dependsOn ":jetbrains-core-gui:guiTest" -} - dependencies { implementation project(':jetbrains-ultimate') project.findProject(':jetbrains-rider')?.collect { diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle deleted file mode 100644 index 7028fa81bc..0000000000 --- a/buildSrc/build.gradle +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -buildscript { - - def props = new Properties() - file("${project.projectDir.parent}/gradle.properties").withInputStream { - props.load(it) - } - props.each { key, value -> extensions."$key" = value } - - repositories { - mavenCentral() - jcenter() - maven { url "https://plugins.gradle.org/m2/" } - } - dependencies { - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" - } -} - -repositories { - mavenLocal() - mavenCentral() - jcenter() -} - -apply plugin: 'kotlin' - -sourceSets { - main.java.srcDir 'src' - test.java.srcDir 'tst' -} - -dependencies { - api "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion" - api "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" - api "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" - api "org.eclipse.jgit:org.eclipse.jgit:5.0.2.201807311906-r" - api "com.atlassian.commonmark:commonmark:0.11.0" - api "software.amazon.awssdk:codegen:$awsSdkVersion" - - testImplementation "org.assertj:assertj-core:$assertjVersion" - testImplementation "junit:junit:$junitVersion" - testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockitoKotlinVersion" -} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000000..f4d883d576 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,66 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + + +val jacksonVersion: String by project +val kotlinVersion: String by project +val awsSdkVersion: String by project + +val assertjVersion: String by project +val junitVersion: String by project +val mockitoVersion: String by project +val mockitoKotlinVersion: String by project + +buildscript { + // This has to be here otherwise properties are not loaded and nothing works + val props = java.util.Properties() + file("${project.projectDir.parent}/gradle.properties").inputStream().use { props.load(it) } + props.entries.forEach { it: Map.Entry -> project.extensions.add(it.key.toString(), it.value) } + + val kotlinVersion: String by project + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +repositories { + mavenLocal() + mavenCentral() + jcenter() +} + +plugins { + // TODO this really doesn't work. The plugin block requires a const string but the above + // hack we had in place to copy the properties also fixes this for now. + val kotlinVersion: String by project + kotlin("jvm") version kotlinVersion + `java-gradle-plugin` +} + +sourceSets { + main.get().java.srcDir("src") + test.get().java.srcDir("src") +} + +dependencies { + api("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion") + api("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") + api("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + api("org.eclipse.jgit:org.eclipse.jgit:5.0.2.201807311906-r") + api("com.atlassian.commonmark:commonmark:0.11.0") + api("software.amazon.awssdk:codegen:$awsSdkVersion") + + testImplementation("org.assertj:assertj-core:$assertjVersion") + testImplementation("junit:junit:$junitVersion") + testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:$mockitoKotlinVersion") + testImplementation("org.mockito:mockito-core:$mockitoVersion") +} + +gradlePlugin { + plugins { + create("changeLog") { + id = "toolkit-change-log" + implementationClass = "toolkits.gradle.changelog.ChangeLogPlugin" + } + } +} diff --git a/buildSrc/src/SourcesUtils.kt b/buildSrc/src/SourcesUtils.kt new file mode 100644 index 0000000000..d34e6e182a --- /dev/null +++ b/buildSrc/src/SourcesUtils.kt @@ -0,0 +1,50 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +@file:JvmName("SourceUtils") + +import org.gradle.api.Project +import java.io.FileFilter + +/** + * Determines the sub-folders under a project that should be included based on ideVersion + * + * [project] the project to use as a directory base + * [type] is the type of the source folder (e.g. 'src', 'tst', 'resources') + * [ideVersion] is the 3 digit numerical version of the JetBrains SDK (e.g. 192, 201 etc) + */ +fun findFolders(project: Project, type: String, ideVersion: String): List = project.projectDir.listFiles(FileFilter { + it.isDirectory && includeFolder(type, ideVersion, it.name) +})?.map { it.name } ?: emptyList() + +/** + * Determines if a folder should be included based on the ideVersion being targeted + * [type] is the type of the source folder (e.g. 'src', 'tst', 'resources') + * [ideVersion] is the 3 digit numerical version of the JetBrains SDK (e.g. 192, 201 etc) + * [folderName] is the folder name to match on, relative to the project directory (e.g. 'tst-201') + * + * Examples: + * Given [includeFolder] is called with a [type] of "tst" and an [ideVersion] of "201" + * + * Then following will match: + * - tst + * - tst-201 + * - tst-201+ + * - tst-192+ + * + * The following with *not* match: + * - tst-resources + * - tst-resources-201 + * - tst-192 + * - tst-202 + * - tst-202+ + */ +internal fun includeFolder(type: String, ideVersion: String, folderName: String): Boolean { + val ideVersionAsInt = ideVersion.toInt() + val match = "$type(-(\\d{3}))?(\\+)?".toRegex().matchEntire(folderName) ?: return false + val (_, version, plus) = match.destructured + return when { + version.isBlank() -> true + plus.isBlank() -> version.toInt() == ideVersionAsInt + else -> version.toInt() <= ideVersionAsInt + } +} diff --git a/buildSrc/src/Tasks.kt b/buildSrc/src/Tasks.kt deleted file mode 100644 index ed723fcc6a..0000000000 --- a/buildSrc/src/Tasks.kt +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -@file:Suppress("MemberVisibilityCanBePrivate") - -import ChangeLogPlugin.Companion.NAME -import org.gradle.api.DefaultTask -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.TaskAction -import toolkits.gradle.changelog.ChangeLogGenerator -import toolkits.gradle.changelog.ChangeLogWriter -import toolkits.gradle.changelog.ChangeType -import toolkits.gradle.changelog.Entry -import toolkits.gradle.changelog.GitStager -import toolkits.gradle.changelog.GithubWriter -import toolkits.gradle.changelog.JetBrainsWriter -import toolkits.gradle.changelog.MAPPER -import toolkits.gradle.changelog.ReleaseCreator -import java.io.File -import java.io.FileFilter -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.util.Scanner -import java.util.UUID - -/* ktlint-disable custom-ktlint-rules:log-not-lazy */ -class ChangeLogPlugin : Plugin { - override fun apply(project: Project) { - project.extensions.create(NAME, ChangeLogPluginExtension::class.java, project) - - project.tasks.create("generateChangeLog", GenerateChangeLog::class.java) { - it.description = "Generates CHANGELOG file from release entries" - } - - project.tasks.create("createRelease", CreateRelease::class.java).apply { - description = "Generates a release entry from unreleased changelog entries" - } - - project.tasks.create("newChange", NewChange::class.java).apply { - description = "Creates a new change entry for inclusion in the Change Log" - } - - project.tasks.create("newFeature", NewChange::class.java).apply { - description = "Creates a new feature change entry for inclusion in the Change Log" - defaultChangeType = ChangeType.FEATURE - } - - project.tasks.create("newBugFix", NewChange::class.java).apply { - description = "Creates a new bug-fix change entry for inclusion in the Change Log" - defaultChangeType = ChangeType.BUGFIX - } - } - - internal companion object { - const val NAME = "changeLog" - } -} - -open class ChangeLogPluginExtension(project: Project) { - var changesDirectory: File = project.rootProject.file(".changes") - var nextReleaseDirectory: File = changesDirectory.resolve("next-release") - var issuesUrl: String = "https://github.com/aws/aws-toolkit-jetbrains/issues" -} - -abstract class ChangeLogTask : DefaultTask() { - protected val git = GitStager.create(project.rootDir) - - @Input - @Optional - var issuesUrl: String? = configuration().issuesUrl - - @InputDirectory - var changesDirectory: File = configuration().changesDirectory - - var nextReleasePath: String = configuration().nextReleaseDirectory.absolutePath - - @InputDirectory - @Optional - var nextReleaseDirectory: File? = configuration().nextReleaseDirectory.takeIf { it.exists() } - - private fun configuration(): ChangeLogPluginExtension = project.rootProject.extensions.findByName(NAME) as ChangeLogPluginExtension -} - -open class NewChange : ChangeLogTask() { - internal var defaultChangeType: ChangeType? = null - - @TaskAction - fun create() { - val changeType = if (project.hasProperty("changeType")) { - (project.property("changeType") as? String?)?.toUpperCase()?.let { ChangeType.valueOf(it) } - } else defaultChangeType - val description = if (project.hasProperty("description")) { - project.property("description") as? String? - } else null - - val input = Scanner(System.`in`) - val file = when { - changeType != null && description != null -> createChange(changeType, description) - else -> promptForChange(input, changeType) - } - git?.stage(file) - } - - private fun promptForChange(input: Scanner, existingChangeType: ChangeType?): File { - val changeType = existingChangeType ?: promptForChangeType(input) - - logger.lifecycle("> Please enter a change description: ") - val description = input.nextLine() - - return createChange(changeType, description) - } - - private fun promptForChangeType(input: Scanner): ChangeType { - val changeList = ChangeType.values() - .mapIndexed { index, changeType -> "${index + 1}. ${changeType.sectionTitle}" } - .joinToString("\n") - val newFeatureIndex = ChangeType.FEATURE.ordinal + 1 - logger.lifecycle("\n$changeList\n> Please enter change type ($newFeatureIndex): ") - - return input.nextLine().let { - if (it.isNotBlank()) { - ChangeType.values()[it.toInt() - 1] - } else { - ChangeType.FEATURE - } - } - } - - private fun createChange(changeType: ChangeType, description: String) = newFile(changeType).apply { - MAPPER.writerWithDefaultPrettyPrinter().writeValue(this, - Entry(changeType, description) - ) - } - - private fun newFile(changeType: ChangeType): File = - File(nextReleasePath, "${changeType.name.toLowerCase()}-${UUID.randomUUID()}.json").apply { - parentFile?.mkdirs() - createNewFile() - } -} - -open class CreateRelease : ChangeLogTask() { - @Input - var releaseDate: String = DateTimeFormatter.ISO_DATE.format(LocalDate.now()) - - @Input - var releaseVersion: String = (project.version as String).substringBeforeLast('-') - - @OutputFile - fun releaseEntry(): File = File(changesDirectory, "$releaseVersion.json") - - @TaskAction - fun create() { - val releaseDate = DateTimeFormatter.ISO_DATE.parse(releaseDate).let { LocalDate.from(it) } - val creator = ReleaseCreator(nextReleaseEntries(), releaseEntry()) - creator.create(releaseVersion, releaseDate) - if (git != null) { - git.stage(releaseEntry()) - git.stage(File(nextReleasePath)) - } - } - - private fun nextReleaseEntries(): List = nextReleaseDirectory?.jsonFiles() ?: emptyList() -} - -open class GenerateChangeLog : ChangeLogTask() { - @Input - var includeUnreleased = project.hasProperty("includeUnreleased") - - @Input - var generateGithub = true - - @Input - var generateJetbrains = true - - @OutputFile - @Optional - var jetbrainsChangeNotesFile: File? = File("${project.buildDir}/resources/META-INF/change-notes.xml") - get() = if (generateJetbrains) field else null - - @OutputFile - @Optional - var githubChangeLogFile: File? = File(project.projectDir, "CHANGELOG.md") - get() = if (generateGithub) field else null - - @TaskAction - fun generate() { - val writers = createWriters() - val generator = ChangeLogGenerator(writers) - logger.info("Generating Changelog of types: $writers") - val unreleasedEntries = unreleasedEntries() - if (includeUnreleased) { - logger.info("Including ${unreleasedEntries.size} unreleased changes") - if (unreleasedEntries.isNotEmpty()) { - generator.addUnreleasedChanges(unreleasedEntries.map { it.toPath() }) - } - } else { - logger.info("Skipping unreleased changes") - } - - generator.addReleasedChanges(releaseEntries().map { it.toPath() }) - generator.close() - - githubChangeLogFile?.let { - git?.stage(it) - } - } - - private fun createWriters(): List { - val writers = mutableListOf() - githubChangeLogFile?.let { - val changeLog = it.apply { createNewFile() }.toPath() - writers.add(GithubWriter(changeLog, issuesUrl)) - } - jetbrainsChangeNotesFile?.let { - it.parentFile.mkdirs() - writers.add(JetBrainsWriter(it, issuesUrl)) - } - return writers.toList() - } - - private fun unreleasedEntries(): List = nextReleaseDirectory?.let { - if (includeUnreleased) { - it.jsonFiles() - } else { - emptyList() - } - } ?: emptyList() - - private fun releaseEntries(): List = changesDirectory.jsonFiles() -} - -internal fun File.jsonFiles(): List = if (this.exists()) { - this.listFiles(FileFilter { it.isFile && it.name.endsWith(".json") }).toList() -} else { - emptyList() -} diff --git a/buildSrc/src/toolkits/gradle/changelog/ChangeLog.kt b/buildSrc/src/toolkits/gradle/changelog/ChangeLog.kt index e1b04b1f60..5d0e494181 100644 --- a/buildSrc/src/toolkits/gradle/changelog/ChangeLog.kt +++ b/buildSrc/src/toolkits/gradle/changelog/ChangeLog.kt @@ -16,7 +16,9 @@ import com.fasterxml.jackson.module.kotlin.readValue import java.io.File import java.time.LocalDate -val MAPPER: ObjectMapper = jacksonObjectMapper().registerModule(JavaTimeModule()).enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) +val MAPPER: ObjectMapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) inline fun readFile(f: File): T { diff --git a/buildSrc/src/toolkits/gradle/changelog/ChangeLogPlugin.kt b/buildSrc/src/toolkits/gradle/changelog/ChangeLogPlugin.kt new file mode 100644 index 0000000000..4e50a47816 --- /dev/null +++ b/buildSrc/src/toolkits/gradle/changelog/ChangeLogPlugin.kt @@ -0,0 +1,32 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package toolkits.gradle.changelog + +import org.gradle.api.Plugin +import org.gradle.api.Project +import toolkits.gradle.changelog.tasks.CreateRelease +import toolkits.gradle.changelog.tasks.NewChange + +@Suppress("unused") // Plugin is created by buildSrc/build.gradle +class ChangeLogPlugin : Plugin { + override fun apply(project: Project) { + project.tasks.register("createRelease", CreateRelease::class.java) { + it.description = "Generates a release entry from unreleased changelog entries" + } + + project.tasks.register("newChange", NewChange::class.java) { + it.description = "Creates a new change entry for inclusion in the Change Log" + } + + project.tasks.register("newFeature", NewChange::class.java) { + it.description = "Creates a new feature change entry for inclusion in the Change Log" + it.defaultChangeType = ChangeType.FEATURE + } + + project.tasks.register("newBugFix", NewChange::class.java) { + it.description = "Creates a new bug-fix change entry for inclusion in the Change Log" + it.defaultChangeType = ChangeType.BUGFIX + } + } +} diff --git a/buildSrc/src/toolkits/gradle/changelog/GithubWriter.kt b/buildSrc/src/toolkits/gradle/changelog/GithubWriter.kt index 621d318047..ee4ca5d7d9 100644 --- a/buildSrc/src/toolkits/gradle/changelog/GithubWriter.kt +++ b/buildSrc/src/toolkits/gradle/changelog/GithubWriter.kt @@ -5,7 +5,7 @@ package toolkits.gradle.changelog import java.nio.file.Path -class GithubWriter(private val file: Path, issueUrl: String? = null) : ChangeLogWriter(issueUrl) { +class GithubWriter(private val file: Path, issueUrl: String?) : ChangeLogWriter(issueUrl) { private val writer = file.toFile().bufferedWriter() override fun append(line: String) { diff --git a/buildSrc/src/toolkits/gradle/changelog/ReleaseCreator.kt b/buildSrc/src/toolkits/gradle/changelog/ReleaseCreator.kt index dfce392db2..3fee200808 100644 --- a/buildSrc/src/toolkits/gradle/changelog/ReleaseCreator.kt +++ b/buildSrc/src/toolkits/gradle/changelog/ReleaseCreator.kt @@ -6,7 +6,7 @@ package toolkits.gradle.changelog import java.io.File import java.time.LocalDate -class ReleaseCreator(private val unreleasedFiles: List, private val nextReleaseFile: File) { +class ReleaseCreator(private val unreleasedFiles: Collection, private val nextReleaseFile: File) { init { if (nextReleaseFile.exists()) { throw RuntimeException("Release file $nextReleaseFile already exists!") diff --git a/buildSrc/src/toolkits/gradle/changelog/tasks/ChangeLogTask.kt b/buildSrc/src/toolkits/gradle/changelog/tasks/ChangeLogTask.kt new file mode 100644 index 0000000000..7a6f0baeee --- /dev/null +++ b/buildSrc/src/toolkits/gradle/changelog/tasks/ChangeLogTask.kt @@ -0,0 +1,27 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package toolkits.gradle.changelog.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileTree +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import toolkits.gradle.changelog.GitStager + +abstract class ChangeLogTask : DefaultTask() { + @Internal + protected val git = GitStager.create(project.rootDir) + + @InputDirectory + val changesDirectory: DirectoryProperty = project.objects.directoryProperty().convention(project.rootProject.layout.projectDirectory.dir(".changes")) + + @InputFiles + val nextReleaseDirectory: DirectoryProperty = project.objects.directoryProperty().convention(changesDirectory.dir("next-release")) + + protected fun DirectoryProperty.jsonFiles(): FileTree = this.asFileTree.matching { + it.include("*.json") + } +} diff --git a/buildSrc/src/toolkits/gradle/changelog/tasks/CreateRelease.kt b/buildSrc/src/toolkits/gradle/changelog/tasks/CreateRelease.kt new file mode 100644 index 0000000000..8ca71867f1 --- /dev/null +++ b/buildSrc/src/toolkits/gradle/changelog/tasks/CreateRelease.kt @@ -0,0 +1,42 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package toolkits.gradle.changelog.tasks + +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import toolkits.gradle.changelog.ReleaseCreator +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +open class CreateRelease : ChangeLogTask() { + @Input + val releaseDate: Property = project.objects.property(String::class.java).convention(DateTimeFormatter.ISO_DATE.format(LocalDate.now())) + + @Input + val releaseVersion: Property = project.objects.property(String::class.java).convention(project.provider { + (project.version as String).substringBeforeLast('-') + }) + + @OutputFile + val releaseFile: RegularFileProperty = project.objects.fileProperty().convention(changesDirectory.file(releaseVersion.map { "$it.json" })) + + @TaskAction + fun create() { + val releaseDate = DateTimeFormatter.ISO_DATE.parse(releaseDate.get()).let { + LocalDate.from(it) + } + + val releaseEntries = nextReleaseDirectory.jsonFiles() + + val creator = ReleaseCreator(releaseEntries.files, releaseFile.get().asFile) + creator.create(releaseVersion.get(), releaseDate) + if (git != null) { + git.stage(releaseFile.get().asFile.absoluteFile) + git.stage(nextReleaseDirectory.get().asFile.absoluteFile) + } + } +} diff --git a/buildSrc/src/toolkits/gradle/changelog/tasks/GenerateChangeLog.kt b/buildSrc/src/toolkits/gradle/changelog/tasks/GenerateChangeLog.kt new file mode 100644 index 0000000000..363658b3f8 --- /dev/null +++ b/buildSrc/src/toolkits/gradle/changelog/tasks/GenerateChangeLog.kt @@ -0,0 +1,63 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package toolkits.gradle.changelog.tasks + +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import toolkits.gradle.changelog.ChangeLogGenerator +import toolkits.gradle.changelog.ChangeLogWriter +import toolkits.gradle.changelog.GithubWriter +import toolkits.gradle.changelog.JetBrainsWriter + +/* ktlint-disable custom-ktlint-rules:log-not-lazy */ +abstract class GenerateChangeLog(private val shouldStage: Boolean) : ChangeLogTask() { + @Input + @Optional + val issuesUrl: Provider = project.objects.property(String::class.java).convention("https://github.com/aws/aws-toolkit-jetbrains/issues") + + @Input + val includeUnreleased: Property = project.objects.property(Boolean::class.java).convention(false) + + @OutputFile + val changeLogFile: RegularFileProperty = project.objects.fileProperty() + + @TaskAction + fun generate() { + val writer = createWriter() + logger.info("Generating Changelog with $writer") + val generator = ChangeLogGenerator(listOf(writer)) + if (includeUnreleased.get()) { + val unreleasedEntries = nextReleaseDirectory.jsonFiles().files + + logger.info("Including ${unreleasedEntries.size} unreleased changes") + if (unreleasedEntries.isNotEmpty()) { + generator.addUnreleasedChanges(unreleasedEntries.map { it.toPath() }) + } + } else { + logger.info("Skipping unreleased changes") + } + + generator.addReleasedChanges(changesDirectory.jsonFiles().map { it.toPath() }) + generator.close() + + if (shouldStage) { + git?.stage(changeLogFile.get().asFile) + } + } + + protected abstract fun createWriter(): ChangeLogWriter +} + +open class GeneratePluginChangeLog : GenerateChangeLog(false) { + override fun createWriter(): ChangeLogWriter = JetBrainsWriter(changeLogFile.get().asFile, issuesUrl.get()) +} + +open class GenerateGithubChangeLog : GenerateChangeLog(true) { + override fun createWriter(): ChangeLogWriter = GithubWriter(changeLogFile.get().asFile.toPath(), issuesUrl.get()) +} diff --git a/buildSrc/src/toolkits/gradle/changelog/tasks/NewChange.kt b/buildSrc/src/toolkits/gradle/changelog/tasks/NewChange.kt new file mode 100644 index 0000000000..1457bd3971 --- /dev/null +++ b/buildSrc/src/toolkits/gradle/changelog/tasks/NewChange.kt @@ -0,0 +1,71 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package toolkits.gradle.changelog.tasks + +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import toolkits.gradle.changelog.ChangeType +import toolkits.gradle.changelog.Entry +import toolkits.gradle.changelog.MAPPER +import java.io.File +import java.util.Scanner +import java.util.UUID + +open class NewChange : ChangeLogTask() { + @get:Internal + internal var defaultChangeType: ChangeType? = null + + @TaskAction + fun create() { + val changeType = if (project.hasProperty("changeType")) { + (project.property("changeType") as? String?)?.toUpperCase()?.let { ChangeType.valueOf(it) } + } else defaultChangeType + val description = if (project.hasProperty("description")) { + project.property("description") as? String? + } else null + + val input = Scanner(System.`in`) + val file = when { + changeType != null && description != null -> createChange(changeType, description) + else -> promptForChange(input, changeType) + } + git?.stage(file) + } + + private fun promptForChange(input: Scanner, existingChangeType: ChangeType?): File { + val changeType = existingChangeType ?: promptForChangeType(input) + + logger.lifecycle("> Please enter a change description: ") + val description = input.nextLine() + + return createChange(changeType, description) + } + + private fun promptForChangeType(input: Scanner): ChangeType { + val changeList = ChangeType.values() + .mapIndexed { index, changeType -> "${index + 1}. ${changeType.sectionTitle}" } + .joinToString("\n") + val newFeatureIndex = ChangeType.FEATURE.ordinal + 1 + logger.lifecycle("\n$changeList\n> Please enter change type ($newFeatureIndex): ") + + return input.nextLine().let { + if (it.isNotBlank()) { + ChangeType.values()[it.toInt() - 1] + } else { + ChangeType.FEATURE + } + } + } + + private fun createChange(changeType: ChangeType, description: String) = newFile(changeType).apply { + MAPPER.writerWithDefaultPrettyPrinter().writeValue(this, + Entry(changeType, description) + ) + } + + private fun newFile(changeType: ChangeType) = nextReleaseDirectory.file("${changeType.name.toLowerCase()}-${UUID.randomUUID()}.json").get().asFile.apply { + parentFile?.mkdirs() + createNewFile() + } +} diff --git a/buildSrc/src/toolkits/gradle/sdk/GenerateSdk.kt b/buildSrc/src/toolkits/gradle/sdk/GenerateSdk.kt index 09e73a02e3..d94f280fe9 100644 --- a/buildSrc/src/toolkits/gradle/sdk/GenerateSdk.kt +++ b/buildSrc/src/toolkits/gradle/sdk/GenerateSdk.kt @@ -4,7 +4,6 @@ package toolkits.gradle.sdk import org.gradle.api.DefaultTask -import org.gradle.api.logging.Logging import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction @@ -28,7 +27,7 @@ open class GenerateSdk : DefaultTask() { fun generate() { outputDir.deleteRecursively() - LOG.info("Generating SDK from $c2jFolder") + logger.info("Generating SDK from $c2jFolder") val models = C2jModels.builder() .serviceModel(loadServiceModel()) .paginatorsModel(loadPaginatorsModel()) @@ -57,8 +56,4 @@ open class GenerateSdk : DefaultTask() { CustomizationConfig::class.java, File(c2jFolder, "customization.config") ).orElse(CustomizationConfig.create()) - - private companion object { - private val LOG = Logging.getLogger(GenerateSdk::class.java) - } } diff --git a/buildSrc/tst/SourceUtilsTest.kt b/buildSrc/tst/SourceUtilsTest.kt new file mode 100644 index 0000000000..ffff559f32 --- /dev/null +++ b/buildSrc/tst/SourceUtilsTest.kt @@ -0,0 +1,35 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class SourceUtilsTest(private val folderName: String, private val expected: Boolean) { + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0} -> {1}") + fun data(): Collection> = listOf( + arrayOf("tst", true), + arrayOf("tst-201", true), + arrayOf("tst-190+", true), + arrayOf("tst-201+", true), + + arrayOf("tst-resources", false), + arrayOf("tst-resources-201", false), + arrayOf("tst-192", false), + arrayOf("tst-202", false), + arrayOf("tst-202+", false), + arrayOf("random", false), + arrayOf("src-tst", false) + ) + } + + @Test + fun `correctly includes folder`() { + assertThat(includeFolder("tst", "201", folderName)).isEqualTo(expected) + } +} diff --git a/buildSrc/tst/toolkits/gradle/changelog/GithubWriterTest.kt b/buildSrc/tst/toolkits/gradle/changelog/GithubWriterTest.kt index 37dfd733c7..22b51e417f 100644 --- a/buildSrc/tst/toolkits/gradle/changelog/GithubWriterTest.kt +++ b/buildSrc/tst/toolkits/gradle/changelog/GithubWriterTest.kt @@ -19,7 +19,7 @@ class GithubWriterTest { @Test fun basicWrite() { val file = folder.newFile() - val sut = GithubWriter(file.toPath()) + val sut = GithubWriter(file.toPath(), null) sut.writeLine( renderEntry( diff --git a/buildspec/linuxIntegrationTests.yml b/buildspec/linuxIntegrationTests.yml index 994b0fb986..79c627fa6d 100644 --- a/buildspec/linuxIntegrationTests.yml +++ b/buildspec/linuxIntegrationTests.yml @@ -14,14 +14,13 @@ env: phases: install: runtime-versions: - java: openjdk11 - dotnet: 2.2 + java: corretto11 + docker: 19 + dotnet: 3.1 commands: - - nohup /usr/local/bin/dockerd --host=unix:///var/run/docker.sock --host=tcp://127.0.0.1:2375 --storage-driver=overlay& - - timeout 15 sh -c "until docker info; do echo .; sleep 1; done" - apt-get update - - apt-get install -y jq python2.7 python-pip python3.6 python3.7 python3.8 python3-pip python3-distutils mono-complete + - apt-get install -y jq python2.7 python-pip python3.6 python3.7 python3.8 python3-pip python3-distutils - aws sts assume-role --role-arn $ASSUME_ROLE_ARN --role-session-name integ-test > creds.json - export KEY_ID=`jq -r '.Credentials.AccessKeyId' creds.json` - export SECRET=`jq -r '.Credentials.SecretAccessKey' creds.json` diff --git a/buildspec/linuxTests.yml b/buildspec/linuxTests.yml index 606178bdde..8672637db9 100644 --- a/buildspec/linuxTests.yml +++ b/buildspec/linuxTests.yml @@ -13,17 +13,14 @@ env: phases: install: runtime-versions: - java: openjdk11 - dotnet: 2.2 - - commands: - - apt update - - apt install -y mono-complete + java: corretto11 + dotnet: 3.1 build: commands: - chmod +x gradlew - ./gradlew check coverageReport --info --full-stacktrace --console plain + - ./gradlew buildPlugin - VCS_COMMIT_ID="${CODEBUILD_RESOLVED_SOURCE_VERSION}" - CI_BUILD_URL=$(echo $CODEBUILD_BUILD_URL | sed 's/#/%23/g') # Encode `#` in the URL because otherwise the url is clipped in the Codecov.io site - CI_BUILD_ID="${CODEBUILD_BUILD_ID}" @@ -32,11 +29,14 @@ phases: post_build: commands: + - BUILD_ARTIFACTS="/tmp/buildArtifacts" - TEST_ARTIFACTS="/tmp/testArtifacts" - mkdir -p $TEST_ARTIFACTS/test-reports + - mkdir -p $BUILD_ARTIFACTS - rsync -rmq --include='*/' --include '**/build/idea-sandbox/system*/log/**' --exclude='*' . $TEST_ARTIFACTS/ || true - rsync -rmq --include='*/' --include '**/build/reports/**' --exclude='*' . $TEST_ARTIFACTS/ || true - rsync -rmq --include='*/' --include '**/test-results/**/*.xml' --exclude='*' . $TEST_ARTIFACTS/test-reports || true + - cp -r ./build/distributions/*.zip $BUILD_ARTIFACTS/ || true reports: unit-test: @@ -49,3 +49,9 @@ artifacts: files: - "**/*" base-directory: /tmp/testArtifacts + secondary-artifacts: + plugin: + files: + - /tmp/buildArtifacts/* + discard-paths: yes + name: plugin.zip diff --git a/buildspec/linuxUiTests.yml b/buildspec/linuxUiTests.yml index 0acacdc47f..ed4d130f87 100644 --- a/buildspec/linuxUiTests.yml +++ b/buildspec/linuxUiTests.yml @@ -16,19 +16,22 @@ env: phases: install: runtime-versions: - java: openjdk11 - dotnet: 2.2 + java: corretto11 + dotnet: 3.1 commands: - apt-get update - - apt-get install -y xvfb icewm procps ffmpeg libswt-gtk-3-java mono-complete + - apt-get install -y xvfb icewm procps ffmpeg libswt-gtk-3-java - mkdir -p /tmp/.aws - aws sts assume-role --role-arn $ASSUME_ROLE_ARN --role-session-name ui-test > /tmp/.aws/creds.json + - export KEY_ID=`jq -r '.Credentials.AccessKeyId' /tmp/.aws/creds.json` + - export SECRET=`jq -r '.Credentials.SecretAccessKey' /tmp/.aws/creds.json` + - export TOKEN=`jq -r '.Credentials.SessionToken' /tmp/.aws/creds.json` - | >/tmp/.aws/credentials echo "[default] - aws_access_key_id=`jq -r '.Credentials.AccessKeyId' /tmp/.aws/creds.json` - aws_secret_access_key=`jq -r '.Credentials.SecretAccessKey' /tmp/.aws/creds.json` - aws_session_token=`jq -r '.Credentials.SessionToken' /tmp/.aws/creds.json`" + aws_access_key_id=$KEY_ID + aws_secret_access_key=$SECRET + aws_session_token=$TOKEN" - pip3 install --user --upgrade aws-sam-cli build: @@ -46,19 +49,18 @@ phases: if [ "$RECORD_UI" ]; then ffmpeg -loglevel warning -f x11grab -video_size 1920x1080 -i :99 -codec:v libx264 -r 12 /tmp/screen_recording.mp4 & fi - - ./gradlew guiTest coverageReport --console plain --info + - env AWS_ACCESS_KEY_ID=$KEY_ID AWS_SECRET_ACCESS_KEY=$SECRET AWS_SESSION_TOKEN=$TOKEN ./gradlew uiTestCore coverageReport --console plain --info post_build: commands: - TEST_ARTIFACTS="/tmp/testArtifacts" - mkdir -p $TEST_ARTIFACTS/test-reports - - rsync -r guitest.log $TEST_ARTIFACTS/gui/ || true - rsync -rmq --include='*/' --include '**/build/idea-sandbox/system*/log/**' --exclude='*' . $TEST_ARTIFACTS/ || true - rsync -rmq --include='*/' --include '**/build/reports/**' --exclude='*' . $TEST_ARTIFACTS/ || true - rsync -rmq --include='*/' --include '**/test-results/**/*.xml' --exclude='*' . $TEST_ARTIFACTS/test-reports || true - if [ "$RECORD_UI" ]; then pkill -2 ffmpeg; while pgrep ffmpeg > /dev/null; do sleep 1; done; fi - - if [ "$RECORD_UI" ]; then cp /tmp/screen_recording.mp4 $TEST_ARTIFACTS/gui/; fi + - if [ "$RECORD_UI" ]; then cp /tmp/screen_recording.mp4 $TEST_ARTIFACTS/; fi - VCS_COMMIT_ID="${CODEBUILD_RESOLVED_SOURCE_VERSION}" - CI_BUILD_URL=$(echo $CODEBUILD_BUILD_URL | sed 's/#/%23/g') # Encode `#` in the URL because otherwise the url is clipped in the Codecov.io site diff --git a/buildspec/windowsTests.yml b/buildspec/windowsTests.yml index 657b492978..1ecd03bae3 100644 --- a/buildspec/windowsTests.yml +++ b/buildspec/windowsTests.yml @@ -16,7 +16,6 @@ phases: if(-Not($Env:CODE_COV_TOKEN -eq $null)) { choco install -y --no-progress codecov } - - choco install -y --no-progress netfx-4.6.1-devpack build: commands: diff --git a/core/build.gradle b/core/build.gradle deleted file mode 100644 index 9d8dbcf572..0000000000 --- a/core/build.gradle +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -dependencies { - compile(project(":resources")) - compile(project(":telemetry-client")) - compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") - compile("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") - compile("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:$jacksonVersion") - compile("software.amazon.awssdk:cognitoidentity:$awsSdkVersion") - compile("software.amazon.awssdk:ecs:$awsSdkVersion") - compile("software.amazon.awssdk:s3:$awsSdkVersion") - compile("software.amazon.awssdk:sts:$awsSdkVersion") - - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") -} diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000000..50778eb78f --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,24 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +val awsSdkVersion: String by project +val jacksonVersion: String by project +val coroutinesVersion: String by project + +dependencies { + api(project(":resources")) + api(project(":telemetry-client")) + api("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + api("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion") + api("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:$jacksonVersion") + api("software.amazon.awssdk:cognitoidentity:$awsSdkVersion") + api("software.amazon.awssdk:ecs:$awsSdkVersion") + api("software.amazon.awssdk:s3:$awsSdkVersion") + api("software.amazon.awssdk:sso:$awsSdkVersion") + api("software.amazon.awssdk:ssooidc:$awsSdkVersion") + api("software.amazon.awssdk:sts:$awsSdkVersion") + + compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") + + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") +} diff --git a/core/src/software/aws/toolkits/core/credentials/CredentialProviderFactory.kt b/core/src/software/aws/toolkits/core/credentials/CredentialProviderFactory.kt index d43152deac..c8a3aad744 100644 --- a/core/src/software/aws/toolkits/core/credentials/CredentialProviderFactory.kt +++ b/core/src/software/aws/toolkits/core/credentials/CredentialProviderFactory.kt @@ -17,17 +17,17 @@ interface CredentialProviderFactory { val id: String /** - * Invoked on creation of the factory to update the credential system with what [ToolkitCredentialsIdentifier] this factory + * Invoked on creation of the factory to update the credential system with what [CredentialIdentifier] this factory * is capable of creating. The provided [credentialLoadCallback] is capable of being invoked multiple times in the case that * the credentials this factory creates is modified in some way. */ fun setUp(credentialLoadCallback: CredentialsChangeListener) /** - * Creates an [AwsCredentialsProvider] for the specified [ToolkitCredentialsIdentifier] scoped to the specified [region] + * Creates an [AwsCredentialsProvider] for the specified [CredentialIdentifier] scoped to the specified [region] */ fun createAwsCredentialProvider( - providerId: ToolkitCredentialsIdentifier, + providerId: CredentialIdentifier, region: AwsRegion, sdkHttpClientSupplier: () -> SdkHttpClient ): AwsCredentialsProvider diff --git a/core/src/software/aws/toolkits/core/credentials/CredentialsChangeEvent.kt b/core/src/software/aws/toolkits/core/credentials/CredentialsChangeEvent.kt index 58389c38a6..0d7eadaf56 100644 --- a/core/src/software/aws/toolkits/core/credentials/CredentialsChangeEvent.kt +++ b/core/src/software/aws/toolkits/core/credentials/CredentialsChangeEvent.kt @@ -8,9 +8,9 @@ package software.aws.toolkits.core.credentials * to give an accurate representation of the state of the credentials system */ data class CredentialsChangeEvent( - val added: List, - val modified: List, - val removed: List + val added: List, + val modified: List, + val removed: List ) typealias CredentialsChangeListener = (changeEvent: CredentialsChangeEvent) -> Unit diff --git a/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProvider.kt b/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProvider.kt index a1f841e580..bacd0519cb 100644 --- a/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProvider.kt +++ b/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProvider.kt @@ -1,47 +1,65 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package software.aws.toolkits.core.credentials import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider -abstract class ToolkitCredentialsIdentifier { +/** + * Represents a possible credential provider that can be used within the toolkit. + * + * Implementers should extend [CredentialIdentifierBase] instead of directly implementing this interface. + */ +interface CredentialIdentifier { /** - * The ID must be unique across all [ToolkitCredentialsIdentifier]. + * The ID must be unique across all [CredentialIdentifier] instances. * It is recommended to concatenate the factory ID into this field to help enforce this requirement. */ - abstract val id: String + val id: String /** * A user friendly display name shown in the UI. */ - abstract val displayName: String + val displayName: String + + /** + * An optional shortened version of the name to display in the UI where space is at a premium + */ + val shortName: String get() = displayName /** * The ID of the corresponding [CredentialProviderFactory] so that the credential manager knows which factory to invoke in order * to resolve this into a [ToolkitCredentialsProvider] */ - abstract val factoryId: String + val factoryId: String - override fun equals(other: Any?): Boolean { + /** + * Some ID types (e.g. Profile) have a concept of a default region, this is optional. + */ + val defaultRegionId: String? get() = null +} + +abstract class CredentialIdentifierBase : CredentialIdentifier { + final override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false - other as ToolkitCredentialsIdentifier + other as CredentialIdentifierBase if (id != other.id) return false return true } - override fun hashCode(): Int = id.hashCode() + final override fun hashCode(): Int = id.hashCode() - override fun toString(): String = "${this::class.simpleName}(id='$id')" + final override fun toString(): String = "${this::class.simpleName}(id='$id')" } -class ToolkitCredentialsProvider(private val identifier: ToolkitCredentialsIdentifier, delegate: AwsCredentialsProvider) : AwsCredentialsProvider by delegate { +class ToolkitCredentialsProvider(private val identifier: CredentialIdentifier, delegate: AwsCredentialsProvider) : AwsCredentialsProvider by delegate { val id: String = identifier.id val displayName = identifier.displayName + val shortName = identifier.shortName override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProviderManager.kt b/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProviderManager.kt index 148661f60c..ea649b49c3 100644 --- a/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProviderManager.kt +++ b/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProviderManager.kt @@ -7,9 +7,9 @@ package software.aws.toolkits.core.credentials * TODO: Deprecate and remove this since it is less efficient than [CredentialsChangeEvent] */ interface ToolkitCredentialsChangeListener { - fun providerAdded(identifier: ToolkitCredentialsIdentifier) {} - fun providerModified(identifier: ToolkitCredentialsIdentifier) {} - fun providerRemoved(identifier: ToolkitCredentialsIdentifier) {} + fun providerAdded(identifier: CredentialIdentifier) {} + fun providerModified(identifier: CredentialIdentifier) {} + fun providerRemoved(identifier: CredentialIdentifier) {} } class CredentialProviderNotFoundException : RuntimeException { diff --git a/core/src/software/aws/toolkits/core/credentials/sso/AccessToken.kt b/core/src/software/aws/toolkits/core/credentials/sso/AccessToken.kt new file mode 100644 index 0000000000..58ed8b8774 --- /dev/null +++ b/core/src/software/aws/toolkits/core/credentials/sso/AccessToken.kt @@ -0,0 +1,18 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials.sso + +import software.amazon.awssdk.services.sso.SsoClient +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import java.time.Instant + +/** + * Access token returned from [SsoOidcClient.createToken] used to retrieve AWS Credentials from [SsoClient.getRoleCredentials]. + */ +data class AccessToken( + val startUrl: String, + val region: String, + val accessToken: String, + val expiresAt: Instant +) diff --git a/core/src/software/aws/toolkits/core/credentials/sso/Authorization.kt b/core/src/software/aws/toolkits/core/credentials/sso/Authorization.kt new file mode 100644 index 0000000000..d40b3a9b3e --- /dev/null +++ b/core/src/software/aws/toolkits/core/credentials/sso/Authorization.kt @@ -0,0 +1,19 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials.sso + +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import java.time.Instant + +/** + * Returned by [SsoOidcClient.startDeviceAuthorization] that contains the required data to construct the user visible SSO login flow. + */ +data class Authorization( + val deviceCode: String, + val userCode: String, + val verificationUri: String, + val verificationUriComplete: String, + val expiresAt: Instant, + val pollInterval: Long +) diff --git a/core/src/software/aws/toolkits/core/credentials/sso/ClientRegistration.kt b/core/src/software/aws/toolkits/core/credentials/sso/ClientRegistration.kt new file mode 100644 index 0000000000..5b59372ad1 --- /dev/null +++ b/core/src/software/aws/toolkits/core/credentials/sso/ClientRegistration.kt @@ -0,0 +1,18 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials.sso + +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import java.time.Instant + +/** + * Client registration that represents the toolkit returned from [SsoOidcClient.registerClient]. + * + * It should be persisted for reuse through many authentication requests. + */ +data class ClientRegistration( + val clientId: String, + val clientSecret: String, + val expiresAt: Instant +) diff --git a/core/src/software/aws/toolkits/core/credentials/sso/DiskCache.kt b/core/src/software/aws/toolkits/core/credentials/sso/DiskCache.kt new file mode 100644 index 0000000000..19af16e629 --- /dev/null +++ b/core/src/software/aws/toolkits/core/credentials/sso/DiskCache.kt @@ -0,0 +1,132 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials.sso + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.databind.util.StdDateFormat +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import software.aws.toolkits.core.utils.deleteIfExists +import software.aws.toolkits.core.utils.filePermissions +import software.aws.toolkits.core.utils.inputStreamIfExists +import software.aws.toolkits.core.utils.outputStream +import software.aws.toolkits.core.utils.toHexString +import software.aws.toolkits.core.utils.touch +import software.aws.toolkits.core.utils.tryOrNull +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.attribute.PosixFilePermission +import java.security.MessageDigest +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter.ISO_INSTANT +import java.time.temporal.ChronoUnit +import java.util.TimeZone + +/** + * Caches the [AccessToken] to disk to allow it to be re-used with other tools such as the CLI. + */ +class DiskCache( + private val cacheDir: Path = Paths.get(System.getProperty("user.home"), ".aws", "sso", "cache"), + private val clock: Clock = Clock.systemUTC() +) : SsoCache { + private val objectMapper = jacksonObjectMapper().also { + it.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + + it.registerModule(JavaTimeModule()) + val customDateModule = SimpleModule() + customDateModule.addDeserializer(Instant::class.java, CliCompatibleInstantDeserializer()) + it.registerModule(customDateModule) // Override the Instant deserializer with custom one + it.dateFormat = StdDateFormat().withTimeZone(TimeZone.getTimeZone(ZoneOffset.UTC)) + } + + override fun loadClientRegistration(ssoRegion: String): ClientRegistration? { + val inputStream = clientRegistrationCache(ssoRegion).inputStreamIfExists() ?: return null + return tryOrNull { + val clientRegistration = objectMapper.readValue(inputStream) + if (clientRegistration.expiresAt.isNotExpired()) { + clientRegistration + } else { + null + } + } + } + + override fun saveClientRegistration(ssoRegion: String, registration: ClientRegistration) { + val registrationCache = clientRegistrationCache(ssoRegion) + registrationCache.touch() + registrationCache.filePermissions(setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)) + + registrationCache.outputStream().use { + objectMapper.writeValue(it, registration) + } + } + + override fun invalidateClientRegistration(ssoRegion: String) { + clientRegistrationCache(ssoRegion).deleteIfExists() + } + + override fun loadAccessToken(ssoUrl: String): AccessToken? { + val cacheFile = accessTokenCache(ssoUrl) + val inputStream = cacheFile.inputStreamIfExists() ?: return null + + return tryOrNull { + val clientRegistration = objectMapper.readValue(inputStream) + // Use same expiration logic as client registration even though RFC/SEP does not specify it. + // This prevents a cache entry being returned as valid and then expired when we go to use it. + if (clientRegistration.expiresAt.isNotExpired()) { + clientRegistration + } else { + null + } + } + } + + override fun saveAccessToken(ssoUrl: String, accessToken: AccessToken) { + val accessTokenCache = accessTokenCache(ssoUrl) + accessTokenCache.touch() + accessTokenCache.filePermissions(setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)) + + accessTokenCache.outputStream().use { + objectMapper.writeValue(it, accessToken) + } + } + + override fun invalidateAccessToken(ssoUrl: String) { + accessTokenCache(ssoUrl).deleteIfExists() + } + + private fun clientRegistrationCache(ssoRegion: String): Path = cacheDir.resolve("aws-toolkit-jetbrains-client-id-$ssoRegion.json") + + private fun accessTokenCache(ssoUrl: String): Path { + val digest = MessageDigest.getInstance("SHA-1") + val sha = digest.digest(ssoUrl.toByteArray(Charsets.UTF_8)).toHexString() + val fileName = "$sha.json" + return cacheDir.resolve(fileName) + } + + // If the item is going to expire in the next 15 mins, we must treat it as already expired + private fun Instant.isNotExpired(): Boolean = this.isAfter(Instant.now(clock).plus(15, ChronoUnit.MINUTES)) + + private class CliCompatibleInstantDeserializer : StdDeserializer(Instant::class.java) { + override fun deserialize(parser: JsonParser, context: DeserializationContext): Instant { + val dateString = parser.valueAsString + + // CLI appends UTC, which Java refuses to parse. Convert it to a Z + val sanitized = if (dateString.endsWith("UTC")) { + dateString.dropLast(3) + 'Z' + } else { + dateString + } + + return ISO_INSTANT.parse(sanitized) { Instant.from(it) } + } + } +} diff --git a/core/src/software/aws/toolkits/core/credentials/sso/SsoAccessTokenProvider.kt b/core/src/software/aws/toolkits/core/credentials/sso/SsoAccessTokenProvider.kt new file mode 100644 index 0000000000..b14f90f576 --- /dev/null +++ b/core/src/software/aws/toolkits/core/credentials/sso/SsoAccessTokenProvider.kt @@ -0,0 +1,135 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials.sso + +import kotlinx.coroutines.delay +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import software.amazon.awssdk.services.ssooidc.model.AuthorizationPendingException +import software.amazon.awssdk.services.ssooidc.model.InvalidClientException +import software.amazon.awssdk.services.ssooidc.model.SlowDownException +import java.time.Clock +import java.time.Duration +import java.time.Instant + +/** + * Takes care of creating/refreshing the SSO access token required to fetch SSO-based credentials. + */ +class SsoAccessTokenProvider( + private val ssoUrl: String, + private val ssoRegion: String, + private val onPendingToken: SsoLoginCallback, + private val cache: SsoCache, + private val client: SsoOidcClient, + private val clock: Clock = Clock.systemUTC() +) { + suspend fun accessToken(): AccessToken { + cache.loadAccessToken(ssoUrl)?.let { + return it + } + + val token = pollForToken() + + cache.saveAccessToken(ssoUrl, token) + + return token + } + + private fun registerClient(): ClientRegistration { + cache.loadClientRegistration(ssoRegion)?.let { + return it + } + + // Based on botocore: https://github.com/boto/botocore/blob/5dc8ee27415dc97cfff75b5bcfa66d410424e665/botocore/utils.py#L1753 + val registerResponse = client.registerClient { + it.clientType(CLIENT_REGISTRATION_TYPE) + it.clientName("aws-toolkit-jetbrains-${Instant.now(clock)}") + } + + val registeredClient = ClientRegistration( + registerResponse.clientId(), + registerResponse.clientSecret(), + Instant.ofEpochSecond(registerResponse.clientSecretExpiresAt()) + ) + + cache.saveClientRegistration(ssoRegion, registeredClient) + + return registeredClient + } + + private fun authorizeClient(clientId: ClientRegistration): Authorization { + // Should not be cached, only good for 1 token and short lived + val authorizationResponse = try { + client.startDeviceAuthorization { + it.startUrl(ssoUrl) + it.clientId(clientId.clientId) + it.clientSecret(clientId.clientSecret) + } + } catch (e: InvalidClientException) { + cache.invalidateClientRegistration(ssoRegion) + throw e + } + + return Authorization( + authorizationResponse.deviceCode(), + authorizationResponse.userCode(), + authorizationResponse.verificationUri(), + authorizationResponse.verificationUriComplete(), + Instant.now(clock).plusSeconds(authorizationResponse.expiresIn().toLong()), + authorizationResponse.interval()?.toLong() + ?: DEFAULT_INTERVAL_SECS + ) + } + + private suspend fun pollForToken(): AccessToken { + val registration = registerClient() + val authorization = authorizeClient(registration) + + onPendingToken.tokenPending(authorization) + + var backOffTime = Duration.ofSeconds(authorization.pollInterval) + + while (true) { + try { + val tokenResponse = client.createToken { + it.clientId(registration.clientId) + it.clientSecret(registration.clientSecret) + it.grantType(GRANT_TYPE) + it.deviceCode(authorization.deviceCode) + } + + val expirationTime = Instant.now(clock).plusSeconds(tokenResponse.expiresIn().toLong()) + + onPendingToken.tokenRetrieved() + + return AccessToken( + ssoUrl, + ssoRegion, + tokenResponse.accessToken(), + expirationTime + ) + } catch (e: SlowDownException) { + backOffTime = backOffTime.plusSeconds(SLOW_DOWN_DELAY_SECS) + } catch (e: AuthorizationPendingException) { + // Do nothing, keep polling + } catch (e: Exception) { + onPendingToken.tokenRetrievalFailure(e) + throw e + } + + delay(backOffTime.toMillis()) + } + } + + fun invalidate() { + cache.invalidateAccessToken(ssoUrl) + } + + private companion object { + const val CLIENT_REGISTRATION_TYPE = "public" + const val GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" + // Default number of seconds to poll for token, https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.5 + const val DEFAULT_INTERVAL_SECS = 5L + const val SLOW_DOWN_DELAY_SECS = 5L + } +} diff --git a/core/src/software/aws/toolkits/core/credentials/sso/SsoCache.kt b/core/src/software/aws/toolkits/core/credentials/sso/SsoCache.kt new file mode 100644 index 0000000000..4650e3d35b --- /dev/null +++ b/core/src/software/aws/toolkits/core/credentials/sso/SsoCache.kt @@ -0,0 +1,14 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials.sso + +interface SsoCache { + fun loadClientRegistration(ssoRegion: String): ClientRegistration? + fun saveClientRegistration(ssoRegion: String, registration: ClientRegistration) + fun invalidateClientRegistration(ssoRegion: String) + + fun loadAccessToken(ssoUrl: String): AccessToken? + fun saveAccessToken(ssoUrl: String, accessToken: AccessToken) + fun invalidateAccessToken(ssoUrl: String) +} diff --git a/core/src/software/aws/toolkits/core/credentials/sso/SsoCredentialProvider.kt b/core/src/software/aws/toolkits/core/credentials/sso/SsoCredentialProvider.kt new file mode 100644 index 0000000000..ddbf393891 --- /dev/null +++ b/core/src/software/aws/toolkits/core/credentials/sso/SsoCredentialProvider.kt @@ -0,0 +1,68 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials.sso + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import software.amazon.awssdk.auth.credentials.AwsCredentials +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials +import software.amazon.awssdk.services.sso.SsoClient +import software.amazon.awssdk.services.ssooidc.model.AccessDeniedException +import software.amazon.awssdk.utils.cache.CachedSupplier +import software.amazon.awssdk.utils.cache.RefreshResult +import java.time.Duration +import java.time.Instant + +/** + * [AwsCredentialsProvider] that contains all the needed hooks to perform an end to end flow of an SSO-based credential. + * + * This credential provider will trigger an SSO login if required, unlike the low level SDKs. + */ +class SsoCredentialProvider( + private val ssoAccount: String, + private val ssoRole: String, + private val ssoClient: SsoClient, + private val ssoAccessTokenProvider: SsoAccessTokenProvider +) : AwsCredentialsProvider { + private val sessionCache: CachedSupplier = CachedSupplier.builder(this::refreshCredentials).build() + + override fun resolveCredentials(): AwsCredentials = sessionCache.get().credentials + + private fun refreshCredentials(): RefreshResult { + val roleCredentials = try { + val accessToken = runBlocking(Dispatchers.IO) { + ssoAccessTokenProvider.accessToken() + } + + ssoClient.getRoleCredentials { + it.accessToken(accessToken.accessToken) + it.accountId(ssoAccount) + it.roleName(ssoRole) + } + } catch (e: AccessDeniedException) { + // OIDC access token was rejected, invalidate the cache and throw + ssoAccessTokenProvider.invalidate() + throw e + } + + val awsCredentials = AwsSessionCredentials.create( + roleCredentials.roleCredentials().accessKeyId(), + roleCredentials.roleCredentials().secretAccessKey(), + roleCredentials.roleCredentials().sessionToken() + ) + + val expirationTime = Instant.ofEpochMilli(roleCredentials.roleCredentials().expiration()) + + val ssoCredentials = + SsoCredentialsHolder(awsCredentials, expirationTime) + + return RefreshResult.builder(ssoCredentials) + .staleTime(expirationTime.minus(Duration.ofMinutes(1))) + .prefetchTime(expirationTime.minus(Duration.ofMinutes(5))) + .build() + } + + private data class SsoCredentialsHolder(val credentials: AwsSessionCredentials, val expirationTime: Instant) +} diff --git a/core/src/software/aws/toolkits/core/credentials/sso/SsoLoginCallback.kt b/core/src/software/aws/toolkits/core/credentials/sso/SsoLoginCallback.kt new file mode 100644 index 0000000000..d8fd7c4fa2 --- /dev/null +++ b/core/src/software/aws/toolkits/core/credentials/sso/SsoLoginCallback.kt @@ -0,0 +1,24 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials.sso + +/** + * Callback interface to allow for UI elements to react to the different stages of the SSO login flow + */ +interface SsoLoginCallback { + /** + * Called when a new authorization is pending within SSO service. User should be notified so they can perform the login flow. + */ + suspend fun tokenPending(authorization: Authorization) + + /** + * Called when the user successfully logs into the SSO service. + */ + fun tokenRetrieved() + + /** + * Called when the SSO login fails + */ + fun tokenRetrievalFailure(e: Exception) +} diff --git a/core/src/software/aws/toolkits/core/credentials/sso/SsoProfileProperty.kt b/core/src/software/aws/toolkits/core/credentials/sso/SsoProfileProperty.kt new file mode 100644 index 0000000000..655b8307e2 --- /dev/null +++ b/core/src/software/aws/toolkits/core/credentials/sso/SsoProfileProperty.kt @@ -0,0 +1,11 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials.sso + +const val SSO_URL = "sso_start_url" +const val SSO_REGION = "sso_region" +const val SSO_ACCOUNT = "sso_account_id" +const val SSO_ROLE_NAME = "sso_role_name" + +const val SSO_EXPERIMENTAL_REGISTRY_KEY = "aws.sso.enabled" diff --git a/core/src/software/aws/toolkits/core/region/Partitions.kt b/core/src/software/aws/toolkits/core/region/Partitions.kt index 90660010a2..bbb2b0e88d 100644 --- a/core/src/software/aws/toolkits/core/region/Partitions.kt +++ b/core/src/software/aws/toolkits/core/region/Partitions.kt @@ -49,10 +49,7 @@ object PartitionParser { } object ServiceEndpointResource : RemoteResource { - override val urls: List = listOf( - "https://idetoolkits.amazonwebservices.com/endpoints.json", - "https://aws-toolkit-endpoints.s3.amazonaws.com/endpoints.json" - ) + override val urls: List = listOf("https://idetoolkits.amazonwebservices.com/endpoints.json") override val name: String = "service-endpoints.json" override val ttl: Duration? = Duration.ofHours(24) override val initialValue: (() -> InputStream)? = { BundledResources.ENDPOINTS_FILE } diff --git a/core/src/software/aws/toolkits/core/region/ToolkitRegionProvider.kt b/core/src/software/aws/toolkits/core/region/ToolkitRegionProvider.kt index 38940b85e1..2c9ad4c126 100644 --- a/core/src/software/aws/toolkits/core/region/ToolkitRegionProvider.kt +++ b/core/src/software/aws/toolkits/core/region/ToolkitRegionProvider.kt @@ -42,8 +42,7 @@ abstract class ToolkitRegionProvider { */ abstract fun defaultPartition(): AwsPartition - @Deprecated("This loads the default region if specified region doesn't exist which does not make sense") - fun lookupRegionById(regionId: String?): AwsRegion = allRegions()[regionId] ?: defaultRegion() + operator fun get(regionId: String): AwsRegion? = allRegions()[regionId] open fun isServiceGlobal(region: AwsRegion, serviceId: String): Boolean { val partition = partitionData()[region.partitionId] ?: throw IllegalStateException("Partition data is missing for ${region.partitionId}") diff --git a/core/src/software/aws/toolkits/core/utils/PathUtils.kt b/core/src/software/aws/toolkits/core/utils/PathUtils.kt index ab72f3bedb..06ec32273f 100644 --- a/core/src/software/aws/toolkits/core/utils/PathUtils.kt +++ b/core/src/software/aws/toolkits/core/utils/PathUtils.kt @@ -4,14 +4,42 @@ package software.aws.toolkits.core.utils import java.io.InputStream +import java.io.OutputStream import java.nio.charset.Charset +import java.nio.file.FileAlreadyExistsException import java.nio.file.Files +import java.nio.file.NoSuchFileException import java.nio.file.Path import java.nio.file.attribute.FileTime +import java.nio.file.attribute.PosixFilePermission fun Path.inputStream(): InputStream = Files.newInputStream(this) +fun Path.inputStreamIfExists(): InputStream? = try { + inputStream() +} catch (e: NoSuchFileException) { + null +} + +fun Path.touch() { + this.createParentDirectories() + try { + Files.createFile(this) + } catch (_: FileAlreadyExistsException) { } +} + +fun Path.outputStream(): OutputStream { + this.createParentDirectories() + return Files.newOutputStream(this) +} +fun Path.createParentDirectories() = Files.createDirectories(this.parent) fun Path.exists() = Files.exists(this) fun Path.deleteIfExists() = Files.deleteIfExists(this) fun Path.lastModified(): FileTime = Files.getLastModifiedTime(this) fun Path.readText(charset: Charset = Charsets.UTF_8) = toFile().readText(charset) fun Path.writeText(text: String, charset: Charset = Charsets.UTF_8) = toFile().writeText(text, charset) +fun Path.filePermissions(permissions: Set) { + // Comes from PosixFileAttributeView#name() + if ("posix" in this.fileSystem.supportedFileAttributeViews()) { + Files.setPosixFilePermissions(this, permissions) + } +} diff --git a/core/src/software/aws/toolkits/core/utils/StringUtils.kt b/core/src/software/aws/toolkits/core/utils/StringUtils.kt index 57600776da..38426ff068 100644 --- a/core/src/software/aws/toolkits/core/utils/StringUtils.kt +++ b/core/src/software/aws/toolkits/core/utils/StringUtils.kt @@ -8,3 +8,5 @@ package software.aws.toolkits.core.utils */ fun String.splitNoBlank(vararg delimiters: Char, ignoreCase: Boolean = false, limit: Int = 0): List = split(*delimiters, ignoreCase = ignoreCase, limit = limit).filter { it.isNotBlank() } + +fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } diff --git a/jetbrains-core/tst-resources/mockito-extensions/org.mockito.plugins.MockMaker b/core/tst-resources/mockito-extensions/org.mockito.plugins.MockMaker similarity index 100% rename from jetbrains-core/tst-resources/mockito-extensions/org.mockito.plugins.MockMaker rename to core/tst-resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/core/tst/software/aws/toolkits/core/credentials/Mocks.kt b/core/tst/software/aws/toolkits/core/credentials/Mocks.kt new file mode 100644 index 0000000000..a5860bc8b5 --- /dev/null +++ b/core/tst/software/aws/toolkits/core/credentials/Mocks.kt @@ -0,0 +1,25 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials + +import com.nhaarman.mockitokotlin2.mock +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.aws.toolkits.core.utils.test.aString + +fun aToolkitCredentialsProvider( + identifier: CredentialIdentifier = aCredentialsIdentifier(), + delegate: AwsCredentialsProvider = mock() +) = ToolkitCredentialsProvider(identifier, delegate) + +fun aCredentialsIdentifier( + id: String = aString(), + displayName: String = aString(), + factoryId: String = aString(), + defaultRegionId: String? = null +) = object : CredentialIdentifierBase() { + override val id: String = id + override val displayName: String = displayName + override val factoryId: String = factoryId + override val defaultRegionId: String? = defaultRegionId +} diff --git a/core/tst/software/aws/toolkits/core/credentials/sso/DiskCacheTest.kt b/core/tst/software/aws/toolkits/core/credentials/sso/DiskCacheTest.kt new file mode 100644 index 0000000000..791d64cc1c --- /dev/null +++ b/core/tst/software/aws/toolkits/core/credentials/sso/DiskCacheTest.kt @@ -0,0 +1,312 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials.sso + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import software.aws.toolkits.core.region.aRegionId +import software.aws.toolkits.core.utils.readText +import software.aws.toolkits.core.utils.writeText +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermission +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +class DiskCacheTest { + @Rule + @JvmField + val tempFolder = TemporaryFolder() + + private val now = Instant.now() + private val clock = Clock.fixed(now, ZoneOffset.UTC) + + private val ssoUrl = "https://123456.awsapps.com/start" + private val ssoRegion = aRegionId() + + private lateinit var cacheLocation: Path + private lateinit var sut: DiskCache + + @Before + fun setUp() { + cacheLocation = tempFolder.newFolder().toPath() + sut = DiskCache(cacheLocation, clock) + } + + @Test + fun nonExistentClientRegistrationReturnsNull() { + assertThat(sut.loadClientRegistration(ssoRegion)).isNull() + } + + @Test + fun corruptClientRegistrationReturnsNull() { + cacheLocation.resolve("aws-toolkit-jetbrains-client-id-$ssoRegion.json").writeText("badData") + + assertThat(sut.loadClientRegistration(ssoRegion)).isNull() + } + + @Test + fun expiredClientRegistrationReturnsNull() { + cacheLocation.resolve("aws-toolkit-jetbrains-client-id-$ssoRegion.json").writeText( + """ + { + "clientId": "DummyId", + "clientSecret": "DummySecret", + "expiresAt": "${DateTimeFormatter.ISO_INSTANT.format(now.minusSeconds(100))}" + } + """.trimIndent() + ) + + assertThat(sut.loadClientRegistration(ssoRegion)).isNull() + } + + @Test + fun clientRegistrationExpiringSoonIsTreatedAsExpired() { + val expiationTime = now.plus(14, ChronoUnit.MINUTES) + cacheLocation.resolve("aws-toolkit-jetbrains-client-id-$ssoRegion.json").writeText( + """ + { + "clientId": "DummyId", + "clientSecret": "DummySecret", + "expiresAt": "${DateTimeFormatter.ISO_INSTANT.format(expiationTime)}" + } + """.trimIndent() + ) + + assertThat(sut.loadClientRegistration(ssoRegion)).isNull() + } + + @Test + fun validClientRegistrationReturnsCorrectly() { + val expiationTime = now.plus(20, ChronoUnit.MINUTES) + cacheLocation.resolve("aws-toolkit-jetbrains-client-id-$ssoRegion.json").writeText( + """ + { + "clientId": "DummyId", + "clientSecret": "DummySecret", + "expiresAt": "${DateTimeFormatter.ISO_INSTANT.format(expiationTime)}" + } + """.trimIndent() + ) + + assertThat(sut.loadClientRegistration(ssoRegion)) + .usingRecursiveComparison() + .isEqualTo( + ClientRegistration( + "DummyId", + "DummySecret", + expiationTime + ) + ) + } + + @Test + fun clientRegistrationSavesCorrectly() { + val expirationTime = DateTimeFormatter.ISO_INSTANT.parse("2020-04-07T21:31:33Z") + sut.saveClientRegistration( + ssoRegion, + ClientRegistration( + "DummyId", + "DummySecret", + Instant.from(expirationTime) + ) + ) + + val clientRegistration = cacheLocation.resolve("aws-toolkit-jetbrains-client-id-$ssoRegion.json") + if (isUnix()) { + assertThat(Files.getPosixFilePermissions(clientRegistration)).containsOnly(PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_READ) + } + assertThat(clientRegistration.readText()) + .isEqualToIgnoringWhitespace( + """ + { + "clientId": "DummyId", + "clientSecret": "DummySecret", + "expiresAt": "2020-04-07T21:31:33Z" + } + """.trimIndent() + ) + } + + @Test + fun invalidateClientRegistrationDeletesTheFile() { + val expiationTime = now.plus(20, ChronoUnit.MINUTES) + val cacheFile = cacheLocation.resolve("aws-toolkit-jetbrains-client-id-$ssoRegion.json") + cacheFile.writeText( + """ + { + "clientId": "DummyId", + "clientSecret": "DummySecret", + "expiresAt": "${DateTimeFormatter.ISO_INSTANT.format(expiationTime)}" + } + """.trimIndent() + ) + + assertThat(sut.loadClientRegistration(ssoRegion)).isNotNull + + sut.invalidateClientRegistration(ssoRegion) + + assertThat(sut.loadClientRegistration(ssoRegion)).isNull() + assertThat(cacheFile).doesNotExist() + } + + @Test + fun nonExistentAccessTokenReturnsNull() { + assertThat(sut.loadAccessToken(ssoUrl)).isNull() + } + + @Test + fun corruptAccessTokenReturnsNull() { + cacheLocation.resolve("c1ac99f782ad92755c6de8647b510ec247330ad1.json").writeText("badData") + + assertThat(sut.loadAccessToken(ssoUrl)).isNull() + } + + @Test + fun expiredAccessTokenReturnsNull() { + cacheLocation.resolve("c1ac99f782ad92755c6de8647b510ec247330ad1.json").writeText( + """ + { + "clientId": "$ssoUrl", + "clientSecret": "$ssoRegion", + "clientSecret": "DummyAccessToken", + "expiresAt": "${DateTimeFormatter.ISO_INSTANT.format(now.minusSeconds(100))}" + } + """.trimIndent() + ) + + assertThat(sut.loadAccessToken(ssoUrl)).isNull() + } + + @Test + fun accessTokenExpiringSoonIsTreatedAsExpired() { + val expiationTime = now.plus(14, ChronoUnit.MINUTES) + cacheLocation.resolve("c1ac99f782ad92755c6de8647b510ec247330ad1.json").writeText( + """ + { + "startUrl": "$ssoUrl", + "region": "$ssoRegion", + "accessToken": "DummyAccessToken", + "expiresAt": "${DateTimeFormatter.ISO_INSTANT.format(expiationTime)}" + } + """.trimIndent() + ) + + assertThat(sut.loadAccessToken(ssoUrl)).isNull() + } + + @Test + fun validAccessTokenReturnsCorrectly() { + val expiationTime = now.plus(20, ChronoUnit.MINUTES) + cacheLocation.resolve("c1ac99f782ad92755c6de8647b510ec247330ad1.json").writeText( + """ + { + "startUrl": "$ssoUrl", + "region": "$ssoRegion", + "accessToken": "DummyAccessToken", + "expiresAt": "${DateTimeFormatter.ISO_INSTANT.format(expiationTime)}" + } + """.trimIndent() + ) + + assertThat(sut.loadAccessToken(ssoUrl)) + .usingRecursiveComparison() + .isEqualTo( + AccessToken( + ssoUrl, + ssoRegion, + "DummyAccessToken", + expiationTime + ) + ) + } + + @Test + fun validAccessTokenFromCliReturnsCorrectly() { + cacheLocation.resolve("c1ac99f782ad92755c6de8647b510ec247330ad1.json").writeText( + """ + { + "startUrl": "$ssoUrl", + "region": "$ssoRegion", + "accessToken": "DummyAccessToken", + "expiresAt": "2999-06-10T00:50:40UTC" + } + """.trimIndent() + ) + + assertThat(sut.loadAccessToken(ssoUrl)) + .usingRecursiveComparison() + .isEqualTo( + AccessToken( + ssoUrl, + ssoRegion, + "DummyAccessToken", + ZonedDateTime.of(2999, 6, 10, 0, 50, 40, 0, ZoneOffset.UTC).toInstant() + ) + ) + } + + @Test + fun accessTokenSavesCorrectly() { + val expirationTime = DateTimeFormatter.ISO_INSTANT.parse("2020-04-07T21:31:33Z") + sut.saveAccessToken( + ssoUrl, + AccessToken( + ssoUrl, + ssoRegion, + "DummyAccessToken", + Instant.from(expirationTime) + ) + ) + + val accessTokenCache = cacheLocation.resolve("c1ac99f782ad92755c6de8647b510ec247330ad1.json") + if (isUnix()) { + assertThat(Files.getPosixFilePermissions(accessTokenCache)).containsOnly(PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_READ) + } + + assertThat(accessTokenCache.readText()) + .isEqualToIgnoringWhitespace( + """ + { + "startUrl": "$ssoUrl", + "region": "$ssoRegion", + "accessToken": "DummyAccessToken", + "expiresAt": "2020-04-07T21:31:33Z" + } + """.trimIndent() + ) + } + + @Test + fun accessTokenInvalidationDeletesFile() { + val expiationTime = now.plus(20, ChronoUnit.MINUTES) + val cacheFile = cacheLocation.resolve("c1ac99f782ad92755c6de8647b510ec247330ad1.json") + cacheFile.writeText( + """ + { + "startUrl": "$ssoUrl", + "region": "$ssoRegion", + "accessToken": "DummyAccessToken", + "expiresAt": "${DateTimeFormatter.ISO_INSTANT.format(expiationTime)}" + } + """.trimIndent() + ) + + assertThat(sut.loadAccessToken(ssoUrl)).isNotNull + + sut.invalidateAccessToken(ssoUrl) + + assertThat(sut.loadAccessToken(ssoUrl)).isNull() + assertThat(cacheFile).doesNotExist() + } + + private fun isUnix() = !System.getProperty("os.name").toLowerCase().startsWith("windows") +} diff --git a/core/tst/software/aws/toolkits/core/credentials/sso/SsoAccessTokenProviderTest.kt b/core/tst/software/aws/toolkits/core/credentials/sso/SsoAccessTokenProviderTest.kt new file mode 100644 index 0000000000..6330e320d5 --- /dev/null +++ b/core/tst/software/aws/toolkits/core/credentials/sso/SsoAccessTokenProviderTest.kt @@ -0,0 +1,361 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials.sso + +import com.nhaarman.mockitokotlin2.KStubbing +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.stub +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Before +import org.junit.Test +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import software.amazon.awssdk.services.ssooidc.model.AuthorizationPendingException +import software.amazon.awssdk.services.ssooidc.model.CreateTokenRequest +import software.amazon.awssdk.services.ssooidc.model.CreateTokenResponse +import software.amazon.awssdk.services.ssooidc.model.InvalidClientException +import software.amazon.awssdk.services.ssooidc.model.InvalidRequestException +import software.amazon.awssdk.services.ssooidc.model.RegisterClientRequest +import software.amazon.awssdk.services.ssooidc.model.RegisterClientResponse +import software.amazon.awssdk.services.ssooidc.model.SlowDownException +import software.amazon.awssdk.services.ssooidc.model.SsoOidcException +import software.amazon.awssdk.services.ssooidc.model.StartDeviceAuthorizationRequest +import software.amazon.awssdk.services.ssooidc.model.StartDeviceAuthorizationResponse +import software.aws.toolkits.core.region.aRegionId +import software.aws.toolkits.core.utils.delegateMock +import software.aws.toolkits.core.utils.test.aString +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit + +class SsoAccessTokenProviderTest { + private val clock = Clock.fixed(Instant.now().truncatedTo(ChronoUnit.MILLIS), ZoneOffset.UTC) + + private val ssoUrl = aString() + private val ssoRegion = aRegionId() + private val clientId = aString() + private val clientSecret = aString() + + private lateinit var ssoLoginCallback: SsoLoginCallback + private lateinit var ssoOidcClient: SsoOidcClient + private lateinit var sut: SsoAccessTokenProvider + private lateinit var ssoCache: SsoCache + + @Before + fun setUp() { + ssoOidcClient = delegateMock() + ssoLoginCallback = mock() + ssoCache = mock() + + sut = SsoAccessTokenProvider(ssoUrl, ssoRegion, ssoLoginCallback, ssoCache, ssoOidcClient, clock) + } + + @Test + fun getAccessTokenWithAccessTokenCache() { + val accessToken = AccessToken(ssoUrl, ssoRegion, "dummyToken", clock.instant()) + ssoCache.stub { + on( + ssoCache.loadAccessToken(ssoUrl) + ).thenReturn( + accessToken + ) + } + + val accessTokenActual = runBlocking { sut.accessToken() } + assertThat(accessTokenActual) + .usingRecursiveComparison() + .isEqualTo(accessToken) + + verify(ssoCache).loadAccessToken(ssoUrl) + } + + @Test + fun getAccessTokenWithClientRegistrationCache() { + val expirationClientRegistration = clock.instant().plusSeconds(120) + setupCacheStub(expirationClientRegistration) + + ssoOidcClient.stub { + stubStartDeviceAuthorization() + stubCreateToken() + } + + val accessToken = runBlocking { sut.accessToken() } + assertThat(accessToken).usingRecursiveComparison() + .isEqualTo( + AccessToken( + ssoUrl, + ssoRegion, + "accessToken", + clock.instant().plusSeconds(180) + ) + ) + + verify(ssoOidcClient).startDeviceAuthorization(any()) + verify(ssoOidcClient).createToken(any()) + verify(ssoCache).loadAccessToken(ssoUrl) + verify(ssoCache).loadClientRegistration(ssoRegion) + verify(ssoCache).saveAccessToken(ssoUrl, accessToken) + } + + @Test + fun getAccessTokenWithoutCaches() { + val expirationClientRegistration = clock.instant().plusSeconds(120) + setupCacheStub(returnValue = null) + + ssoOidcClient.stub { + on( + ssoOidcClient.registerClient( + RegisterClientRequest.builder() + .clientType("public") + .clientName("aws-toolkit-jetbrains-${Instant.now(clock)}") + .build() + ) + ).thenReturn( + RegisterClientResponse.builder() + .clientId(clientId) + .clientSecret(clientSecret) + .clientSecretExpiresAt(expirationClientRegistration.toEpochMilli()) + .build() + ) + + stubStartDeviceAuthorization() + stubCreateToken() + } + + val accessToken = runBlocking { sut.accessToken() } + assertThat(accessToken).usingRecursiveComparison() + .isEqualTo( + AccessToken( + ssoUrl, + ssoRegion, + "accessToken", + clock.instant().plusSeconds(180) + ) + ) + + verify(ssoOidcClient).registerClient(any()) + verify(ssoOidcClient).startDeviceAuthorization(any()) + verify(ssoOidcClient).createToken(any()) + verify(ssoCache).loadAccessToken(ssoUrl) + verify(ssoCache).loadClientRegistration(ssoRegion) + verify(ssoCache).saveClientRegistration(eq(ssoRegion), any()) + verify(ssoCache).saveAccessToken(ssoUrl, accessToken) + } + + @Test + fun getAccessTokenWithoutCachesMultiplePolls() { + val expirationClientRegistration = clock.instant().plusSeconds(120) + + setupCacheStub(expirationClientRegistration) + + ssoOidcClient.stub { + stubStartDeviceAuthorization(interval = 1) + on( + ssoOidcClient.createToken(createTokenRequest()) + ).thenThrow( + AuthorizationPendingException.builder().build() + ).thenReturn( + createTokenResponse() + ) + } + + val startTime = Instant.now() + val accessToken = runBlocking { sut.accessToken() } + val callDuration = Duration.between(startTime, Instant.now()) + + assertThat(accessToken).usingRecursiveComparison() + .isEqualTo( + AccessToken( + ssoUrl, + ssoRegion, + "accessToken", + clock.instant().plusSeconds(180) + ) + ) + + assertThat(callDuration.seconds).isGreaterThanOrEqualTo(1).isLessThan(2) + + verify(ssoOidcClient).startDeviceAuthorization(any()) + verify(ssoOidcClient, times(2)).createToken(any()) + verify(ssoCache).loadAccessToken(ssoUrl) + verify(ssoCache).loadClientRegistration(ssoRegion) + verify(ssoCache).saveAccessToken(ssoUrl, accessToken) + } + + @Test + fun exceptionStopsPolling() { + val expirationClientRegistration = clock.instant().plusSeconds(120) + + setupCacheStub(expirationClientRegistration) + + ssoOidcClient.stub { + stubStartDeviceAuthorization() + stubCreateToken(throws = true) + } + + assertThatThrownBy { runBlocking { sut.accessToken() } }.isInstanceOf(InvalidRequestException::class.java) + + verify(ssoOidcClient).startDeviceAuthorization(any()) + verify(ssoOidcClient).createToken(any()) + verify(ssoCache).loadAccessToken(ssoUrl) + verify(ssoCache).loadClientRegistration(ssoRegion) + } + + @Test + fun backOffTimeIsRespected() { + val expirationClientRegistration = clock.instant().plusSeconds(120) + setupCacheStub(expirationClientRegistration) + + ssoOidcClient.stub { + stubStartDeviceAuthorization(interval = 1) + + on( + ssoOidcClient.createToken(createTokenRequest()) + ).thenThrow( + SlowDownException.builder().build() + ).thenReturn( + createTokenResponse() + ) + } + + val startTime = Instant.now() + val accessToken = runBlocking { sut.accessToken() } + val callDuration = Duration.between(startTime, Instant.now()) + + assertThat(accessToken).usingRecursiveComparison() + .isEqualTo( + AccessToken( + ssoUrl, + ssoRegion, + "accessToken", + clock.instant().plusSeconds(180) + ) + ) + + assertThat(callDuration.seconds).isGreaterThanOrEqualTo(6) + + verify(ssoCache).saveAccessToken(ssoUrl, accessToken) + + verify(ssoOidcClient).startDeviceAuthorization(any()) + verify(ssoOidcClient, times(2)).createToken(any()) + verify(ssoCache).loadAccessToken(ssoUrl) + verify(ssoCache).loadClientRegistration(ssoRegion) + verify(ssoCache).saveAccessToken(ssoUrl, accessToken) + } + + @Test + fun failToGetClientRegistrationLeadsToError() { + setupCacheStub(returnValue = null) + + ssoOidcClient.stub { + on( + ssoOidcClient.registerClient(any()) + ).thenThrow( + SsoOidcException.builder().build() + ) + } + + assertThatThrownBy { runBlocking { sut.accessToken() } }.isInstanceOf(SsoOidcException::class.java) + + verify(ssoOidcClient).registerClient(any()) + verify(ssoCache).loadAccessToken(ssoUrl) + verify(ssoCache).loadClientRegistration(ssoRegion) + } + + @Test + fun invalidClientRegistrationClearsTheCache() { + setupCacheStub(Instant.now(clock)) + + ssoOidcClient.stub { + on( + ssoOidcClient.startDeviceAuthorization(any()) + ).thenThrow( + InvalidClientException.builder().build() + ) + } + + assertThatThrownBy { runBlocking { sut.accessToken() } }.isInstanceOf(InvalidClientException::class.java) + + verify(ssoCache).invalidateClientRegistration(ssoRegion) + } + + @Test + fun invalidateClearsTheCache() { + sut.invalidate() + + verify(ssoCache).invalidateAccessToken(ssoUrl) + } + + private fun setupCacheStub(expirationClientRegistration: Instant) { + setupCacheStub(ClientRegistration(clientId, clientSecret, expirationClientRegistration)) + } + + private fun setupCacheStub(returnValue: ClientRegistration?) { + ssoCache.stub { + on( + ssoCache.loadAccessToken(ssoUrl) + ).thenReturn( + null + ) + + on( + ssoCache.loadClientRegistration(ssoRegion) + ).thenReturn( + returnValue + ) + } + } + + private fun KStubbing.stubStartDeviceAuthorization(interval: Int? = null) { + on( + ssoOidcClient.startDeviceAuthorization( + StartDeviceAuthorizationRequest.builder() + .clientId(clientId) + .clientSecret(clientSecret) + .startUrl(ssoUrl) + .build() + ) + ).thenReturn( + StartDeviceAuthorizationResponse.builder() + .expiresIn(120) + .deviceCode("dummyCode") + .userCode("dummyUserCode") + .verificationUri("someUrl") + .verificationUriComplete("someUrlComplete") + .apply { if (interval != null) interval(interval) } + .build() + ) + } + + private fun KStubbing.stubCreateToken(throws: Boolean = false) { + on( + ssoOidcClient.createToken(createTokenRequest()) + ).apply { + if (throws) { + thenThrow(InvalidRequestException.builder().build()) + } else { + thenReturn(createTokenResponse()) + } + } + } + + private fun createTokenRequest(): CreateTokenRequest = CreateTokenRequest.builder() + .clientId(clientId) + .clientSecret(clientSecret) + .deviceCode("dummyCode") + .grantType("urn:ietf:params:oauth:grant-type:device_code") + .build() + + private fun createTokenResponse(): CreateTokenResponse = CreateTokenResponse.builder() + .accessToken("accessToken") + .expiresIn(180) + .build() +} diff --git a/core/tst/software/aws/toolkits/core/credentials/sso/SsoCredentialProviderTest.kt b/core/tst/software/aws/toolkits/core/credentials/sso/SsoCredentialProviderTest.kt new file mode 100644 index 0000000000..1f1ee40638 --- /dev/null +++ b/core/tst/software/aws/toolkits/core/credentials/sso/SsoCredentialProviderTest.kt @@ -0,0 +1,116 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials.sso + +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.stub +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Before +import org.junit.Test +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials +import software.amazon.awssdk.services.sso.SsoClient +import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest +import software.amazon.awssdk.services.sso.model.GetRoleCredentialsResponse +import software.amazon.awssdk.services.sso.model.RoleCredentials +import software.amazon.awssdk.services.ssooidc.model.AccessDeniedException +import software.aws.toolkits.core.utils.delegateMock +import software.aws.toolkits.core.utils.test.aString +import java.time.Instant +import java.time.temporal.ChronoUnit + +class SsoCredentialProviderTest { + private val accessTokenId = aString() + private val accountId = aString() + private val roleName = aString() + private val accessKey = aString() + private val secretKey = aString() + private val sessionToken = aString() + private val credentials = AwsSessionCredentials.create(accessKey, secretKey, sessionToken) + private val accessToken = AccessToken(aString(), aString(), accessTokenId, Instant.now().plusSeconds(10)) + + private lateinit var ssoClient: SsoClient + private lateinit var ssoAccessTokenProvider: SsoAccessTokenProvider + private lateinit var sut: SsoCredentialProvider + + @Before + fun setUp() { + ssoClient = delegateMock() + ssoAccessTokenProvider = mock() { + onBlocking { + it.accessToken() + }.thenReturn( + accessToken + ) + } + sut = SsoCredentialProvider(accountId, roleName, ssoClient, ssoAccessTokenProvider) + } + + @Test + fun cachingDoesNotApplyToExpiredSession() { + createSsoResponse(Instant.now().minusSeconds(5000)) + + assertThat(sut.resolveCredentials()).usingRecursiveComparison().isEqualTo(credentials) + + // Resolve again + assertThat(sut.resolveCredentials()).usingRecursiveComparison().isEqualTo(credentials) + + verify(ssoClient, times(2)).getRoleCredentials(any()) + } + + @Test + fun cachingDoesApplyToExpiredSession() { + createSsoResponse(Instant.now().plus(2, ChronoUnit.HOURS)) + + assertThat(sut.resolveCredentials()).usingRecursiveComparison().isEqualTo(credentials) + + // Resolve again + assertThat(sut.resolveCredentials()).usingRecursiveComparison().isEqualTo(credentials) + + verify(ssoClient).getRoleCredentials(any()) + } + + @Test + fun rejectedAccessTokenInvalidatesIt() { + ssoClient.stub { + on( + ssoClient.getRoleCredentials(any()) + ).thenThrow( + AccessDeniedException.builder().build() + ) + } + + assertThatThrownBy { sut.resolveCredentials() }.isNotNull() + + verify(ssoAccessTokenProvider).invalidate() + } + + private fun createSsoResponse(expirationTime: Instant) { + ssoClient.stub { + on( + ssoClient.getRoleCredentials( + GetRoleCredentialsRequest.builder() + .accessToken(accessTokenId) + .accountId(accountId) + .roleName(roleName) + .build() + ) + ).thenReturn( + GetRoleCredentialsResponse.builder() + .roleCredentials( + RoleCredentials.builder() + .accessKeyId(accessKey) + .secretAccessKey(secretKey) + .sessionToken(sessionToken) + .expiration(expirationTime.toEpochMilli()) + .build() + ) + .build() + ) + } + } +} diff --git a/core/tst/software/aws/toolkits/core/region/AwsRegionTest.kt b/core/tst/software/aws/toolkits/core/region/AwsRegionTest.kt index e1cfcbd9f3..cfce0d283d 100644 --- a/core/tst/software/aws/toolkits/core/region/AwsRegionTest.kt +++ b/core/tst/software/aws/toolkits/core/region/AwsRegionTest.kt @@ -3,11 +3,12 @@ package software.aws.toolkits.core.region -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat +import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized +import software.aws.toolkits.core.utils.test.aString +import kotlin.random.Random @RunWith(Parameterized::class) class AwsRegionTest(private val region: AwsRegion, private val expectedCategory: String, private val expectedDisplayName: String) { @@ -32,11 +33,20 @@ class AwsRegionTest(private val region: AwsRegion, private val expectedCategory: @Test fun displayNameShouldMatch() { - assertThat(region.displayName, equalTo(expectedDisplayName)) + assertThat(region.displayName).isEqualTo(expectedDisplayName) } @Test fun categoryShouldMatch() { - assertThat(region.category, equalTo(expectedCategory)) + assertThat(region.category).isEqualTo(expectedCategory) } } + +fun anAwsRegion(id: String = aRegionId(), name: String = aString(), partitionId: String = aString()) = AwsRegion(id, name, partitionId) + +fun aRegionId(): String { + val prefix = arrayOf("af", "us", "ca", "eu", "ap", "me", "cn").random() + val compass = arrayOf("north", "south", "east", "west", "central") + val count = Random.nextInt(1, 10) + return "$prefix-$compass-$count" +} diff --git a/core/tst/software/aws/toolkits/core/utils/CompletionStageUtils.kt b/core/tst/software/aws/toolkits/core/utils/CompletionStageUtils.kt new file mode 100644 index 0000000000..18aa984125 --- /dev/null +++ b/core/tst/software/aws/toolkits/core/utils/CompletionStageUtils.kt @@ -0,0 +1,17 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.utils + +import org.jetbrains.annotations.TestOnly +import java.util.concurrent.CompletionStage +import java.util.concurrent.ExecutionException + +// Wait for a completion stage to end, and throw the exception that caused it to fail +// if it fails. +@TestOnly +fun CompletionStage.unwrap(): T = try { + this.toCompletableFuture().get() +} catch (e: ExecutionException) { + throw e.cause ?: e +} diff --git a/core/tst/software/aws/toolkits/core/utils/LogUtilsTest.kt b/core/tst/software/aws/toolkits/core/utils/LogUtilsTest.kt index 475afce63a..d39b86bfe7 100644 --- a/core/tst/software/aws/toolkits/core/utils/LogUtilsTest.kt +++ b/core/tst/software/aws/toolkits/core/utils/LogUtilsTest.kt @@ -10,8 +10,7 @@ import com.nhaarman.mockitokotlin2.reset import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyZeroInteractions import com.nhaarman.mockitokotlin2.whenever -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat +import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.slf4j.Logger @@ -24,10 +23,8 @@ class LogUtilsTest { @Test fun exceptionIsLoggedAndSuppressedInTryOrNull() { val expectedException = RuntimeException("Boom") - val result = log.tryOrNull("message", level = Level.WARN) { throw expectedException } - + log.tryOrNull("message", level = Level.WARN) { throw expectedException } verify(log).warn(any(), eq(expectedException)) - assertThat(result, equalTo(null)) } @Test @@ -35,7 +32,7 @@ class LogUtilsTest { val expectedException = RuntimeException("Boom") val exception = catch { log.tryOrThrowNullable("message") { throw expectedException } } verify(log).error(any(), eq(expectedException)) - assertThat(exception === expectedException, equalTo(true)) + assertThat(exception).isEqualTo(expectedException) } @Test @@ -43,7 +40,7 @@ class LogUtilsTest { val expectedException = RuntimeException("Boom") val exception = catch { log.tryOrThrow("message") { throw expectedException } } verify(log).error(any(), eq(expectedException)) - assertThat(exception === expectedException, equalTo(true)) + assertThat(exception).isEqualTo(expectedException) } @Test @@ -56,7 +53,7 @@ class LogUtilsTest { fun smartCastToNonNullOnTryOrThrow() { val nullableValue: String = log.tryOrThrow("message") { mightBeNull(shouldBeNull = false) } val nonNullableValue: String = log.tryOrThrow("message") { willNeverBeNull() } - assertThat(nullableValue, equalTo(nonNullableValue)) + assertThat(nullableValue).isEqualTo(nonNullableValue) } @Test @@ -150,9 +147,8 @@ class LogUtilsTest { @Test fun logWhenNull() { - val result = log.logWhenNull("message", level = Level.WARN) { null } + log.logWhenNull("message", level = Level.WARN) { null } verify(log).warn("message", null) - assertThat(result, equalTo(null)) } @Before diff --git a/core/tst/software/aws/toolkits/core/utils/RuleUtils.kt b/core/tst/software/aws/toolkits/core/utils/RuleUtils.kt index d2abbeef32..3cb636a72c 100644 --- a/core/tst/software/aws/toolkits/core/utils/RuleUtils.kt +++ b/core/tst/software/aws/toolkits/core/utils/RuleUtils.kt @@ -6,7 +6,7 @@ package software.aws.toolkits.core.utils import java.util.Random object RuleUtils { - fun randomName(prefix: String, length: Int = 63): String { + fun randomName(prefix: String = "a", length: Int = 63): String { val userName = System.getProperty("user.name", "unknown") return "${prefix.toLowerCase()}-${userName.toLowerCase()}-${Random().nextInt(10000)}".take(length) } @@ -15,4 +15,6 @@ object RuleUtils { val callingClass = Thread.currentThread().stackTrace[3].className return callingClass.substringAfterLast(".") } + + fun randomNumber(min: Int = 0, max: Int = 65535): Int = Random().nextInt(max - min + 1) + min } diff --git a/core/tst/software/aws/toolkits/core/utils/test/AssertJAsserts.kt b/core/tst/software/aws/toolkits/core/utils/test/AssertJAsserts.kt new file mode 100644 index 0000000000..07aab489ca --- /dev/null +++ b/core/tst/software/aws/toolkits/core/utils/test/AssertJAsserts.kt @@ -0,0 +1,10 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.utils.test + +import org.assertj.core.api.ObjectAssert + +@Suppress("UNCHECKED_CAST") +val ObjectAssert.notNull: ObjectAssert + get() = this.isNotNull as ObjectAssert diff --git a/core/tst/software/aws/toolkits/core/utils/test/TestUtils.kt b/core/tst/software/aws/toolkits/core/utils/test/TestUtils.kt index 777874212d..79c0a63218 100644 --- a/core/tst/software/aws/toolkits/core/utils/test/TestUtils.kt +++ b/core/tst/software/aws/toolkits/core/utils/test/TestUtils.kt @@ -5,7 +5,11 @@ package software.aws.toolkits.core.utils.test import java.time.Duration import java.time.Instant +import java.util.UUID import java.util.concurrent.atomic.AtomicInteger +import kotlin.random.Random + +fun aString(length: Int = Random.nextInt(5, 30)): String = UUID.randomUUID().toString().substring(length) fun retryableAssert( timeout: Duration? = null, diff --git a/designs/credentialManagement/credentialManagement.md b/designs/credentialManagement/credentialManagement.md new file mode 100644 index 0000000000..6cb560e707 --- /dev/null +++ b/designs/credentialManagement/credentialManagement.md @@ -0,0 +1,153 @@ +# Credential Management + +## Abstract + +This document describes the process of managing and switching of AWS credentials within the toolkit. + +## Motivation + +AWS provides many different ways to retrieve the credentials required to make calls to AWS including but not limited to: +credential/config files, environment variables, system properties, and HTTP metadata APIs. A user may also assume roles using +a source set of credentials and may have MFA enabled. + +Due to the large matrix of possible credential sources, the toolkit strives to provide an intuitive abstraction layer on top of some of these sources so that +the implementation details are hidden fom the user. The system should also enable future expansion of new sources, including the possibility of sources provided +by third party plugins. + +## Classes and Concepts +![ClassDiagram] + +1. `AwsRegion` - Date class that represents an AWS Region and joins together related data for that region. This data is sourced from the endpoints.json file. +It contains the following data: + 1. `ID` - Contains the ID of the region (e.g. `us-west-2`) + 1. `Name` - Contains the human readable name for the region (e.g. `US West (Oregon)`) + 1. `Partiton ID` - Contains the ID of the top level AWS partition (e.g. `aws`, `aws-cn`) + +1. `CredentialIdentifier` - Represents the globally unique identifier for a possible credential profile in the toolkit. This identifier must be deterministic +meaning that if two `CredentialIdentifier`s for the same credential source should be equal even across different IDE sessions. +This is shown to the user as the **Profile** in the UI. + +1. `AwsCredentialsProvider` - SDK interface that resolves AWS Credentials from the provider. For more info, see [AwsCredentialsProvider] in the SDK. + +1. `ToolkitCredentialsProvider` - A class that implements `AwsCredentialsProvider`. This class does not +do any actual resolving of credentials, but instead leaves it to another concrete implementation by delegating to another implementation of +`AwsCredentialsProvider`. This class works as a bridge between a `CredentialIdentifier` and a `AwsCredentialsProvider`. + +1. `CredentialProviderFactory` - Abstract class that knows and understands one "category" of credentials (e.g. Shared Credentials files) and has two jobs. + + 1. Detect valid `CredentialIdentifier`s that should be presented to the user as a possible choice to use in the IDE. A `CredentialIdentifier` is determined + to be valid if and only if the credential source has all the required information to comply with the underlying credential source's contract. For example, + a factory that handles static credentials would need to make sure that both access and secret keys are provided. + + **It MUST not verify if the credentials themselves are valid (able to make an AWS call) at creation time of the `CredentialIdentifier`.** + + 1. Convert the `CredentialIdentifier` it created earlier iinto a valid `AwsCredentialProvider`. This is implementation specific and may re-use existing + `AwsCredentialProvider` implementations. + +1. `CredentialManager` - Acts as the entry point to the credential system for the rest of the toolkit. Its +job is to keep track of all `CredentialIdentifier` that should be presented to the user as a possible profile they can use. + +### Extension System + +In order to make the credential management subsystem extendable, we leverage the IntelliJ [Extension Point] system. Whenever `CredentialManager` needs to +communicate with a `CredentialProviderFactory`, it queries the Extension Point `aws.toolkit.credentialProviderFactory` for implementations and proceeds to +search the list for the factory with the requested ID. + +Example of usage: +```xml + + + +``` + +### Deep Credential Validation + +In order to validate the credentials returned by the `ToolkitCredentialsProvider` are able to make AWS calls, we make a call to `sts::getCallerIdentity`. +If the call fails, we consider the credentials to be invalid. + +## Connection Settings Management + +The class [AwsConnectionManager] is the entry point into this system. + +The concept of _Active Connection Settings_ represents the current user selected credentials and region that the toolkit uses to perform actions in the AWS Explorer as well as +being used as defaults when more than one option is possible. + +Due to the nature of the IntellJ projects (project level) each has their own windows while existing in one JVM (application level). Since we store active +connection settings at the project level, each window can have a different active `CredenitalIdentifier` and/or `AwsRegion` selected. + +### Connection State + +A state machine around the connection validation steps the toolkit goes through. Attempts to encapsulate both state, data available at each state and exposes an +`isTerminal` property that indicates if this state is temporary in the 'connection validation' workflow or if this is a terminal state. + +States: +* `InitializingToolkit` - Initial state of of the `AwsConnectionManager` when a project is opened. In this state we are reading the previous settings for the +project for setting defaults if none are found. At this point the `CredentialManager` should be started, if it is not already. + + Note: Due to `AwsConnectionManager` being at the project level, another project may have started the `CredentialManager` since it is scoped at the + application level. +* `ValidatingConnection` - Represents that we are performing a [deep credential check](#deep-credential-validation) on the requested _Active Connection Settings_ +* `ValidConnection` - Represents that the deep check passed and the user is told the credentials can be used +* `IncompleteConfiguration` - Represents the either a region or `CredentialIdentifier` is not selected so we lack the required data to talk to AWS. +* `InvalidConnection` - Represents that the deep check failed and the user should be told the why and possible remediate actions. +* `RequiresUserAction` - Represents that we have just left `InitializingToolkit` but the _Active Connection Settings_ will require user action in order for the +deeper credential check to proceed such as an MFA prompt. This state is present in order to improve the UX by not forcing the user to perform an action on +project opening. + +### Picking Defaults +In order to provide a good "out of the box" experience, the toolkit will attempt to use sane defaults when a project is opened with out any previous settings. + +#### Credential Profile +The Toolkit will attempt to load the `default` profile located in the Shared Credentials files. + +An example `~/.aws/config`: +```ini +[default] +credential_process = /usr/bin/myCredentialProcess +``` + +#### Region +The Toolkit attempts to determine a default region based on a heuristic, the region ID must exist in the `endpoints.json` metadata to be considered valid. +If a region ID resolved by one step in the heuristic does not exist in the metadata, the Toolkit will continue down the list until a valid region is found. + +1. **Last selected (by Project)** - if the _Project_ has previously been opened with the Toolkit - the last region selected when the toolkit closed will be +preserved. +1. **Environment variable / system property** - uses the AWS Java SDK [SystemSettingsRegionProvider] Region Provider to determine region based on the +`AWS_REGION` environment variable or `aws.region` system property. +1. **Default Profile** - uses the AWS Java SDK [AwsProfileRegionProvider] Region Provider to interrogate the `default` profile from the Shared Credentials +files, using `region` if found in the profile. + An example `~/.aws/config`: + ```ini + [default] + region = us-west-2 + ``` + +1. **us-east-1** - looks for `us-east-1` in resolved metadata. +1. **First region in metadata** - if all else fails look for the first region that exists in the `endpoints.json` file. + +If all of the above fails, the toolkit will throw an exception due to the region data has not been populated and must be considered a fatal error. This +indicates that the toolkit either was built incorrectly, or has a severe bug in it since the toolkit can not operate without the region data. + +### Combined State Flow +![StateFlow] + +### Retrieving AWS Credentials +When another section of the toolkit needs to retrieve AWS credentials, it must request an `AwsCredentialProvider` using +`CredentialManager.getAwsCredentialProvider(CredentialIdentifier, AwsRegion)`. The `CredentialIdentifier` represents the credential profile we are trying to +resolve, and the region parameter is required so that we can determine the correct STS endpoint to call. + +`CredentialManager` returns a `ToolkitCredentialsProvider` which is an immutable class which exposes its underlying `CredentialIdentifier` while implementing the +`AwsCredentialsProvider` interface so that it can be given transparently to the SDKs. The `AwsCredentialsProvider.resolveCredentials` method call is proxied +over to a `AwsCredentialProviderProxy`. + +`AwsCredentialProviderProxy` acts as a "pointer" to the real `AwsCredentialProvider` created by the `CredentialProviderFactory` while also keeping track of the +region that was used to create it. This allows us to keep references to the `ToolkitCredentialsProvider` to keep resolving credentials even when the underlying +source has been updated, such as when the shared credentials files has been modified by an external process. + +[AwsCredentialsProvider]: https://github.com/aws/aws-sdk-java-v2/blob/master/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProvider.java +[ClassDiagram]: images/classDiagram.svg +[Extension Point]: https://www.jetbrains.org/intellij/sdk/docs/basics/plugin_structure/plugin_extension_points.html +[AwsConnectionManager]: ../../jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsConnectionManager.kt +[StateFlow]: images/credManageStateFlow.svg +[SystemSettingsRegionProvider]: https://github.com/aws/aws-sdk-java-v2/blob/master/core/regions/src/main/java/software/amazon/awssdk/regions/providers/SystemSettingsRegionProvider.java +[AwsProfileRegionProvider]: https://github.com/aws/aws-sdk-java-v2/blob/master/core/regions/src/main/java/software/amazon/awssdk/regions/providers/AwsProfileRegionProvider.java diff --git a/designs/credentialManagement/design.md b/designs/credentialManagement/design.md deleted file mode 100644 index 13d9138be4..0000000000 --- a/designs/credentialManagement/design.md +++ /dev/null @@ -1,130 +0,0 @@ -# Credential Management - -## Abstract - -This document describes the process of managing and switching of AWS credentials within the toolkit. - -## Motivation - -AWS provides many different ways to retrieve the credentials required to make calls to AWS including but not limited to: -credential/config files, environment variables, system properties, EC2 metadata APIs. You can also assume roles using -a source set of credentials or use MFA. Due to this large matrix of possibilities the toolkit strives to provide an -intuitive abstraction layer on top of some of these sources so that the implementation details are hidden and a -consistent user experience can be provided, while also enabling future expansion of new sources, including possibly by -third party plugins. - -## Specification - -This toolkit will introduce the following concepts: - -1. `ToolkitCredentialsProvider` - An abstract class that implements [AwsCredentialsProvider] in the SDK. This class does not -do any actual resolving of credentials, but instead leaves that to concrete implementations. It instead provides -an ID that is globally unique across all credential providers as well as defining a way to generate a display name. - -2. `ToolkitCredentialsProviderFactory` - Factory interface that knows how to create one or more `ToolkitCredentialsProvider` -for a credential source. A `ToolkitCredentialsProviderFactory` can create 0 or more instances of `ToolkitCredentialsProvider` -as long as each one is valid. Valid is defined as the credential source has all the required information to comply with -the underlying credential source's contract. For example, a factory that handles static credentials would need to make sure that -both access and secret keys are provided. It does not verify if the credentials themselves are valid (able to make an AWS call) -at creation time of the `ToolkitCredentialsProvider`. - -3. `ToolkitCredentialsProviderManager` - This class acts as the union of all `ToolkitCredentialsProviderFactory`. Its -job is to be able to list all `ToolkitCredentialsProvider` and return the provider that is referenced by its unique global ID. - It also has the ability to have listeners registered to it so they can listen for changes when `ToolkitCredentialsProvider` are -added or removed such as when the shared credentials file is modified. - -4. `Active Connection Settings` - Represents the current credentials and region that the toolkit uses to perform actions or defaults to when -more than one option is possible. Due to the nature of the IntellJ IDE being multiple windows in one JVM, each project -window can have a different active credential selected. - -### Diagram -![ClassDiagram] - -### Extension System - -We register all `ToolkitCredentialsProviderFactory` through the IntelliJ extension point system by creating a custom -extension point bean (`CredentialProviderFactoryEP`) under the FQN `aws.toolkit.credentialProviderFactory` which has a -single `implementation` attribute. The extension system is only queried once, and the list of instances of -`ToolkitCredentialsProviderFactory` must be immutable. - -Example of usage: -```xml - - - -``` - -### Credential Validation - -In order to validate the credentials returned by the `ToolkitCredentialsProvider`, we make a call to -`sts::getCallerIdentity`. If the call fails, we consider the credentials to be invalid. - -### Built-in Providers - -#### Shared Credentials File(s) (Profile file) - -Also known as credential profiles, this sources the credentials from the `~/.aws/config` and `~/.aws/credentials` file -according to [CLI documentation][CliConfigDocs]. We try to comply as close as possible with the CLI behavior, but not -all keys are supported in the toolkit. We ignore properties not related to credential management as well. - -Supported keys: -* `aws_access_key_id` -* `aws_secret_access_key` -* `aws_session_token` -* `source_profile` -* `external_id` -* `role_session_name` -* `mfa_serial` -* `credential_process` - -##### Refreshing - -We start a file watcher to watch the `credential` and `config` files for changes. Upon detecting changes we will internally -reload, add, or remove instances of `ProfileToolkitCredentialsProvider` based on if the profile is still syntactically valid. - -If a profile is modified, but its name is not changed, its `ProfileToolkitCredentialsProvider` should be modified internally. -This means external references to the object are still valid. - -## Settings Management - -### Active Connection Settings Flow - -Active connection settings are managed at the project level and does validation upon switching of either the active credential provider or region. -The credential manager can have an internal state of one of the following: -1. `Initializing` - The state upon loading of the IDE, this state is the initial state whenever a new project is opened. -It can be entered from either the UI (EDT) thread of a background thread based on the loading order of the IDE, but the processing of the -state must happen on a background thread. This state includes loading of previous settings and if settings do not exist, query for defaults according to -the default profile resolving logic as defined by the SDKs. This state must exit into the `Validating` state. -2. `Validating` - This state is can be entered into by any thread, but the validation logic as defined by [Credential Validation](#credential-validation) must -be on a background thread. The result of the validation should be notified on the UI thread. If they are valid, the manager will enter the `Active` state, -else it will enter `Invalid`. -3. `Active` - This state represents that there are active credentials and they are valid. -4. `Invalid` - This state represents that the selected credential profile / region pair has failed the validation check. When we enter this state, we report -that there are no active credentials, but we do not clear the users selection. This is required to allow for switching of credentials and regions that may take two -operations to succeed. - -### Status Bar - -We register a status bar widget in order to provide the user with an indicator of what credential is active in their -project. It will also indicate what state we are in inside of the account settings manager. - -The status bar also acts as an entry point to switching by having a switcher in the context menu. - -![NoCredentialsStatusBar] - -*Image when no credentials are active* - -![DefaultCredentialsStatusBar] - -*Image when a profile named `default is active* - -### Multi-Factor Authentication - -If the credential provider has MFA, we will need to prompt the user for their OTP. This works by blocking the -`resolveCredentials` call in [AwsCredentialProvider] until a input dialog message prompt is filled in. - -[AwsCredentialsProvider]: https://github.com/aws/aws-sdk-java-v2/blob/master/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProvider.java -[CliConfigDocs]: https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#credentials -[DefaultCredentialsStatusBar]: images/defaultCrdentialsStatusBar.png -[NoCredentialsStatusBar]: images/noCrdentialsStatusBar.png -[ClassDiagram]: images/classDiagram.svg diff --git a/designs/credentialManagement/images/classDiagram.svg b/designs/credentialManagement/images/classDiagram.svg index 774d50be65..de97bcd5a8 100644 --- a/designs/credentialManagement/images/classDiagram.svg +++ b/designs/credentialManagement/images/classDiagram.svg @@ -1,31 +1,124 @@ -ToolkitCredentialsProviderManagerList<ToolkitCredentialsProvider> getCredentialProviders()ToolkitCredentialsProvider getCredentialProvider(String id)ToolkitCredentialsProviderFactoryT extends ToolkitCredentialsProviderString typeList<T> providersList<T> listCredentialProviders()T get(String id)void addProvider(T provider)void removeProvider(T provider)ToolkitCredentialsProviderString displayNameString idboolean validate()AwsCredentialsProviderAwsCredentials resolveCredentials()InheritanceCreatesmany1Ownsmany1AwsRegionString partitionIdString nameString idToolkitCredentialsIdentifierString factoryIdString displayNameString idCredentialsChangeEventadded: List<ToolkitCredentialsIdentifier>,modified: List<ToolkitCredentialsIdentifier>,removed: List<ToolkitCredentialsIdentifier>CredentialsChangeListeneronChange(CredentialsChangeEventCredentialManagerList<ToolkitCredentialsIdentifier> getCredentialIdentifiers()ToolkitCredentialsProvider getCredentialProvider(String id)CredentialProviderFactoryString idvoid setUp(callback CredentialsChangeListener)ToolkitCredentialsProvider createAwsCredentialProvider(ToolkitCredentialsIdentifier, AwsRegion, Suppier<SdkHttpClient>)ToolkitCredentialsProviderString displayNameString idAwsCredentialsProviderAwsCredentials resolveCredentials()InheritanceCreatesmany1Ownsmany1 \ No newline at end of file + +PlantUML version 1.2020.16beta3(Unknown compile time) +(GPL source distribution) +Java Runtime: Java(TM) SE Runtime Environment +JVM: Java HotSpot(TM) 64-Bit Server VM +Default Encoding: UTF-8 +Language: en +Country: US +--> diff --git a/designs/credentialManagement/images/credManageStateFlow.svg b/designs/credentialManagement/images/credManageStateFlow.svg new file mode 100644 index 0000000000..9098fc2465 --- /dev/null +++ b/designs/credentialManagement/images/credManageStateFlow.svg @@ -0,0 +1,907 @@ + + + + + + + + + + + +
+
+ User Opens Toolkit +
+
+
+ User Opens Toolkit +
+
+ + + + + + +
+
+ Connection State: INITIALIZING +
+
+
+ Connection State: INITIALIZING +
+
+ + + + +
+
+
Previous credentials?
+
+
+
+ <div>Previous credentials?</div> + +
+
+ + + + + +
+
no +
+
+
+ no +
+
+ + + + + +
+
yes +
+
+
+ yes +
+
+ + + + +
+
+ "default" profile? +
+
+
+ "default" profile? +
+
+ + + + + +
+
no +
+
+
+ no +
+
+ + + + + +
+
yes +
+
+
+ yes +
+
+ + + + +
+
+ Set as selected credentials +
+
+
+ Set as selected credentials +
+
+ + + + + + +
+
+ Connection State: NOT_CONFIGURED +
+
+
+ Connection State: NOT_CONFIGURED +
+
+ + + + + + + + + + + + + +
+
+
Has "default" +
+
+
profile?
+
+
+
+ [Not supported by viewer] +
+
+ + + + + +
+
no +
+
+
+ no +
+
+ + + + + +
+
no +
+
+
+ no +
+
+ + + + + +
+
yes +
+
+
+ yes +
+
+ + + + + + +
+
+
Set as selected region
+
+
+
+ <div>Set as selected region</div> + +
+
+ + + + +
+
+
AWS_REGION +
+
+
env var?
+
+
+
+ [Not supported by viewer] +
+
+ + + + + +
+
no +
+
+
+ no +
+
+ + + + + +
+
yes +
+
+
+ yes +
+
+ + + + +
+
+
"us-east-1" in +
+
+
region data?
+
+
+
+ [Not supported by viewer] +
+
+ + + + + +
+
no +
+
+
+ no +
+
+ + + + + +
+
yes +
+
+
+ yes +
+
+ + + + +
+
+
Previous region?
+
+
+
+ <div>Previous region?</div> +
+
+ + + + + +
+
no +
+
+
+ no +
+
+ + + + + +
+
yes +
+
+
+ yes +
+
+ + + + +
+
+ Creds require user interaction? +
+
+
+ Creds require user interaction? +
+
+ + + + + +
+
no +
+
+
+ no +
+
+ + + + + +
+
yes +
+
+
+ yes +
+
+ + + + +
+
+ Connection State: NOT_VALIDATED +
+
+
+ Connection State: NOT_VALIDATED +
+
+ + + + + + +
+
+ Connection State: VALIDATING +
+
+
+ Connection State: VALIDATING +
+
+ + + + + + + + + +
+
+ User switches region +
+
+
+ User switches region +
+
+ + + + + + + + + +
+
+ User switches creds +
+
+
+ User switches creds +
+
+ + + + + + +
+
+ Explorer / status bar indicates not validated +
+
+
+ Explorer / status bar indicates not validated + +
+
+ + + + + + +
+
+
STS Call
+
works? +
+
+
+
+
+ [Not supported by viewer] +
+
+ + + + + +
+
+
no
+
+
+
+ [Not supported by viewer] +
+
+ + + + + +
+
yes +
+
+
+ yes +
+
+ + + + +
+
+ Explorer / status bar indicates not configured +
+
+
+ Explorer / status bar indicates not + configured + +
+
+ + + + + + + + + + +
+
+ Connection State: VALID +
+
+
+ Connection State: VALID +
+
+ + + + +
+
+ Refresh Explorer Tree / Update status bar +
+
+
+ Refresh Explorer Tree / Update status bar + +
+
+ + + + + + + + +
+
+ User triggers (re-)validation +
+
+
+ User triggers (re-)validation +
+
+ + + + + + +
+
+ profile has region? +
+
+
+ profile has region? +
+
+ + + + + +
+
yes +
+
+
+ yes +
+
+ + + + +
+
+ Has region & cred ID? +
+
+
+ Has region & cred ID? +
+
+ + + + + +
+
no +
+
+
+ no +
+
+ + + + + +
+
yes +
+
+
+ yes +
+
+ + + + +
+
+ Endpoint data loaded +
+
+
+ Endpoint data loaded +
+
+ + + + + +
+
yes +
+
+
+ yes +
+
+ + + + +
+
+
Take first region
+
+
+
+ <div>Take first region</div> +
+
+ + + + + + +
+
+ USER ACTION +
+
+
+ USER ACTION +
+
+ + + + +
+
+ STATE TRANSITION +
+
+
+ STATE TRANSITION +
+
+ + + + +
+
+ IDE ACTION +
+
+
+ IDE ACTION +
+
+ + + + +
+
+ + LEGEND + +
+
+
+ [Not supported by viewer] +
+
+ + + + + + + +
+
no +
+
+
+ no +
+
+ + + + +
+
+ Fatal error, toolkit can't work +
+
+
+ Fatal error, toolkit can't work +
+
+
+
diff --git a/designs/credentialManagement/images/defaultCrdentialsStatusBar.png b/designs/credentialManagement/images/defaultCrdentialsStatusBar.png deleted file mode 100644 index 190d60c9db..0000000000 Binary files a/designs/credentialManagement/images/defaultCrdentialsStatusBar.png and /dev/null differ diff --git a/designs/credentialManagement/images/noCrdentialsStatusBar.png b/designs/credentialManagement/images/noCrdentialsStatusBar.png deleted file mode 100644 index fc8635ff8a..0000000000 Binary files a/designs/credentialManagement/images/noCrdentialsStatusBar.png and /dev/null differ diff --git a/designs/credentialManagement/images/ssoLoginFlow.svg b/designs/credentialManagement/images/ssoLoginFlow.svg new file mode 100644 index 0000000000..f497d929cb --- /dev/null +++ b/designs/credentialManagement/images/ssoLoginFlow.svg @@ -0,0 +1,73 @@ +External ProgramAWS ServiceUserUserBrowserBrowserToolkitToolkitSSO CredProviderSSO CredProviderSSO AccessToken ProviderSSO AccessToken ProviderCacheCacheSSO OIDC ServiceSSO OIDC ServiceSSO ServiceSSO ServiceResolve CredentialsRole creds expiredRequest Access TokenRequest TokenReturn TokenAccess token expired / missingRequest Client RegistrationReturn Client RegistrationClient registration expired / missingRequest to Register ClientClient RegistrationSave Client RegistrationStart Device AuthReturn SSO Login DetailsOpen SSO Login PagePerform SSO Loginloop[Check For Token]Create Tokenalt[Success]Token ReturnedSave Token[Auth Pending]Auth Pending or Slow Down ErrorSleep[Error]Other ErrorAbort Process, Throw ErrorReport ErrorReturn Access TokenRequest Role CredentialsReturn Role's AWS Session CredentialsAWS Credentials diff --git a/designs/credentialManagement/profileFileSupport.md b/designs/credentialManagement/profileFileSupport.md new file mode 100644 index 0000000000..95404b4765 --- /dev/null +++ b/designs/credentialManagement/profileFileSupport.md @@ -0,0 +1,54 @@ +# Profile File Support + +## Abstract + +This document describes the process of supporting the AWS Profile files in the [Credential Management](credentialManagement.md) system + +## Shared Credentials File(s) + +AWS SDKs and tools are capable of sharing credentials by storing them in a common location. Also known as shared credential files, the **config** and +**credentials** files are detailed at [CLI documentation][CliConfigDocs]. We try to comply as close as possible with the CLI credential resolution behavior, +but only the keys related to credential management are supported in the toolkit. + +Supported keys: +* `aws_access_key_id` +* `aws_secret_access_key` +* `aws_session_token` +* `source_profile` +* `external_id` +* `role_session_name` +* `mfa_serial` +* `credential_process` +* `sso_*` - See [SSO Document](ssoSupport.md) + +### File Parsing +Parsing and merging of the **config** and **credentials** files is out of scope of this document and is handled by the Java SDK. Please see [ProfileFile](https://github.com/aws/aws-sdk-java-v2/blob/master/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileFile.java). + +If there are any issues parsing either of the files, the entire parsing job MUST be treated as a failure and the error shown to the user and no state changes +are made to the `CredentialManager`. + +### File Locating Resolution +Locating of the profile files is handled by the Java SDK. Please see [ProfileFileLocation](https://github.com/aws/aws-sdk-java-v2/blob/master/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileFileLocation.java). + +If file location logic returns an invalid path, the files must be treated as not existing. + +### Profile File Modification Detection + +An Application Service (`ProfileWatcher`) is registered by the toolkit. The service instructs the IDE to notify it when the parent directory of the `config` +and/or `credentials` files are modified. The Toolkit monitors the parent of the folder in case the files do not exist yet. + +When the `ProfileCredentialProviderFactory` is created, it registers itself as a listener on the `ProfileWatcher`. + +Profiles are considered modified if, and only if, its contents (properties keys/values) modified or dependent profiles are modified. The changing of a profile +name MUST be considered an addition of a new profile, and deletion of the old profile. Changes to the properties in the profile or a profile in its +`source_profile` chain MUST be treated as an edit of the profile if the names of the profiles are unchanged. + +### Multi-Factor Authentication + +We support assuming a role with MFA. If the requested credential profile has the `mfa_serial` property , we will need to prompt the user for their OTP. +This works by blocking the `resolveCredentials` call in `AwsCredentialProvider` and shows an input dialog message prompt is filled in on the UI thread. + +**This has the downside of deadlocking if the UI (EDT) thread is blocking waiting on a call to AWS which always happens on a background thread.** This means +that any UI elements that are populated by an AWS call must be done so in an asynchronous manner. + +[CliConfigDocs]: https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#credentials diff --git a/designs/credentialManagement/ssoSupport.md b/designs/credentialManagement/ssoSupport.md new file mode 100644 index 0000000000..33dee8392f --- /dev/null +++ b/designs/credentialManagement/ssoSupport.md @@ -0,0 +1,32 @@ +# AWS SSO Support + +## Abstract + +This document describes the process of supporting Amazon SSO defined through a shared credentials profile. + +## Specification + +The [AWS SSO-OIDC service][SsoOidc] is SSO's implementation of the Device Authorization Grant flow as defined in [RFC8628][RFC8628]. + +## SSO Login Sequence +![][SsoLoginFlow] + +## Cache +The SSO login flow contains two long lived tokens (`client registration id` and `access token`). These tokens MUST be cached in order to prevent the user from +having to re-do the SSO login flow whenever their assumed role session credentials expire. We currently only offer one implementation of the cache. + +### Disk Cache +The disk cache is is written to allow interop of the Toolkit with other tools (e.g. AWS CLI) meaning that if they perform an SSO login in the terminal before +starting the IDE, they do not need to perform it again and vice versa. The long lived tokens are cached in the `~/.aws/sso/cache/` folder with `0600` permissions. + +## Profile keys +The AWS shared credential file added new standard keys to support SSO: + +* `sso_start_url` - The URL that points to the organization's AWS SSO user portal. +* `sso_region` - The AWS Region that contains the AWS SSO portal host. This is separate from, and can be a different region than the default region parameter. +* `sso_account_id` - The AWS account ID that contains the IAM role that you want to use with this profile. +* `sso_role_name` - The name of the IAM role that defines the user's permissions when using this profile. + +[SsoOidc]: https://docs.aws.amazon.com/singlesignon/latest/OIDCAPIReference/Welcome.html +[RFC8628]: https://tools.ietf.org/html/rfc8628 +[SsoLoginFlow]: images/ssoLoginFlow.svg diff --git a/gradle.properties b/gradle.properties index 11a1fbb21e..7017be1642 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=1.17-SNAPSHOT +toolkitVersion=1.18-SNAPSHOT # Publish Settings publishToken= @@ -11,16 +11,22 @@ publishChannel= # Common dependencies ideProfileName=2019.3 kotlinVersion=1.3.70 -awsSdkVersion=2.11.9 +awsSdkVersion=2.13.58 coroutinesVersion=1.3.3 ideaPluginVersion=0.4.20 ktlintVersion=0.36.0 jacksonVersion=2.9.8 -telemetryVersion=0.0.30 +telemetryVersion=0.0.34 assertjVersion=3.15.0 junitVersion=4.12 +junit5Version=5.6.2 mockitoKotlinVersion=2.2.0 +mockitoVersion=3.4.0 + +remoteRobotPort=8080 +remoteRobotVersion=0.9.35 +uiTestFixturesVersion=1.1.18 # Code style kotlin.code.style=official diff --git a/intellijJVersions.gradle b/intellijJVersions.gradle index 7a5a507248..92e1558420 100644 --- a/intellijJVersions.gradle +++ b/intellijJVersions.gradle @@ -1,13 +1,12 @@ static def ideProfiles() { return [ "2019.3": [ - "guiTestFramework": "com.intellij.testGuiFramework:193.SNAPSHOT.1@nightly", "sinceVersion": "193", "untilVersion": "193.*", - "products": [ + "products" : [ "IC": [ sdkVersion: "IC-2019.3", - plugins: [ + plugins : [ "org.jetbrains.plugins.terminal", "org.jetbrains.plugins.yaml", "PythonCore:193.5233.139", @@ -19,7 +18,7 @@ static def ideProfiles() { ], "IU": [ sdkVersion: "IU-2019.3", - plugins: [ + plugins : [ "org.jetbrains.plugins.terminal", "Pythonid:193.5233.109", "org.jetbrains.plugins.yaml", @@ -28,10 +27,10 @@ static def ideProfiles() { ] ], "RD": [ - sdkVersion: "RD-2019.3.4", + sdkVersion : "RD-2019.3.4", rdGenVersion: "0.193.146", nugetVersion: "2019.3.4", - plugins: [ + plugins : [ "org.jetbrains.plugins.yaml" ] ] @@ -40,10 +39,10 @@ static def ideProfiles() { "2020.1": [ "sinceVersion": "201", "untilVersion": "201.*", - "products": [ + "products" : [ "IC": [ sdkVersion: "IC-2020.1", - plugins: [ + plugins : [ "org.jetbrains.plugins.terminal", "org.jetbrains.plugins.yaml", "PythonCore:201.6668.31", @@ -55,19 +54,57 @@ static def ideProfiles() { ], "IU": [ sdkVersion: "IU-2020.1", - plugins: [ + plugins : [ "org.jetbrains.plugins.terminal", "Pythonid:201.6668.31", "org.jetbrains.plugins.yaml", "JavaScript", "JavaScriptDebugger", + "com.intellij.database", ] ], "RD": [ - sdkVersion: "RD-2020.1-SNAPSHOT", + sdkVersion : "RD-2020.1.0", rdGenVersion: "0.201.69", - nugetVersion: "2020.1.0-eap05", - plugins: [ + nugetVersion: "2020.1.0", + plugins : [ + "org.jetbrains.plugins.yaml" + ] + ] + ] + ], + "2020.2": [ + "sinceVersion": "202", + "untilVersion": "202.*", + "products" : [ + "IC": [ + sdkVersion: "IC-202.6250.13-EAP-SNAPSHOT", + plugins : [ + "org.jetbrains.plugins.terminal", + "org.jetbrains.plugins.yaml", + "PythonCore:202.6250.13", + "java", + "com.intellij.gradle", + "org.jetbrains.idea.maven", + "Docker:202.6250.6" + ] + ], + "IU": [ + sdkVersion: "IU-202.6250.13-EAP-SNAPSHOT", + plugins : [ + "org.jetbrains.plugins.terminal", + "Pythonid:202.6250.13", + "org.jetbrains.plugins.yaml", + "JavaScript", + "JavaScriptDebugger", + "com.intellij.database", + ] + ], + "RD": [ + sdkVersion : "RD-2020.2-SNAPSHOT", + rdGenVersion: "0.202.113", + nugetVersion: "2020.2.0-eap07", + plugins : [ "org.jetbrains.plugins.yaml" ] ] @@ -108,14 +145,6 @@ def ideUntilVersion() { return guiVersion } -def guiTestFramework() { - def guiVersion = ideProfile()["guiTestFramework"] - if (guiVersion == null) { - throw new IllegalArgumentException("Missing 'guiTestFramework' key for ${resolveIdeProfileName()}") - } - return guiVersion -} - // https://www.myget.org/feed/rd-snapshots/package/maven/com.jetbrains.rd/rd-gen def rdGenVersion() { def rdGen = ideProduct("RD").rdGenVersion @@ -159,7 +188,8 @@ static def shortenVersion(String ver) { if (result) { return result.group(1) + result.group(2) } - } catch(Exception ignored) { } + } catch (Exception ignored) { + } return ver } @@ -170,7 +200,6 @@ ext { ideSinceVersion = this.&ideSinceVersion ideUntilVersion = this.&ideUntilVersion ideProfile = this.&ideProfile - guiTestFramework = this.&guiTestFramework rdGenVersion = this.&rdGenVersion riderNugetSdkVersion = this.&riderNugetSdkVersion resolveIdeProfileName = this.&resolveIdeProfileName diff --git a/jetbrains-core-gui/build.gradle b/jetbrains-core-gui/build.gradle deleted file mode 100644 index 20505956b7..0000000000 --- a/jetbrains-core-gui/build.gradle +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -apply plugin: 'org.jetbrains.intellij' -apply plugin: 'jacoco' - -dependencies { - compile project(":") - compile project(path: ":core", configuration: 'testArtifacts') -} - -intellij { - version ideSdkVersion("IC") - updateSinceUntilBuild false - downloadSources false - instrumentCode false - // GUI test framework is not avilable in 2020.1 - if(resolveIdeProfileName() != "2020.1") { - plugins = idePlugins("IC") + [guiTestFramework(), project(":")] - } -} - -jacoco.applyTo(runIde) -runIde { - systemProperties System.properties.findAll {(it.key as String).startsWith("idea") || (it.key as String).startsWith("jb")} - debugOptions { - enabled = false - port = 5005 - server = true - suspend = true - } - systemProperty("testDataPath", project.rootDir.toPath().resolve("testdata").toString()) - systemProperty("ide.mac.file.chooser.native", "false") - if (System.getenv("CI") != null) { - systemProperty("aws.sharedCredentialsFile", "/tmp/.aws/credentials") - } - - /* Need to split the space-delimited value in the exec.args */ - args System.getProperty("exec.args", "").split(",") - jacoco { - includeNoLocationClasses = true - includes = ["software.aws.toolkits.*"] - } -} - -// don't run gui tests as part of check -test.enabled = false - -task guiTest(type: Test) { - workingDir = project.rootDir - systemProperty("idea.gui.tests.gradle.runner", true) - include '**/*TestSuite*' -} - -task sourcesJar(type: Jar, dependsOn: classes) { - classifier = 'sources' - from sourceSets.main.allSource -} - -task classesJar(type: Jar, dependsOn: classes) { - classifier = 'classes' - from sourceSets.main.output - from project(':jetbrains-core').sourceSets.main.output - from project(':jetbrains-ultimate').sourceSets.main.output - - project.findProject(':jetbrains-rider')?.collect { - from it.sourceSets.main.output - } - - from project(':core').sourceSets.test.output - exclude 'META-INF/plugin.xml' - exclude 'testData/*' -} - -task testsJar(type: Jar, dependsOn: classes) { - classifier = 'tests' - from sourceSets.test.output - exclude 'testData/*' -} - -prepareSandbox { - from(classesJar) { - into "testGuiFramework/lib" - } - from(sourceSets.main.resources) { - exclude 'META-INF' - into "testGuiFramework/lib" - } - from(sourceSets.test.resources) { - exclude 'META-INF' - into "testGuiFramework/lib" - } - from(testsJar) { - into "testGuiFramework/lib" - } -} diff --git a/jetbrains-core-gui/tst/NewProjectWizardTestSuite.kt b/jetbrains-core-gui/tst/NewProjectWizardTestSuite.kt deleted file mode 100644 index 9c5ace8320..0000000000 --- a/jetbrains-core-gui/tst/NewProjectWizardTestSuite.kt +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import com.intellij.testGuiFramework.framework.GuiTestSuite -import com.intellij.testGuiFramework.framework.RunWithIde -import com.intellij.testGuiFramework.launcher.ide.CommunityIde -import org.junit.runner.RunWith -import org.junit.runners.Suite -import software.aws.toolkits.jetbrains.settings.SetSamCli -import software.aws.toolkits.jetbrains.ui.s3.S3BrowserTest -import software.aws.toolkits.jetbrains.ui.wizard.SamInitProjectBuilderIntelliJTest - -@RunWith(Suite::class) -@RunWithIde(CommunityIde::class) -@Suite.SuiteClasses(S3BrowserTest::class, SetSamCli::class, SamInitProjectBuilderIntelliJTest::class) -class NewProjectWizardTestSuite : GuiTestSuite() diff --git a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/EmptyProjectTestCase.kt b/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/EmptyProjectTestCase.kt deleted file mode 100644 index c65ab7aaa0..0000000000 --- a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/EmptyProjectTestCase.kt +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains - -import com.intellij.openapi.application.ApplicationInfo -import com.intellij.testGuiFramework.impl.GuiTestCase -import com.intellij.testGuiFramework.util.scenarios.newProjectDialogModel -import org.junit.Before -import software.aws.toolkits.jetbrains.fixtures.createEmptyProject -import java.nio.file.Path -import java.nio.file.Paths - -abstract class EmptyProjectTestCase : GuiTestCase() { - - protected val testDataPath: Path = Paths.get(System.getProperty("testDataPath")) - - @Before - fun createEmptyProject() { - // TODO fix tests on 2019.3 - val info = ApplicationInfo.getInstance() - if (info.majorVersion != "2019" || info.minorVersionMainPart != "2") { - return - } - - welcomeFrame { - createNewProject() - newProjectDialogModel.createEmptyProject(projectFolder) - } - } -} diff --git a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/IdeFrameUtils.kt b/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/IdeFrameUtils.kt deleted file mode 100644 index 610dc40c5c..0000000000 --- a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/IdeFrameUtils.kt +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.fixtures - -import com.intellij.openapi.actionSystem.impl.ActionMenuItem -import com.intellij.openapi.wm.impl.IdeFrameImpl -import com.intellij.testGuiFramework.fixtures.IdeFrameFixture -import com.intellij.testGuiFramework.impl.findComponentWithTimeout -import com.intellij.testGuiFramework.impl.jList -import com.intellij.testGuiFramework.util.step -import com.intellij.ui.SimpleColoredComponent - -fun IdeFrameFixture.clickMenuItem(predicate: (ActionMenuItem) -> Boolean) { - findComponentWithTimeout { predicate(it) }.let { robot().click(it) } -} - -fun IdeFrameFixture.configureConnection(profile: String, region: String) { - step("Configure connection to profile: $profile, region $region") { - val component = - findComponentWithTimeout { - it.javaClass.name.contains("IdeStatusBarImpl") && it.toolTipText == "AWS Connection Settings" - } - - robot().click(component) - jList(region).clickItem(region) - - robot().click(component) - jList(profile).clickItem(profile) - } -} diff --git a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/JBEditorTabsUtils.kt b/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/JBEditorTabsUtils.kt deleted file mode 100644 index ad0f7cda08..0000000000 --- a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/JBEditorTabsUtils.kt +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.fixtures - -import com.intellij.testGuiFramework.framework.Timeouts -import com.intellij.testGuiFramework.impl.findComponentWithTimeout -import com.intellij.testGuiFramework.util.step -import org.fest.swing.core.Robot -import org.fest.swing.fixture.ContainerFixture -import org.fest.swing.timing.Timeout -import java.awt.Container -import javax.swing.JPanel - -class JBTabsFixture(private val robot: Robot, private val tabLabel: JPanel) { - fun selectTab() { - robot.click(tabLabel) - } -} - -fun ContainerFixture.jbTab(tabTitle: String, timeout: Timeout = Timeouts.defaultTimeout) = - step("search JBTabs with label '$tabTitle'") { - val tabLabel: JPanel = findComponentWithTimeout(timeout) { it.toString() == tabTitle } - JBTabsFixture(robot(), tabLabel) - } diff --git a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/NewProjectDialogModelUtil.kt b/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/NewProjectDialogModelUtil.kt deleted file mode 100644 index 0574497960..0000000000 --- a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/NewProjectDialogModelUtil.kt +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.fixtures - -import com.intellij.testGuiFramework.impl.button -import com.intellij.testGuiFramework.impl.combobox -import com.intellij.testGuiFramework.impl.jList -import com.intellij.testGuiFramework.util.scenarios.NewProjectDialogModel -import com.intellij.testGuiFramework.util.scenarios.NewProjectDialogModel.Constants.buttonFinish -import com.intellij.testGuiFramework.util.scenarios.NewProjectDialogModel.Constants.buttonNext -import com.intellij.testGuiFramework.util.scenarios.assertProjectPathExists -import com.intellij.testGuiFramework.util.scenarios.connectDialog -import com.intellij.testGuiFramework.util.scenarios.fileSystemUtils -import com.intellij.testGuiFramework.util.scenarios.selectProjectGroup -import com.intellij.testGuiFramework.util.scenarios.selectSdk -import com.intellij.testGuiFramework.util.scenarios.typeProjectNameAndLocation -import com.intellij.testGuiFramework.util.scenarios.waitLoadingTemplates -import com.intellij.testGuiFramework.util.step -import java.util.Arrays -import kotlin.test.assertNotNull - -private const val AWS_GROUP = "AWS" - -data class ServerlessProjectOptions(val runtime: String, val template: String) - -fun NewProjectDialogModel.createServerlessProject( - projectPath: String, - templateOptions: ServerlessProjectOptions, - sdkRegex: Regex -) { - with(guiTestCase) { - fileSystemUtils.assertProjectPathExists(projectPath) - - with(connectDialog()) { - step("select '$AWS_GROUP' project group") { - waitLoadingTemplates() - - val list = jList(AWS_GROUP) - step("click '$AWS_GROUP'") { list.clickItem(AWS_GROUP) } - list.requireSelection(AWS_GROUP) - } - - val projectType = "AWS Serverless Application" - step("select project type '$projectType'") { - jList(projectType).clickItem(projectType) - } - - step("setup '$projectType'") { - button(buttonNext).click() - - typeProjectNameAndLocation(projectPath) - - step("select runtime '${templateOptions.runtime}'") { - combobox("Runtime:").selectItem(templateOptions.runtime) - } - - step("select template '${templateOptions.template}'") { - combobox("SAM Template:").selectItem(templateOptions.template) - } - - val sdkCandidates = sdkChooser().contents() - val sdkChoice = sdkCandidates.firstOrNull { it.matches(sdkRegex) } - assertNotNull(sdkChoice, "No valid SDK found, choices are: ${Arrays.toString(sdkCandidates)}") - selectSdk(sdkChoice) - - step("close New Project dialog with Finish") { - button(buttonFinish).click() - waitTillGone() - } - } - } - } -} - -fun NewProjectDialogModel.createEmptyProject(projectPath: String) { - with(connectDialog()) { - selectProjectGroup(NewProjectDialogModel.Groups.Empty) - button(buttonNext).click() - typeProjectNameAndLocation(projectPath) - button(buttonFinish).click() - } - - with(guiTestCase) { - ideFrame { - waitForBackgroundTasksToFinish() - dialog("Project Structure") { - button(NewProjectDialogModel.Constants.buttonCancel).click() - } - } - } -} diff --git a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/ProjectStructureDialogModelUtils.kt b/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/ProjectStructureDialogModelUtils.kt deleted file mode 100644 index d6a73d50b2..0000000000 --- a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/ProjectStructureDialogModelUtils.kt +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.fixtures - -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import com.intellij.testGuiFramework.fixtures.JDialogFixture -import com.intellij.testGuiFramework.impl.jList -import com.intellij.testGuiFramework.impl.testTreeItemExist -import com.intellij.testGuiFramework.util.Predicate -import com.intellij.testGuiFramework.util.scenarios.ProjectStructureDialogModel -import com.intellij.testGuiFramework.util.scenarios.ProjectStructureDialogModel.Constants.itemLibrary -import com.intellij.testGuiFramework.util.scenarios.ProjectStructureDialogModel.Constants.menuProject -import com.intellij.testGuiFramework.util.scenarios.checkLibrary -import com.intellij.testGuiFramework.util.scenarios.connectDialog -import com.intellij.testGuiFramework.util.step - -fun ProjectStructureDialogModel.checkPage(page: String, checks: JDialogFixture.() -> Unit) { - with(guiTestCase) { - step("at '$page' page in Project Structure dialog") { - val dialog = connectDialog() - dialog.jList(page).clickItem(page) - dialog.checks() - } - } -} - -fun ProjectStructureDialogModel.checkProject(checks: JDialogFixture.() -> Unit) { - checkPage(menuProject, checks) -} - -fun ProjectStructureDialogModel.checkLibraryPrefixPresent(library: String) { - checkLibrary { - guiTestCase.testTreeItemExist(itemLibrary, library, predicate = Predicate.startWith) - } -} diff --git a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/SDKComboBoxUtil.kt b/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/SDKComboBoxUtil.kt deleted file mode 100644 index 41cec22408..0000000000 --- a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/SDKComboBoxUtil.kt +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.fixtures - -import com.intellij.openapi.ui.ComboBoxWithWidePopup -import com.intellij.testGuiFramework.cellReader.ExtendedJComboboxCellReader -import com.intellij.testGuiFramework.framework.Timeouts -import com.intellij.testGuiFramework.impl.findComponentWithTimeout -import com.intellij.testGuiFramework.util.step -import org.fest.swing.core.Robot -import org.fest.swing.fixture.ContainerFixture -import org.fest.swing.fixture.JComboBoxFixture -import org.fest.swing.timing.Timeout -import java.awt.Container - -class SdkChooserFixture(robot: Robot, jdkComboBox: ComboBoxWithWidePopup<*>) : JComboBoxFixture(robot, jdkComboBox) { - init { - this.replaceCellReader(ExtendedJComboboxCellReader()) - } -} - -fun ContainerFixture.sdkChooser(timeout: Timeout = Timeouts.defaultTimeout) = - step("search for SDK combo box") { - val jdkComboBox: ComboBoxWithWidePopup<*> = findComponentWithTimeout(timeout) { - it.javaClass.name == "com.intellij.openapi.roots.ui.configuration.JdkComboBox" - } - SdkChooserFixture(robot(), jdkComboBox) - } diff --git a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/WelcomeFrameUtils.kt b/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/WelcomeFrameUtils.kt deleted file mode 100644 index e7d8af8dc5..0000000000 --- a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/fixtures/WelcomeFrameUtils.kt +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.fixtures - -import com.intellij.openapi.util.SystemInfo -import com.intellij.testGuiFramework.fixtures.WelcomeFrameFixture -import com.intellij.testGuiFramework.impl.actionLink -import com.intellij.testGuiFramework.impl.popupMenu - -fun WelcomeFrameFixture.openSettingsDialog() { - actionLink("Configure").click() - val prefName = if (SystemInfo.isMac) "Preferences" else "Settings" - popupMenu(prefName).clickSearchedItem() -} diff --git a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/settings/SetSamCli.kt b/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/settings/SetSamCli.kt deleted file mode 100644 index 51f60c586c..0000000000 --- a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/settings/SetSamCli.kt +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.settings - -import com.intellij.testGuiFramework.fixtures.JDialogFixture -import com.intellij.testGuiFramework.fixtures.SearchTextFieldFixture -import com.intellij.testGuiFramework.impl.GuiTestCase -import com.intellij.testGuiFramework.impl.button -import com.intellij.testGuiFramework.impl.findComponentWithTimeout -import com.intellij.testGuiFramework.impl.jTree -import com.intellij.testGuiFramework.impl.textfield -import com.intellij.testGuiFramework.util.step -import com.intellij.ui.SearchTextField -import org.junit.Test -import software.aws.toolkits.jetbrains.fixtures.openSettingsDialog - -class SetSamCli : GuiTestCase() { - @Test - fun setSamCli() { - val samPath = System.getenv("SAM_CLI_EXEC") ?: "sam" - welcomeFrame { - step("Open preferences page") { - openSettingsDialog() - - dialog(defaultSettingsTitle) { - // Search for AWS because sometimes it is off the screen - step("Search for AWS") { - findSearchTextField().click() - - robot().enterText("AWS") - } - - jTree("Tools", "AWS").clickPath() - - step("Set SAM CLI executable path to $samPath") { - val execPath = textfield("SAM CLI executable:") - execPath.setText(samPath) - } - button("OK").click() - } - } - } - } - - private fun JDialogFixture.findSearchTextField(): SearchTextFieldFixture { - val searchTextField = findComponentWithTimeout(this.target(), SearchTextField::class.java) - return SearchTextFieldFixture(this.robot(), searchTextField) - } -} diff --git a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/ui/s3/S3BrowserTest.kt b/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/ui/s3/S3BrowserTest.kt deleted file mode 100644 index c25e39e659..0000000000 --- a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/ui/s3/S3BrowserTest.kt +++ /dev/null @@ -1,244 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.ui.s3 - -import com.intellij.openapi.application.ApplicationInfo -import com.intellij.openapi.fileEditor.FileEditorManager -import com.intellij.testGuiFramework.driver.ExtendedJTreePathFinder -import com.intellij.testGuiFramework.fixtures.IdeFrameFixture -import com.intellij.testGuiFramework.fixtures.TreeTableFixture -import com.intellij.testGuiFramework.impl.GuiTestUtilKt -import com.intellij.testGuiFramework.impl.button -import com.intellij.testGuiFramework.impl.findComponentWithTimeout -import com.intellij.testGuiFramework.impl.jTree -import com.intellij.testGuiFramework.impl.textfield -import com.intellij.testGuiFramework.impl.waitAMoment -import com.intellij.testGuiFramework.util.Predicate -import com.intellij.testGuiFramework.util.step -import com.intellij.ui.treeStructure.treetable.TreeTable -import org.fest.swing.core.MouseButton -import org.fest.swing.driver.ComponentPreconditions -import org.fest.swing.driver.JTreeLocation -import org.junit.After -import org.junit.Test -import software.aws.toolkits.jetbrains.EmptyProjectTestCase -import software.aws.toolkits.jetbrains.fixtures.clickMenuItem -import software.aws.toolkits.jetbrains.fixtures.configureConnection -import java.awt.Dimension -import java.awt.Point -import java.awt.Rectangle -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.util.UUID -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -class S3BrowserTest : EmptyProjectTestCase() { - - private val profile = "Profile:default" - private val date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) - private val bucket = "uitest-$date-${UUID.randomUUID()}" - - @Test - fun s3MainFunctionality() { - // TODO fix tests on 2019.3 - val info = ApplicationInfo.getInstance() - if (info.majorVersion != "2019" || info.minorVersionMainPart != "2") { - return - } - - ideFrame { - waitForBackgroundTasksToFinish() - - configureConnection(profile, "Oregon (us-west-2)") - - toolwindow("aws.explorer") { - activate() - step("Create bucket named $bucket") { - jTree(S3_NAME).path(S3_NAME).rightClickPath() - clickMenuItem { it.text.startsWith(CREATE_BUCKET) } - dialog(CREATE_BUCKET) { - textfield(null).setText(bucket) - button(CREATE_BUTTON).click() - } - waitAMoment() - } - - step("Open editor for bucket $bucket") { - with(jTree(S3_NAME).expandPath(S3_NAME)) { - path(S3_NAME, bucket).doubleClickPath() - waitAMoment() - } - } - } - - treeTable { - step("Upload object to top-level") { - rightClick() - clickMenuItem { it.text.contains(UPLOAD_ACTION) } - fileChooserDialog { - setPath(testDataPath.resolve("testFiles").resolve(JSON_FILE).toString()) - clickOk() - } - - waitAMoment() - - assertNotNull(findPath(JSON_FILE)) - } - - step("Create folder") { - rightClick() - clickMenuItem { it.text.contains(NEW_FOLDER_ACTION) } - dialog(NEW_FOLDER_ACTION) { - textfield(null).setText(FOLDER) - button(OK_BUTTON).clickWhenEnabled() - } - - waitAMoment() - - assertNotNull(findPath(FOLDER)) - } - - step("Upload object to folder") { - rightClick(0, FOLDER) - clickMenuItem { it.text.contains(UPLOAD_ACTION) } - fileChooserDialog { - setPath(testDataPath.resolve("testFiles").resolve(JSON_FILE).toString()) - clickOk() - } - - waitAMoment() - - doubleClick(0, FOLDER) - - assertNotNull(findPath(FOLDER, JSON_FILE)) - } - - step("Rename a file") { - rightClick(0, FOLDER, JSON_FILE) - clickMenuItem { it.text.contains(RENAME_ACTION) } - - dialog(RENAME_ACTION) { - textfield(null).setText(NEW_JSON_FILE_NAME) - button(OK_BUTTON).clickWhenEnabled() - } - - waitAMoment() - - assertNotNull(findPath(FOLDER, NEW_JSON_FILE_NAME)) - } - - step("Delete a file") { - rightClick(0, FOLDER, NEW_JSON_FILE_NAME) - clickMenuItem { it.text.contains(DELETE_ACTION) } - - findMessageDialog(DELETE_ACTION).click(DELETE_PREFIX) - - waitAMoment() - - assertNull(findPath(FOLDER, NEW_JSON_FILE_NAME)) - } - - step("Open known file-types") { - doubleClick(0, JSON_FILE) - - waitAMoment() - - assertNotNull(FileEditorManager.getInstance(project).allEditors.mapNotNull { it.file }.find { - it.name.contains(JSON_FILE) && it.fileType::class.simpleName?.contains("JsonFileType") == true - }) - } - } - } - } - - @After - fun cleanUp() { - // TODO fix tests on 2019.3 - val info = ApplicationInfo.getInstance() - if (info.majorVersion != "2019" || info.minorVersionMainPart != "2") { - return - } - step("Delete bucket named $bucket") { - ideFrame { - toolwindow("aws.explorer") { - with(jTree(S3_NAME).expandPath(S3_NAME)) { - path(S3_NAME, bucket).rightClickPath() - } - - clickMenuItem { it.text.contains(DELETE_PREFIX) } - dialog(DELETE_PREFIX, predicate = Predicate.startWith) { - textfield(null).setText(bucket) - button(OK_BUTTON).clickWhenEnabled() - } - - waitAMoment() - } - } - } - } - - private fun TreeTableFixture.findPath(vararg paths: String) = try { - ExtendedJTreePathFinder(target().tree).findMatchingPath(paths.toList()) - } catch (_: Exception) { - null - } - - private fun IdeFrameFixture.treeTable(block: TreeTableFixture.() -> Unit) { - block(TreeTableFixture(robot(), findComponentWithTimeout(this.target(), TreeTable::class.java))) - } - - /** Copied from [com.intellij.testGuiFramework.fixtures.TreeTableFixture] and added support for right-click / double-click **/ - private fun TreeTableFixture.rightClick(column: Int, vararg pathStrings: String) { - - step("right-click at column #$column with path ${pathStrings.joinToString(prefix = "[", postfix = "]")}") { - val clickPoint = findPointForPath(column, pathStrings) - - robot().click(target(), clickPoint, MouseButton.RIGHT_BUTTON, 1) - } - } - - private fun TreeTableFixture.doubleClick(column: Int, vararg pathStrings: String) { - - step("double-click at column #$column with path ${pathStrings.joinToString(prefix = "[", postfix = "]")}") { - val clickPoint = findPointForPath(column, pathStrings) - - robot().click(target(), clickPoint, MouseButton.LEFT_BUTTON, 2) - } - } - - private fun TreeTableFixture.findPointForPath(column: Int, pathStrings: Array): Point { - ComponentPreconditions.checkEnabledAndShowing(target()) - - val tree = target().tree - val path = ExtendedJTreePathFinder(tree).findMatchingPath(pathStrings.toList()) - - val clickPoint = GuiTestUtilKt.computeOnEdt { - var x = target().location.x + (0 until column).sumBy { target().columnModel.getColumn(it).width } - x += target().columnModel.getColumn(column).width / 3 - val y = JTreeLocation().pathBoundsAndCoordinates(tree, path).second.y - Point(x, y) - }!! - - val visibleHeight = target().visibleRect.height - - target().scrollRectToVisible(Rectangle(Point(0, clickPoint.y + visibleHeight / 2), Dimension(0, 0))) - return clickPoint - } - - companion object { - const val FOLDER = "some-folder" - const val JSON_FILE = "hello.json" - const val CREATE_BUCKET = "Create S3 Bucket" - const val CREATE_BUTTON = "Create" - const val S3_NAME = "S3" - const val UPLOAD_ACTION = "Upload..." - const val NEW_FOLDER_ACTION = "New folder..." - const val OK_BUTTON = "OK" - const val DELETE_PREFIX = "Delete" - const val RENAME_ACTION = "Rename..." - const val DELETE_ACTION = "$DELETE_PREFIX..." - const val NEW_JSON_FILE_NAME = "new-name.json" - } -} diff --git a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/ui/wizard/SamInitProjectBuilderIntelliJTest.kt b/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/ui/wizard/SamInitProjectBuilderIntelliJTest.kt deleted file mode 100644 index 84347d0613..0000000000 --- a/jetbrains-core-gui/tst/software/aws/toolkits/jetbrains/ui/wizard/SamInitProjectBuilderIntelliJTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.ui.wizard - -import com.intellij.testGuiFramework.framework.param.GuiTestSuiteParam -import com.intellij.testGuiFramework.impl.GuiTestCase -import com.intellij.testGuiFramework.impl.waitAMoment -import com.intellij.testGuiFramework.util.scenarios.checkModule -import com.intellij.testGuiFramework.util.scenarios.newProjectDialogModel -import com.intellij.testGuiFramework.util.scenarios.openProjectStructureAndCheck -import com.intellij.testGuiFramework.util.scenarios.projectStructureDialogModel -import com.intellij.testGuiFramework.util.scenarios.projectStructureDialogScenarios -import com.intellij.testGuiFramework.util.step -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized -import software.aws.toolkits.core.utils.test.retryableAssert -import software.aws.toolkits.jetbrains.fixtures.ServerlessProjectOptions -import software.aws.toolkits.jetbrains.fixtures.checkLibraryPrefixPresent -import software.aws.toolkits.jetbrains.fixtures.checkProject -import software.aws.toolkits.jetbrains.fixtures.createServerlessProject -import software.aws.toolkits.jetbrains.fixtures.jbTab -import software.aws.toolkits.jetbrains.fixtures.sdkChooser -import java.io.Serializable -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@RunWith(GuiTestSuiteParam::class) -class SamInitProjectBuilderIntelliJTest(private val testParameters: TestParameters) : GuiTestCase() { - data class TestParameters( - val runtime: String, - val templateName: String, - val sdkRegex: Regex, - val libraries: Set = emptySet(), - val runConfigNames: Set = emptySet() - ) : Serializable { - override fun toString() = "$runtime - $templateName" - } - - @Test - fun testNewFromTemplate() { - welcomeFrame { - createNewProject() - newProjectDialogModel.createServerlessProject( - projectFolder, - ServerlessProjectOptions(testParameters.runtime, testParameters.templateName), - testParameters.sdkRegex - ) - } - - waitAMoment() - - ideFrame { - step("check the project structure is correct") { - with(projectStructureDialogModel) { - projectStructureDialogScenarios.openProjectStructureAndCheck { - step("check the SDKs are correct") { - step("check the module SDK is inheriting project SDK") { - projectStructureDialogModel.checkModule { - step("select the dependencies tab") { - jbTab("Dependencies").selectTab() - sdkChooser().requireSelection("Project SDK.*".toPattern()) - } - } - } - - step("check the project SDK is correct") { - projectStructureDialogModel.checkProject { - sdkChooser().requireSelection(testParameters.sdkRegex.toPattern()) - } - } - } - - if (testParameters.libraries.isNotEmpty()) { - step("check the libraries are correct") { - testParameters.libraries.forEach { - step("looking for library '$it'") { - checkLibraryPrefixPresent(it) - } - } - } - } - } - } - } - - step("check the run configuration is created") { - retryableAssert { - assertTrue(runConfigurationList.getRunConfigurationList().containsAll(testParameters.runConfigNames)) - } - } - - step("check the default README.md file is open in editor") { - assertEquals("README.md", editor.currentFileName) - } - } - } - - companion object { - @JvmStatic - @Parameterized.Parameters(name = "{0}") - fun data() = listOf( - TestParameters( - runtime = "java8", - templateName = "AWS SAM Hello World (Maven)", - sdkRegex = """.*(1\.8|11).*""".toRegex(), - libraries = setOf("Maven: com.amazonaws:aws-lambda-java-core:"), - runConfigNames = setOf("[Local] HelloWorldFunction") - ), - TestParameters( - runtime = "java8", - templateName = "AWS SAM Hello World (Gradle)", - sdkRegex = """.*(1\.8|11).*""".toRegex(), - libraries = setOf("Gradle: com.amazonaws:aws-lambda-java-core:"), - runConfigNames = setOf("[Local] HelloWorldFunction") - ), - TestParameters( - runtime = "python3.6", - templateName = "AWS SAM Hello World", - sdkRegex = "Python.*".toRegex(), - runConfigNames = setOf("[Local] HelloWorldFunction") - ), - TestParameters( - runtime = "python3.6", - templateName = "AWS SAM DynamoDB Event Example", - sdkRegex = "Python.*".toRegex(), - runConfigNames = setOf("[Local] ReadDynamoDBEvent") - ) - ) - } -} diff --git a/jetbrains-core/build.gradle b/jetbrains-core/build.gradle deleted file mode 100644 index ca2bf81c36..0000000000 --- a/jetbrains-core/build.gradle +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import software.aws.toolkits.telemetry.generator.gradle.GenerateTelemetry - -apply plugin: 'org.jetbrains.intellij' - -buildscript { - repositories { - mavenCentral() - maven { setUrl("https://jitpack.io") } - } - dependencies { - classpath("software.aws.toolkits:telemetry-generator:$telemetryVersion") - } -} - -intellij { - def rootIntelliJTask = rootProject.intellij - version ideSdkVersion("IC") - pluginName rootIntelliJTask.pluginName - updateSinceUntilBuild rootIntelliJTask.updateSinceUntilBuild - downloadSources = rootIntelliJTask.downloadSources - plugins = idePlugins("IC") -} - -patchPluginXml { - sinceBuild ideSinceVersion() - untilBuild ideUntilVersion() -} - -configurations { - testArtifacts -} - -task generateTelemetry(type: GenerateTelemetry) { - inputFiles = [] - outputDirectory = file("${project.buildDir}/generated-src") -} -compileKotlin.dependsOn(generateTelemetry) - -sourceSets { - main.kotlin.srcDirs += "${project.buildDir}/generated-src" -} - -test { - systemProperty("log.dir", "${project.intellij.sandboxDirectory}-test/logs") -} - -task pluginChangeLog(type: GenerateChangeLog) { - includeUnreleased = true - generateGithub = false - issuesUrl = "https://github.com/aws/aws-toolkit-jetbrains/issues" - jetbrainsChangeNotesFile = project.file("$buildDir/changelog/change-notes.xml") -} - -jar.dependsOn(pluginChangeLog) -jar { - archiveBaseName = 'aws-intellij-toolkit-core' - from(pluginChangeLog.jetbrainsChangeNotesFile) { - into "META-INF" - } -} - -dependencies { - api(project(":core")) - api("software.amazon.awssdk:s3:$awsSdkVersion") - api("software.amazon.awssdk:lambda:$awsSdkVersion") - api("software.amazon.awssdk:iam:$awsSdkVersion") - api("software.amazon.awssdk:ecs:$awsSdkVersion") - api("software.amazon.awssdk:cloudformation:$awsSdkVersion") - api("software.amazon.awssdk:schemas:$awsSdkVersion") - api("software.amazon.awssdk:cloudwatchlogs:$awsSdkVersion") - api("software.amazon.awssdk:apache-client:$awsSdkVersion") - api("software.amazon.awssdk:resourcegroupstaggingapi:$awsSdkVersion") - - testImplementation project(path: ":core", configuration: 'testArtifacts') - testImplementation('com.github.tomakehurst:wiremock-jre8:2.26.0') - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-debug:$coroutinesVersion") - - integrationTestImplementation('org.eclipse.jetty:jetty-servlet:9.4.15.v20190215') - integrationTestImplementation('org.eclipse.jetty:jetty-proxy:9.4.15.v20190215') -} diff --git a/jetbrains-core/build.gradle.kts b/jetbrains-core/build.gradle.kts new file mode 100644 index 0000000000..aa9998b3a9 --- /dev/null +++ b/jetbrains-core/build.gradle.kts @@ -0,0 +1,107 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import groovy.lang.Closure +import org.gradle.jvm.tasks.Jar +import org.jetbrains.intellij.IntelliJPluginExtension +import org.jetbrains.intellij.tasks.PatchPluginXmlTask +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import software.aws.toolkits.telemetry.generator.gradle.GenerateTelemetry +import toolkits.gradle.changelog.tasks.GeneratePluginChangeLog +// Cannot be removed or else it will fail to compile +import org.jetbrains.intellij.IntelliJPlugin + +plugins { + id("org.jetbrains.intellij") +} +apply(from = "../intellijJVersions.gradle") + +buildscript { + val telemetryVersion: String by project + repositories { + mavenCentral() + maven { setUrl("https://jitpack.io") } + } + dependencies { + classpath("software.aws.toolkits:telemetry-generator:$telemetryVersion") + } +} + +val telemetryVersion: String by project +val awsSdkVersion: String by project +val coroutinesVersion: String by project + +val ideSdkVersion: Closure by ext +val idePlugins: Closure> by ext +val ideSinceVersion: Closure by ext +val ideUntilVersion: Closure by ext + +val compileKotlin: KotlinCompile by tasks +val patchPluginXml: PatchPluginXmlTask by tasks + +intellij { + val rootIntelliJTask = rootProject.intellij + version = ideSdkVersion("IC") + setPlugins(*(idePlugins("IC").toArray())) + pluginName = rootIntelliJTask.pluginName + updateSinceUntilBuild = rootIntelliJTask.updateSinceUntilBuild + downloadSources = rootIntelliJTask.downloadSources +} + +patchPluginXml.setSinceBuild(ideSinceVersion()) +patchPluginXml.setUntilBuild(ideUntilVersion()) + +configurations { + testArtifacts +} + +val generateTelemetry = tasks.register("generateTelemetry") { + inputFiles = listOf() + outputDirectory = file("${project.buildDir}/generated-src") +} +compileKotlin.dependsOn(generateTelemetry) + +sourceSets { + main.get().java.srcDir("${project.buildDir}/generated-src") +} + +tasks.test { + systemProperty("log.dir", "${project.intellij.sandboxDirectory}-test/logs") +} + +val changelog = tasks.register("pluginChangeLog") { + includeUnreleased.set(true) + changeLogFile.set(project.file("$buildDir/changelog/change-notes.xml")) +} + +tasks.jar { + dependsOn(changelog) + archiveBaseName.set("aws-intellij-toolkit-core") + from(changelog.get().changeLogFile) { + into("META-INF") + } +} + +dependencies { + api(project(":core")) + api("software.amazon.awssdk:s3:$awsSdkVersion") + api("software.amazon.awssdk:lambda:$awsSdkVersion") + api("software.amazon.awssdk:iam:$awsSdkVersion") + api("software.amazon.awssdk:ecs:$awsSdkVersion") + api("software.amazon.awssdk:cloudformation:$awsSdkVersion") + api("software.amazon.awssdk:schemas:$awsSdkVersion") + api("software.amazon.awssdk:cloudwatchlogs:$awsSdkVersion") + api("software.amazon.awssdk:apache-client:$awsSdkVersion") + api("software.amazon.awssdk:resourcegroupstaggingapi:$awsSdkVersion") + api("software.amazon.awssdk:rds:$awsSdkVersion") + api("software.amazon.awssdk:redshift:$awsSdkVersion") + api("software.amazon.awssdk:secretsmanager:$awsSdkVersion") + + testImplementation(project(path = ":core", configuration = "testArtifacts")) + testImplementation("com.github.tomakehurst:wiremock-jre8:2.26.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-debug:$coroutinesVersion") + + integrationTestImplementation("org.eclipse.jetty:jetty-servlet:9.4.15.v20190215") + integrationTestImplementation("org.eclipse.jetty:jetty-proxy:9.4.15.v20190215") +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugTestCase.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugTestCase.kt index f1a1e5e396..bd78ed8de6 100644 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugTestCase.kt +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugTestCase.kt @@ -20,7 +20,7 @@ import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.core.rules.ECSTemporaryServiceRule import software.aws.toolkits.jetbrains.core.MockResourceCache import software.aws.toolkits.jetbrains.core.Resource -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.credentials.runUnderRealCredentials import software.aws.toolkits.jetbrains.core.region.MockRegionProvider import software.aws.toolkits.jetbrains.services.clouddebug.actions.DeinstrumentResourceFromExplorerAction @@ -56,7 +56,7 @@ abstract class CloudDebugTestCase(private val taskDefName: String) { // does not validate that a SSM session is successfully created val region = AwsRegion("us-west-2", "US West 2", "aws") MockRegionProvider.getInstance().addRegion(region) - ProjectAccountSettingsManager.getInstance(getProject()).changeRegion(region) + AwsConnectionManager.getInstance(getProject()).changeRegion(region) instrumentationRole = cfnRule.outputs["TaskRole"] ?: throw RuntimeException("Could not find instrumentation role in CloudFormation outputs") service = createService() runUnderRealCredentials(getProject()) { diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployTest.kt index df9cb707a3..c660ac7410 100644 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployTest.kt +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployTest.kt @@ -18,7 +18,7 @@ import software.amazon.awssdk.services.cloudformation.model.Parameter import software.amazon.awssdk.services.s3.S3Client import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.core.rules.S3TemporaryBucketRule -import software.aws.toolkits.jetbrains.core.credentials.MockProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.MockAwsConnectionManager import software.aws.toolkits.jetbrains.core.credentials.runUnderRealCredentials import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule import software.aws.toolkits.jetbrains.utils.setSamExecutableFromEnvironment @@ -49,7 +49,7 @@ class SamDeployTest { fun setUp() { setSamExecutableFromEnvironment() - MockProjectAccountSettingsManager.getInstance(projectRule.project).changeRegion(AwsRegion(Region.US_WEST_2.id(), "us-west-2", "aws")) + MockAwsConnectionManager.getInstance(projectRule.project).changeRegion(AwsRegion(Region.US_WEST_2.id(), "us-west-2", "aws")) } @Test diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt index 63af3e1521..5400806dd6 100644 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt @@ -3,40 +3,57 @@ package software.aws.toolkits.jetbrains.services.lambda.java +import com.intellij.testFramework.IdeaTestUtil +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Before import org.junit.Rule import org.junit.Test import software.amazon.awssdk.services.lambda.model.Runtime -import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder +import software.aws.toolkits.core.rules.EnvironmentVariableHelper +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils.buildLambda +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils.buildLambdaFromTemplate +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils.packageLambda +import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule import software.aws.toolkits.jetbrains.utils.rules.addFileToModule import software.aws.toolkits.jetbrains.utils.rules.addModule +import software.aws.toolkits.jetbrains.utils.setSamExecutableFromEnvironment import software.aws.toolkits.jetbrains.utils.setUpGradleProject +import software.aws.toolkits.jetbrains.utils.setUpJdk import software.aws.toolkits.jetbrains.utils.setUpMavenProject import software.aws.toolkits.resources.message import java.nio.file.Paths -class JavaLambdaBuilderTest : BaseLambdaBuilderTest() { +class JavaLambdaBuilderTest { @Rule @JvmField val projectRule = HeavyJavaCodeInsightTestFixtureRule() - override val lambdaBuilder: LambdaBuilder - get() = JavaLambdaBuilder() + @Rule + @JvmField + val envVarsRule = EnvironmentVariableHelper() + + private val sut = JavaLambdaBuilder() @Before - override fun setUp() { - super.setUp() + fun setUp() { + setSamExecutableFromEnvironment() + + envVarsRule.remove("JAVA_HOME") + projectRule.fixture.addModule("main") + projectRule.setUpJdk() } @Test fun gradleBuiltFromHandler() { val handlerPsi = projectRule.setUpGradleProject() - val builtLambda = buildLambda(projectRule.module, handlerPsi, Runtime.JAVA8, "com.example.SomeClass") - verifyEntries( + val builtLambda = sut.buildLambda(projectRule.module, handlerPsi, Runtime.JAVA8, "com.example.SomeClass") + LambdaBuilderTestUtils.verifyEntries( builtLambda, "com/example/SomeClass.class", "lib/aws-lambda-java-core-1.2.0.jar" @@ -63,8 +80,8 @@ class JavaLambdaBuilderTest : BaseLambdaBuilderTest() { ) val templatePath = Paths.get(templateFile.virtualFile.path) - val builtLambda = buildLambdaFromTemplate(projectRule.module, templatePath, "SomeFunction") - verifyEntries( + val builtLambda = sut.buildLambdaFromTemplate(projectRule.module, templatePath, "SomeFunction") + LambdaBuilderTestUtils.verifyEntries( builtLambda, "com/example/SomeClass.class", "lib/aws-lambda-java-core-1.2.0.jar" @@ -75,8 +92,8 @@ class JavaLambdaBuilderTest : BaseLambdaBuilderTest() { fun gradlePackage() { val handlerPsi = projectRule.setUpGradleProject() - val lambdaPackage = packageLambda(projectRule.module, handlerPsi, Runtime.JAVA8, "com.example.SomeClass") - verifyZipEntries( + val lambdaPackage = sut.packageLambda(projectRule.module, handlerPsi, Runtime.JAVA8, "com.example.SomeClass") + LambdaBuilderTestUtils.verifyZipEntries( lambdaPackage, "com/example/SomeClass.class", "lib/aws-lambda-java-core-1.2.0.jar" @@ -87,8 +104,8 @@ class JavaLambdaBuilderTest : BaseLambdaBuilderTest() { fun mavenBuiltFromHandler() { val handlerPsi = projectRule.setUpMavenProject() - val builtLambda = buildLambda(projectRule.module, handlerPsi, Runtime.JAVA8, "com.example.SomeClass") - verifyEntries( + val builtLambda = sut.buildLambda(projectRule.module, handlerPsi, Runtime.JAVA8, "com.example.SomeClass") + LambdaBuilderTestUtils.verifyEntries( builtLambda, "com/example/SomeClass.class", "lib/aws-lambda-java-core-1.2.0.jar" @@ -115,8 +132,8 @@ class JavaLambdaBuilderTest : BaseLambdaBuilderTest() { ) val templatePath = Paths.get(templateFile.virtualFile.path) - val builtLambda = buildLambdaFromTemplate(projectRule.module, templatePath, "SomeFunction") - verifyEntries( + val builtLambda = sut.buildLambdaFromTemplate(projectRule.module, templatePath, "SomeFunction") + LambdaBuilderTestUtils.verifyEntries( builtLambda, "com/example/SomeClass.class", "lib/aws-lambda-java-core-1.2.0.jar" @@ -127,8 +144,8 @@ class JavaLambdaBuilderTest : BaseLambdaBuilderTest() { fun mavenPackage() { val handlerPsi = projectRule.setUpMavenProject() - val lambdaPackage = packageLambda(projectRule.module, handlerPsi, Runtime.JAVA8, "com.example.SomeClass") - verifyZipEntries( + val lambdaPackage = sut.packageLambda(projectRule.module, handlerPsi, Runtime.JAVA8, "com.example.SomeClass") + LambdaBuilderTestUtils.verifyZipEntries( lambdaPackage, "com/example/SomeClass.class", "lib/aws-lambda-java-core-1.2.0.jar" @@ -150,8 +167,36 @@ class JavaLambdaBuilderTest : BaseLambdaBuilderTest() { ) assertThatThrownBy { - buildLambda(projectRule.module, handlerPsi, Runtime.JAVA8, "com.example.SomeClass") + sut.buildLambda(projectRule.module, handlerPsi, Runtime.JAVA8, "com.example.SomeClass") }.isInstanceOf(IllegalStateException::class.java) .hasMessageEndingWith(message("lambda.build.java.unsupported_build_system", projectRule.module.name)) } + + @Test + fun javaHomePassedWhenNotInContainer() { + val commandLine = runBlocking { + JavaLambdaBuilder().constructSamBuildCommand( + projectRule.module, + Paths.get("."), + "SomeId", + SamOptions(buildInContainer = false), + Paths.get(".") + ) + } + assertThat(commandLine.environment).extractingByKey("JAVA_HOME").isEqualTo(IdeaTestUtil.requireRealJdkHome()) + } + + @Test + fun javaHomeNotPassedWheInContainer() { + val commandLine = runBlocking { + JavaLambdaBuilder().constructSamBuildCommand( + projectRule.module, + Paths.get("."), + "SomeId", + SamOptions(buildInContainer = true), + Paths.get(".") + ) + } + assertThat(commandLine.environment).doesNotContainKey("JAVA_HOME") + } } diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/java/JavaLocalLambdaRunConfigurationIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/java/JavaLocalLambdaRunConfigurationIntegrationTest.kt index 9e84249719..f426e51644 100644 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/java/JavaLocalLambdaRunConfigurationIntegrationTest.kt +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/java/JavaLocalLambdaRunConfigurationIntegrationTest.kt @@ -26,6 +26,7 @@ import software.aws.toolkits.jetbrains.utils.rules.addClass import software.aws.toolkits.jetbrains.utils.rules.addModule import software.aws.toolkits.jetbrains.utils.setSamExecutableFromEnvironment import software.aws.toolkits.jetbrains.utils.setUpGradleProject +import software.aws.toolkits.jetbrains.utils.setUpJdk @RunWith(Parameterized::class) class JavaLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runtime) { @@ -70,6 +71,8 @@ class JavaLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runtim else -> throw NotImplementedError() } + projectRule.setUpJdk() + projectRule.setUpGradleProject(compatibility) runInEdtAndWait { diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaBuilderTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaBuilderTest.kt index 7d2a974399..1cdda3bfa9 100644 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaBuilderTest.kt +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaBuilderTest.kt @@ -7,34 +7,42 @@ import com.intellij.testFramework.PsiTestUtil import com.intellij.testFramework.runInEdtAndGet import com.jetbrains.python.psi.PyFile import com.jetbrains.python.psi.PyFunction +import org.junit.Before import org.junit.Rule import org.junit.Test import software.amazon.awssdk.services.lambda.model.Runtime -import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder -import software.aws.toolkits.jetbrains.services.lambda.java.BaseLambdaBuilderTest +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils.buildLambda +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils.buildLambdaFromTemplate +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils.packageLambda import software.aws.toolkits.jetbrains.utils.rules.PythonCodeInsightTestFixtureRule +import software.aws.toolkits.jetbrains.utils.setSamExecutableFromEnvironment import java.nio.file.Paths -class PythonLambdaBuilderTest : BaseLambdaBuilderTest() { +class PythonLambdaBuilderTest { @Rule @JvmField val projectRule = PythonCodeInsightTestFixtureRule() - override val lambdaBuilder: LambdaBuilder - get() = PythonLambdaBuilder() + private val sut = PythonLambdaBuilder() + + @Before + fun setUp() { + setSamExecutableFromEnvironment() + } @Test fun contentRootIsAdded() { val module = projectRule.module val handler = addPythonHandler("hello_world") addRequirementsFile("") - val builtLambda = buildLambda(module, handler, Runtime.PYTHON3_6, "hello_world/app.handle") - verifyEntries( + val builtLambda = sut.buildLambda(module, handler, Runtime.PYTHON3_6, "hello_world/app.handle") + LambdaBuilderTestUtils.verifyEntries( builtLambda, "hello_world/app.py", "requirements.txt" ) - verifyPathMappings( + LambdaBuilderTestUtils.verifyPathMappings( module, builtLambda, "%PROJECT_ROOT%" to "/", @@ -47,15 +55,16 @@ class PythonLambdaBuilderTest : BaseLambdaBuilderTest() { val module = projectRule.module val handler = addPythonHandler("src") addRequirementsFile("src") + PsiTestUtil.addSourceRoot(projectRule.module, handler.containingFile.virtualFile.parent) - val builtLambda = buildLambda(module, handler, Runtime.PYTHON3_6, "app.handle") - verifyEntries( + val builtLambda = sut.buildLambda(module, handler, Runtime.PYTHON3_6, "app.handle") + LambdaBuilderTestUtils.verifyEntries( builtLambda, "app.py", "requirements.txt" ) - verifyPathMappings( + LambdaBuilderTestUtils.verifyPathMappings( module, builtLambda, "%PROJECT_ROOT%/src" to "/", @@ -69,13 +78,13 @@ class PythonLambdaBuilderTest : BaseLambdaBuilderTest() { val handler = addPythonHandler("src") addRequirementsFile("src") - val builtLambda = buildLambda(module, handler, Runtime.PYTHON3_6, "app.handle") - verifyEntries( + val builtLambda = sut.buildLambda(module, handler, Runtime.PYTHON3_6, "app.handle") + LambdaBuilderTestUtils.verifyEntries( builtLambda, "app.py", "requirements.txt" ) - verifyPathMappings( + LambdaBuilderTestUtils.verifyPathMappings( module, builtLambda, "%PROJECT_ROOT%/src" to "/", @@ -89,13 +98,13 @@ class PythonLambdaBuilderTest : BaseLambdaBuilderTest() { val handler = addPythonHandler("hello_world") addRequirementsFile("", "requests==2.20.0") - val builtLambda = buildLambda(module, handler, Runtime.PYTHON3_6, "hello_world/app.handle") - verifyEntries( + val builtLambda = sut.buildLambda(module, handler, Runtime.PYTHON3_6, "hello_world/app.handle") + LambdaBuilderTestUtils.verifyEntries( builtLambda, "hello_world/app.py", "requests/__init__.py" ) - verifyPathMappings( + LambdaBuilderTestUtils.verifyPathMappings( module, builtLambda, "%PROJECT_ROOT%" to "/", @@ -123,13 +132,13 @@ class PythonLambdaBuilderTest : BaseLambdaBuilderTest() { ) val templatePath = Paths.get(templateFile.virtualFile.path) - val builtLambda = buildLambdaFromTemplate(module, templatePath, "SomeFunction") - verifyEntries( + val builtLambda = sut.buildLambdaFromTemplate(module, templatePath, "SomeFunction") + LambdaBuilderTestUtils.verifyEntries( builtLambda, "app.py", "requests/__init__.py" ) - verifyPathMappings( + LambdaBuilderTestUtils.verifyPathMappings( module, builtLambda, "%PROJECT_ROOT%/hello_world" to "/", @@ -142,8 +151,8 @@ class PythonLambdaBuilderTest : BaseLambdaBuilderTest() { val handler = addPythonHandler("hello_world") addRequirementsFile("", "requests==2.20.0") - val lambdaPackage = packageLambda(projectRule.module, handler, Runtime.PYTHON3_6, "hello_world/app.handle") - verifyZipEntries( + val lambdaPackage = sut.packageLambda(projectRule.module, handler, Runtime.PYTHON3_6, "hello_world/app.handle") + LambdaBuilderTestUtils.verifyZipEntries( lambdaPackage, "hello_world/app.py", "requests/__init__.py" @@ -155,13 +164,13 @@ class PythonLambdaBuilderTest : BaseLambdaBuilderTest() { val module = projectRule.module val handler = addPythonHandler("hello_world") addRequirementsFile("") - val builtLambda = buildLambda(module, handler, Runtime.PYTHON3_6, "hello_world/app.handle", true) - verifyEntries( + val builtLambda = sut.buildLambda(module, handler, Runtime.PYTHON3_6, "hello_world/app.handle", true) + LambdaBuilderTestUtils.verifyEntries( builtLambda, "hello_world/app.py", "requirements.txt" ) - verifyPathMappings( + LambdaBuilderTestUtils.verifyPathMappings( module, builtLambda, "%PROJECT_ROOT%" to "/", @@ -174,8 +183,8 @@ class PythonLambdaBuilderTest : BaseLambdaBuilderTest() { val handler = addPythonHandler("hello_world") addRequirementsFile("", "requests==2.20.0") - val lambdaPackage = packageLambda(projectRule.module, handler, Runtime.PYTHON3_6, "hello_world/app.handle", true) - verifyZipEntries( + val lambdaPackage = sut.packageLambda(projectRule.module, handler, Runtime.PYTHON3_6, "hello_world/app.handle", true) + LambdaBuilderTestUtils.verifyZipEntries( lambdaPackage, "hello_world/app.py", "requests/__init__.py" diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt index aea84a2a69..906615f3d6 100644 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt @@ -4,7 +4,7 @@ package software.aws.toolkits.jetbrains.utils import com.intellij.openapi.Disposable -import com.intellij.openapi.application.WriteAction +import com.intellij.openapi.application.runWriteAction import com.intellij.openapi.components.ServiceManager import com.intellij.openapi.externalSystem.importing.ImportSpecBuilder import com.intellij.openapi.externalSystem.model.DataNode @@ -18,6 +18,7 @@ import com.intellij.openapi.externalSystem.util.ExternalSystemUtil import com.intellij.openapi.projectRoots.JavaSdk import com.intellij.openapi.projectRoots.ProjectJdkTable import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil +import com.intellij.openapi.roots.ModuleRootModificationUtil import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Ref import com.intellij.openapi.util.SystemInfo @@ -31,6 +32,7 @@ import com.intellij.util.io.isDirectory import com.intellij.util.io.readBytes import com.intellij.util.io.write import com.intellij.xdebugger.XDebuggerUtil +import org.jetbrains.annotations.NotNull import org.jetbrains.idea.maven.project.MavenProjectsManager import org.jetbrains.plugins.gradle.settings.GradleProjectSettings import org.jetbrains.plugins.gradle.util.GradleConstants @@ -42,7 +44,29 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths -internal fun HeavyJavaCodeInsightTestFixtureRule.setUpGradleProject(compatibility: String = "1.8"): PsiClass { +fun HeavyJavaCodeInsightTestFixtureRule.setUpJdk(jdkName: String = "Real JDK"): @NotNull String { + val jdkHome = IdeaTestUtil.requireRealJdkHome() + + runInEdtAndWait { + runWriteAction { + VfsRootAccess.allowRootAccess(this.fixture.testRootDisposable, jdkHome) + val jdkHomeDir = LocalFileSystem.getInstance().refreshAndFindFileByPath(jdkHome)!! + val jdk = SdkConfigurationUtil.setupSdk(emptyArray(), jdkHomeDir, JavaSdk.getInstance(), false, null, jdkName)!! + + ProjectJdkTable.getInstance().addJdk(jdk) + Disposer.register( + this.fixture.testRootDisposable, + Disposable { runWriteAction { ProjectJdkTable.getInstance().removeJdk(jdk) } } + ) + + ModuleRootModificationUtil.setModuleSdk(this.module, jdk) + } + } + + return jdkHome +} + +fun HeavyJavaCodeInsightTestFixtureRule.setUpGradleProject(compatibility: String = "1.8"): PsiClass { val fixture = this.fixture val buildFile = fixture.addFileToModule( this.module, @@ -81,19 +105,8 @@ internal fun HeavyJavaCodeInsightTestFixtureRule.setUpGradleProject(compatibilit """.trimIndent() ) - val jdkHome = IdeaTestUtil.requireRealJdkHome() - VfsRootAccess.allowRootAccess(fixture.testRootDisposable, jdkHome) - val jdkHomeDir = LocalFileSystem.getInstance().refreshAndFindFileByPath(jdkHome)!! val jdkName = "Gradle JDK" - val jdk = SdkConfigurationUtil.setupSdk(emptyArray(), jdkHomeDir, JavaSdk.getInstance(), false, null, jdkName)!! - - WriteAction.runAndWait { - ProjectJdkTable.getInstance().addJdk(jdk) - } - - Disposer.register( - fixture.testRootDisposable, - Disposable { WriteAction.runAndWait { ProjectJdkTable.getInstance().removeJdk(jdk) } }) + setUpJdk(jdkName) ExternalSystemApiUtil.subscribe( project, diff --git a/jetbrains-core/resources/META-INF/plugin.xml b/jetbrains-core/resources/META-INF/plugin.xml index 2834dbacef..f55a290570 100644 --- a/jetbrains-core/resources/META-INF/plugin.xml +++ b/jetbrains-core/resources/META-INF/plugin.xml @@ -83,6 +83,8 @@ com.intellij.modules.python JavaScriptDebugger com.intellij.modules.rider + + @@ -92,6 +94,10 @@ + + + @@ -125,15 +131,14 @@ - + - + - - - - + + + @@ -146,22 +151,25 @@ + + serviceImplementation="software.aws.toolkits.jetbrains.settings.DefaultAwsSettings" + testServiceImplementation="software.aws.toolkits.jetbrains.settings.MockAwsSettings" /> - - - - + + @@ -230,6 +238,8 @@ id="annotatorYAML" implementationClass="software.aws.toolkits.jetbrains.services.cloudformation.annotations.CloudFormationLintAnnotator"/> + + @@ -344,8 +354,10 @@ + + diff --git a/jetbrains-core/resources/icons/logos/IAM_large.svg b/jetbrains-core/resources/icons/logos/IAM_large.svg deleted file mode 100644 index b97126860c..0000000000 --- a/jetbrains-core/resources/icons/logos/IAM_large.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - SecurityIdentityCompliance - - - - - - - - - - - \ No newline at end of file diff --git a/jetbrains-core/resources/icons/resources/Redshift.svg b/jetbrains-core/resources/icons/resources/Redshift.svg new file mode 100644 index 0000000000..5f940e06bf --- /dev/null +++ b/jetbrains-core/resources/icons/resources/Redshift.svg @@ -0,0 +1,13 @@ + + + redshift dark + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/resources/Redshift_dark.svg b/jetbrains-core/resources/icons/resources/Redshift_dark.svg new file mode 100644 index 0000000000..91b52bc36d --- /dev/null +++ b/jetbrains-core/resources/icons/resources/Redshift_dark.svg @@ -0,0 +1,13 @@ + + + redshift light + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/resources/rds/Mysql.svg b/jetbrains-core/resources/icons/resources/rds/Mysql.svg new file mode 100644 index 0000000000..2a0fe0226b --- /dev/null +++ b/jetbrains-core/resources/icons/resources/rds/Mysql.svg @@ -0,0 +1,9 @@ + + + mysql - dark + + + + + + diff --git a/jetbrains-core/resources/icons/resources/rds/Mysql_dark.svg b/jetbrains-core/resources/icons/resources/rds/Mysql_dark.svg new file mode 100644 index 0000000000..6e94dee16e --- /dev/null +++ b/jetbrains-core/resources/icons/resources/rds/Mysql_dark.svg @@ -0,0 +1,9 @@ + + + mysql - light + + + + + + diff --git a/jetbrains-core/resources/icons/resources/rds/Postgres.svg b/jetbrains-core/resources/icons/resources/rds/Postgres.svg new file mode 100644 index 0000000000..780df6400f --- /dev/null +++ b/jetbrains-core/resources/icons/resources/rds/Postgres.svg @@ -0,0 +1,9 @@ + + + PostGreSQL - dark option 1 + + + + + + diff --git a/jetbrains-core/resources/icons/resources/rds/Postgres_dark.svg b/jetbrains-core/resources/icons/resources/rds/Postgres_dark.svg new file mode 100644 index 0000000000..ef21866f47 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/rds/Postgres_dark.svg @@ -0,0 +1,9 @@ + + + PostGreSQL - light option 1 + + + + + + diff --git a/jetbrains-core/src/icons/AwsIcons.kt b/jetbrains-core/src/icons/AwsIcons.kt index c8ad76cb1b..4b8220c4e4 100644 --- a/jetbrains-core/src/icons/AwsIcons.kt +++ b/jetbrains-core/src/icons/AwsIcons.kt @@ -14,7 +14,6 @@ import javax.swing.Icon object AwsIcons { object Logos { @JvmField val AWS = IconLoader.getIcon("/icons/logos/AWS.svg") // 13x13 - @JvmField val IAM_LARGE = IconLoader.getIcon("/icons/logos/IAM_large.svg") // 64x64 @JvmField val CLOUD_FORMATION_TOOL = IconLoader.getIcon("/icons/logos/CloudFormationTool.svg") // 13x13 @JvmField val EVENT_BRIDGE = IconLoader.getIcon("/icons/logos/EventBridge.svg") // 13x13 } @@ -37,11 +36,16 @@ object AwsIcons { @JvmField val SCHEMA = IconLoader.getIcon("/icons/resources/Schema.svg") // 16x16 @JvmField val SERVERLESS_APP = IconLoader.getIcon("/icons/resources/ServerlessApp.svg") // 16x16 @JvmField val S3_BUCKET = IconLoader.getIcon("/icons/resources/S3Bucket.svg") // 16x16 + @JvmField val REDSHIFT = IconLoader.getIcon("/icons/resources/Redshift.svg") // 16x16 object Ecs { @JvmField val ECS_CLUSTER = IconLoader.getIcon("/icons/resources/ecs/EcsCluster.svg") @JvmField val ECS_SERVICE = IconLoader.getIcon("/icons/resources/ecs/EcsService.svg") @JvmField val ECS_TASK_DEFINITION = IconLoader.getIcon("/icons/resources/ecs/EcsTaskDefinition.svg") } + object Rds { + @JvmField val MYSQL = IconLoader.getIcon("/icons/resources/rds/Mysql.svg") // 16x16 + @JvmField val POSTGRES = IconLoader.getIcon("/icons/resources/rds/Postgres.svg") // 16x16 + } } object Actions { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsClientManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsClientManager.kt index 137e53adc8..155b542ab0 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsClientManager.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsClientManager.kt @@ -14,21 +14,22 @@ import com.intellij.openapi.util.Disposer import software.amazon.awssdk.core.SdkClient import software.amazon.awssdk.http.SdkHttpClient import software.aws.toolkits.core.ToolkitClientManager +import software.aws.toolkits.core.credentials.CredentialIdentifier import software.aws.toolkits.core.credentials.CredentialProviderNotFoundException import software.aws.toolkits.core.credentials.ToolkitCredentialsChangeListener -import software.aws.toolkits.core.credentials.ToolkitCredentialsIdentifier import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.core.region.ToolkitRegionProvider import software.aws.toolkits.core.utils.tryOrNull import software.aws.toolkits.jetbrains.AwsToolkit +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettings import software.aws.toolkits.jetbrains.core.credentials.CredentialManager -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider open class AwsClientManager(project: Project) : ToolkitClientManager(), Disposable { - private val accountSettingsManager = ProjectAccountSettingsManager.getInstance(project) + private val accountSettingsManager = AwsConnectionManager.getInstance(project) private val regionProvider = AwsRegionProvider.getInstance() init { @@ -36,7 +37,7 @@ open class AwsClientManager(project: Project) : ToolkitClientManager(), Disposab val busConnection = ApplicationManager.getApplication().messageBus.connect(project) busConnection.subscribe(CredentialManager.CREDENTIALS_CHANGED, object : ToolkitCredentialsChangeListener { - override fun providerRemoved(identifier: ToolkitCredentialsIdentifier) { + override fun providerRemoved(identifier: CredentialIdentifier) { invalidateSdks(identifier.id) } }) @@ -84,3 +85,7 @@ inline fun Project.awsClient( ): T = AwsClientManager .getInstance(this) .getClient(credentialsProviderOverride = credentialsProviderOverride, regionOverride = regionOverride) + +inline fun Project.awsClient(connectionSettings: ConnectionSettings): T = AwsClientManager + .getInstance(this) + .getClient(connectionSettings.credentials, connectionSettings.region) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsResourceCache.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsResourceCache.kt index 2dae59f2c1..83d461cf86 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsResourceCache.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsResourceCache.kt @@ -4,20 +4,21 @@ package software.aws.toolkits.jetbrains.core import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.ServiceManager import com.intellij.openapi.project.Project import com.intellij.util.Alarm import com.intellij.util.AlarmFactory import software.amazon.awssdk.core.SdkClient +import software.aws.toolkits.core.credentials.CredentialIdentifier import software.aws.toolkits.core.credentials.ToolkitCredentialsChangeListener -import software.aws.toolkits.core.credentials.ToolkitCredentialsIdentifier import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.core.credentials.CredentialManager -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.credentials.toEnvironmentVariables import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance import software.aws.toolkits.jetbrains.core.executables.ExecutableManager @@ -42,7 +43,7 @@ interface AwsResourceCache { /** * Get a [resource] either by making a call or returning it from the cache if present and unexpired. Uses the currently [AwsRegion] - * & [ToolkitCredentialsProvider] active in [ProjectAccountSettingsManager]. + * & [ToolkitCredentialsProvider] active in [AwsConnectionManager]. * * @param[useStale] if an exception occurs attempting to refresh the resource return a cached version if it exists (even if it's expired). Default: true * @param[forceFetch] force the resource to refresh (and update cache) even if a valid cache version exists. Default: false @@ -220,17 +221,17 @@ class DefaultAwsResourceCache( private val clock: Clock, private val maximumCacheEntries: Int, private val maintenanceInterval: Duration -) : AwsResourceCache, ToolkitCredentialsChangeListener { +) : AwsResourceCache, Disposable, ToolkitCredentialsChangeListener { @Suppress("unused") constructor(project: Project) : this(project, Clock.systemDefaultZone(), MAXIMUM_CACHE_ENTRIES, DEFAULT_MAINTENANCE_INTERVAL) private val cache = ConcurrentHashMap>() - private val accountSettings by lazy { ProjectAccountSettingsManager.getInstance(project) } + private val accountSettings by lazy { AwsConnectionManager.getInstance(project) } private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, project) init { - ApplicationManager.getApplication().messageBus.connect().subscribe(CredentialManager.CREDENTIALS_CHANGED, this) + ApplicationManager.getApplication().messageBus.connect(this).subscribe(CredentialManager.CREDENTIALS_CHANGED, this) scheduleCacheMaintenance() } @@ -315,9 +316,13 @@ class DefaultAwsResourceCache( cache.clear() } - override fun providerRemoved(identifier: ToolkitCredentialsIdentifier) = clearByCredential(identifier.id) + override fun dispose() { + clear() + } + + override fun providerRemoved(identifier: CredentialIdentifier) = clearByCredential(identifier.id) - override fun providerModified(identifier: ToolkitCredentialsIdentifier) = clearByCredential(identifier.id) + override fun providerModified(identifier: CredentialIdentifier) = clearByCredential(identifier.id) private fun clearByCredential(providerId: String) { cache.keys.removeIf { it.credentialsId == providerId } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsConnectionManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsConnectionManager.kt new file mode 100644 index 0000000000..548d6bea14 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsConnectionManager.kt @@ -0,0 +1,300 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SimpleModificationTracker +import com.intellij.util.ExceptionUtil +import com.intellij.util.messages.Topic +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.future.await +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.core.credentials.CredentialProviderNotFoundException +import software.aws.toolkits.core.credentials.ToolkitCredentialsChangeListener +import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.jetbrains.services.sts.StsResources +import software.aws.toolkits.jetbrains.utils.MRUList +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AwsTelemetry + +abstract class AwsConnectionManager(private val project: Project) : SimpleModificationTracker() { + private val resourceCache = AwsResourceCache.getInstance(project) + private val regionProvider = AwsRegionProvider.getInstance() + private val credentialsRegionHandler = CredentialsRegionHandler.getInstance(project) + + @Volatile + private var validationJob: Job? = null + + @Volatile + var connectionState: ConnectionState = ConnectionState.InitializingToolkit + internal set(value) { + field = value + if (!project.isDisposed) { + project.messageBus.syncPublisher(CONNECTION_SETTINGS_STATE_CHANGED).settingsStateChanged(value) + } + } + + protected val recentlyUsedProfiles = MRUList(MAX_HISTORY) + protected val recentlyUsedRegions = MRUList(MAX_HISTORY) + + // Internal state is visible for AwsSettingsPanel and ChangeAccountSettingsActionGroup + internal var selectedCredentialIdentifier: CredentialIdentifier? = null + internal var selectedRegion: AwsRegion? = null + + private var selectedCredentialsProvider: ToolkitCredentialsProvider? = null + + init { + ApplicationManager.getApplication().messageBus.connect(project) + .subscribe(CredentialManager.CREDENTIALS_CHANGED, object : ToolkitCredentialsChangeListener { + override fun providerRemoved(identifier: CredentialIdentifier) { + if (selectedCredentialIdentifier == identifier) { + changeConnectionSettings(null, selectedRegion) + } + } + + override fun providerModified(identifier: CredentialIdentifier) { + if (selectedCredentialIdentifier == identifier) { + refreshConnectionState() + } + } + }) + } + + fun isValidConnectionSettings(): Boolean = connectionState is ConnectionState.ValidConnection + + fun connectionSettings(): ConnectionSettings? = when (val state = connectionState) { + is ConnectionState.ValidConnection -> ConnectionSettings(state.credentials, state.region) + else -> null + } + + /** + * Re-trigger validation of the current connection + */ + fun refreshConnectionState() { + changeFieldsAndNotify { } + } + + /** + * Internal setter that allows for null values and is intended to set the internal state and still notify + */ + protected fun changeConnectionSettings(identifier: CredentialIdentifier?, region: AwsRegion?) { + changeFieldsAndNotify { + identifier?.let { + recentlyUsedProfiles.add(it.id) + } + + region?.let { + recentlyUsedRegions.add(it.id) + } + + selectedCredentialIdentifier = identifier + selectedRegion = region + } + } + + /** + * Changes the credentials and then validates them. Notifies listeners of results + */ + fun changeCredentialProvider(identifier: CredentialIdentifier) { + changeFieldsAndNotify { + recentlyUsedProfiles.add(identifier.id) + + selectedCredentialIdentifier = identifier + + selectedRegion = credentialsRegionHandler.determineSelectedRegion(identifier, selectedRegion) + } + } + + /** + * Changes the region and then validates them. Notifies listeners of results + */ + fun changeRegion(region: AwsRegion) { + changeFieldsAndNotify { + recentlyUsedRegions.add(region.id) + selectedRegion = region + } + } + + @Synchronized + private fun changeFieldsAndNotify(fieldUpdateBlock: () -> Unit) { + incModificationCount() + + validationJob?.cancel(CancellationException("Newer connection settings chosen")) + val isInitial = connectionState is ConnectionState.InitializingToolkit + connectionState = ConnectionState.ValidatingConnection + + fieldUpdateBlock() + + validationJob = GlobalScope.launch(Dispatchers.IO) { + val credentialsIdentifier = selectedCredentialIdentifier + val region = selectedRegion + if (credentialsIdentifier == null || region == null) { + connectionState = ConnectionState.IncompleteConfiguration(credentialsIdentifier, region) + incModificationCount() + return@launch + } + + if (isInitial && credentialsIdentifier is InteractiveCredential && credentialsIdentifier.userActionRequired()) { + connectionState = ConnectionState.RequiresUserAction(credentialsIdentifier) + + incModificationCount() + return@launch + } + + try { + val credentialsProvider = CredentialManager.getInstance().getAwsCredentialProvider(credentialsIdentifier, region) + + validate(credentialsProvider, region) + selectedCredentialsProvider = credentialsProvider + connectionState = ConnectionState.ValidConnection(credentialsProvider, region) + } catch (e: Exception) { + connectionState = ConnectionState.InvalidConnection(e) + LOGGER.warn(e) { message("credentials.profile.validation_error", credentialsIdentifier.displayName) } + } finally { + incModificationCount() + AwsTelemetry.validateCredentials(project, success = isValidConnectionSettings()) + validationJob = null + } + } + } + + /** + * Legacy method, should be considered deprecated and avoided since it loads defaults out of band + */ + val activeRegion: AwsRegion + get() = selectedRegion ?: AwsRegionProvider.getInstance().defaultRegion().also { + LOGGER.warn(IllegalStateException()) { "Using activeRegion when region is null, calling code needs to be migrated to handle null" } + } + + /** + * Legacy method, should be considered deprecated and avoided since it loads defaults out of band + */ + val activeCredentialProvider: ToolkitCredentialsProvider + @Throws(CredentialProviderNotFoundException::class) + get() = selectedCredentialsProvider ?: throw CredentialProviderNotFoundException(message("credentials.profile.not_configured")).also { + LOGGER.warn(IllegalStateException()) { "Using activeCredentialProvider when credentials is null, calling code needs to be migrated to handle null" } + } + + /** + * Returns the list of recently used [AwsRegion] + */ + fun recentlyUsedRegions(): List = recentlyUsedRegions.elements().mapNotNull { regionProvider.allRegions()[it] } + + /** + * Returns the list of recently used [CredentialIdentifier] + */ + fun recentlyUsedCredentials(): List { + val credentialManager = CredentialManager.getInstance() + return recentlyUsedProfiles.elements().mapNotNull { credentialManager.getCredentialIdentifierById(it) } + } + + /** + * Internal method that executes the actual validation of credentials + */ + protected open suspend fun validate(credentialsProvider: ToolkitCredentialsProvider, region: AwsRegion) { + withContext(Dispatchers.IO) { + // TODO: Convert the cache over to suspend methods + resourceCache.getResource( + StsResources.ACCOUNT, + region = region, + credentialProvider = credentialsProvider, + useStale = false, + forceFetch = true + ).await() + } + } + + companion object { + /*** + * MessageBus topic for when the active credential profile or region is changed + */ + val CONNECTION_SETTINGS_STATE_CHANGED: Topic = Topic.create( + "AWS Account setting changed", + ConnectionSettingsStateChangeNotifier::class.java + ) + + @JvmStatic + fun getInstance(project: Project): AwsConnectionManager = ServiceManager.getService(project, AwsConnectionManager::class.java) + + private val LOGGER = getLogger() + private const val MAX_HISTORY = 5 + internal val AwsConnectionManager.selectedPartition get() = selectedRegion?.let { AwsRegionProvider.getInstance().partitions()[it.partitionId] } + } +} + +/** + * A state machine around the connection validation steps the toolkit goes through. Attempts to encapsulate both state, data available at each state and + * a consistent place to determine how to display state information (e.g. [displayMessage]). Exposes an [isTerminal] property that indicates if this + * state is temporary in the 'connection validation' workflow or if this is a terminal state. + */ +sealed class ConnectionState(val displayMessage: String, val isTerminal: Boolean) { + /** + * An optional short message to display in places where space is at a premium + */ + open val shortMessage: String = displayMessage + + open val actions: List = emptyList() + + object InitializingToolkit : ConnectionState(message("settings.states.initializing"), isTerminal = false) + + object ValidatingConnection : ConnectionState(message("settings.states.validating"), isTerminal = false) { + override val shortMessage: String = message("settings.states.validating.short") + } + + class ValidConnection(internal val credentials: ToolkitCredentialsProvider, internal val region: AwsRegion) : + ConnectionState("${credentials.displayName}@${region.displayName}", isTerminal = true) { + override val shortMessage: String = "${credentials.shortName}@${region.id}" + } + + class IncompleteConfiguration(credentials: CredentialIdentifier?, region: AwsRegion?) : ConnectionState( + when { + region == null && credentials == null -> message("settings.none_selected") + region == null -> message("settings.regions.none_selected") + credentials == null -> message("settings.credentials.none_selected") + else -> throw IllegalArgumentException("At least one of regionId ($region) or toolkitCredentialsIdentifier ($credentials) must be null") + }, + isTerminal = true + ) + + class InvalidConnection(private val cause: Exception) : + ConnectionState(message("settings.states.invalid", ExceptionUtil.getMessage(cause) ?: ExceptionUtil.getThrowableText(cause)), isTerminal = true) { + override val shortMessage = message("settings.states.invalid.short") + + override val actions = listOf(RefreshConnectionAction(message("settings.retry"))) + } + + class RequiresUserAction(interactiveCredentials: InteractiveCredential) : + ConnectionState(interactiveCredentials.userActionDisplayMessage, isTerminal = true) { + override val shortMessage = interactiveCredentials.userActionShortDisplayMessage + + override val actions = listOf(interactiveCredentials.userAction) + } +} + +interface ConnectionSettingsStateChangeNotifier { + fun settingsStateChanged(newState: ConnectionState) +} + +/** + * Legacy method, should be considered deprecated and avoided since it loads defaults out of band + */ +fun Project.activeRegion(): AwsRegion = AwsConnectionManager.getInstance(this).activeRegion + +/** + * Legacy method, should be considered deprecated and avoided since it loads defaults out of band + */ +fun Project.activeCredentialProvider(): ToolkitCredentialsProvider = AwsConnectionManager.getInstance(this).activeCredentialProvider diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsSettingsPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsSettingsPanel.kt index a5c793f99b..90d9906829 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsSettingsPanel.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsSettingsPanel.kt @@ -17,6 +17,7 @@ import com.intellij.openapi.wm.StatusBar import com.intellij.openapi.wm.StatusBarWidget import com.intellij.openapi.wm.StatusBarWidgetProvider import com.intellij.util.Consumer +import software.aws.toolkits.jetbrains.core.credentials.ChangeAccountSettingsMode.BOTH import software.aws.toolkits.resources.message import java.awt.Component import java.awt.event.MouseEvent @@ -27,26 +28,17 @@ class AwsSettingsPanelInstaller : StatusBarWidgetProvider { private class AwsSettingsPanel(private val project: Project) : StatusBarWidget, StatusBarWidget.MultipleTextValuesPresentation, - ConnectionSettingsChangeNotifier { - private val accountSettingsManager = ProjectAccountSettingsManager.getInstance(project) + ConnectionSettingsStateChangeNotifier { + private val accountSettingsManager = AwsConnectionManager.getInstance(project) private val settingsSelector = SettingsSelector(project) private lateinit var statusBar: StatusBar @Suppress("FunctionName") override fun ID(): String = "AwsSettingsPanel" - override fun getTooltipText() = SettingsSelector.tooltipText - - override fun getSelectedValue(): String { - val credentials = accountSettingsManager.selectedCredentialIdentifier - val region = accountSettingsManager.selectedRegion - val statusLine = when { - credentials == null -> message("settings.credentials.none_selected") - region == null -> message("settings.regions.none_selected") - else -> "${credentials.displayName}@${region.name}" - } - return "AWS: $statusLine" - } + override fun getTooltipText() = "${SettingsSelector.tooltipText} [${accountSettingsManager.connectionState.displayMessage}]" + + override fun getSelectedValue() = "AWS: ${accountSettingsManager.connectionState.shortMessage}" override fun getPopupStep() = settingsSelector.settingsPopup(statusBar.component) @@ -56,11 +48,11 @@ private class AwsSettingsPanel(private val project: Project) : StatusBarWidget, override fun install(statusBar: StatusBar) { this.statusBar = statusBar - project.messageBus.connect().subscribe(ProjectAccountSettingsManager.CONNECTION_SETTINGS_CHANGED, this) + project.messageBus.connect(this).subscribe(AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, this) updateWidget() } - override fun settingsChanged(event: ConnectionSettingsChangeEvent) { + override fun settingsStateChanged(newState: ConnectionState) { updateWidget() } @@ -71,28 +63,27 @@ private class AwsSettingsPanel(private val project: Project) : StatusBarWidget, override fun dispose() {} } -class SettingsSelectorAction(private val showRegions: Boolean = true) : AnAction(message("configure.toolkit")), DumbAware { +class SettingsSelectorAction(private val mode: ChangeAccountSettingsMode = BOTH) : AnAction(message("configure.toolkit")), DumbAware { override fun actionPerformed(e: AnActionEvent) { val project = e.getRequiredData(PlatformDataKeys.PROJECT) val settingsSelector = SettingsSelector(project) - settingsSelector.settingsPopup(e.dataContext, showRegions = showRegions).showCenteredInCurrentWindow(project) + settingsSelector.settingsPopup(e.dataContext, mode).showCenteredInCurrentWindow(project) } } class SettingsSelector(private val project: Project) { - fun settingsPopup(contextComponent: Component, showRegions: Boolean = true): ListPopup { - val dataContext = DataManager.getInstance().getDataContext(contextComponent) - return settingsPopup(dataContext, showRegions) - } - - fun settingsPopup(dataContext: DataContext, showRegions: Boolean = true): ListPopup = JBPopupFactory.getInstance().createActionGroupPopup( - tooltipText, - ChangeAccountSettingsActionGroup(project, showRegions), - dataContext, - JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, - true, - ActionPlaces.STATUS_BAR_PLACE - ) + fun settingsPopup(contextComponent: Component, mode: ChangeAccountSettingsMode = BOTH): ListPopup = + settingsPopup(DataManager.getInstance().getDataContext(contextComponent), mode) + + fun settingsPopup(dataContext: DataContext, mode: ChangeAccountSettingsMode = BOTH): ListPopup = + JBPopupFactory.getInstance().createActionGroupPopup( + tooltipText, + ChangeAccountSettingsActionGroup(project, mode), + dataContext, + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true, + ActionPlaces.STATUS_BAR_PLACE + ) companion object { internal val tooltipText = message("settings.title") diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ChangeConnectionSettingsMenu.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ChangeConnectionSettingsMenu.kt index c69d09dec0..4bc1d93786 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ChangeConnectionSettingsMenu.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ChangeConnectionSettingsMenu.kt @@ -8,27 +8,38 @@ import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.actionSystem.Presentation import com.intellij.openapi.actionSystem.Separator import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.actionSystem.ex.ComboBoxAction import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.psi.util.CachedValueProvider -import software.aws.toolkits.core.credentials.ToolkitCredentialsIdentifier +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.core.region.AwsPartition import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager.Companion.selectedPartition +import software.aws.toolkits.jetbrains.core.credentials.ChangeAccountSettingsMode.BOTH +import software.aws.toolkits.jetbrains.core.credentials.ChangeAccountSettingsMode.CREDENTIALS +import software.aws.toolkits.jetbrains.core.credentials.ChangeAccountSettingsMode.REGIONS import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider import software.aws.toolkits.jetbrains.utils.actions.ComputableActionGroup import software.aws.toolkits.resources.message - -class ChangeAccountSettingsActionGroup(private val project: Project, private val showRegions: Boolean) : ComputableActionGroup(), DumbAware { - private val accountSettingsManager = ProjectAccountSettingsManager.getInstance(project) - private val partitionSelector = ChangePartitionActionGroup() - private val regionSelector = ChangeRegionActionGroup(partitionSelector, accountSettingsManager) +import javax.swing.JComponent + +class ChangeAccountSettingsActionGroup(project: Project, private val mode: ChangeAccountSettingsMode) : ComputableActionGroup(), DumbAware { + private val accountSettingsManager = AwsConnectionManager.getInstance(project) + private val regionSelector = ChangeRegionActionGroup( + accountSettingsManager.selectedPartition, + accountSettingsManager, + ChangePartitionActionGroup(accountSettingsManager) + ) private val credentialSelector = ChangeCredentialsActionGroup(true) override fun createChildrenProvider(actionManager: ActionManager?): CachedValueProvider> = CachedValueProvider { val actions = mutableListOf() - if (showRegions) { + if (mode.showRegions) { val usedRegions = accountSettingsManager.recentlyUsedRegions() if (usedRegions.isEmpty()) { regionSelector.isPopup = false @@ -44,47 +55,41 @@ class ChangeAccountSettingsActionGroup(private val project: Project, private val } } - val usedCredentials = accountSettingsManager.recentlyUsedCredentials() - if (usedCredentials.isEmpty()) { - actions.add(Separator.create(message("settings.credentials"))) + if (mode.showCredentials) { + val usedCredentials = accountSettingsManager.recentlyUsedCredentials() + if (usedCredentials.isEmpty()) { + actions.add(Separator.create(message("settings.credentials"))) - credentialSelector.isPopup = false - actions.add(credentialSelector) - } else { - actions.add(Separator.create(message("settings.credentials.recent"))) - usedCredentials.forEach { - actions.add(ChangeCredentialsAction(it)) - } + credentialSelector.isPopup = false + actions.add(credentialSelector) + } else { + actions.add(Separator.create(message("settings.credentials.recent"))) + usedCredentials.forEach { + actions.add(ChangeCredentialsAction(it)) + } - credentialSelector.isPopup = true - actions.add(credentialSelector) + credentialSelector.isPopup = true + actions.add(credentialSelector) + } } + actions.add(Separator.create()) + actions.addAll(accountSettingsManager.connectionState.actions) + CachedValueProvider.Result.create(actions.toTypedArray(), accountSettingsManager) } } -private class ChangePartitionActionGroup : DefaultActionGroup(message("settings.partitions"), true), DumbAware { - init { - addAll(AwsRegionProvider.getInstance().partitions().map { - object : ToggleAction(it.value.description), DumbAware { - private val partition = it.value - override fun isSelected(e: AnActionEvent) = getAccountSetting(e).selectedPartition == partition - - override fun setSelected(e: AnActionEvent, state: Boolean) { - if (state) { - getAccountSetting(e).changePartition(partition) - } - } - } - }) - } +enum class ChangeAccountSettingsMode( + internal val showRegions: Boolean, + internal val showCredentials: Boolean +) { + CREDENTIALS(false, true), + REGIONS(true, false), + BOTH(true, true) } -private class ChangeCredentialsActionGroup(popup: Boolean) : ComputableActionGroup( - message("settings.credentials.profile_sub_menu"), - popup -), DumbAware { +private class ChangeCredentialsActionGroup(popup: Boolean) : ComputableActionGroup(message("settings.credentials.profile_sub_menu"), popup), DumbAware { override fun createChildrenProvider(actionManager: ActionManager?): CachedValueProvider> = CachedValueProvider { val credentialManager = CredentialManager.getInstance() @@ -99,36 +104,52 @@ private class ChangeCredentialsActionGroup(popup: Boolean) : ComputableActionGro } } -private class ChangeRegionActionGroup( - private val partitionSelector: ChangePartitionActionGroup, - private val accountSettingsManager: ProjectAccountSettingsManager -) : - ComputableActionGroup(message("settings.regions.region_sub_menu"), true), DumbAware { +internal class ChangePartitionActionGroup(private val accountSettingsManager: AwsConnectionManager) : + ComputableActionGroup(message("settings.partitions"), true), DumbAware { + override fun createChildrenProvider(actionManager: ActionManager?): CachedValueProvider> = CachedValueProvider { + val selectedPartitionId = accountSettingsManager.selectedPartition?.id + val actions = AwsRegionProvider.getInstance().partitions().values.filter { it.id != selectedPartitionId }.map { partition -> + ChangeRegionActionGroup(partition, accountSettingsManager, name = partition.description) + } as List + + CachedValueProvider.Result.create(actions.toTypedArray(), accountSettingsManager) + } +} + +internal class ChangeRegionActionGroup( + private val partition: AwsPartition?, + private val accountSettingsManager: AwsConnectionManager, + private val partitionSelector: ChangePartitionActionGroup? = null, + name: String = message("settings.regions.region_sub_menu") +) : ComputableActionGroup(name, true), DumbAware { private val regionProvider = AwsRegionProvider.getInstance() override fun createChildrenProvider(actionManager: ActionManager?): CachedValueProvider> = CachedValueProvider { - val partition = accountSettingsManager.selectedPartition val (regionMap, partitionGroup) = partition?.let { // if a partition has been selected, only show regions in that partition // and the partition selector regionProvider.regions(partition.id) to partitionSelector // otherwise show everything with no partition selector } ?: regionProvider.allRegions() to null - val regions = regionMap.values.groupBy { it.category } - val partitionActions = partitionGroup?.let { listOf(it) } ?: emptyList() - - val actions = partitionActions + - regions.flatMap { (category, subRegions) -> - listOf(Separator.create(category)) + - subRegions.map { - ChangeRegionAction(it) + + val actions = mutableListOf() + + regionMap.values.groupBy { it.category }.forEach { (category, subRegions) -> + actions.add(Separator.create(category)) + subRegions.forEach { + actions.add(ChangeRegionAction(it)) } - } as List + } + + if (partitionGroup != null && regionProvider.partitions().size > 1) { + actions.add(Separator.create()) + actions.add(partitionGroup) + } CachedValueProvider.Result.create(actions.toTypedArray(), accountSettingsManager) } } -private class ChangeRegionAction(private val region: AwsRegion) : ToggleAction(region.displayName), DumbAware { +internal class ChangeRegionAction(private val region: AwsRegion) : ToggleAction(region.displayName), DumbAware { override fun isSelected(e: AnActionEvent): Boolean = getAccountSetting(e).selectedRegion == region override fun setSelected(e: AnActionEvent, state: Boolean) { @@ -138,7 +159,7 @@ private class ChangeRegionAction(private val region: AwsRegion) : ToggleAction(r } } -private class ChangeCredentialsAction(private val credentialsProvider: ToolkitCredentialsIdentifier) : ToggleAction(credentialsProvider.displayName), +internal class ChangeCredentialsAction(private val credentialsProvider: CredentialIdentifier) : ToggleAction(credentialsProvider.displayName), DumbAware { override fun isSelected(e: AnActionEvent): Boolean = getAccountSetting(e).selectedCredentialIdentifier == credentialsProvider @@ -149,5 +170,44 @@ private class ChangeCredentialsAction(private val credentialsProvider: ToolkitCr } } -private fun getAccountSetting(e: AnActionEvent): ProjectAccountSettingsManager = - ProjectAccountSettingsManager.getInstance(e.getRequiredData(PlatformDataKeys.PROJECT)) +private fun getAccountSetting(e: AnActionEvent): AwsConnectionManager = + AwsConnectionManager.getInstance(e.getRequiredData(PlatformDataKeys.PROJECT)) + +class SettingsSelectorComboBoxAction( + private val project: Project, + private val mode: ChangeAccountSettingsMode +) : ComboBoxAction(), DumbAware { + private val accountSettingsManager by lazy { + AwsConnectionManager.getInstance(project) + } + + init { + updatePresentation(templatePresentation) + } + + override fun createPopupActionGroup(button: JComponent?) = DefaultActionGroup(ChangeAccountSettingsActionGroup(project, mode)) + + override fun update(e: AnActionEvent) { + updatePresentation(e.presentation) + } + + override fun displayTextInToolbar(): Boolean = true + + private fun updatePresentation(presentation: Presentation) { + val (short, long) = when (mode) { + CREDENTIALS -> credentialsText() + REGIONS -> regionText() + BOTH -> "${credentialsText()}@${regionText()}" to null + } + presentation.text = short + presentation.description = long + } + + private fun regionText() = accountSettingsManager.selectedRegion?.let { + it.id to it.displayName + } ?: message("settings.regions.none_selected") to null + + private fun credentialsText() = accountSettingsManager.selectedCredentialIdentifier?.let { + it.shortName to it.displayName + } ?: message("settings.credentials.none_selected") to null +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialManager.kt index a96e6f8620..1757d3b95c 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialManager.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialManager.kt @@ -3,21 +3,19 @@ package software.aws.toolkits.jetbrains.core.credentials -import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.ServiceManager import com.intellij.openapi.extensions.ExtensionPointName -import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.SimpleModificationTracker import com.intellij.util.messages.MessageBus import com.intellij.util.messages.Topic import software.amazon.awssdk.auth.credentials.AwsCredentials import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider import software.amazon.awssdk.auth.credentials.AwsSessionCredentials +import software.aws.toolkits.core.credentials.CredentialIdentifier import software.aws.toolkits.core.credentials.CredentialProviderFactory import software.aws.toolkits.core.credentials.CredentialProviderNotFoundException import software.aws.toolkits.core.credentials.ToolkitCredentialsChangeListener -import software.aws.toolkits.core.credentials.ToolkitCredentialsIdentifier import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.core.utils.getLogger @@ -26,36 +24,36 @@ import software.aws.toolkits.jetbrains.core.AwsSdkClient import java.util.concurrent.ConcurrentHashMap abstract class CredentialManager : SimpleModificationTracker() { - private val providerIds = ConcurrentHashMap() - private val awsCredentialProviderCache = ConcurrentHashMap>() + private val providerIds = ConcurrentHashMap() + private val awsCredentialProviderCache = ConcurrentHashMap>() protected abstract fun factoryMapping(): Map @Throws(CredentialProviderNotFoundException::class) - fun getAwsCredentialProvider(providerId: ToolkitCredentialsIdentifier, region: AwsRegion): ToolkitCredentialsProvider = + fun getAwsCredentialProvider(providerId: CredentialIdentifier, region: AwsRegion): ToolkitCredentialsProvider = ToolkitCredentialsProvider(providerId, AwsCredentialProviderProxy(providerId, region)) - fun getCredentialIdentifiers(): List = providerIds.values + fun getCredentialIdentifiers(): List = providerIds.values .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) - fun getCredentialIdentifierById(id: String): ToolkitCredentialsIdentifier? = providerIds[id] + fun getCredentialIdentifierById(id: String): CredentialIdentifier? = providerIds[id] // TODO: Convert these to bulk listeners so we only send N messages where N is # of extensions vs # of providers - protected fun addProvider(identifier: ToolkitCredentialsIdentifier) { + protected fun addProvider(identifier: CredentialIdentifier) { providerIds[identifier.id] = identifier incModificationCount() ApplicationManager.getApplication().messageBus.syncPublisher(CREDENTIALS_CHANGED).providerAdded(identifier) } - protected fun modifyProvider(identifier: ToolkitCredentialsIdentifier) { + protected fun modifyProvider(identifier: CredentialIdentifier) { awsCredentialProviderCache.remove(identifier) incModificationCount() ApplicationManager.getApplication().messageBus.syncPublisher(CREDENTIALS_CHANGED).providerModified(identifier) } - protected fun removeProvider(identifier: ToolkitCredentialsIdentifier) { + protected fun removeProvider(identifier: CredentialIdentifier) { providerIds.remove(identifier.id) awsCredentialProviderCache.remove(identifier) @@ -68,11 +66,11 @@ abstract class CredentialManager : SimpleModificationTracker() { * ToolkitCredentialsProvider (like ones passed to existing SDK clients), to keep operating even if the credentials they represent have been updated such * as loading from disk when new values. */ - private inner class AwsCredentialProviderProxy(private val providerId: ToolkitCredentialsIdentifier, private val region: AwsRegion) : + private inner class AwsCredentialProviderProxy(private val providerId: CredentialIdentifier, private val region: AwsRegion) : AwsCredentialsProvider { override fun resolveCredentials(): AwsCredentials = getOrCreateAwsCredentialsProvider(providerId, region).resolveCredentials() - private fun getOrCreateAwsCredentialsProvider(providerId: ToolkitCredentialsIdentifier, region: AwsRegion): AwsCredentialsProvider { + private fun getOrCreateAwsCredentialsProvider(providerId: CredentialIdentifier, region: AwsRegion): AwsCredentialsProvider { val partitionCache = awsCredentialProviderCache.computeIfAbsent(providerId) { ConcurrentHashMap() } // If we already resolved creds for this partition and provider ID, just return it @@ -104,22 +102,13 @@ abstract class CredentialManager : SimpleModificationTracker() { } class DefaultCredentialManager : CredentialManager() { - private val rootDisposable = Disposer.newDisposable() - private val extensionMap: Map by lazy { - EP_NAME.extensionList - .onEach { - if (it is Disposable) { - Disposer.register(rootDisposable, it) - } - }.associateBy { + EP_NAME.extensionList.associateBy { it.id } } init { - Disposer.register(ApplicationManager.getApplication(), rootDisposable) - extensionMap.values.forEach { providerFactory -> LOG.tryOrNull("Failed to set up $providerFactory") { providerFactory.setUp { change -> diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialStatusNotification.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialStatusNotification.kt index 68eedce41d..a89a089d83 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialStatusNotification.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialStatusNotification.kt @@ -4,24 +4,21 @@ package software.aws.toolkits.jetbrains.core.credentials import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project -import com.intellij.openapi.startup.StartupActivity import software.aws.toolkits.jetbrains.utils.createNotificationExpiringAction import software.aws.toolkits.jetbrains.utils.createShowMoreInfoDialogAction import software.aws.toolkits.jetbrains.utils.notifyWarn import software.aws.toolkits.resources.message -class CredentialStatusNotification : StartupActivity, DumbAware, ConnectionSettingsChangeNotifier { - override fun runActivity(project: Project) { - project.messageBus.connect().subscribe(ProjectAccountSettingsManager.CONNECTION_SETTINGS_CHANGED, this) - } - - override fun settingsChanged(event: ConnectionSettingsChangeEvent) { - if (event is InvalidConnectionSettings) { +class CredentialStatusNotification(private val project: Project) : ConnectionSettingsStateChangeNotifier { + private val actionManager = ActionManager.getInstance() + override fun settingsStateChanged(newState: ConnectionState) { + if (newState is ConnectionState.InvalidConnection) { val title = message("credentials.invalid.title") - val message = message("credentials.profile.validation_error", event.credentialsProvider.displayName) + val message = message("credentials.invalid.description") + notifyWarn( + project = project, title = title, content = message, notificationActions = listOf( @@ -29,9 +26,10 @@ class CredentialStatusNotification : StartupActivity, DumbAware, ConnectionSetti message("credentials.invalid.more_info"), title, message, - event.cause.localizedMessage + newState.displayMessage ), - createNotificationExpiringAction(ActionManager.getInstance().getAction("aws.settings.upsertCredentials")) + createNotificationExpiringAction(actionManager.getAction("aws.settings.upsertCredentials")), + createNotificationExpiringAction(RefreshConnectionAction(message("settings.retry"))) ) ) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialsRegionHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialsRegionHandler.kt new file mode 100644 index 0000000000..8b31aca35a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialsRegionHandler.kt @@ -0,0 +1,65 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import com.intellij.notification.NotificationAction +import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.jetbrains.settings.AwsSettings +import software.aws.toolkits.jetbrains.settings.UseAwsCredentialRegion +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message + +/** + * Encapsulates logic for handling of regions when a new credential identifier is selected + */ +interface CredentialsRegionHandler { + fun determineSelectedRegion(identifier: CredentialIdentifier, selectedRegion: AwsRegion?): AwsRegion? + + companion object { + fun getInstance(project: Project): CredentialsRegionHandler = ServiceManager.getService(project, CredentialsRegionHandler::class.java) + } +} + +internal open class DefaultCredentialsRegionHandler(private val project: Project) : CredentialsRegionHandler { + private val regionProvider by lazy { AwsRegionProvider.getInstance() } + private val settings by lazy { AwsSettings.getInstance() } + + override fun determineSelectedRegion(identifier: CredentialIdentifier, selectedRegion: AwsRegion?): AwsRegion? { + if (settings.useDefaultCredentialRegion == UseAwsCredentialRegion.Never) { + return selectedRegion + } + val defaultCredentialRegion = identifier.defaultRegionId?.let { regionProvider[it] } ?: return selectedRegion + when { + selectedRegion == defaultCredentialRegion -> return defaultCredentialRegion + selectedRegion?.partitionId != defaultCredentialRegion.partitionId -> return defaultCredentialRegion + settings.useDefaultCredentialRegion == UseAwsCredentialRegion.Always -> return defaultCredentialRegion + settings.useDefaultCredentialRegion == UseAwsCredentialRegion.Prompt -> promptForRegionChange(defaultCredentialRegion) + } + return selectedRegion + } + + private fun promptForRegionChange(defaultCredentialRegion: AwsRegion) { + notifyInfo( + message("aws.notification.title"), + message("settings.credentials.prompt_for_default_region_switch", defaultCredentialRegion.id), + project = project, + notificationActions = listOf( + NotificationAction.create(message("settings.credentials.prompt_for_default_region_switch.yes")) { event, _ -> + ChangeRegionAction(defaultCredentialRegion).actionPerformed(event) + }, + NotificationAction.create(message("settings.credentials.prompt_for_default_region_switch.always")) { event, _ -> + settings.useDefaultCredentialRegion = UseAwsCredentialRegion.Always + ChangeRegionAction(defaultCredentialRegion).actionPerformed(event) + }, + NotificationAction.createSimple(message("settings.credentials.prompt_for_default_region_switch.never")) { + settings.useDefaultCredentialRegion = UseAwsCredentialRegion.Never + } + ) + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultProjectAccountSettingsManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManager.kt similarity index 81% rename from jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultProjectAccountSettingsManager.kt rename to jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManager.kt index 9faee84ce5..6322ab1ab2 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultProjectAccountSettingsManager.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManager.kt @@ -1,4 +1,4 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package software.aws.toolkits.jetbrains.core.credentials @@ -22,7 +22,7 @@ data class ConnectionSettingsState( ) @State(name = "accountSettings", storages = [Storage("aws.xml")]) -class DefaultProjectAccountSettingsManager(private val project: Project) : ProjectAccountSettingsManager(project), +class DefaultAwsConnectionManager(private val project: Project) : AwsConnectionManager(project), PersistentStateComponent { override fun getState(): ConnectionSettingsState = ConnectionSettingsState( activeProfile = selectedCredentialIdentifier?.id, @@ -31,9 +31,13 @@ class DefaultProjectAccountSettingsManager(private val project: Project) : Proje recentlyUsedRegions = recentlyUsedRegions.elements() ) + override fun noStateLoaded() { + loadState(ConnectionSettingsState()) + } + override fun loadState(state: ConnectionSettingsState) { // This can be called more than once, so we need to re-do our init sequence - connectionState = ConnectionState.INITIALIZING + connectionState = ConnectionState.InitializingToolkit // Load reversed so that oldest is as the bottom state.recentlyUsedRegions.reversed() @@ -49,11 +53,10 @@ class DefaultProjectAccountSettingsManager(private val project: Project) : Proje CredentialManager.getInstance().getCredentialIdentifierById(credentialId) } - val regionId = state.activeRegion ?: AwsRegionProvider.getInstance().defaultRegion().id + val regionId = state.activeRegion ?: credentials?.defaultRegionId ?: AwsRegionProvider.getInstance().defaultRegion().id val region = AwsRegionProvider.getInstance().allRegions()[regionId] - val partition = region?.partitionId?.let { AwsRegionProvider.getInstance().partitions()[it] } - changeConnectionSettings(credentials, partition, region) + changeConnectionSettings(credentials, region) } } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/InteractiveCredential.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/InteractiveCredential.kt new file mode 100644 index 0000000000..edc322b5fa --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/InteractiveCredential.kt @@ -0,0 +1,22 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import com.intellij.openapi.actionSystem.AnAction +import software.aws.toolkits.core.credentials.CredentialIdentifier + +/** + * Interface that indicates that [CredentialIdentifier] may require interaction from a user before they can be used + */ +interface InteractiveCredential : CredentialIdentifier { + val userActionDisplayMessage: String + val userActionShortDisplayMessage: String get() = userActionDisplayMessage + + val userAction: AnAction + + /** + * Determines if user action is required at this time (e.g. may check expiry of cookies, etc) + */ + suspend fun userActionRequired(): Boolean +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/MfaSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/MfaSupport.kt new file mode 100644 index 0000000000..2ed444b989 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/MfaSupport.kt @@ -0,0 +1,31 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.ui.Messages +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import software.aws.toolkits.jetbrains.utils.getCoroutineUiContext +import software.aws.toolkits.resources.message + +interface MfaRequiredInteractiveCredentials : InteractiveCredential { + override val userActionDisplayMessage: String get() = message("credentials.mfa.display", displayName) + override val userActionShortDisplayMessage: String get() = message("credentials.mfa.display.short") + + override val userAction: AnAction get() = RefreshConnectionAction(message("credentials.mfa.action")) + + override suspend fun userActionRequired(): Boolean = true +} + +fun promptForMfaToken(name: String, mfaSerial: String): String = runBlocking { + withContext(getCoroutineUiContext(ModalityState.any())) { + Messages.showInputDialog( + message("credentials.mfa.message", mfaSerial), + message("credentials.mfa.title", name), + null + ) ?: throw IllegalStateException("MFA challenge is required") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ProjectAccountSettingsManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ProjectAccountSettingsManager.kt deleted file mode 100644 index 094c3863a4..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ProjectAccountSettingsManager.kt +++ /dev/null @@ -1,270 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.core.credentials - -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.components.ServiceManager -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.SimpleModificationTracker -import com.intellij.util.messages.Topic -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.jetbrains.annotations.TestOnly -import software.aws.toolkits.core.credentials.CredentialProviderNotFoundException -import software.aws.toolkits.core.credentials.ToolkitCredentialsChangeListener -import software.aws.toolkits.core.credentials.ToolkitCredentialsIdentifier -import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider -import software.aws.toolkits.core.region.AwsPartition -import software.aws.toolkits.core.region.AwsRegion -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.warn -import software.aws.toolkits.jetbrains.core.AwsResourceCache -import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider -import software.aws.toolkits.jetbrains.services.sts.StsResources -import software.aws.toolkits.jetbrains.utils.MRUList -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.AwsTelemetry -import java.util.concurrent.CancellationException - -abstract class ProjectAccountSettingsManager(private val project: Project) : SimpleModificationTracker() { - private val resourceCache = AwsResourceCache.getInstance(project) - private val regionProvider = AwsRegionProvider.getInstance() - - @Volatile - private var validationJob: Job? = null - @Volatile - internal var connectionState: ConnectionState = ConnectionState.INITIALIZING - @TestOnly get - - protected val recentlyUsedProfiles = MRUList(MAX_HISTORY) - protected val recentlyUsedRegions = MRUList(MAX_HISTORY) - - // Internal state is visible for AwsSettingsPanel and ChangeAccountSettingsActionGroup - internal var selectedCredentialIdentifier: ToolkitCredentialsIdentifier? = null - internal var selectedPartition: AwsPartition? = null - internal var selectedRegion: AwsRegion? = null - - private var selectedCredentialsProvider: ToolkitCredentialsProvider? = null - - init { - ApplicationManager.getApplication().messageBus.connect(project) - .subscribe(CredentialManager.CREDENTIALS_CHANGED, object : ToolkitCredentialsChangeListener { - override fun providerRemoved(identifier: ToolkitCredentialsIdentifier) { - if (selectedCredentialIdentifier == identifier) { - changeConnectionSettings(null, selectedPartition, selectedRegion) - } - } - }) - } - - fun isValidConnectionSettings(): Boolean = connectionState == ConnectionState.VALID - - fun connectionSettings(): ConnectionSettings? = selectedCredentialsProvider?.let { creds -> - selectedRegion?.let { region -> - ConnectionSettings(creds, region) - } - } - - /** - * Internal setter that allows for null values and is intended to set the internal state and still notify - */ - protected fun changeConnectionSettings(identifier: ToolkitCredentialsIdentifier?, partition: AwsPartition?, region: AwsRegion?) { - changeFieldsAndNotify { - identifier?.let { - recentlyUsedProfiles.add(it.id) - } - - region?.let { - recentlyUsedRegions.add(it.id) - } - - selectedCredentialIdentifier = identifier - selectedPartition = partition - selectedRegion = region - } - } - - // TODO: Make this not null, few tests need to be fixed - /** - * Changes the credentials and then validates them. Notifies listeners of results - */ - fun changeCredentialProvider(identifier: ToolkitCredentialsIdentifier?) { - changeFieldsAndNotify { - identifier?.let { - recentlyUsedProfiles.add(identifier.id) - } - - selectedCredentialIdentifier = identifier - } - } - - /** - * Changes the region and then validates them. Notifies listeners of results - */ - fun changeRegion(region: AwsRegion) { - changeFieldsAndNotify { - region.let { - recentlyUsedRegions.add(region.id) - } - selectedRegion = region - selectedPartition = regionProvider.partitions()[region.partitionId] - } - } - - /** - * Changes the partition and then validates them. Notifies listeners of results - */ - fun changePartition(partition: AwsPartition) { - changeFieldsAndNotify { - selectedRegion = null - selectedPartition = partition - } - } - - private fun changeFieldsAndNotify(fieldUpdateBlock: () -> Unit) { - incModificationCount() - - connectionState = ConnectionState.VALIDATING - validationJob?.cancel(CancellationException("Newer connection settings chosen")) - - // Clear existing provider - selectedCredentialsProvider = null - - fieldUpdateBlock() - - validationJob = GlobalScope.launch(Dispatchers.IO) { - broadcastChangeEvent(ConnectionSettingsStateChange(connectionState)) - - val credentialsIdentifier = selectedCredentialIdentifier - val region = selectedRegion - if (credentialsIdentifier == null || region == null) { - connectionState = ConnectionState.INVALID - broadcastChangeEvent(ConnectionSettingsStateChange(connectionState)) - incModificationCount() - return@launch - } - - try { - val credentialsProvider = CredentialManager.getInstance().getAwsCredentialProvider(credentialsIdentifier, region) - - validate(credentialsProvider, region) - connectionState = ConnectionState.VALID - selectedCredentialsProvider = credentialsProvider - - broadcastChangeEvent(ValidConnectionSettings(connectionState)) - } catch (e: Exception) { - connectionState = ConnectionState.INVALID - LOGGER.warn(e) { message("credentials.profile.validation_error", credentialsIdentifier.displayName) } - broadcastChangeEvent(InvalidConnectionSettings(credentialsIdentifier, region, e, connectionState)) - } finally { - incModificationCount() - AwsTelemetry.validateCredentials(project, success = isValidConnectionSettings()) - validationJob = null - } - } - } - - /** - * Legacy method, should be considered deprecated and avoided since it loads defaults out of band - */ - val activeRegion: AwsRegion - get() = selectedRegion ?: AwsRegionProvider.getInstance().defaultRegion().also { - LOGGER.warn(IllegalStateException()) { "Using activeRegion when region is null, calling code needs to be migrated to handle null" } - } - - /** - * Legacy method, should be considered deprecated and avoided since it loads defaults out of band - */ - val activeCredentialProvider: ToolkitCredentialsProvider - @Throws(CredentialProviderNotFoundException::class) - get() = selectedCredentialsProvider ?: throw CredentialProviderNotFoundException(message("credentials.profile.not_configured")).also { - LOGGER.warn(IllegalStateException()) { "Using activeCredentialProvider when credentials is null, calling code needs to be migrated to handle null" } - } - - /** - * Returns the list of recently used [AwsRegion] - */ - fun recentlyUsedRegions(): List = recentlyUsedRegions.elements().mapNotNull { regionProvider.allRegions()[it] } - - /** - * Returns the list of recently used [ToolkitCredentialsIdentifier] - */ - fun recentlyUsedCredentials(): List { - val credentialManager = CredentialManager.getInstance() - return recentlyUsedProfiles.elements().mapNotNull { credentialManager.getCredentialIdentifierById(it) } - } - - /** - * Internal method that executes the actual validation of credentials - */ - protected open suspend fun validate(credentialsProvider: ToolkitCredentialsProvider, region: AwsRegion): Boolean = withContext(Dispatchers.IO) { - // TODO: Convert the cache over to suspend methods - resourceCache.getResourceNow( - StsResources.ACCOUNT, - region = region, - credentialProvider = credentialsProvider, - useStale = false, - forceFetch = true - ) - true - } - - private fun broadcastChangeEvent(event: ConnectionSettingsChangeEvent) { - if (!project.isDisposed) { - project.messageBus.syncPublisher(CONNECTION_SETTINGS_CHANGED).settingsChanged(event) - } - } - - companion object { - /*** - * MessageBus topic for when the active credential profile or region is changed - */ - val CONNECTION_SETTINGS_CHANGED: Topic = Topic.create( - "AWS Account setting changed", - ConnectionSettingsChangeNotifier::class.java - ) - - @JvmStatic - fun getInstance(project: Project): ProjectAccountSettingsManager = ServiceManager.getService(project, ProjectAccountSettingsManager::class.java) - - private val LOGGER = getLogger() - private const val MAX_HISTORY = 5 - } -} - -enum class ConnectionState { - INITIALIZING, - VALIDATING, - INVALID, - VALID -} - -interface ConnectionSettingsChangeNotifier { - fun settingsChanged(event: ConnectionSettingsChangeEvent) -} - -sealed class ConnectionSettingsChangeEvent(val state: ConnectionState) -class ConnectionSettingsStateChange(state: ConnectionState) : ConnectionSettingsChangeEvent(state) - -class InvalidConnectionSettings( - val credentialsProvider: ToolkitCredentialsIdentifier, - val region: AwsRegion, - val cause: Exception, - state: ConnectionState -) : ConnectionSettingsChangeEvent(state) - -class ValidConnectionSettings(state: ConnectionState) : ConnectionSettingsChangeEvent(state) - -/** - * Legacy method, should be considered deprecated and avoided since it loads defaults out of band - */ -fun Project.activeRegion(): AwsRegion = ProjectAccountSettingsManager.getInstance(this).activeRegion - -/** - * Legacy method, should be considered deprecated and avoided since it loads defaults out of band - */ -fun Project.activeCredentialProvider(): ToolkitCredentialsProvider = ProjectAccountSettingsManager.getInstance(this).activeCredentialProvider diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/RefreshConnectionAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/RefreshConnectionAction.kt new file mode 100644 index 0000000000..c59c30a634 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/RefreshConnectionAction.kt @@ -0,0 +1,27 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.resources.message + +class RefreshConnectionAction(text: String = message("settings.refresh.description")) : AnAction(text, null, AllIcons.Actions.Refresh), DumbAware { + override fun update(e: AnActionEvent) { + val project = e.project ?: return + e.presentation.isEnabled = when (val state = AwsConnectionManager.getInstance(project).connectionState) { + is ConnectionState.IncompleteConfiguration -> false + else -> state.isTerminal + } + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + AwsResourceCache.getInstance(project).clear() + AwsConnectionManager.getInstance(project).refreshConnectionState() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/SsoSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/SsoSupport.kt new file mode 100644 index 0000000000..77bdb8f641 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/SsoSupport.kt @@ -0,0 +1,60 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.ui.Messages +import kotlinx.coroutines.withContext +import software.aws.toolkits.core.credentials.sso.Authorization +import software.aws.toolkits.core.credentials.sso.DiskCache +import software.aws.toolkits.core.credentials.sso.SsoCache +import software.aws.toolkits.core.credentials.sso.SsoLoginCallback +import software.aws.toolkits.jetbrains.utils.getCoroutineUiContext +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message + +/** + * Shared disk cache for SSO for the IDE + */ +val diskCache by lazy { DiskCache() } + +object SsoPrompt : SsoLoginCallback { + override suspend fun tokenPending(authorization: Authorization) { + withContext(getCoroutineUiContext(ModalityState.any())) { + val result = Messages.showOkCancelDialog( + message("credentials.sso.login.message", authorization.verificationUri, authorization.userCode), + message("credentials.sso.login.title"), + message("credentials.sso.login.open_browser"), + Messages.CANCEL_BUTTON, + null + ) + + if (result == Messages.OK) { + BrowserUtil.browse(authorization.verificationUriComplete) + } else { + throw IllegalStateException(message("credentials.sso.login.cancelled")) + } + } + } + + override fun tokenRetrieved() {} + + override fun tokenRetrievalFailure(e: Exception) { + e.notifyError(message("credentials.sso.login.failed")) + } +} + +interface SsoRequiredInteractiveCredentials : InteractiveCredential { + val ssoCache: SsoCache + val ssoUrl: String + + override val userActionDisplayMessage: String get() = message("credentials.sso.display", displayName) + override val userActionShortDisplayMessage: String get() = message("credentials.sso.display.short") + + override val userAction: AnAction get() = RefreshConnectionAction(message("credentials.sso.action")) + + override suspend fun userActionRequired(): Boolean = ssoCache.loadAccessToken(ssoUrl) == null +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitCredentialProcessProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitCredentialProcessProvider.kt new file mode 100644 index 0000000000..595e6d712d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitCredentialProcessProvider.kt @@ -0,0 +1,100 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.MapperFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.ProcessOutput +import com.intellij.execution.util.ExecUtil +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.util.registry.Registry +import com.intellij.util.execution.ParametersListUtil +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.AwsCredentials +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials +import software.amazon.awssdk.utils.cache.CachedSupplier +import software.amazon.awssdk.utils.cache.RefreshResult +import software.aws.toolkits.resources.message +import java.time.Instant + +class ToolkitCredentialProcessProvider internal constructor( + private val command: String, + private val parser: CredentialProcessOutputParser +) : AwsCredentialsProvider { + constructor(command: String) : this(command, DefaultCredentialProcessOutputParser) + + private val entrypoint by lazy { + ParametersListUtil.parse(command).first() + } + private val cmd by lazy { + if (SystemInfo.isWindows) { + GeneralCommandLine("cmd", "/C", command) + } else { + GeneralCommandLine("sh", "-c", command) + } + } + private val processCredentialCache = CachedSupplier.builder { refresh() }.build() + + override fun resolveCredentials(): AwsCredentials = processCredentialCache.get() + + private fun refresh(): RefreshResult { + val timeout = Registry.intValue("aws.credentialProcess.timeout", DEFAULT_TIMEOUT) + val output = ExecUtil.execAndGetOutput(cmd, timeout) + + if (output.isTimeout) { + handleException(message("credentials.profile.credential_process.timeout_exception_prefix", entrypoint), output) + } + + if (output.exitCode != 0) { + handleException(message("credentials.profile.credential_process.execution_exception_prefix", entrypoint), output) + } + + val result = try { + parser.parse(output.stdout) + } catch (e: Exception) { + handleException(message("credentials.profile.credential_process.parse_exception_prefix"), output) + } + val credentials = when (val token = result.sessionToken) { + null -> AwsBasicCredentials.create(result.accessKeyId, result.secretAccessKey) + else -> AwsSessionCredentials.create(result.accessKeyId, result.secretAccessKey, token) + } + return RefreshResult.builder(credentials).staleTime(result.expiration ?: Instant.MAX).build() + } + + private fun handleException(msgPrefix: String, process: ProcessOutput): Nothing { + val errorOutput = process.stderr.takeIf { it.isNotBlank() } + val msg = "$msgPrefix${errorOutput?.let { ": $it" } ?: ""}" + throw RuntimeException(msg) + } + + internal companion object { + private const val DEFAULT_TIMEOUT = 30000 + } +} + +internal data class CredentialProcessOutput(val accessKeyId: String, val secretAccessKey: String, val sessionToken: String?, val expiration: Instant?) + +internal abstract class CredentialProcessOutputParser { + abstract fun parse(input: String): CredentialProcessOutput +} + +internal object DefaultCredentialProcessOutputParser : CredentialProcessOutputParser() { + private val mapper = jacksonObjectMapper() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .registerModule(JavaTimeModule()) + + override fun parse(input: String): CredentialProcessOutput = try { + mapper.readValue(input) + } catch (e: JsonProcessingException) { + e.clearLocation() + throw e + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileCredentialProviderFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileCredentialProviderFactory.kt index 207a33abd0..f092d583d2 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileCredentialProviderFactory.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileCredentialProviderFactory.kt @@ -1,35 +1,47 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package software.aws.toolkits.jetbrains.core.credentials.profiles -import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.ui.Messages -import com.intellij.openapi.util.Ref -import icons.AwsIcons +import com.intellij.openapi.util.registry.Registry +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider import software.amazon.awssdk.auth.credentials.AwsBasicCredentials import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider import software.amazon.awssdk.auth.credentials.AwsSessionCredentials -import software.amazon.awssdk.auth.credentials.ProcessCredentialsProvider import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider import software.amazon.awssdk.http.SdkHttpClient import software.amazon.awssdk.profiles.Profile import software.amazon.awssdk.profiles.ProfileProperty import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.sso.SsoClient +import software.amazon.awssdk.services.ssooidc.SsoOidcClient import software.amazon.awssdk.services.sts.StsClient import software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider import software.amazon.awssdk.services.sts.model.AssumeRoleRequest import software.aws.toolkits.core.ToolkitClientManager +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.core.credentials.CredentialIdentifierBase import software.aws.toolkits.core.credentials.CredentialProviderFactory import software.aws.toolkits.core.credentials.CredentialsChangeEvent import software.aws.toolkits.core.credentials.CredentialsChangeListener -import software.aws.toolkits.core.credentials.ToolkitCredentialsIdentifier +import software.aws.toolkits.core.credentials.sso.SSO_ACCOUNT +import software.aws.toolkits.core.credentials.sso.SSO_EXPERIMENTAL_REGISTRY_KEY +import software.aws.toolkits.core.credentials.sso.SSO_REGION +import software.aws.toolkits.core.credentials.sso.SSO_ROLE_NAME +import software.aws.toolkits.core.credentials.sso.SSO_URL +import software.aws.toolkits.core.credentials.sso.SsoAccessTokenProvider +import software.aws.toolkits.core.credentials.sso.SsoCache +import software.aws.toolkits.core.credentials.sso.SsoCredentialProvider import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.jetbrains.core.AwsClientManager import software.aws.toolkits.jetbrains.core.credentials.CorrectThreadCredentialsProvider +import software.aws.toolkits.jetbrains.core.credentials.MfaRequiredInteractiveCredentials +import software.aws.toolkits.jetbrains.core.credentials.SsoPrompt +import software.aws.toolkits.jetbrains.core.credentials.SsoRequiredInteractiveCredentials +import software.aws.toolkits.jetbrains.core.credentials.ToolkitCredentialProcessProvider +import software.aws.toolkits.jetbrains.core.credentials.diskCache +import software.aws.toolkits.jetbrains.core.credentials.promptForMfaToken import software.aws.toolkits.jetbrains.utils.createNotificationExpiringAction import software.aws.toolkits.jetbrains.utils.createShowMoreInfoDialogAction import software.aws.toolkits.jetbrains.utils.notifyError @@ -41,14 +53,25 @@ const val DEFAULT_PROFILE_ID = "profile:default" private const val PROFILE_FACTORY_ID = "ProfileCredentialProviderFactory" -private class ProfileCredentialsIdentifier(internal val profileName: String) : ToolkitCredentialsIdentifier() { +private open class ProfileCredentialsIdentifier(val profileName: String, override val defaultRegionId: String?) : CredentialIdentifierBase() { override val id = "profile:$profileName" override val displayName = message("credentials.profile.name", profileName) override val factoryId = PROFILE_FACTORY_ID + override val shortName: String = profileName } -class ProfileCredentialProviderFactory : CredentialProviderFactory, Disposable { - private val profileWatcher = ProfileWatcher(this) +private class ProfileCredentialsIdentifierMfa(profileName: String, defaultRegionId: String?) : + ProfileCredentialsIdentifier(profileName, defaultRegionId), MfaRequiredInteractiveCredentials + +private class ProfileCredentialsIdentifierSso( + profileName: String, + defaultRegionId: String?, + override val ssoCache: SsoCache, + override val ssoUrl: String +) : ProfileCredentialsIdentifier(profileName, defaultRegionId), + SsoRequiredInteractiveCredentials + +class ProfileCredentialProviderFactory : CredentialProviderFactory { private val profileHolder = ProfileHolder() override val id = PROFILE_FACTORY_ID @@ -57,9 +80,9 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory, Disposable { // Load the initial data, then start the background watcher loadProfiles(credentialLoadCallback, true) - profileWatcher.start(onFileChange = { + ProfileWatcher.getInstance().addListener { loadProfiles(credentialLoadCallback, false) - }) + } } private fun loadProfiles(credentialLoadCallback: CredentialsChangeListener, initialLoad: Boolean) { @@ -80,17 +103,17 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory, Disposable { val previousProfile = previousProfilesSnapshot.remove(it.key) if (previousProfile == null) { // It was not in the snapshot, so it must be new - profilesAdded.add(ProfileCredentialsIdentifier(it.key)) + profilesAdded.add(it.value.asId(newProfiles.validProfiles)) } else { // If the profile was modified, notify people, else do nothing if (previousProfile != it.value) { - profilesModified.add(ProfileCredentialsIdentifier(it.key)) + profilesModified.add(it.value.asId(newProfiles.validProfiles)) } } } // Any remaining profiles must have either become invalid or removed from the cred/config files - previousProfilesSnapshot.keys.asSequence().map { ProfileCredentialsIdentifier(it) }.toCollection(profilesRemoved) + previousProfilesSnapshot.values.asSequence().map { it.asId(newProfiles.validProfiles) }.toCollection(profilesRemoved) profileHolder.update(newProfiles.validProfiles) credentialLoadCallback(CredentialsChangeEvent(profilesAdded, profilesModified, profilesRemoved)) @@ -134,7 +157,7 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory, Disposable { if (newProfiles.invalidProfiles.isNotEmpty()) { val message = newProfiles.invalidProfiles.values.joinToString("\n") { it.message ?: it::class.java.name } - val errorDialogTitle = message("credentials.invalid.title") + val errorDialogTitle = message("credentials.profile.failed_load") val numErrorMessage = message("credentials.profile.refresh_errors", newProfiles.invalidProfiles.size) notifyInfo( @@ -148,10 +171,8 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory, Disposable { } } - override fun dispose() {} - override fun createAwsCredentialProvider( - providerId: ToolkitCredentialsIdentifier, + providerId: CredentialIdentifier, region: AwsRegion, sdkHttpClientSupplier: () -> SdkHttpClient ): AwsCredentialsProvider { @@ -161,15 +182,12 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory, Disposable { val profile = profileHolder.getProfile(profileProviderId.profileName) ?: throw IllegalStateException("Profile ${profileProviderId.profileName} looks to have been removed") - return createAwsCredentialProvider(profile, region, sdkHttpClientSupplier()) + return createAwsCredentialProvider(profile, region, sdkHttpClientSupplier) } - private fun createAwsCredentialProvider( - profile: Profile, - region: AwsRegion, - sdkClient: SdkHttpClient - ) = when { - profile.propertyExists(ProfileProperty.ROLE_ARN) -> createAssumeRoleProvider(profile, region, sdkClient) + private fun createAwsCredentialProvider(profile: Profile, region: AwsRegion, sdkHttpClientSupplier: () -> SdkHttpClient) = when { + profile.propertyExists(SSO_URL) && Registry.`is`(SSO_EXPERIMENTAL_REGISTRY_KEY) -> createSsoProvider(profile, sdkHttpClientSupplier) + profile.propertyExists(ProfileProperty.ROLE_ARN) -> createAssumeRoleProvider(profile, region, sdkHttpClientSupplier) profile.propertyExists(ProfileProperty.AWS_SESSION_TOKEN) -> createStaticSessionProvider(profile) profile.propertyExists(ProfileProperty.AWS_ACCESS_KEY_ID) -> createBasicProvider(profile) profile.propertyExists(ProfileProperty.CREDENTIAL_PROCESS) -> createCredentialProcessProvider(profile) @@ -178,22 +196,57 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory, Disposable { } } - private fun createAssumeRoleProvider( - profile: Profile, - region: AwsRegion, - sdkClient: SdkHttpClient - ): AwsCredentialsProvider { + private fun createSsoProvider(profile: Profile, sdkHttpClientSupplier: () -> SdkHttpClient): AwsCredentialsProvider { + val ssoRegion = profile.requiredProperty(SSO_REGION) + val sdkHttpClient = sdkHttpClientSupplier() + val ssoClient = ToolkitClientManager.createNewClient( + SsoClient::class, + sdkHttpClient, + Region.of(ssoRegion), + AnonymousCredentialsProvider.create(), + AwsClientManager.userAgent + ) + + val ssoOidcClient = ToolkitClientManager.createNewClient( + SsoOidcClient::class, + sdkHttpClient, + Region.of(ssoRegion), + AnonymousCredentialsProvider.create(), + AwsClientManager.userAgent + ) + + val ssoAccessTokenProvider = SsoAccessTokenProvider( + profile.requiredProperty(SSO_URL), + ssoRegion, + SsoPrompt, + diskCache, + ssoOidcClient + ) + + return SsoCredentialProvider( + profile.requiredProperty(SSO_ACCOUNT), + profile.requiredProperty(SSO_ROLE_NAME), + ssoClient, + ssoAccessTokenProvider + ) + } + + private fun createAssumeRoleProvider(profile: Profile, region: AwsRegion, sdkHttpClientSupplier: () -> SdkHttpClient): AwsCredentialsProvider { val sourceProfileName = profile.requiredProperty(ProfileProperty.SOURCE_PROFILE) val sourceProfile = profileHolder.getProfile(sourceProfileName) ?: throw IllegalStateException("Profile $sourceProfileName looks to have been removed") + val sdkHttpClient = sdkHttpClientSupplier() + + val parentCredentialProvider = createAwsCredentialProvider(sourceProfile, region, sdkHttpClientSupplier) + // Override the default SPI for getting the active credentials since we are making an internal // to this provider client val stsClient = ToolkitClientManager.createNewClient( StsClient::class, - sdkClient, + sdkHttpClient, Region.of(region.id), - createAwsCredentialProvider(sourceProfile, region, sdkClient), + parentCredentialProvider, AwsClientManager.userAgent ) @@ -205,20 +258,21 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory, Disposable { val mfaSerial = profile.property(ProfileProperty.MFA_SERIAL) .orElse(null) - return CorrectThreadCredentialsProvider( - StsAssumeRoleCredentialsProvider.builder() - .stsClient(stsClient) - .refreshRequest(Supplier { - createAssumeRoleRequest( - profile.name(), - mfaSerial, - roleArn, - roleSessionName, - externalId - ) - }) - .build() - ) + val assumeRoleCredentialsProvider = StsAssumeRoleCredentialsProvider.builder() + .stsClient(stsClient) + .refreshRequest(Supplier { + createAssumeRoleRequest( + profile.name(), + mfaSerial, + roleArn, + roleSessionName, + externalId + ) + }) + .build() + + // TODO: Do we still need this wrapper? + return CorrectThreadCredentialsProvider(assumeRoleCredentialsProvider) } private fun createAssumeRoleRequest( @@ -227,30 +281,19 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory, Disposable { roleArn: String, roleSessionName: String?, externalId: String? - ): AssumeRoleRequest = AssumeRoleRequest.builder() - .roleArn(roleArn) - .roleSessionName(roleSessionName) - .externalId(externalId).also { request -> - mfaSerial?.let { _ -> - request.serialNumber(mfaSerial) - .tokenCode(promptMfaToken(profileName, mfaSerial)) - } - }.build() - - private fun promptMfaToken(name: String, mfaSerial: String): String { - val result = Ref() - - ApplicationManager.getApplication().invokeAndWait({ - val mfaCode: String = Messages.showInputDialog( - message("credentials.profile.mfa.message", mfaSerial), - message("credentials.profile.mfa.title", name), - AwsIcons.Logos.IAM_LARGE - ) ?: throw IllegalStateException("MFA challenge is required") - - result.set(mfaCode) - }, ModalityState.any()) + ): AssumeRoleRequest { + val requestBuilder = AssumeRoleRequest.builder() + .roleArn(roleArn) + .roleSessionName(roleSessionName) + .externalId(externalId) + + mfaSerial?.let { _ -> + requestBuilder + .serialNumber(mfaSerial) + .tokenCode(promptForMfaToken(profileName, mfaSerial)) + } - return result.get() + return requestBuilder.build() } private fun createBasicProvider(profile: Profile) = StaticCredentialsProvider.create( @@ -268,9 +311,26 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory, Disposable { ) ) - private fun createCredentialProcessProvider(profile: Profile) = ProcessCredentialsProvider.builder() - .command(profile.requiredProperty(ProfileProperty.CREDENTIAL_PROCESS)) - .build() + private fun createCredentialProcessProvider(profile: Profile) = + ToolkitCredentialProcessProvider(profile.requiredProperty(ProfileProperty.CREDENTIAL_PROCESS)) + + private fun Profile.asId(profiles: Map): ProfileCredentialsIdentifier { + val name = this.name() + val defaultRegion = this.properties()[ProfileProperty.REGION] + + return when { + this.requiresMfa(profiles) -> ProfileCredentialsIdentifierMfa(name, defaultRegion) + this.requiresSso(profiles) -> ProfileCredentialsIdentifierSso(name, defaultRegion, + diskCache, this.requiredProperty(SSO_URL)) + else -> ProfileCredentialsIdentifier(name, defaultRegion) + } + } + + private fun Profile.requiresMfa(profiles: Map) = this.traverseCredentialChain(profiles) + .any { it.propertyExists(ProfileProperty.MFA_SERIAL) } + + private fun Profile.requiresSso(profiles: Map) = this.traverseCredentialChain(profiles) + .any { it.propertyExists(SSO_URL) } } private class ProfileHolder { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileReader.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileReader.kt index f5e3d1702d..ffe0a258d7 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileReader.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileReader.kt @@ -3,10 +3,15 @@ package software.aws.toolkits.jetbrains.core.credentials.profiles -import com.intellij.util.text.nullize +import com.intellij.openapi.util.registry.Registry import software.amazon.awssdk.profiles.Profile import software.amazon.awssdk.profiles.ProfileFile import software.amazon.awssdk.profiles.ProfileProperty +import software.aws.toolkits.core.credentials.sso.SSO_ACCOUNT +import software.aws.toolkits.core.credentials.sso.SSO_EXPERIMENTAL_REGISTRY_KEY +import software.aws.toolkits.core.credentials.sso.SSO_REGION +import software.aws.toolkits.core.credentials.sso.SSO_ROLE_NAME +import software.aws.toolkits.core.credentials.sso.SSO_URL import software.aws.toolkits.resources.message data class Profiles(val validProfiles: Map, val invalidProfiles: Map) @@ -34,6 +39,7 @@ fun validateAndGetProfiles(): Profiles { private fun validateProfile(profile: Profile, allProfiles: Map) { when { + profile.propertyExists(SSO_URL) && Registry.`is`(SSO_EXPERIMENTAL_REGISTRY_KEY) -> validateSsoProfile(profile) profile.propertyExists(ProfileProperty.ROLE_ARN) -> validateAssumeRoleProfile(profile, allProfiles) profile.propertyExists(ProfileProperty.AWS_SESSION_TOKEN) -> validateStaticSessionProfile(profile) profile.propertyExists(ProfileProperty.AWS_ACCESS_KEY_ID) -> validateBasicProfile(profile) @@ -46,29 +52,15 @@ private fun validateProfile(profile: Profile, allProfiles: Map) } } -private fun validateAssumeRoleProfile(profile: Profile, allProfiles: Map) { - val profileChain = linkedSetOf() - var currentProfile = profile - - while (currentProfile.propertyExists(ProfileProperty.ROLE_ARN)) { - val currentProfileName = currentProfile.name() - if (!profileChain.add(currentProfileName)) { - val chain = profileChain.joinToString("->", postfix = "->$currentProfileName") - throw IllegalArgumentException(message("credentials.profile.circular_profiles", profile.name(), chain)) - } - - val sourceProfile = currentProfile.requiredProperty(ProfileProperty.SOURCE_PROFILE) - currentProfile = allProfiles[sourceProfile] - ?: throw IllegalArgumentException( - message( - "credentials.profile.source_profile_not_found", - currentProfileName, - sourceProfile - ) - ) - } +fun validateSsoProfile(profile: Profile) { + profile.requiredProperty(SSO_ACCOUNT) + profile.requiredProperty(SSO_REGION) + profile.requiredProperty(SSO_ROLE_NAME) +} - validateProfile(currentProfile, allProfiles) +private fun validateAssumeRoleProfile(profile: Profile, allProfiles: Map) { + val rootProfile = profile.traverseCredentialChain(allProfiles).last() + validateProfile(rootProfile, allProfiles) } private fun validateStaticSessionProfile(profile: Profile) { @@ -81,19 +73,3 @@ private fun validateBasicProfile(profile: Profile) { profile.requiredProperty(ProfileProperty.AWS_ACCESS_KEY_ID) profile.requiredProperty(ProfileProperty.AWS_SECRET_ACCESS_KEY) } - -fun Profile.propertyExists(propertyName: String): Boolean = this.property(propertyName).isPresent - -fun Profile.requiredProperty(propertyName: String): String = this.property(propertyName) - .filter { - it.nullize() != null - } - .orElseThrow { - IllegalArgumentException( - message( - "credentials.profile.missing_property", - this.name(), - propertyName - ) - ) - } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileUtils.kt new file mode 100644 index 0000000000..f4994b7443 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileUtils.kt @@ -0,0 +1,50 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.profiles + +import com.intellij.util.text.nullize +import software.amazon.awssdk.profiles.Profile +import software.amazon.awssdk.profiles.ProfileProperty +import software.aws.toolkits.resources.message + +fun Profile.traverseCredentialChain(profiles: Map): Sequence = sequence { + val profileChain = linkedSetOf() + var currentProfile = this@traverseCredentialChain + + yield(currentProfile) + + while (currentProfile.propertyExists(ProfileProperty.ROLE_ARN)) { + val currentProfileName = currentProfile.name() + if (!profileChain.add(currentProfileName)) { + val chain = profileChain.joinToString("->", postfix = "->$currentProfileName") + throw IllegalArgumentException(message("credentials.profile.circular_profiles", name(), chain)) + } + + val sourceProfile = currentProfile.requiredProperty(ProfileProperty.SOURCE_PROFILE) + currentProfile = profiles[sourceProfile] + ?: throw IllegalArgumentException( + message( + "credentials.profile.source_profile_not_found", + currentProfileName, + sourceProfile + ) + ) + + yield(currentProfile) + } +} + +fun Profile.propertyExists(propertyName: String): Boolean = this.property(propertyName).isPresent + +fun Profile.requiredProperty(propertyName: String): String = this.property(propertyName) + .filter { it.nullize() != null } + .orElseThrow { + IllegalArgumentException( + message( + "credentials.profile.missing_property", + this.name(), + propertyName + ) + ) + } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileWatcher.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileWatcher.kt index 333d5c8608..bfd37fe185 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileWatcher.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileWatcher.kt @@ -4,35 +4,74 @@ package software.aws.toolkits.jetbrains.core.credentials.profiles import com.intellij.openapi.Disposable -import com.intellij.openapi.util.Disposer +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.AsyncFileListener import com.intellij.openapi.vfs.LocalFileSystem -import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.openapi.vfs.pointers.VirtualFilePointer +import com.intellij.util.containers.ContainerUtil import software.amazon.awssdk.profiles.ProfileFileLocation +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import java.nio.file.Paths -class ProfileWatcher(parentDisposable: Disposable) : AsyncFileListener, Disposable { - private val watchRoots = mutableSetOf() - private var onUpdate: (() -> Unit)? = null +interface ProfileWatcher { + fun addListener(listener: () -> Unit) - init { - Disposer.register(parentDisposable, this) + companion object { + fun getInstance() = service() } +} + +class DefaultProfileWatcher : AsyncFileListener, Disposable, ProfileWatcher { + private val listeners = ContainerUtil.createLockFreeCopyOnWriteList<() -> Unit>() + private val watchRoots = mutableSetOf() + private val watchPointers = mutableMapOf() private val watchLocationsStrings = setOf( FileUtil.normalize(ProfileFileLocation.configurationFilePath().toAbsolutePath().toString()), FileUtil.normalize(ProfileFileLocation.credentialsFilePath().toAbsolutePath().toString()) ) - override fun prepareChange(events: MutableList): AsyncFileListener.ChangeApplier? { - val isRelevant = events.any { VfsUtilCore.isUnder(it.path, watchLocationsStrings) } + init { + LOG.info { "Starting profile watcher, profile locations: $watchLocationsStrings" } + + val localFileSystem = LocalFileSystem.getInstance() + + val watchLocationParents = watchLocationsStrings.map { + val path = Paths.get(FileUtil.toSystemDependentName(it)) + + // Make VFS aware of it + localFileSystem.refreshAndFindFileByIoFile(path.toFile()) + + // Use the parent as the watch root in case file does not exist yet + // Note: This system requires that the parent folder already exists + FileUtil.normalize(path.parent.toString()) + }.toSet() + + watchRoots.addAll(localFileSystem.addRootsToWatch(watchLocationParents, true)) + + LOG.info { "Added watch roots: $watchRoots" } + + VirtualFileManager.getInstance().addAsyncFileListener(this, this) + } + + override fun prepareChange(events: List): AsyncFileListener.ChangeApplier? { + LOG.debug { "Received events: $events" } + val isRelevant = events.any { watchLocationsStrings.contains(it.path) } return if (isRelevant) { + LOG.info { "Profile file change detected, scheduling refresh" } object : AsyncFileListener.ChangeApplier { override fun afterVfsChange() { - onUpdate?.invoke() + // Off load this, since this is called under a write lock + ApplicationManager.getApplication().executeOnPooledThread { + listeners.forEach { it() } + } } } } else { @@ -40,14 +79,18 @@ class ProfileWatcher(parentDisposable: Disposable) : AsyncFileListener, Disposab } } - fun start(onFileChange: () -> Unit) { - onUpdate = onFileChange - - watchRoots.addAll(LocalFileSystem.getInstance().addRootsToWatch(watchLocationsStrings, false)) - VirtualFileManager.getInstance().addAsyncFileListener(this, this) + override fun addListener(listener: () -> Unit) { + listeners.add(listener) } override fun dispose() { + LOG.info { "Stopping profile watcher, removing roots $watchRoots" } LocalFileSystem.getInstance().removeWatchedRoots(watchRoots) + listeners.clear() + watchPointers.clear() + } + + private companion object { + val LOG = getLogger() } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/ExecutableType.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/ExecutableType.kt index de8b73003b..de4be18095 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/ExecutableType.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/ExecutableType.kt @@ -21,7 +21,7 @@ interface ExecutableType { companion object { val EP_NAME = ExtensionPointName>("aws.toolkit.executable") - internal fun executables(): List> = EP_NAME.extensions.toList() + internal fun executables(): List> = EP_NAME.extensionList @JvmStatic fun > getExecutable(clazz: Class): T = executables().filterIsInstance(clazz).first() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerFactory.kt index e44615579d..b5eae11630 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerFactory.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerFactory.kt @@ -4,17 +4,14 @@ package software.aws.toolkits.jetbrains.core.explorer import com.intellij.icons.AllIcons -import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.Separator import com.intellij.openapi.project.DumbAware -import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.openapi.wm.ex.ToolWindowEx -import software.aws.toolkits.jetbrains.core.AwsResourceCache -import software.aws.toolkits.jetbrains.core.credentials.ChangeAccountSettingsActionGroup import software.aws.toolkits.jetbrains.core.help.HelpIds import software.aws.toolkits.jetbrains.ui.feedback.FeedbackDialog import software.aws.toolkits.jetbrains.utils.actions.OpenBrowserAction @@ -27,24 +24,14 @@ class AwsExplorerFactory : ToolWindowFactory, DumbAware { toolWindow.component.parent.add(explorer) toolWindow.helpId = HelpIds.EXPLORER_WINDOW.id if (toolWindow is ToolWindowEx) { + val actionManager = ActionManager.getInstance() toolWindow.setTitleActions( - // order from left to right - object : DumbAwareAction(message("general.refresh"), message("explorer.refresh.description"), AllIcons.Actions.Refresh) { - override fun actionPerformed(e: AnActionEvent) { - AwsResourceCache.getInstance(project).clear() - explorer.invalidateTree() - } - }, + actionManager.getAction("aws.settings.refresh"), Separator.create(), FeedbackDialog.getAction(project) ) toolWindow.setAdditionalGearActions( DefaultActionGroup().apply { - add( - DefaultActionGroup(message("settings.title"), true).also { - it.add(ChangeAccountSettingsActionGroup(project, true)) - } - ) add( OpenBrowserAction( title = message("explorer.view_documentation"), @@ -66,6 +53,7 @@ class AwsExplorerFactory : ToolWindowFactory, DumbAware { ) ) add(FeedbackDialog.getAction(project)) + add(actionManager.getAction("aws.settings.show")) } ) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ExplorerToolWindow.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ExplorerToolWindow.kt index aae9eb04d3..2c39212b55 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ExplorerToolWindow.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ExplorerToolWindow.kt @@ -1,16 +1,21 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 @file:Suppress("DEPRECATION") // TODO: Investigate AsyncTreeModel FIX_WHEN_MIN_IS_201 package software.aws.toolkits.jetbrains.core.explorer import com.intellij.execution.Location +import com.intellij.ide.DataManager import com.intellij.ide.util.treeView.AbstractTreeNode import com.intellij.ide.util.treeView.NodeDescriptor import com.intellij.ide.util.treeView.NodeRenderer import com.intellij.ide.util.treeView.TreeState import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionToolbar.WRAP_LAYOUT_POLICY import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataKey import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.Separator @@ -20,17 +25,22 @@ import com.intellij.openapi.components.ServiceManager import com.intellij.openapi.project.Project import com.intellij.openapi.ui.SimpleToolWindowPanel import com.intellij.ui.DoubleClickListener +import com.intellij.ui.HyperlinkAdapter import com.intellij.ui.HyperlinkLabel +import com.intellij.ui.JBColor import com.intellij.ui.PopupHandler import com.intellij.ui.ScrollPaneFactory import com.intellij.ui.TreeUIHelper -import com.intellij.ui.components.panels.Wrapper +import com.intellij.ui.components.panels.NonOpaquePanel import com.intellij.ui.treeStructure.Tree -import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsChangeEvent -import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsChangeNotifier +import com.intellij.util.ui.GridBag +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ChangeAccountSettingsMode +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsStateChangeNotifier import software.aws.toolkits.jetbrains.core.credentials.ConnectionState -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager -import software.aws.toolkits.jetbrains.core.credentials.SettingsSelector +import software.aws.toolkits.jetbrains.core.credentials.SettingsSelectorComboBoxAction import software.aws.toolkits.jetbrains.core.explorer.ExplorerDataKeys.SELECTED_NODES import software.aws.toolkits.jetbrains.core.explorer.ExplorerDataKeys.SELECTED_RESOURCE_NODES import software.aws.toolkits.jetbrains.core.explorer.ExplorerDataKeys.SELECTED_SERVICE_NODE @@ -43,55 +53,104 @@ import software.aws.toolkits.jetbrains.core.explorer.nodes.ResourceActionNode import software.aws.toolkits.jetbrains.core.explorer.nodes.ResourceLocationNode import software.aws.toolkits.jetbrains.ui.tree.AsyncTreeModel import software.aws.toolkits.jetbrains.ui.tree.StructureTreeModel -import software.aws.toolkits.resources.message import java.awt.Component +import java.awt.GridBagConstraints +import java.awt.GridBagLayout import java.awt.event.MouseEvent -import javax.swing.JPanel +import javax.swing.JComponent +import javax.swing.JTextPane import javax.swing.JTree +import javax.swing.event.HyperlinkEvent +import javax.swing.text.SimpleAttributeSet +import javax.swing.text.StyleConstants import javax.swing.tree.DefaultMutableTreeNode import javax.swing.tree.TreeModel -class ExplorerToolWindow(private val project: Project) : SimpleToolWindowPanel(true, true), ConnectionSettingsChangeNotifier { +class ExplorerToolWindow(project: Project) : SimpleToolWindowPanel(true, true), ConnectionSettingsStateChangeNotifier { private val actionManager = ActionManagerEx.getInstanceEx() - private val treePanelWrapper: Wrapper = Wrapper() - private val errorPanel: JPanel + private val treePanelWrapper = NonOpaquePanel() private val awsTreeModel = AwsExplorerTreeStructure(project) private val structureTreeModel = StructureTreeModel(awsTreeModel, project) private val awsTree = createTree(AsyncTreeModel(structureTreeModel, true, project)) private val awsTreePanel = ScrollPaneFactory.createScrollPane(awsTree) - private val settingsSelector by lazy { - SettingsSelector(project) - } + private val accountSettingsManager = AwsConnectionManager.getInstance(project) init { - val select = HyperlinkLabel(message("configure.toolkit")) - select.addHyperlinkListener { settingsSelector.settingsPopup(select, showRegions = false).showInCenterOf(select) } + val group = DefaultActionGroup( + SettingsSelectorComboBoxAction(project, ChangeAccountSettingsMode.CREDENTIALS), + SettingsSelectorComboBoxAction(project, ChangeAccountSettingsMode.REGIONS) + ) - errorPanel = JPanel() - errorPanel.add(select) + toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, group, true).apply { + layoutPolicy = WRAP_LAYOUT_POLICY + }.component + background = UIUtil.getTreeBackground() setContent(treePanelWrapper) - if (ProjectAccountSettingsManager.getInstance(project).isValidConnectionSettings()) { - treePanelWrapper.setContent(awsTreePanel) - } else { - treePanelWrapper.setContent(errorPanel) + project.messageBus.connect().subscribe(AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, this) + settingsStateChanged(accountSettingsManager.connectionState) + } + + private fun createInfoPanel(state: ConnectionState): JComponent { + val panel = NonOpaquePanel(GridBagLayout()) + + val gridBag = GridBag() + gridBag.defaultAnchor = GridBagConstraints.CENTER + gridBag.defaultInsets = JBUI.insetsBottom(JBUI.scale(6)) + + val textPane = JTextPane().apply { + val textColor = if (state is ConnectionState.InvalidConnection) { + JBColor.red + } else { + UIUtil.getInactiveTextColor() + } + + with(styledDocument) { + val center = SimpleAttributeSet().apply { + StyleConstants.setAlignment(this, StyleConstants.ALIGN_CENTER) + StyleConstants.setForeground(this, textColor) + } + setParagraphAttributes(0, length, center, false) + } + + text = state.displayMessage + isEditable = false + background = UIUtil.getTreeBackground() } - project.messageBus.connect().subscribe(ProjectAccountSettingsManager.CONNECTION_SETTINGS_CHANGED, this) + panel.add(textPane, gridBag.nextLine().next()) + + state.actions.forEach { + panel.add(createActionLabel(it), gridBag.nextLine().next()) + } + + return panel + } + + private fun createActionLabel(action: AnAction): HyperlinkLabel { + val label = HyperlinkLabel(action.templateText ?: "BUG: $action lacks a text description") + label.addHyperlinkListener(object : HyperlinkAdapter() { + override fun hyperlinkActivated(e: HyperlinkEvent) { + val event = AnActionEvent.createFromAnAction(action, e.inputEvent, ActionPlaces.UNKNOWN, DataManager.getInstance().getDataContext(label)) + action.actionPerformed(event) + } + }) + + return label } - override fun settingsChanged(event: ConnectionSettingsChangeEvent) { + override fun settingsStateChanged(newState: ConnectionState) { runInEdt { - when (event.state) { - ConnectionState.VALID -> { - invalidateTree() - treePanelWrapper.setContent(awsTreePanel) + treePanelWrapper.setContent( + when (newState) { + is ConnectionState.ValidConnection -> { + invalidateTree() + awsTreePanel + } + else -> createInfoPanel(newState) } - else -> { - treePanelWrapper.setContent(errorPanel) - } - } + ) } } @@ -103,15 +162,21 @@ class ExplorerToolWindow(private val project: Project) : SimpleToolWindowPanel(t * @param selectedNode AbstractTreeNode to redraw the tree from */ fun invalidateTree(selectedNode: AbstractTreeNode<*>? = null) { - // Save the state and reapply it after we invalidate (which is the point where the state is wiped). - // Items are expanded again if their user object is unchanged (.equals()). - val state = TreeState.createOn(awsTree) - if (selectedNode != null) { - structureTreeModel.invalidate(selectedNode, true) - } else { - structureTreeModel.invalidate() + withSavedState(awsTree) { + if (selectedNode != null) { + structureTreeModel.invalidate(selectedNode, true) + } else { + structureTreeModel.invalidate() + } } - state.applyTo(awsTree) + } + + // Save the state and reapply it after we invalidate (which is the point where the state is wiped). + // Items are expanded again if their user object is unchanged (.equals()). + private fun withSavedState(tree: Tree, block: () -> Unit) { + val state = TreeState.createOn(tree) + block() + state.applyTo(tree) } private fun createTree(model: TreeModel): Tree { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerRootNode.kt index 5ea0e583c4..f0403e425e 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerRootNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerRootNode.kt @@ -7,20 +7,26 @@ import com.intellij.ide.projectView.PresentationData import com.intellij.ide.util.treeView.AbstractTreeNode import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.project.Project -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider /** * The root node of the AWS explorer tree. */ class AwsExplorerRootNode(private val nodeProject: Project) : AbstractTreeNode(nodeProject, Object()) { - private val regionProvider = AwsRegionProvider.getInstance() - private val settings = ProjectAccountSettingsManager.getInstance(nodeProject) - private val EP_NAME = ExtensionPointName("aws.toolkit.explorer.serviceNode") + override fun getChildren(): List> { + val settings = AwsConnectionManager.getInstance(nodeProject) + val region = settings.selectedRegion ?: return emptyList() + val regionProvider = AwsRegionProvider.getInstance() - override fun getChildren(): List> = EP_NAME.extensionList - .filter { regionProvider.isServiceSupported(settings.activeRegion, it.serviceId) } - .map { it.buildServiceRootNode(nodeProject) } + return EP_NAME.extensionList + .filter { regionProvider.isServiceSupported(region, it.serviceId) } + .map { it.buildServiceRootNode(nodeProject) } + } override fun update(presentation: PresentationData) { } + + companion object { + private val EP_NAME = ExtensionPointName("aws.toolkit.explorer.serviceNode") + } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/help/HelpIds.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/help/HelpIds.kt index 5a88b01fda..cc4a8718a1 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/help/HelpIds.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/help/HelpIds.kt @@ -65,8 +65,12 @@ enum class HelpIds(shortId: String, val url: String) { CFN_LINT( "cloudformation.linter", "https://github.com/aws-cloudformation/cfn-python-lint/blob/master/README.md" - ) - ; + ), + // RDS + RDS_SETUP_IAM_AUTH( + "rdsIamAuth", + "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html" + ); val id = "aws.toolkit.$shortId" } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/plugins/PluginUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/plugins/PluginUtils.kt new file mode 100644 index 0000000000..b24f4aaf55 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/plugins/PluginUtils.kt @@ -0,0 +1,9 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.plugins + +import com.intellij.ide.plugins.PluginManagerCore.getPlugin +import com.intellij.openapi.extensions.PluginId + +fun pluginIsInstalledAndEnabled(pluginId: String): Boolean = getPlugin(PluginId.findId(pluginId))?.isEnabled == true diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/region/AwsRegionProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/region/AwsRegionProvider.kt index 81d78dc73c..1f622a03d8 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/region/AwsRegionProvider.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/region/AwsRegionProvider.kt @@ -1,9 +1,10 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package software.aws.toolkits.jetbrains.core.region import com.intellij.openapi.components.ServiceManager +import org.slf4j.event.Level import software.amazon.awssdk.regions.providers.AwsProfileRegionProvider import software.amazon.awssdk.regions.providers.AwsRegionProviderChain import software.amazon.awssdk.regions.providers.SystemSettingsRegionProvider @@ -14,10 +15,15 @@ import software.aws.toolkits.core.region.ServiceEndpointResource import software.aws.toolkits.core.region.ToolkitRegionProvider import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.inputStream -import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.core.utils.logWhenNull +import software.aws.toolkits.core.utils.tryOrNull import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider class AwsRegionProvider constructor(remoteResourceResolverProvider: RemoteResourceResolverProvider) : ToolkitRegionProvider() { + private val regionChain by lazy { + // Querying the instance metadata is expensive due to high timeouts and retries + AwsRegionProviderChain(SystemSettingsRegionProvider(), AwsProfileRegionProvider()) + } private val partitions: Map by lazy { val inputStream = remoteResourceResolverProvider.get().resolve(ServiceEndpointResource).toCompletableFuture().get()?.inputStream() val partitions = inputStream?.use { PartitionParser.parse(it) }?.partitions ?: return@lazy emptyMap() @@ -33,25 +39,27 @@ class AwsRegionProvider constructor(remoteResourceResolverProvider: RemoteResour override fun partitionData(): Map = partitions - override fun defaultPartition(): AwsPartition = partitions()[DEFAULT_PARTITION] - ?: throw IllegalStateException("Could not find default partition: $DEFAULT_PARTITION") + override fun defaultPartition(): AwsPartition = partitions().getValue(defaultRegion().partitionId) - override fun defaultRegion(): AwsRegion = try { - // Querying the instance metadata is expensive due to high timeouts and retries - val regionProviderChange = AwsRegionProviderChain(SystemSettingsRegionProvider(), AwsProfileRegionProvider()) - regionProviderChange.region.id().let { regions(DEFAULT_PARTITION)[it] } ?: fallbackRegion() - } catch (e: Exception) { - LOG.warn(e) { "Failed to find default region" } - fallbackRegion() - } + override fun defaultRegion(): AwsRegion { + val regionIdFromChain = LOG.tryOrNull("Failed to find default region in chain", level = Level.WARN) { + regionChain.region.id() + } + + val regionFromChain = regionIdFromChain?.let { regionId -> + LOG.logWhenNull("Could not find $regionId in endpoint data") { + this[regionId] + } + } - private fun fallbackRegion(): AwsRegion = regions(DEFAULT_PARTITION).getOrElse(DEFAULT_REGION) { - throw IllegalStateException("Region provider data is missing default data") + return regionFromChain + ?: this[DEFAULT_REGION] + ?: allRegions().values.firstOrNull() + ?: throw IllegalStateException("Region provider data is missing default data") } companion object { private const val DEFAULT_REGION = "us-east-1" - private const val DEFAULT_PARTITION = "aws" private val LOG = getLogger() @JvmStatic diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentDialog.kt index 4ba3ef0817..9fdbd04f5f 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentDialog.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentDialog.kt @@ -17,7 +17,7 @@ import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.core.AwsClientManager import software.aws.toolkits.jetbrains.core.awsClient -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.help.HelpIds import software.aws.toolkits.jetbrains.services.RoleValidation import software.aws.toolkits.jetbrains.services.iam.IamResources @@ -41,8 +41,8 @@ class InstrumentDialog(private val project: Project, val clusterArn: String, val } private fun createUIComponents() { - val credentials = ProjectAccountSettingsManager.getInstance(project).activeCredentialProvider - val region = ProjectAccountSettingsManager.getInstance(project).activeRegion + val credentials = AwsConnectionManager.getInstance(project).activeCredentialProvider + val region = AwsConnectionManager.getInstance(project).activeRegion iamRole = ResourceSelector.builder(project) .resource { IamResources.LIST_ALL } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/PseCliAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/PseCliAction.kt index 1e82df77ed..c8d296dedd 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/PseCliAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/PseCliAction.kt @@ -30,7 +30,7 @@ import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.error import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.core.AwsResourceCache -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.credentials.toEnvironmentVariables import software.aws.toolkits.jetbrains.core.executables.CloudDebugExecutable import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance @@ -88,8 +88,8 @@ abstract class PseCliAction(val project: Project, val actionName: String, privat // validate CLI CloudDebugResolver.validateOrUpdateCloudDebug(project, messageEmitter, null) - val region = ProjectAccountSettingsManager.getInstance(project).activeRegion.toEnvironmentVariables() - val credentials = ProjectAccountSettingsManager.getInstance(project).activeCredentialProvider.resolveCredentials().toEnvironmentVariables() + val region = AwsConnectionManager.getInstance(project).activeRegion.toEnvironmentVariables() + val credentials = AwsConnectionManager.getInstance(project).activeCredentialProvider.resolveCredentials().toEnvironmentVariables() val clouddebug = ExecutableManager.getInstance().getExecutable().thenApply { if (it is ExecutableInstance.Executable) { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/StartRemoteShellAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/StartRemoteShellAction.kt index 8096e67ae9..8b8e95aad2 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/StartRemoteShellAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/StartRemoteShellAction.kt @@ -4,11 +4,9 @@ package software.aws.toolkits.jetbrains.services.clouddebug.actions import com.intellij.execution.process.CapturingProcessHandler -import com.intellij.ide.plugins.PluginManager import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task @@ -17,6 +15,7 @@ import icons.TerminalIcons import org.jetbrains.plugins.terminal.LocalTerminalDirectRunner import org.jetbrains.plugins.terminal.TerminalTabState import org.jetbrains.plugins.terminal.TerminalView +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.credentials.activeCredentialProvider import software.aws.toolkits.jetbrains.core.credentials.activeRegion import software.aws.toolkits.jetbrains.core.credentials.toEnvironmentVariables @@ -24,6 +23,7 @@ import software.aws.toolkits.jetbrains.core.executables.CloudDebugExecutable import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance import software.aws.toolkits.jetbrains.core.executables.ExecutableManager import software.aws.toolkits.jetbrains.core.executables.getExecutable +import software.aws.toolkits.jetbrains.core.plugins.pluginIsInstalledAndEnabled import software.aws.toolkits.jetbrains.services.clouddebug.CliOutputParser import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugConstants import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugConstants.INSTRUMENTED_STATUS @@ -45,7 +45,7 @@ class StartRemoteShellAction(private val project: Project, private val container ) { private val disabled by lazy { - !PluginManager.isPluginInstalled(PluginId.findId("org.jetbrains.plugins.terminal")) || + !pluginIsInstalledAndEnabled("org.jetbrains.plugins.terminal") || // exec doesn't allow you to go into the sidecar container container.containerDefinition.name() == CloudDebugConstants.CLOUD_DEBUG_SIDECAR_CONTAINER_NAME || !EcsUtils.isInstrumented(container.service.serviceArn()) @@ -78,7 +78,11 @@ class StartRemoteShellAction(private val project: Project, private val container throw Exception("cloud debug executable not found") } - val description = CloudDebuggingResources.describeInstrumentedResource(project, cluster, service) + val connectionManager = AwsConnectionManager.getInstance(project) + val credentials = connectionManager.activeCredentialProvider + val region = connectionManager.activeRegion + + val description = CloudDebuggingResources.describeInstrumentedResource(credentials, region, cluster, service) if (description == null || description.status != INSTRUMENTED_STATUS || description.taskRole.isEmpty()) { runInEdt { notifyError(message("cloud_debug.execution.failed.not_set_up")) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/RetrieveRole.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/RetrieveRole.kt index 949509dbf3..ddd5687091 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/RetrieveRole.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/RetrieveRole.kt @@ -28,7 +28,12 @@ class RetrieveRole(private val settings: EcsServiceCloudDebuggingRunSettings) : val startTime = Instant.now() var result = Result.Succeeded try { - val description = CloudDebuggingResources.describeInstrumentedResource(context.project, settings.clusterArn, settings.serviceArn) + val description = CloudDebuggingResources.describeInstrumentedResource( + settings.credentialProvider, + settings.region, + settings.clusterArn, + settings.serviceArn + ) if (description == null || description.status != INSTRUMENTED_STATUS || description.taskRole.isEmpty()) { throw RuntimeException("Resource somehow became de-instrumented?") } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/resources/CloudDebuggingResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/resources/CloudDebuggingResources.kt index 19da4b5f02..3b5f710c7d 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/resources/CloudDebuggingResources.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/resources/CloudDebuggingResources.kt @@ -14,13 +14,13 @@ import com.intellij.execution.process.ProcessAdapter import com.intellij.execution.process.ProcessEvent import com.intellij.execution.process.ProcessHandlerFactory import com.intellij.execution.process.ProcessOutputTypes -import com.intellij.openapi.project.Project import com.intellij.openapi.util.Key +import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider +import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.core.ExecutableBackedCacheResource import software.aws.toolkits.jetbrains.core.Resource -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager import software.aws.toolkits.jetbrains.core.credentials.toEnvironmentVariables import software.aws.toolkits.jetbrains.core.executables.CloudDebugExecutable import software.aws.toolkits.jetbrains.services.clouddebug.execution.MessageEmitter @@ -47,7 +47,12 @@ object CloudDebuggingResources { /* * Describes instrumented resources using cluster name/arn and service name/arn. Input can be original or instrumented service */ - fun describeInstrumentedResource(project: Project, clusterName: String, serviceName: String): DescribeResult? { + fun describeInstrumentedResource( + credentialsProvider: ToolkitCredentialsProvider, + region: AwsRegion, + clusterName: String, + serviceName: String + ): DescribeResult? { val execTask = try { CloudDebugCliValidate.validateAndLoadCloudDebugExecutable() } catch (e: Exception) { @@ -55,9 +60,8 @@ object CloudDebuggingResources { return null } - val accountSettings = ProjectAccountSettingsManager.getInstance(project) - val credentials = accountSettings.activeCredentialProvider.resolveCredentials().toEnvironmentVariables() - val region = accountSettings.activeRegion.toEnvironmentVariables() + val credentialsEnvVars = credentialsProvider.resolveCredentials().toEnvironmentVariables() + val regionEnvVars = region.toEnvironmentVariables() val generalCommandLine = execTask.getCommandLine() .withParameters("describe") @@ -65,8 +69,8 @@ object CloudDebuggingResources { .withParameters(EcsUtils.serviceArnToName(clusterName)) .withParameters("--service") .withParameters(EcsUtils.originalServiceName(serviceName)) - .withEnvironment(credentials) - .withEnvironment(region) + .withEnvironment(credentialsEnvVars) + .withEnvironment(regionEnvVars) return try { val processOutput = CapturingProcessRunner( diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/EventsTable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/EventsTable.kt index 961ce93146..d9ccbd4d5d 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/EventsTable.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/EventsTable.kt @@ -3,7 +3,10 @@ package software.aws.toolkits.jetbrains.services.cloudformation.stack import com.intellij.openapi.Disposable +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.SideBorder import software.amazon.awssdk.services.cloudformation.model.StackEvent +import software.aws.toolkits.jetbrains.utils.ui.WrappingCellRenderer import software.aws.toolkits.resources.message import java.awt.Component import javax.swing.JComponent @@ -43,8 +46,11 @@ internal class EventsTableImpl : EventsTable, Disposable { DynamicTableView.Field(message("cloudformation.stack.status"), renderer = StatusCellRenderer()) { e -> e.resourceStatusAsString() }, DynamicTableView.Field(message("cloudformation.stack.logical_id")) { e -> e.logicalResourceId() }, DynamicTableView.Field(message("cloudformation.stack.physical_id")) { e -> e.physicalResourceId() }, - DynamicTableView.Field(message("cloudformation.stack.reason")) { e -> e.resourceStatusReason() ?: "" } - ) + DynamicTableView.Field( + message("cloudformation.stack.reason"), + WrappingCellRenderer(wrapOnSelection = true, toggleableWrap = false) + ) { e -> e.resourceStatusReason() ?: "" } + ).apply { component.border = IdeBorderFactory.createBorder(SideBorder.BOTTOM) } override val component: JComponent = table.component diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/OutputsTableView.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/OutputsTableView.kt index be68e5e1a9..415ddd4d0a 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/OutputsTableView.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/OutputsTableView.kt @@ -3,6 +3,7 @@ package software.aws.toolkits.jetbrains.services.cloudformation.stack import com.intellij.openapi.Disposable +import com.intellij.util.ui.JBUI import software.amazon.awssdk.services.cloudformation.model.Output import software.aws.toolkits.resources.message import javax.swing.JComponent @@ -13,7 +14,7 @@ class OutputsTableView : View, OutputsListener, Disposable { DynamicTableView.Field(message("cloudformation.stack.outputs.value")) { it.outputValue() }, DynamicTableView.Field(message("cloudformation.stack.outputs.description")) { it.description() }, DynamicTableView.Field(message("cloudformation.stack.outputs.export")) { it.exportName() } - ) + ).apply { component.border = JBUI.Borders.empty() } override val component: JComponent = table.component diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/Stack.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/Stack.kt index 1dc2b0554a..1a018fd8e5 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/Stack.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/Stack.kt @@ -12,6 +12,9 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.ui.OnePixelSplitter import com.intellij.ui.components.JBTabbedPane +import com.intellij.uiDesigner.core.GridConstraints +import com.intellij.uiDesigner.core.GridLayoutManager +import com.intellij.util.ui.JBUI import icons.AwsIcons import software.amazon.awssdk.services.cloudformation.model.StackStatus import software.aws.toolkits.jetbrains.core.AwsClientManager @@ -76,10 +79,41 @@ private class StackUI(private val project: Project, private val stackName: Strin val mainPanel = OnePixelSplitter(false, TREE_TABLE_INITIAL_PROPORTION).apply { firstComponent = tree.component secondComponent = JBTabbedPane().apply { - this.add(message("cloudformation.stack.tab_labels.events"), JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - add(eventsTable.component) - add(pageButtons.component) + this.add(message("cloudformation.stack.tab_labels.events"), JPanel(GridLayoutManager(2, 1)).apply { + add( + eventsTable.component, + GridConstraints( + 0, + 0, + 1, + 1, + 0, + GridConstraints.FILL_BOTH, + GridConstraints.SIZEPOLICY_CAN_GROW or GridConstraints.SIZEPOLICY_WANT_GROW or GridConstraints.SIZEPOLICY_CAN_SHRINK, + GridConstraints.SIZEPOLICY_CAN_GROW or GridConstraints.SIZEPOLICY_WANT_GROW or GridConstraints.SIZEPOLICY_CAN_SHRINK, + null, + null, + null + ) + ) + add( + pageButtons.component, + GridConstraints( + 1, + 0, + 1, + 1, + 0, + GridConstraints.FILL_HORIZONTAL, + GridConstraints.SIZEPOLICY_CAN_GROW or GridConstraints.SIZEPOLICY_WANT_GROW or GridConstraints.SIZEPOLICY_CAN_SHRINK, + GridConstraints.SIZEPOLICY_CAN_GROW or GridConstraints.SIZEPOLICY_CAN_SHRINK, + null, + null, + null + ) + ) + tabComponentInsets = JBUI.emptyInsets() + border = JBUI.Borders.empty() }) this.add(message("cloudformation.stack.tab_labels.outputs"), JPanel().apply { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/LogGroupTable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/LogGroupTable.kt index 62948254a3..1cca277c9c 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/LogGroupTable.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/LogGroupTable.kt @@ -102,8 +102,7 @@ class LogGroupTable( private fun addTableMouseListener(table: JBTable) { object : DoubleClickListener() { - override fun onDoubleClick(e: MouseEvent?): Boolean { - e ?: return false + override fun onDoubleClick(e: MouseEvent): Boolean { val logStream = table.getSelectedRowLogStream() ?: return false val window = CloudWatchLogWindow.getInstance(project) window.showLogStream(logGroup, logStream) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/LogStreamTable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/LogStreamTable.kt index 2ef84a7b23..bfeb5c85f0 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/LogStreamTable.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/LogStreamTable.kt @@ -66,6 +66,11 @@ class LogStreamTable( autoResizeMode = JTable.AUTO_RESIZE_LAST_COLUMN setPaintBusy(true) emptyText.text = message("loading_resource.loading") + // Set the row height to 20. This is a magic number, so let me explain. This is + // The height of a JLabel (16) + 1 inner border (top and bottom) + 1 outer border + // (top and bottom) = 20 total. If we don't do this, as we scroll to the bottom, + // it will spring back to the top in a very comical but very not great way + setRowHeight(20) } // TODO this also searches the date column which we don't want to do. diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/TableUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/TableUtils.kt index 59f46fd986..dbce163a09 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/TableUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/TableUtils.kt @@ -4,9 +4,6 @@ package software.aws.toolkits.jetbrains.services.cloudwatch.logs.editor import com.intellij.ui.SimpleColoredComponent -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBTextArea -import com.intellij.ui.speedSearch.SpeedSearchSupply import com.intellij.ui.speedSearch.SpeedSearchUtil import com.intellij.util.text.DateFormatUtil import com.intellij.util.text.SyncDateFormat @@ -14,27 +11,20 @@ import com.intellij.util.ui.ColumnInfo import com.intellij.util.ui.ListTableModel import software.amazon.awssdk.services.cloudwatchlogs.model.LogStream import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogStreamEntry -import software.aws.toolkits.jetbrains.utils.ui.drawSearchMatch +import software.aws.toolkits.jetbrains.utils.ui.WrappingCellRenderer +import software.aws.toolkits.jetbrains.utils.ui.setSelectionHighlighting import software.aws.toolkits.resources.message import java.awt.BorderLayout import java.awt.Component -import java.awt.Graphics -import java.awt.Graphics2D -import java.awt.Shape import java.text.SimpleDateFormat -import javax.swing.JComponent import javax.swing.JLabel import javax.swing.JPanel import javax.swing.JTable -import javax.swing.JTextArea import javax.swing.SortOrder import javax.swing.border.CompoundBorder -import javax.swing.border.EmptyBorder import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.TableCellRenderer import javax.swing.table.TableRowSorter -import javax.swing.text.Highlighter -import javax.swing.text.JTextComponent class LogStreamsStreamColumn : ColumnInfo(message("cloudwatch.logs.log_streams")) { private val renderer = LogStreamsStreamColumnRenderer() @@ -91,7 +81,7 @@ class LogStreamDateColumn : ColumnInfo(message("general. } class LogStreamMessageColumn : ColumnInfo(message("general.message")) { - private val renderer = WrappingLogStreamMessageRenderer() + private val renderer = WrappingCellRenderer(wrapOnSelection = true, toggleableWrap = true) fun wrap() { renderer.wrap = true } @@ -105,35 +95,6 @@ class LogStreamMessageColumn : ColumnInfo(message("gener override fun getRenderer(item: LogStreamEntry?): TableCellRenderer? = renderer } -private class WrappingLogStreamMessageRenderer : TableCellRenderer { - var wrap = false - - // JBTextArea has a different font from JBLabel (the default in a table) so harvest the font off of it - private val font = JBLabel().font - - override fun getTableCellRendererComponent(table: JTable?, value: Any?, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component { - val component = JBTextArea() - if (table == null) { - return component - } - - component.wrapStyleWord = wrap - component.lineWrap = wrap - component.text = (value as? String)?.trim() - component.font = font - component.setSelectionHighlighting(table, isSelected) - - component.setSize(table.columnModel.getColumn(column).width, component.preferredSize.height) - if (table.getRowHeight(row) != component.preferredSize.height) { - table.setRowHeight(row, component.preferredSize.height) - } - - component.speedSearchHighlighter(table) - - return component - } -} - private class ResizingDateColumnRenderer(showSeconds: Boolean) : TableCellRenderer { private val defaultRenderer = DefaultTableCellRenderer() private val formatter: SyncDateFormat = if (showSeconds) { @@ -165,41 +126,8 @@ private class ResizingDateColumnRenderer(showSeconds: Boolean) : TableCellRender if (isSelected) { // this border has an outside and inside border, take only the outside border wrapper.border = (component.border as? CompoundBorder)?.outsideBorder - // Push the text up to compensate for the new border - component.border = EmptyBorder(-1, 0, 0, 0) - } else { - component.border = null } + component.border = null return wrapper } } - -private fun Component.setSelectionHighlighting(table: JTable, isSelected: Boolean) { - if (isSelected) { - foreground = table.selectionForeground - background = table.selectionBackground - } else { - foreground = table.foreground - background = table.background - } -} - -private class SpeedSearchHighlighter : Highlighter.HighlightPainter { - override fun paint(g: Graphics?, startingPoint: Int, endingPoint: Int, bounds: Shape?, component: JTextComponent?) { - component ?: return - val graphics = g as? Graphics2D ?: return - val beginningRect = component.modelToView(startingPoint) - val endingRect = component.modelToView(endingPoint) - drawSearchMatch(graphics, beginningRect.x.toFloat(), endingRect.x.toFloat(), beginningRect.y.toFloat(), beginningRect.height) - } -} - -private fun JTextArea.speedSearchHighlighter(speedSearchEnabledComponent: JComponent) { - // matchingFragments does work with wrapped text but not around words if they are wrapped, so it will also need to be extended - // in the future - val speedSearch = SpeedSearchSupply.getSupply(speedSearchEnabledComponent) ?: return - val fragments = speedSearch.matchingFragments(text)?.iterator() ?: return - fragments.forEach { - highlighter?.addHighlight(it.startOffset, it.endOffset, SpeedSearchHighlighter()) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugRunConfiguration.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugRunConfiguration.kt index b710664626..9b08cf1de5 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugRunConfiguration.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugRunConfiguration.kt @@ -64,7 +64,7 @@ class EcsCloudDebugRunConfiguration(project: Project, private val configFactory: override fun getState(executor: Executor, environment: ExecutionEnvironment): CloudDebugRunState? = try { - val cloudDebugRunSettings = resolveEcsServiceCloudDebuggingRunSettings() + val cloudDebugRunSettings = resolveEcsServiceCloudDebuggingRunSettings(deepCheck = false) CloudDebugRunState(environment, cloudDebugRunSettings) } catch (e: Exception) { @@ -80,7 +80,7 @@ class EcsCloudDebugRunConfiguration(project: Project, private val configFactory: } override fun checkSettingsBeforeRun() { - val immutableSettings = resolveEcsServiceCloudDebuggingRunSettings() + val immutableSettings = resolveEcsServiceCloudDebuggingRunSettings(deepCheck = true) if (!EcsUtils.isInstrumented(immutableSettings.serviceArn)) { throw RuntimeConfigurationError( @@ -92,7 +92,7 @@ class EcsCloudDebugRunConfiguration(project: Project, private val configFactory: } override fun checkConfiguration() { - val immutableSettings = resolveEcsServiceCloudDebuggingRunSettings() + val immutableSettings = resolveEcsServiceCloudDebuggingRunSettings(deepCheck = false) if (immutableSettings.containerOptions.isEmpty()) { throw RuntimeConfigurationError(message("cloud_debug.run_configuration.missing.container")) } @@ -238,7 +238,7 @@ class EcsCloudDebugRunConfiguration(project: Project, private val configFactory: fun containerOptions(): Map = serializableOptions.containerOptions - private fun resolveEcsServiceCloudDebuggingRunSettings(): EcsServiceCloudDebuggingRunSettings { + private fun resolveEcsServiceCloudDebuggingRunSettings(deepCheck: Boolean): EcsServiceCloudDebuggingRunSettings { val region = resolveRegion() val credentialProvider = resolveCredentials() val clusterArn = clusterArn() @@ -258,25 +258,30 @@ class EcsCloudDebugRunConfiguration(project: Project, private val configFactory: }.filterNotNull().toMutableSet() val resourceCache = AwsResourceCache.getInstance(project) - val validContainerNames = try { - val service = resourceCache.getResourceNow( - EcsResources.describeService(clusterArn, serviceArn), - region, - credentialProvider - ) - val containers = resourceCache.getResourceNow( - EcsResources.listContainers(service.taskDefinition()), - region, - credentialProvider - ) - containers.map { it.name() }.toSet() - } catch (e: Exception) { - throw RuntimeConfigurationError(e.message) + val validContainerNames = if (deepCheck) { + try { + val service = resourceCache.getResourceNow( + EcsResources.describeService(clusterArn, serviceArn), + region, + credentialProvider + ) + val containers = resourceCache.getResourceNow( + EcsResources.listContainers(service.taskDefinition()), + region, + credentialProvider + ) + + containers.map { it.name() }.toSet() + } catch (e: Exception) { + throw RuntimeConfigurationError(e.message) + } + } else { + emptySet() } val containerOptionsMap = containerOptions().mapValues { (containerName, containerOptions) -> - if (!validContainerNames.contains(containerName)) { + if (deepCheck && !validContainerNames.contains(containerName)) { throw RuntimeConfigurationError( message( "cloud_debug.ecs.run_config.container.doesnt_exist_in_service", diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugSettingsEditorPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugSettingsEditorPanel.kt index ae95039120..5533fe8cce 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugSettingsEditorPanel.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugSettingsEditorPanel.kt @@ -281,7 +281,7 @@ class EcsCloudDebugSettingsEditorPanel(private val project: Project) : Disposabl val serviceArn = configuration.serviceArn() val containerSettings = configuration.containerOptions() val credentialProviderId = configuration.credentialProviderId() ?: return - val region = AwsRegionProvider.getInstance().lookupRegionById(configuration.regionId()) + val region = configuration.regionId()?.let { AwsRegionProvider.getInstance()[it] } ?: return val credentialIdentifier = credentialManager.getCredentialIdentifierById(credentialProviderId) ?: return val credentialProvider = credentialManager.getAwsCredentialProvider(credentialIdentifier, region) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/ImportFromDockerfile.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/ImportFromDockerfile.kt index 75aeb00809..41a66a462d 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/ImportFromDockerfile.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/ImportFromDockerfile.kt @@ -5,8 +5,6 @@ package software.aws.toolkits.jetbrains.services.ecs.execution import com.intellij.docker.dockerFile.DockerFileType import com.intellij.docker.dockerFile.parser.psi.DockerPsiCommand -import com.intellij.ide.plugins.PluginManager -import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.fileChooser.FileChooser import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.project.Project @@ -22,6 +20,7 @@ import com.intellij.psi.PsiElement import com.intellij.psi.PsiManager import com.intellij.psi.impl.source.tree.LeafPsiElement import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.core.plugins.pluginIsInstalledAndEnabled import software.aws.toolkits.resources.message import java.awt.event.ActionEvent import java.awt.event.ActionListener @@ -29,7 +28,7 @@ import java.io.File object DockerUtil { @JvmStatic - fun dockerPluginAvailable() = PluginId.findId("Docker")?.let { PluginManager.isPluginInstalled(it) } == true + fun dockerPluginAvailable() = pluginIsInstalledAndEnabled("Docker") } class ImportFromDockerfile @JvmOverloads constructor( diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaBuilder.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaBuilder.kt index ffaea3a8f1..ab89307ca2 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaBuilder.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaBuilder.kt @@ -3,23 +3,23 @@ package software.aws.toolkits.jetbrains.services.lambda +import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.configurations.RuntimeConfigurationError -import com.intellij.execution.process.ProcessAdapter -import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.CapturingProcessRunner +import com.intellij.execution.process.ColoredProcessHandler import com.intellij.execution.process.ProcessHandler -import com.intellij.execution.process.ProcessHandlerFactory import com.intellij.openapi.application.ReadAction import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.module.Module import com.intellij.openapi.project.rootManager -import com.intellij.openapi.util.Key import com.intellij.openapi.util.io.FileUtil import com.intellij.psi.PsiElement import com.intellij.util.io.Compressor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking import software.amazon.awssdk.services.lambda.model.Runtime import software.aws.toolkits.core.utils.exists -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.info import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance import software.aws.toolkits.jetbrains.core.executables.ExecutableManager import software.aws.toolkits.jetbrains.core.executables.getExecutable @@ -30,10 +30,8 @@ import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils import software.aws.toolkits.resources.message -import java.io.File import java.nio.file.Path -import java.util.concurrent.CompletableFuture -import java.util.concurrent.ExecutionException +import java.nio.file.Paths abstract class LambdaBuilder { @@ -58,24 +56,70 @@ abstract class LambdaBuilder { ): BuiltLambda { val baseDir = baseDirectory(module, handlerElement) - val customTemplate = File(getOrCreateBuildDirectory(module), "template.yaml") - FileUtil.createIfDoesntExist(customTemplate) + val customTemplate = getBuildDirectory(module).resolve("template.yaml") val logicalId = "Function" SamTemplateUtils.writeDummySamTemplate(customTemplate, logicalId, runtime, baseDir, handler, timeout, memorySize, envVars) - return buildLambdaFromTemplate(module, customTemplate.toPath(), logicalId, samOptions, onStart) + return buildLambdaFromTemplate(module, customTemplate, logicalId, samOptions, onStart) } - open fun buildLambdaFromTemplate( + suspend fun constructSamBuildCommand( + module: Module, + templateLocation: Path, + logicalId: String, + samOptions: SamOptions, + buildDir: Path + ): GeneralCommandLine { + val executable = ExecutableManager.getInstance().getExecutable().await() + val samExecutable = when (executable) { + is ExecutableInstance.Executable -> executable + else -> { + throw RuntimeException((executable as? ExecutableInstance.BadExecutable)?.validationError ?: "") + } + } + + val commandLine = samExecutable.getCommandLine() + .withParameters("build") + .withParameters(logicalId) + .withParameters("--template") + .withParameters(templateLocation.toString()) + .withParameters("--build-dir") + .withParameters(buildDir.toString()) + + if (samOptions.buildInContainer) { + commandLine.withParameters("--use-container") + } + + if (samOptions.skipImagePull) { + commandLine.withParameters("--skip-pull-image") + } + + samOptions.dockerNetwork?.let { network -> + val sanitizedNetwork = network.trim() + if (sanitizedNetwork.isNotBlank()) { + commandLine.withParameters("--docker-network").withParameters(sanitizedNetwork) + } + } + + samOptions.additionalBuildArgs?.let { buildArgs -> + if (buildArgs.isNotBlank()) { + commandLine.withParameters(*buildArgs.split(" ").toTypedArray()) + } + } + + commandLine.withEnvironment(additionalEnvironmentVariables(module, samOptions)) + + return commandLine + } + + fun buildLambdaFromTemplate( module: Module, templateLocation: Path, logicalId: String, samOptions: SamOptions, onStart: (ProcessHandler) -> Unit = {} ): BuiltLambda { - val future = CompletableFuture() - val functions = SamTemplateUtils.findFunctionsFromTemplate( module.project, templateLocation.toFile() @@ -93,93 +137,35 @@ abstract class LambdaBuilder { ) } - ExecutableManager.getInstance().getExecutable().thenApply { - val samExecutable = when (it) { - is ExecutableInstance.Executable -> it - else -> { - future.completeExceptionally(RuntimeException((it as? ExecutableInstance.BadExecutable)?.validationError ?: "")) - return@thenApply - } - } - - val buildDir = getOrCreateBuildDirectory(module).toPath() + val buildDir = getBuildDirectory(module) - val commandLine = samExecutable.getCommandLine() - .withParameters("build") - .withParameters(logicalId) - .withParameters("--template") - .withParameters(templateLocation.toString()) - .withParameters("--build-dir") - .withParameters(buildDir.toString()) - - if (samOptions.buildInContainer) { - commandLine.withParameters("--use-container") - } - - if (samOptions.skipImagePull) { - commandLine.withParameters("--skip-pull-image") - } - - samOptions.dockerNetwork?.let { - if (it.isNotBlank()) { - commandLine.withParameters("--docker-network") - .withParameters(it.trim()) - } - } - - samOptions.additionalBuildArgs?.let { - if (it.isNotBlank()) { - commandLine.withParameters(*it.split(" ").toTypedArray()) - } - } + return runBlocking(Dispatchers.IO) { + val commandLine = constructSamBuildCommand(module, templateLocation, logicalId, samOptions, buildDir) val pathMappings = listOf( PathMapping(templateLocation.parent.resolve(codeLocation).toString(), "/"), PathMapping(buildDir.resolve(logicalId).toString(), "/") ) - val processHandler = ProcessHandlerFactory.getInstance().createColoredProcessHandler(commandLine) - processHandler.addProcessListener(object : ProcessAdapter() { - override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { - // TODO find a way to show this in the UI - // log output is for diagnostic and integrations tests as well - LOG.info { event.text.trim() } - } - - override fun processTerminated(event: ProcessEvent) { - if (event.exitCode == 0) { - val builtTemplate = buildDir.resolve("template.yaml") - - if (!builtTemplate.exists()) { - future.completeExceptionally(IllegalStateException("Failed to locate built template, $builtTemplate does not exist")) - } - - future.complete( - BuiltLambda( - builtTemplate, - buildDir.resolve(logicalId), - pathMappings - ) - ) - } else { - future.completeExceptionally(IllegalStateException(message("sam.build.failed"))) - } - } - }) + val processHandler = ColoredProcessHandler(commandLine) onStart.invoke(processHandler) - processHandler.startNotify() - } + val processOutput = CapturingProcessRunner(processHandler).runProcess() + if (processOutput.exitCode != 0) { + throw IllegalStateException(message("sam.build.failed")) + } - return try { - future.get() - } catch (e: ExecutionException) { - throw e.cause ?: e + val builtTemplate = buildDir.resolve("template.yaml") + if (!builtTemplate.exists()) { + throw IllegalStateException("Failed to locate built template, $builtTemplate does not exist") + } + + return@runBlocking BuiltLambda(builtTemplate, buildDir.resolve(logicalId), pathMappings) } } - open fun packageLambda( + fun packageLambda( module: Module, handlerElement: PsiElement, handler: String, @@ -198,17 +184,15 @@ abstract class LambdaBuilder { /** * Returns the build directory of the project. Create this if it doesn't exist yet. */ - private fun getOrCreateBuildDirectory(module: Module): File { + protected open fun getBuildDirectory(module: Module): Path { val contentRoot = module.rootManager.contentRoots.firstOrNull() ?: throw IllegalStateException(message("lambda.build.module_with_no_content_root", module.name)) - val buildFolder = File(contentRoot.path, ".aws-sam/build") - FileUtil.createDirectory(buildFolder) - return buildFolder + return Paths.get(contentRoot.path, ".aws-sam", "build") } - companion object : RuntimeGroupExtensionPointObject(ExtensionPointName("aws.toolkit.lambda.builder")) { - private val LOG = getLogger() - } + protected open fun additionalEnvironmentVariables(module: Module, samOptions: SamOptions): Map = emptyMap() + + companion object : RuntimeGroupExtensionPointObject(ExtensionPointName("aws.toolkit.lambda.builder")) } /** diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/DeployServerlessApplicationAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/DeployServerlessApplicationAction.kt index 3e74fb3872..1e2f666655 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/DeployServerlessApplicationAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/DeployServerlessApplicationAction.kt @@ -18,7 +18,7 @@ import com.intellij.openapi.vfs.VirtualFile import icons.AwsIcons import software.amazon.awssdk.services.cloudformation.CloudFormationClient import software.aws.toolkits.jetbrains.core.awsClient -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance import software.aws.toolkits.jetbrains.core.executables.ExecutableManager import software.aws.toolkits.jetbrains.core.executables.getExecutable @@ -54,7 +54,7 @@ class DeployServerlessApplicationAction : AnAction( override fun actionPerformed(e: AnActionEvent) { val project = e.getRequiredData(PlatformDataKeys.PROJECT) - if (!ProjectAccountSettingsManager.getInstance(project).isValidConnectionSettings()) { + if (!AwsConnectionManager.getInstance(project).isValidConnectionSettings()) { notifyNoActiveCredentialsError(project = project) return } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/completion/HandlerCompletionProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/completion/HandlerCompletionProvider.kt index 3892becf69..87712775a8 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/completion/HandlerCompletionProvider.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/completion/HandlerCompletionProvider.kt @@ -5,7 +5,7 @@ package software.aws.toolkits.jetbrains.services.lambda.completion import com.intellij.codeInsight.completion.CompletionParameters import com.intellij.codeInsight.completion.CompletionResultSet -import com.intellij.codeInsight.lookup.CharFilter +import com.intellij.codeInsight.lookup.CharFilter.Result import com.intellij.openapi.project.Project import com.intellij.util.textCompletion.TextCompletionProvider import software.amazon.awssdk.services.lambda.model.Runtime @@ -13,6 +13,7 @@ import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup +import java.lang.IllegalStateException class HandlerCompletionProvider(private val project: Project, runtime: Runtime?) : TextCompletionProvider { @@ -32,9 +33,11 @@ class HandlerCompletionProvider(private val project: Project, runtime: Runtime?) override fun applyPrefixMatcher(result: CompletionResultSet, prefix: String): CompletionResultSet { if (!isCompletionSupported) return result - val prefixMatcher = handlerCompletion!!.getPrefixMatcher(prefix) - result.withPrefixMatcher(prefixMatcher) - return result + val completion = handlerCompletion + ?: throw IllegalStateException("handlerCompletion must be defined if completion is enabled.") + + val prefixMatcher = completion.getPrefixMatcher(prefix) + return result.withPrefixMatcher(prefixMatcher) } override fun getAdvertisement(): String? = null @@ -49,12 +52,12 @@ class HandlerCompletionProvider(private val project: Project, runtime: Runtime?) result.stopHere() } - override fun acceptChar(c: Char): CharFilter.Result? { - if (!isCompletionSupported) return CharFilter.Result.HIDE_LOOKUP + override fun acceptChar(char: Char): Result? { + if (!isCompletionSupported) return Result.HIDE_LOOKUP return when { - c.isWhitespace() -> CharFilter.Result.SELECT_ITEM_AND_FINISH_LOOKUP - else -> CharFilter.Result.ADD_TO_PREFIX + char == ':' || char == '.' || Character.isLetterOrDigit(char) -> Result.ADD_TO_PREFIX + else -> null } } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployDialog.kt index 49a89830f5..19bacac926 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployDialog.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployDialog.kt @@ -20,7 +20,7 @@ import com.intellij.openapi.vfs.VirtualFile import com.intellij.util.ExceptionUtil import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.warn -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.credentials.toEnvironmentVariables import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance import software.aws.toolkits.jetbrains.core.executables.ExecutableManager @@ -50,9 +50,10 @@ class SamDeployDialog( private val progressIndicator = ProgressIndicatorBase() private val view = SamDeployView(project, progressIndicator) private var currentStep = 0 - private val credentialsProvider = ProjectAccountSettingsManager.getInstance(project).activeCredentialProvider - private val region = ProjectAccountSettingsManager.getInstance(project).activeRegion - private val changeSetRegex = "(arn:aws.*?:cloudformation:.*changeSet/[^\\s]*)".toRegex() + private val credentialsProvider = AwsConnectionManager.getInstance(project).activeCredentialProvider + private val region = AwsConnectionManager.getInstance(project).activeRegion + private val changeSetRegex = "(arn:${region.partitionId}:cloudformation:.*changeSet/[^\\s]*)".toRegex() + val deployFuture: CompletableFuture lateinit var changeSetName: String private set diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunConfigurationProducer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunConfigurationProducer.kt index c8d5cd07b7..2deb337efd 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunConfigurationProducer.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunConfigurationProducer.kt @@ -14,7 +14,7 @@ import com.intellij.psi.PsiElement import org.jetbrains.yaml.psi.YAMLKeyValue import org.jetbrains.yaml.psi.YAMLPsiElement import software.aws.toolkits.core.region.AwsRegion -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.services.lambda.LambdaHandlerResolver import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup import software.aws.toolkits.jetbrains.services.lambda.execution.LambdaRunConfigurationType @@ -106,7 +106,7 @@ class LocalLambdaRunConfigurationProducer : LazyRunConfigurationProducer { - val settingsManager = ProjectAccountSettingsManager.getInstance(project) + val settingsManager = AwsConnectionManager.getInstance(project) val region = try { settingsManager.activeRegion } catch (_: Exception) { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamRunningState.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamRunningState.kt index ec598aaf07..0c8bb22e7c 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamRunningState.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamRunningState.kt @@ -5,8 +5,8 @@ package software.aws.toolkits.jetbrains.services.lambda.execution.local import com.intellij.execution.configurations.CommandLineState import com.intellij.execution.executors.DefaultDebugExecutor +import com.intellij.execution.process.KillableColoredProcessHandler import com.intellij.execution.process.ProcessHandler -import com.intellij.execution.process.ProcessHandlerFactory import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.openapi.util.io.FileUtil import software.aws.toolkits.jetbrains.core.credentials.toEnvironmentVariables @@ -70,7 +70,9 @@ class SamRunningState( runner.patchCommandLine(commandLine) - return ProcessHandlerFactory.getInstance().createColoredProcessHandler(commandLine) + // Unix: Sends SIGINT on destroy so Docker container is shut down + // Windows: Run with mediator to allow for Cntrl+C to be used + return KillableColoredProcessHandler(commandLine, true) } private fun createEventFile(): String { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunConfigurationProducer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunConfigurationProducer.kt index 4e268de78a..819710a794 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunConfigurationProducer.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunConfigurationProducer.kt @@ -10,7 +10,7 @@ import com.intellij.execution.actions.LazyRunConfigurationProducer import com.intellij.execution.configurations.ConfigurationFactory import com.intellij.openapi.util.Ref import com.intellij.psi.PsiElement -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.services.lambda.execution.LambdaRunConfigurationType class RemoteLambdaRunConfigurationProducer : LazyRunConfigurationProducer() { @@ -30,7 +30,7 @@ class RemoteLambdaRunConfigurationProducer : LazyRunConfigurationProducer throw IllegalStateException(message("lambda.build.java.unsupported_build_system", module.name)) } + override fun additionalEnvironmentVariables(module: Module, samOptions: SamOptions): Map { + if (samOptions.buildInContainer) { + return emptyMap() + } + + val sdk = ModuleRootManager.getInstance(module).sdk ?: return emptyMap() + val sdkHome = sdk.homePath ?: return emptyMap() + + return if (sdk.sdkType is JavaSdkType) { + mapOf("JAVA_HOME" to FileUtil.toSystemDependentName(sdkHome)) + } else { + emptyMap() + } + } + private fun isGradle(module: Module): Boolean = ExternalSystemModulePropertyManager.getInstance(module) .getExternalSystemId() == "GRADLE" @@ -29,7 +47,7 @@ class JavaLambdaBuilder : LambdaBuilder() { ?: throw IllegalStateException(message("lambda.build.unable_to_locate_project_root", module)) private fun isMaven(module: Module): Boolean { - if (PluginManager.getPlugin(PluginId.getId("org.jetbrains.idea.maven"))?.isEnabled == true) { + if (pluginIsInstalledAndEnabled("org.jetbrains.idea.maven")) { return MavenProjectsManager.getInstance(module.project).isMavenizedModule(module) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamExecutable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamExecutable.kt index 546e0ef08f..56f315ef01 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamExecutable.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamExecutable.kt @@ -20,7 +20,7 @@ class SamExecutable : ExecutableType, AutoResolvable, Validatable { // inclusive val samMinVersion = SemVer("0.47.0", 0, 47, 0) // exclusive - val samMaxVersion = SemVer("0.60.0", 0, 60, 0) + val samMaxVersion = SemVer("2.0.0", 2, 0, 0) override fun version(path: Path): SemVer = ExecutableCommon.getVersion( path.toString(), diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamSchemaDownloadPostCreationAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamSchemaDownloadPostCreationAction.kt index 20e1ba0ce0..2c01aeacf8 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamSchemaDownloadPostCreationAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamSchemaDownloadPostCreationAction.kt @@ -8,7 +8,7 @@ import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.VirtualFile import software.aws.toolkits.jetbrains.core.AwsClientManager -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.services.schemas.SchemaCodeLangs import software.aws.toolkits.jetbrains.services.schemas.SchemaTemplateParameters import software.aws.toolkits.jetbrains.services.schemas.code.SchemaCodeDownloadRequestDetails @@ -48,17 +48,17 @@ class SamSchemaDownloadPostCreationAction { } private fun initializeNewProjectCredentialsFromSourceCreatingProject(newApplicationProject: Project, sourceCreatingProject: Project) { - val newApplicationProjectSettings = ProjectAccountSettingsManager.getInstance(newApplicationProject) + val newApplicationProjectSettings = AwsConnectionManager.getInstance(newApplicationProject) if (newApplicationProjectSettings.isValidConnectionSettings()) { return } - val sourceCreatingProjectSettings = ProjectAccountSettingsManager.getInstance(sourceCreatingProject) + val sourceCreatingProjectSettings = AwsConnectionManager.getInstance(sourceCreatingProject) if (!sourceCreatingProjectSettings.isValidConnectionSettings()) { return } - newApplicationProjectSettings.changeCredentialProvider(sourceCreatingProjectSettings.selectedCredentialIdentifier) + sourceCreatingProjectSettings.selectedCredentialIdentifier?.let { newApplicationProjectSettings.changeCredentialProvider(it) } newApplicationProjectSettings.changeRegion(sourceCreatingProjectSettings.activeRegion) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamTemplateUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamTemplateUtils.kt index 44a2a22168..e8ce31a3cd 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamTemplateUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamTemplateUtils.kt @@ -7,14 +7,18 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiElement import com.intellij.testFramework.LightVirtualFile +import com.intellij.util.io.createFile import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.core.utils.exists import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.core.utils.writeText import software.aws.toolkits.jetbrains.services.cloudformation.CloudFormationTemplate import software.aws.toolkits.jetbrains.services.cloudformation.Function import software.aws.toolkits.jetbrains.services.cloudformation.SERVERLESS_FUNCTION_TYPE import software.aws.toolkits.jetbrains.utils.yamlWriter import java.io.File +import java.nio.file.Path object SamTemplateUtils { private val LOG = getLogger() @@ -45,11 +49,10 @@ object SamTemplateUtils { } @JvmStatic - fun functionFromElement(element: PsiElement): Function? = - CloudFormationTemplate.convertPsiToResource(element) as? Function + fun functionFromElement(element: PsiElement): Function? = CloudFormationTemplate.convertPsiToResource(element) as? Function fun writeDummySamTemplate( - tempFile: File, + tempFile: Path, logicalId: String, runtime: Runtime, codeUri: String, @@ -58,6 +61,9 @@ object SamTemplateUtils { memorySize: Int, envVars: Map = emptyMap() ) { + if (!tempFile.exists()) { + tempFile.createFile() + } tempFile.writeText(yamlWriter { mapping("Resources") { mapping(logicalId) { @@ -72,7 +78,7 @@ object SamTemplateUtils { if (envVars.isNotEmpty()) { mapping("Environment") { mapping("Variables") { - envVars.forEach { key, value -> + envVars.forEach { (key, value) -> keyValue(key, value) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateLambdaFunction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateLambdaFunction.kt index f5b1d37f0c..0ff4739c96 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateLambdaFunction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateLambdaFunction.kt @@ -11,7 +11,7 @@ import com.intellij.psi.SmartPsiElementPointer import icons.AwsIcons import software.amazon.awssdk.services.lambda.model.Runtime import software.amazon.awssdk.services.lambda.model.TracingMode -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.services.cloudformation.CloudFormationTemplateIndex import software.aws.toolkits.jetbrains.services.iam.IamRole import software.aws.toolkits.jetbrains.services.lambda.LambdaHandlerResolver @@ -42,7 +42,7 @@ class CreateLambdaFunction( val project = e.getRequiredData(LangDataKeys.PROJECT) val runtime = e.runtime() - if (!ProjectAccountSettingsManager.getInstance(project).isValidConnectionSettings()) { + if (!AwsConnectionManager.getInstance(project).isValidConnectionSettings()) { notifyNoActiveCredentialsError(project = project) return } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/EditFunctionDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/EditFunctionDialog.kt index 5474cf11d0..efbffefa47 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/EditFunctionDialog.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/EditFunctionDialog.kt @@ -21,7 +21,7 @@ import software.amazon.awssdk.services.lambda.model.Runtime import software.amazon.awssdk.services.s3.S3Client import software.aws.toolkits.jetbrains.core.AwsClientManager import software.aws.toolkits.jetbrains.core.awsClient -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.help.HelpIds import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider import software.aws.toolkits.jetbrains.services.iam.CreateIamRoleDialog @@ -146,7 +146,7 @@ class EditFunctionDialog( view.xrayEnabled.isSelected = xrayEnabled val regionProvider = AwsRegionProvider.getInstance() - val settings = ProjectAccountSettingsManager.getInstance(project) + val settings = AwsConnectionManager.getInstance(project) view.setXrayControlVisibility(mode != UPDATE_CODE && lambdaTracingConfigIsAvailable(settings.activeRegion)) view.iamRole.selectedItem = role diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaLineMarker.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaLineMarker.kt index b880dd4350..a3f624d45e 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaLineMarker.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaLineMarker.kt @@ -20,7 +20,7 @@ import com.intellij.psi.SmartPointerManager import icons.AwsIcons import software.amazon.awssdk.services.lambda.model.Runtime import software.aws.toolkits.jetbrains.core.AwsResourceCache -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.services.cloudformation.CloudFormationTemplateIndex.Companion.listFunctions import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder import software.aws.toolkits.jetbrains.services.lambda.LambdaHandlerResolver @@ -74,12 +74,6 @@ class LambdaLineMarker : LineMarkerProviderDescriptor() { } else null } - override fun collectSlowLineMarkers( - elements: MutableList, - result: MutableCollection> - ) { - } - private fun shouldShowLineMarker(psiFile: PsiFile, handler: String, runtimeGroup: RuntimeGroup): Boolean { val project = psiFile.project return LambdaSettings.getInstance(project).showAllHandlerGutterIcons || @@ -95,7 +89,7 @@ class LambdaLineMarker : LineMarkerProviderDescriptor() { // Handler defined in remote Lambda with the same runtime group is valid private fun handlerInRemote(psiFile: PsiFile, handler: String, runtimeGroup: RuntimeGroup): Boolean { - if (!ProjectAccountSettingsManager.getInstance(psiFile.project).isValidConnectionSettings()) { + if (!AwsConnectionManager.getInstance(psiFile.project).isValidConnectionSettings()) { return false } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeTable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeTable.kt index b3cc8f5643..01661a3770 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeTable.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeTable.kt @@ -69,16 +69,14 @@ class S3TreeTable( } private val openFileListener = object : DoubleClickListener() { - override fun onDoubleClick(e: MouseEvent?): Boolean { - e ?: return false + override fun onDoubleClick(e: MouseEvent): Boolean { val row = rowAtPoint(e.point).takeIf { it >= 0 } ?: return false return handleOpeningFile(row) } } private val loadMoreListener = object : DoubleClickListener() { - override fun onDoubleClick(e: MouseEvent?): Boolean { - e ?: return false + override fun onDoubleClick(e: MouseEvent): Boolean { val row = rowAtPoint(e.point).takeIf { it >= 0 } ?: return false return handleLoadingMore(row) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/resources/S3Resources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/resources/S3Resources.kt index 3317e86504..8966256256 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/resources/S3Resources.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/resources/S3Resources.kt @@ -12,7 +12,7 @@ import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.tryOrNull import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource import software.aws.toolkits.jetbrains.core.Resource -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.filter import software.aws.toolkits.jetbrains.core.map import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider @@ -42,7 +42,7 @@ object S3Resources { } fun listBucketsByActiveRegion(project: Project): Resource> { - val activeRegion = ProjectAccountSettingsManager.getInstance(project).activeRegion + val activeRegion = AwsConnectionManager.getInstance(project).activeRegion return LIST_REGIONALIZED_BUCKETS.filter { it.region == activeRegion }.map { it.bucket } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchDialogManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchDialogManager.kt index dddd2a6168..c0f62a61e9 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchDialogManager.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchDialogManager.kt @@ -5,7 +5,7 @@ package software.aws.toolkits.jetbrains.services.schemas.search import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.services.schemas.search.SchemaSearchDialogManager.DialogStateCacheKey.AllRegistriesDialogStateCacheKey import software.aws.toolkits.jetbrains.services.schemas.search.SchemaSearchDialogManager.DialogStateCacheKey.SingleRegistryDialogStateCacheKey @@ -14,8 +14,8 @@ class SchemaSearchDialogManager { private val allRegistriesSearchDialogStateCache: MutableMap = mutableMapOf() fun searchRegistryDialog(registry: String, project: Project): DialogWrapper { - val credentialId = ProjectAccountSettingsManager.getInstance(project).activeCredentialProvider.id - val region = ProjectAccountSettingsManager.getInstance(project).activeRegion.id + val credentialId = AwsConnectionManager.getInstance(project).activeCredentialProvider.id + val region = AwsConnectionManager.getInstance(project).activeRegion.id val dialog = SchemaSearchSingleRegistryDialog( registry, @@ -35,8 +35,8 @@ class SchemaSearchDialogManager { } fun searchAllRegistriesDialog(project: Project): DialogWrapper { - val credentialId = ProjectAccountSettingsManager.getInstance(project).activeCredentialProvider.id - val region = ProjectAccountSettingsManager.getInstance(project).activeRegion.id + val credentialId = AwsConnectionManager.getInstance(project).activeCredentialProvider.id + val region = AwsConnectionManager.getInstance(project).activeRegion.id val dialog = SchemaSearchAllRegistriesDialog( project, diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemasSearchDialogBase.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemasSearchDialogBase.kt index c527d3ed55..3f51531b28 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemasSearchDialogBase.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemasSearchDialogBase.kt @@ -281,7 +281,7 @@ abstract class SchemasSearchDialogBase( top.add(JBLabel(headerText), BorderLayout.WEST) top.add(searchTextField, BorderLayout.CENTER) - resultsList.installCellRenderer(createResultRenderer()) + resultsList.installCellRenderer(createResultRenderer()) versionsCombo.renderer = SimpleListCellRenderer.create("") { message("schemas.search.version.prefix", it) } val resultsScrollPane = JBScrollPane() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/secretsmanager/SecretsManagerResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/secretsmanager/SecretsManagerResources.kt new file mode 100644 index 0000000000..8a0ab249c2 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/secretsmanager/SecretsManagerResources.kt @@ -0,0 +1,15 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.secretsmanager + +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient +import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource + +object SecretsManagerResources { + val secrets = ClientBackedCachedResource(SecretsManagerClient::class, "secretsmanager.secrets") { + listSecretsPaginator().toList().flatMap { it.secretList() } + } +} + +fun String.arnToName() = this.substringAfterLast(':') diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sts/StsResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sts/StsResources.kt index d398d3a06d..6453e03fca 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sts/StsResources.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sts/StsResources.kt @@ -8,8 +8,10 @@ import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource import java.time.Duration object StsResources { - @Suppress("UsePropertyAccessSyntax") val ACCOUNT = ClientBackedCachedResource(StsClient::class, "sts.account", expiry = Duration.ofDays(1)) { - getCallerIdentity().account() + callerIdentity.account() + } + val USER = ClientBackedCachedResource(StsClient::class, "sts.user", expiry = Duration.ofDays(1)) { + callerIdentity.userId() } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettings.kt index 2c0d41b3c2..44690150af 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettings.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettings.kt @@ -3,17 +3,24 @@ package software.aws.toolkits.jetbrains.settings +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.LangDataKeys import com.intellij.openapi.components.PersistentStateComponent import com.intellij.openapi.components.ServiceManager import com.intellij.openapi.components.State import com.intellij.openapi.components.Storage +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.DumbAware import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService +import software.aws.toolkits.resources.message import java.util.UUID import java.util.prefs.Preferences interface AwsSettings { var isTelemetryEnabled: Boolean var promptedForTelemetry: Boolean + var useDefaultCredentialRegion: UseAwsCredentialRegion val clientId: UUID companion object { @@ -22,6 +29,14 @@ interface AwsSettings { } } +enum class UseAwsCredentialRegion(private val description: String) { + Always(message("settings.credentials.prompt_for_default_region_switch.always.description")), + Prompt(message("settings.credentials.prompt_for_default_region_switch.ask.description")), + Never(message("settings.credentials.prompt_for_default_region_switch.never.description")); + + override fun toString(): String = description +} + @State(name = "aws", storages = [Storage("aws.xml")]) class DefaultAwsSettings : PersistentStateComponent, AwsSettings { private val preferences = Preferences.userRoot().node(this.javaClass.canonicalName) @@ -46,6 +61,12 @@ class DefaultAwsSettings : PersistentStateComponent, AwsSettin state.promptedForTelemetry = value } + override var useDefaultCredentialRegion: UseAwsCredentialRegion + get() = state.useDefaultCredentialRegion?.let { UseAwsCredentialRegion.valueOf(it) } ?: UseAwsCredentialRegion.Prompt + set(value) { + state.useDefaultCredentialRegion = value.name + } + override val clientId: UUID @Synchronized get() = UUID.fromString(preferences.get(CLIENT_ID_KEY, UUID.randomUUID().toString())).also { preferences.put(CLIENT_ID_KEY, it.toString()) @@ -58,5 +79,12 @@ class DefaultAwsSettings : PersistentStateComponent, AwsSettin data class AwsConfiguration( var isTelemetryEnabled: Boolean? = null, - var promptedForTelemetry: Boolean? = null + var promptedForTelemetry: Boolean? = null, + var useDefaultCredentialRegion: String? = null ) + +class ShowSettingsAction : AnAction(message("aws.settings.show.label")), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + ShowSettingsUtil.getInstance().showSettingsDialog(e.getRequiredData(LangDataKeys.PROJECT), AwsSettingsConfigurable::class.java) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.form index 77e47b8359..36e5e9f6eb 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.form +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.form @@ -47,7 +47,7 @@ - + @@ -57,13 +57,32 @@ - + + + + + + + + + + + + + + + + + + + + @@ -133,4 +152,11 @@ + + + + + + + diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.java index 88885d8aea..27d57d29cc 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.java +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.java @@ -12,6 +12,7 @@ import com.intellij.openapi.options.ConfigurationException; import com.intellij.openapi.options.SearchableConfigurable; import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.ComboBox; import com.intellij.openapi.ui.TextFieldWithBrowseButton; import com.intellij.openapi.util.text.StringUtil; import com.intellij.ui.IdeBorderFactory; @@ -61,6 +62,8 @@ public class AwsSettingsConfigurable implements SearchableConfigurable { private JPanel remoteDebugSettings; private JPanel applicationLevelSettings; + private ComboBox defaultRegionHandling; + public AwsSettingsConfigurable(Project project) { this.project = project; @@ -88,6 +91,7 @@ private void createUIComponents() { samExecutablePath = createCliConfigurationElement(getSamExecutableInstance(), SAM); cfnLintHelp = createHelpLink(HelpIds.CFN_LINT); cfnLintExecutablePath = createCliConfigurationElement(getCfnLintExecutableInstance(), CFNLINT); + defaultRegionHandling = new ComboBox<>(UseAwsCredentialRegion.values()); } @NotNull @@ -111,7 +115,8 @@ public boolean isModified() { !Objects.equals(getCloudDebugTextboxInput(), getSavedExecutablePath(getCloudDebugExecutableInstance(), false)) || !Objects.equals(getCfnLintTextboxInput(), getSavedExecutablePath(getCfnLintExecutableInstance(), false)) || isModified(showAllHandlerGutterIcons, lambdaSettings.getShowAllHandlerGutterIcons()) || - isModified(enableTelemetry, awsSettings.isTelemetryEnabled()); + isModified(enableTelemetry, awsSettings.isTelemetryEnabled()) || + isModified(defaultRegionHandling, awsSettings.getUseDefaultCredentialRegion()); } @Override @@ -132,7 +137,7 @@ public void apply() throws ConfigurationException { getSavedExecutablePath(getCfnLintExecutableInstance(), false), getCfnLintTextboxInput()); - saveTelemetrySettings(); + saveAwsSettings(); saveLambdaSettings(); } @@ -146,6 +151,7 @@ public void reset() { cfnLintExecutablePath.setText(getSavedExecutablePath(getCfnLintExecutableInstance(), false)); showAllHandlerGutterIcons.setSelected(lambdaSettings.getShowAllHandlerGutterIcons()); enableTelemetry.setSelected(awsSettings.isTelemetryEnabled()); + defaultRegionHandling.setSelectedItem(awsSettings.getUseDefaultCredentialRegion()); } @NotNull @@ -280,9 +286,10 @@ private void validateAndSaveCliSettings( ExecutableManager.getInstance().setExecutablePath(executableType, path); } - private void saveTelemetrySettings() { + private void saveAwsSettings() { AwsSettings awsSettings = AwsSettings.getInstance(); awsSettings.setTelemetryEnabled(enableTelemetry.isSelected()); + awsSettings.setUseDefaultCredentialRegion((UseAwsCredentialRegion) Objects.requireNonNull(defaultRegionHandling.getSelectedItem())); } private void saveLambdaSettings() { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CredentialProviderSelector.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CredentialProviderSelector.kt index 89f3aef2b9..53b4df8683 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CredentialProviderSelector.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CredentialProviderSelector.kt @@ -7,7 +7,7 @@ import com.intellij.openapi.ui.ComboBox import com.intellij.ui.CollectionComboBoxModel import com.intellij.ui.SimpleListCellRenderer import com.intellij.util.containers.OrderedSet -import software.aws.toolkits.core.credentials.ToolkitCredentialsIdentifier +import software.aws.toolkits.core.credentials.CredentialIdentifier import software.aws.toolkits.jetbrains.utils.ui.selected /** @@ -23,7 +23,7 @@ class CredentialProviderSelector : ComboBox() { setRenderer(RENDERER) } - fun setCredentialsProviders(providers: List) { + fun setCredentialsProviders(providers: List) { comboBoxModel.items = providers } @@ -33,14 +33,14 @@ class CredentialProviderSelector : ComboBox() { fun getSelectedCredentialsProvider(): String? { selected().let { return when (it) { - is ToolkitCredentialsIdentifier -> it.id + is CredentialIdentifier -> it.id is String -> it else -> null } } } - fun setSelectedCredentialsProvider(provider: ToolkitCredentialsIdentifier) { + fun setSelectedCredentialsProvider(provider: CredentialIdentifier) { selectedItem = provider } @@ -68,7 +68,7 @@ class CredentialProviderSelector : ComboBox() { val RENDERER = SimpleListCellRenderer.create("") { when (it) { is String -> "$it (Not valid)" - is ToolkitCredentialsIdentifier -> it.displayName + is CredentialIdentifier -> it.displayName else -> "" } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ResourceSelector.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ResourceSelector.kt index 0b0bc387cb..c8a8ca85ce 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ResourceSelector.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ResourceSelector.kt @@ -20,7 +20,7 @@ import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.core.AwsResourceCache import software.aws.toolkits.jetbrains.core.Resource -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.jetbrains.utils.ui.find import software.aws.toolkits.resources.message @@ -262,7 +262,7 @@ class ResourceSelector private constructor( } fun build() = ResourceSelector(project, resource, comboBoxModel, resolveCustomRenderer(), loadOnCreate, sortOnLoad, awsConnection ?: { - val settings = ProjectAccountSettingsManager.getInstance(project) + val settings = AwsConnectionManager.getInstance(project) settings.activeRegion to settings.activeCredentialProvider }) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettingsSelector.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettingsSelector.kt index 706c2c7b98..860b11c727 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettingsSelector.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettingsSelector.kt @@ -6,7 +6,7 @@ package software.aws.toolkits.jetbrains.ui.connection import com.intellij.openapi.project.Project import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.jetbrains.core.credentials.CredentialManager -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider import javax.swing.JComponent @@ -22,7 +22,7 @@ class AwsConnectionSettingsSelector( view.region.setRegions(regionProvider.allRegions().values.toMutableList()) view.credentialProvider.setCredentialsProviders(credentialManager.getCredentialIdentifiers()) - val accountSettingsManager = ProjectAccountSettingsManager.getInstance(project) + val accountSettingsManager = AwsConnectionManager.getInstance(project) view.region.selectedRegion = accountSettingsManager.activeRegion if (accountSettingsManager.isValidConnectionSettings()) { accountSettingsManager.selectedCredentialIdentifier?.let { @@ -44,7 +44,7 @@ class AwsConnectionSettingsSelector( fun selectorPanel(): JComponent = view.panel fun resetAwsConnectionOptions(regionId: String?, credentialProviderId: String?) { - regionId?.let { view.region.selectedRegion = regionProvider.lookupRegionById(it) } + regionId?.let { view.region.selectedRegion = regionProvider[it] } credentialProviderId?.let { providerId -> try { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/SubmitFeedbackPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/SubmitFeedbackPanel.kt index 1ef8447982..5b909a735d 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/SubmitFeedbackPanel.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/SubmitFeedbackPanel.kt @@ -80,7 +80,7 @@ class SubmitFeedbackPanel(initialSentiment: Sentiment? = null) { private fun onTextAreaUpdate() { val currentLength = comment?.length ?: 0 - val lengthText = message("feedback.limit.label", currentLength, MAX_LENGTH) + val lengthText = message("feedback.limit.label", MAX_LENGTH - currentLength) lengthLimitLabel.text = if (currentLength >= MAX_LENGTH) { "$lengthText" } else { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitSelectionPanel.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitSelectionPanel.java index 034df02a69..c6f01be797 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitSelectionPanel.java +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitSelectionPanel.java @@ -24,11 +24,11 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import software.amazon.awssdk.services.lambda.model.Runtime; -import software.aws.toolkits.core.credentials.ToolkitCredentialsIdentifier; +import software.aws.toolkits.core.credentials.CredentialIdentifier; import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider; import software.aws.toolkits.core.region.AwsRegion; import software.aws.toolkits.jetbrains.core.credentials.CredentialManager; -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager; +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager; import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance; import software.aws.toolkits.jetbrains.core.executables.ExecutableManager; import software.aws.toolkits.jetbrains.core.executables.ExecutableType; @@ -169,7 +169,7 @@ private void templateUpdate() { this.awsCredentialSelectionUi = AwsConnectionSettingsPanel.create(selectedTemplate, generator, this::awsCredentialsUpdated); addAwsConnectionSettingsPanel(awsCredentialSelectionUi); - ProjectAccountSettingsManager accountSettingsManager = ProjectAccountSettingsManager.Companion.getInstance(generator.getDefaultSourceCreatingProject()); + AwsConnectionManager accountSettingsManager = AwsConnectionManager.Companion.getInstance(generator.getDefaultSourceCreatingProject()); if (accountSettingsManager.isValidConnectionSettings()) { awsCredentialsUpdated(accountSettingsManager.getActiveRegion(), accountSettingsManager.getActiveCredentialProvider().getId()); } else { @@ -183,7 +183,7 @@ private Unit awsCredentialsUpdated(AwsRegion awsRegion, String credentialProvide } CredentialManager credentialManager = CredentialManager.getInstance(); - ToolkitCredentialsIdentifier credentialIdentifier = credentialManager.getCredentialIdentifierById(credentialProviderId); + CredentialIdentifier credentialIdentifier = credentialManager.getCredentialIdentifierById(credentialProviderId); if (credentialIdentifier == null) { throw new IllegalArgumentException("Unknown credential provider selected"); } @@ -191,8 +191,8 @@ private Unit awsCredentialsUpdated(AwsRegion awsRegion, String credentialProvide return awsCredentialsUpdated(awsRegion, credentialIdentifier); } - private Unit awsCredentialsUpdated(@NotNull AwsRegion awsRegion, @NotNull ToolkitCredentialsIdentifier credentialIdentifier) { - ProjectAccountSettingsManager accountSettingsManager = ProjectAccountSettingsManager.getInstance(generator.getDefaultSourceCreatingProject()); + private Unit awsCredentialsUpdated(@NotNull AwsRegion awsRegion, @NotNull CredentialIdentifier credentialIdentifier) { + AwsConnectionManager accountSettingsManager = AwsConnectionManager.getInstance(generator.getDefaultSourceCreatingProject()); if (!accountSettingsManager.isValidConnectionSettings() || !accountSettingsManager.getActiveCredentialProvider().getId().equals(credentialIdentifier.getId())) { accountSettingsManager.changeCredentialProvider(credentialIdentifier); @@ -204,7 +204,7 @@ private Unit awsCredentialsUpdated(@NotNull AwsRegion awsRegion, @NotNull Toolki return initSchemaSelectionPanel(awsRegion, credentialIdentifier); } - private Unit initSchemaSelectionPanel(AwsRegion awsRegion, ToolkitCredentialsIdentifier credentialIdentifier) { + private Unit initSchemaSelectionPanel(AwsRegion awsRegion, CredentialIdentifier credentialIdentifier) { Runtime selectedRuntime = (Runtime) runtimeComboBox.getSelectedItem(); if (selectedRuntime == null) { addNoOpConditionalPanels(); diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamProjectGenerator.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamProjectGenerator.kt index 41afff6210..bd3a4b96ec 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamProjectGenerator.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamProjectGenerator.kt @@ -24,7 +24,7 @@ import com.intellij.platform.HideableProjectGenerator import com.intellij.platform.ProjectGeneratorPeer import com.intellij.platform.ProjectTemplate import icons.AwsIcons -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.core.help.HelpIds import software.aws.toolkits.jetbrains.services.lambda.SamNewProjectSettings import software.aws.toolkits.jetbrains.services.lambda.SamProjectTemplate @@ -80,7 +80,7 @@ class SamProjectGenerator : ProjectTemplate, val newDefaultProject = DefaultProjectFactory.getInstance().defaultProject // Explicitly eager load ProjectAccountSettingsManager for the project to subscribe to credential change events - ProjectAccountSettingsManager.getInstance(newDefaultProject) + AwsConnectionManager.getInstance(newDefaultProject) return newDefaultProject } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaResourceSelectorSelectionPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaResourceSelectorSelectionPanel.kt index eec52f0df2..9d530327b9 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaResourceSelectorSelectionPanel.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaResourceSelectorSelectionPanel.kt @@ -6,7 +6,7 @@ package software.aws.toolkits.jetbrains.ui.wizard import com.intellij.openapi.project.Project import com.intellij.openapi.ui.ValidationInfo import com.intellij.ui.ComboboxSpeedSearch -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup import software.aws.toolkits.jetbrains.services.schemas.resources.SchemasResources import software.aws.toolkits.jetbrains.ui.AwsConnection @@ -54,7 +54,7 @@ class SchemaResourceSelectorSelectionPanel( override val schemaSelectionPanel: JComponent = schemaPanel private fun initializeAwsConnection(): AwsConnection? { - val settings = ProjectAccountSettingsManager.getInstance(project) + val settings = AwsConnectionManager.getInstance(project) return if (settings.isValidConnectionSettings()) { settings.activeRegion to settings.activeCredentialProvider } else { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt index 3162619883..ebf27db98d 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt @@ -17,6 +17,7 @@ import com.intellij.ui.ScrollPaneFactory import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.AwsToolkit +import software.aws.toolkits.jetbrains.core.credentials.ChangeAccountSettingsMode import software.aws.toolkits.jetbrains.core.credentials.SettingsSelectorAction import software.aws.toolkits.jetbrains.core.help.HelpIds import software.aws.toolkits.jetbrains.settings.AwsSettingsConfigurable @@ -42,11 +43,9 @@ fun Throwable.notifyError(title: String = "", project: Project? = null) { private fun notify(type: NotificationType, title: String, content: String = "", project: Project? = null, notificationActions: Collection) { val notification = Notification(GROUP_DISPLAY_ID, title, content, type) - notificationActions.forEach { - notification.addAction(it) + notification.addAction(if (it !is NotificationAction) createNotificationExpiringAction(it) else it) } - notify(notification, project) } @@ -80,7 +79,7 @@ fun notifyNoActiveCredentialsError( title = title, content = content, project = project, - action = SettingsSelectorAction(showRegions = false) + action = SettingsSelectorAction(ChangeAccountSettingsMode.CREDENTIALS) ) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/SpinUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/SpinUtils.kt index 5e5a88774f..00b7a79d80 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/SpinUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/SpinUtils.kt @@ -20,3 +20,18 @@ fun spinUntil(duration: Duration, condition: () -> Boolean) { } } } + +/** + * Keeps running the function until it returns a non-null value. Checks every 100ms + */ +suspend fun spinUntilResult(duration: Duration, func: () -> T?): T { + val start = System.nanoTime() + while (System.nanoTime() - start <= duration.toNanos()) { + func()?.let { + return it + } + + delay(100) + } + throw IllegalStateException("Function did not return value within $duration") +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/UiUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/UiUtils.kt index 21e341e9ec..497d33f2a9 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/UiUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/UiUtils.kt @@ -13,21 +13,33 @@ import com.intellij.ui.ClickListener import com.intellij.ui.EditorTextField import com.intellij.ui.JBColor import com.intellij.ui.JreHiDpiUtil +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextArea import com.intellij.ui.paint.LinePainter2D +import com.intellij.ui.speedSearch.SpeedSearchSupply import com.intellij.util.ui.GraphicsUtil import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import software.aws.toolkits.jetbrains.utils.formatText import java.awt.AlphaComposite import java.awt.Color +import java.awt.Component +import java.awt.Graphics import java.awt.Graphics2D +import java.awt.Shape import java.awt.event.MouseEvent import java.awt.geom.RoundRectangle2D import javax.swing.AbstractButton import javax.swing.JComboBox import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JTable +import javax.swing.JTextArea import javax.swing.JTextField import javax.swing.ListModel +import javax.swing.table.DefaultTableCellRenderer +import javax.swing.text.Highlighter +import javax.swing.text.JTextComponent fun JTextField?.blankAsNull(): String? = if (this?.text?.isNotBlank() == true) { text @@ -126,3 +138,62 @@ fun drawSearchMatch(graphics2D: Graphics2D, startXf: Float, endXf: Float, startY } config.restore() } + +fun Component.setSelectionHighlighting(table: JTable, isSelected: Boolean) { + if (isSelected) { + foreground = table.selectionForeground + background = table.selectionBackground + } else { + foreground = table.foreground + background = table.background + } +} + +private class SpeedSearchHighlighter : Highlighter.HighlightPainter { + override fun paint(g: Graphics?, startingPoint: Int, endingPoint: Int, bounds: Shape?, component: JTextComponent?) { + component ?: return + val graphics = g as? Graphics2D ?: return + val beginningRect = component.modelToView(startingPoint) + val endingRect = component.modelToView(endingPoint) + drawSearchMatch(graphics, beginningRect.x.toFloat(), endingRect.x.toFloat(), beginningRect.y.toFloat(), beginningRect.height) + } +} + +private fun JTextArea.speedSearchHighlighter(speedSearchEnabledComponent: JComponent) { + // matchingFragments does work with wrapped text but not around words if they are wrapped, so it will also need to be extended + // in the future + val speedSearch = SpeedSearchSupply.getSupply(speedSearchEnabledComponent) ?: return + val fragments = speedSearch.matchingFragments(text)?.iterator() ?: return + fragments.forEach { + highlighter?.addHighlight(it.startOffset, it.endOffset, SpeedSearchHighlighter()) + } +} + +class WrappingCellRenderer(private val wrapOnSelection: Boolean, private val toggleableWrap: Boolean) : DefaultTableCellRenderer() { + var wrap: Boolean = false + + // JBTextArea has a different font from JBLabel (the default in a table) so harvest the font off of it + private val jLabelFont = JBLabel().font + + override fun getTableCellRendererComponent(table: JTable?, value: Any?, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component { + val defaultComponent = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) + table ?: return defaultComponent + val component = JBTextArea() + + component.border = (defaultComponent as? JLabel)?.border ?: JBUI.Borders.empty(2, 2) + component.wrapStyleWord = (wrapOnSelection && isSelected) || (toggleableWrap && wrap) + component.lineWrap = (wrapOnSelection && isSelected) || (toggleableWrap && wrap) + component.font = jLabelFont + component.text = (value as? String)?.trim() + component.setSelectionHighlighting(table, isSelected) + + component.setSize(table.columnModel.getColumn(column).width, component.preferredSize.height) + if (table.getRowHeight(row) != component.preferredSize.height) { + table.setRowHeight(row, component.preferredSize.height) + } + + component.speedSearchHighlighter(table) + + return component + } +} diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsClientManagerTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsClientManagerTest.kt index 039bef26f0..b512c45f17 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsClientManagerTest.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsClientManagerTest.kt @@ -26,13 +26,11 @@ import software.amazon.awssdk.http.SdkHttpClient import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.core.region.Endpoint import software.aws.toolkits.core.region.Service -import software.aws.toolkits.jetbrains.core.credentials.ConnectionState import software.aws.toolkits.jetbrains.core.credentials.CredentialManager import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager -import software.aws.toolkits.jetbrains.core.credentials.MockProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.MockAwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.waitUntilConnectionStateIsStable import software.aws.toolkits.jetbrains.core.region.MockRegionProvider -import software.aws.toolkits.jetbrains.utils.spinUntil -import java.time.Duration import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.jvm.isAccessible @@ -53,12 +51,12 @@ class AwsClientManagerTest { mockCredentialManager = MockCredentialsManager.getInstance() mockCredentialManager.reset() MockRegionProvider.getInstance().reset() - MockProjectAccountSettingsManager.getInstance(projectRule.project).reset() + MockAwsConnectionManager.getInstance(projectRule.project).reset() } @After fun tearDown() { - MockProjectAccountSettingsManager.getInstance(projectRule.project).reset() + MockAwsConnectionManager.getInstance(projectRule.project).reset() MockRegionProvider.getInstance().reset() mockCredentialManager.reset() } @@ -105,7 +103,7 @@ class AwsClientManagerTest { val projectManager = ProjectManagerEx.getInstanceEx() runInEdtAndWait { - projectManager.openTestProject(project) + projectManager.openProject(project) } val sut = getClientManager(project) @@ -150,10 +148,10 @@ class AwsClientManagerTest { val sut = getClientManager() val first = sut.getClient() - val testSettings = MockProjectAccountSettingsManager.getInstance(projectRule.project) + val testSettings = MockAwsConnectionManager.getInstance(projectRule.project) testSettings.changeRegionAndWait(AwsRegion("us-west-2", "us-west-2", "aws")) - spinUntil(Duration.ofSeconds(10)) { testSettings.connectionState == ConnectionState.VALID || testSettings.connectionState == ConnectionState.INVALID } + testSettings.waitUntilConnectionStateIsStable() val afterRegionUpdate = sut.getClient() diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsResourceCacheTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsResourceCacheTest.kt index b308e25aa8..4cba261389 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsResourceCacheTest.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsResourceCacheTest.kt @@ -22,9 +22,10 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test -import software.aws.toolkits.core.credentials.ToolkitCredentialsIdentifier +import software.aws.toolkits.core.credentials.CredentialIdentifier import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.test.aString import software.aws.toolkits.core.utils.test.retryableAssert import software.aws.toolkits.jetbrains.core.credentials.CredentialManager import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager @@ -49,11 +50,11 @@ class AwsResourceCacheTest { private val mockClock = mock() private val mockResource = mock>() - private val sut = DefaultAwsResourceCache(projectRule.project, mockClock, 1000, Duration.ofMinutes(1)) - private lateinit var cred1Identifier: ToolkitCredentialsIdentifier + private lateinit var sut: AwsResourceCache + private lateinit var cred1Identifier: CredentialIdentifier private lateinit var cred1Provider: ToolkitCredentialsProvider - private lateinit var cred2Identifier: ToolkitCredentialsIdentifier + private lateinit var cred2Identifier: CredentialIdentifier private lateinit var cred2Provider: ToolkitCredentialsProvider @Before @@ -67,7 +68,9 @@ class AwsResourceCacheTest { cred2Identifier = credentialsManager.addCredentials("Cred2") cred2Provider = credentialsManager.getAwsCredentialProvider(cred2Identifier, MockRegionProvider.getInstance().defaultRegion()) + sut = DefaultAwsResourceCache(projectRule.project, mockClock, 1000, Duration.ofMinutes(1)) sut.clear() + reset(mockClock, mockResource) whenever(mockResource.expiry()).thenReturn(DEFAULT_EXPIRY) whenever(mockResource.id).thenReturn("mock") @@ -457,5 +460,7 @@ class AwsResourceCacheTest { } private class StringResource(id: String) : DummyResource(id, id) + + fun dummyResource(value: String = aString()): Resource.Cached = StringResource(value) } } diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/ExecutableBackedCacheResourceTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/ExecutableBackedCacheResourceTest.kt index 5708200e12..c24cf5266a 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/ExecutableBackedCacheResourceTest.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/ExecutableBackedCacheResourceTest.kt @@ -7,8 +7,8 @@ import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.testFramework.DisposableRule import com.intellij.testFramework.ExtensionTestUtil import com.intellij.testFramework.ProjectRule -import org.assertj.core.api.Assertions import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.After import org.junit.Before import org.junit.Rule @@ -50,7 +50,7 @@ class ExecutableBackedCacheResourceTest { fun testExecutableIsNotInstalledCausesException() { createMockExecutable("invalidBinary") - Assertions.assertThatThrownBy { + assertThatThrownBy { executeCacheResource {} }.isInstanceOf(IllegalStateException::class.java) } diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/MockClientManager.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/MockClientManager.kt index c09402d700..6412bedb33 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/MockClientManager.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/MockClientManager.kt @@ -74,6 +74,10 @@ class MockClientManagerRule(private val project: () -> Project) : ExternalResour @Deprecated("Do not use, visible for inline") internal fun manager() = mockClientManager + fun reset() { + mockClientManager.reset() + } + inline fun create(): T = delegateMock().also { @Suppress("DEPRECATION") manager().register(T::class, it) diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/MockResourceCache.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/MockResourceCache.kt index 64229e4f05..723be654af 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/MockResourceCache.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/MockResourceCache.kt @@ -6,10 +6,13 @@ package software.aws.toolkits.jetbrains.core import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.ServiceManager import com.intellij.openapi.project.Project +import com.intellij.testFramework.ProjectRule +import org.junit.rules.ExternalResource import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider import software.aws.toolkits.core.region.AwsRegion -import software.aws.toolkits.jetbrains.core.credentials.ProjectAccountSettingsManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.jetbrains.services.sts.StsResources +import software.aws.toolkits.jetbrains.utils.rules.CodeInsightTestFixtureRule import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage import java.util.concurrent.ConcurrentHashMap @@ -18,7 +21,7 @@ import java.util.concurrent.ConcurrentHashMap class MockResourceCache(private val project: Project) : AwsResourceCache { private val map = ConcurrentHashMap() - private val accountSettings by lazy { ProjectAccountSettingsManager.getInstance(project) } + private val accountSettings by lazy { AwsConnectionManager.getInstance(project) } override fun getResourceIfPresent(resource: Resource, useStale: Boolean): T? = getResourceIfPresent(resource, accountSettings.activeRegion, accountSettings.activeCredentialProvider, useStale) @@ -89,6 +92,8 @@ class MockResourceCache(private val project: Project) : AwsResourceCache { map.clear() } + fun entryCount() = map.size + fun addEntry(resource: Resource.Cached, value: T) = addEntry(resource, accountSettings.activeRegion.id, accountSettings.activeCredentialProvider.id, value) @@ -122,3 +127,20 @@ class MockResourceCache(private val project: Project) : AwsResourceCache { private data class CacheKey(val resourceId: String, val regionId: String, val credentialsId: String) } } + +class MockResourceCacheRule(private val project: () -> Project) : ExternalResource() { + constructor(projectRule: ProjectRule) : this({ projectRule.project }) + constructor(projectRule: CodeInsightTestFixtureRule) : this({ projectRule.project }) + + private lateinit var cache: MockResourceCache + + override fun before() { + cache = MockResourceCache.getInstance(project()) + } + + override fun after() { + cache.clear() + } + + fun get() = cache +} diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/ChangeAccountSettingsActionGroupTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/ChangeAccountSettingsActionGroupTest.kt new file mode 100644 index 0000000000..85958eb83d --- /dev/null +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/ChangeAccountSettingsActionGroupTest.kt @@ -0,0 +1,97 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.core.region.anAwsRegion +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider.RegionProviderRule + +class ChangeAccountSettingsActionGroupTest { + + @Rule + @JvmField + val projectRule = ProjectRule() + + @Rule + @JvmField + val regionProviderRule = RegionProviderRule() + + @Rule + @JvmField + val settingsManagerRule = MockAwsConnectionManager.ProjectAccountSettingsManagerRule(projectRule) + + @Test + fun `Can display both region and credentials selection`() { + val group = ChangeAccountSettingsActionGroup(projectRule.project, ChangeAccountSettingsMode.BOTH) + val actions = group.getChildren(null) + + assertThat(actions).hasAtLeastOneElementOfType(ChangeRegionAction::class.java) + assertThat(actions).hasAtLeastOneElementOfType(ChangeCredentialsAction::class.java) + } + + @Test + fun `Can display only region selection`() { + val group = ChangeAccountSettingsActionGroup(projectRule.project, ChangeAccountSettingsMode.REGIONS) + val actions = group.getChildren(null) + + assertThat(actions).hasAtLeastOneElementOfType(ChangeRegionAction::class.java) + assertThat(actions).doesNotHaveAnyElementsOfTypes(ChangeCredentialsAction::class.java) + } + + @Test + fun `Can display only credentials selection`() { + val group = ChangeAccountSettingsActionGroup(projectRule.project, ChangeAccountSettingsMode.CREDENTIALS) + val actions = group.getChildren(null) + + assertThat(actions).doesNotHaveAnyElementsOfTypes(ChangeRegionAction::class.java) + assertThat(actions).hasAtLeastOneElementOfType(ChangeCredentialsAction::class.java) + } + + @Test + fun `Region group all regions at the top-level for selected partition and shows regions for non-selected paritions in a partition sub-menu`() { + val selectedRegion = anAwsRegion(partitionId = "selected").also { regionProviderRule.regionProvider.addRegion(it) } + val otherPartitionRegion = anAwsRegion(partitionId = "nonSelected").also { regionProviderRule.regionProvider.addRegion(it) } + val anotherRegionInSamePartition = anAwsRegion(partitionId = otherPartitionRegion.partitionId).also { regionProviderRule.regionProvider.addRegion(it) } + + settingsManagerRule.settingsManager.changeRegionAndWait(selectedRegion) + + val group = ChangeAccountSettingsActionGroup(projectRule.project, ChangeAccountSettingsMode.REGIONS) + + val regionActionGroup = getRegionActions(group) + + val topLevelRegionActions = regionActionGroup.filterIsInstance() + val partitionActions = regionActionGroup.filterIsInstance().first().getChildren(null) + val nonSelectedSubAction = partitionActions.filterIsInstance().first { it.templateText == otherPartitionRegion.partitionId } + .getChildren(null).filterIsInstance() + + assertThat(topLevelRegionActions).hasOnlyOneElementSatisfying { + it.templateText == selectedRegion.displayName + } + assertThat(partitionActions).noneMatch { it.templateText == selectedRegion.partitionId } + + assertThat(nonSelectedSubAction).hasSize(2) + assertThat(nonSelectedSubAction.map { it.templateText }).containsExactlyInAnyOrder( + otherPartitionRegion.displayName, + anotherRegionInSamePartition.displayName + ) + } + + @Test + fun `Don't show partition selector if there is only one partition`() { + val selectedRegion = regionProviderRule.regionProvider.defaultRegion() + + settingsManagerRule.settingsManager.changeRegionAndWait(selectedRegion) + + val group = ChangeAccountSettingsActionGroup(projectRule.project, ChangeAccountSettingsMode.REGIONS) + val actions = getRegionActions(group) + + assertThat(actions).doesNotHaveAnyElementsOfTypes(ChangePartitionActionGroup::class.java) + } + + private fun getRegionActions(group: ChangeAccountSettingsActionGroup) = group.getChildren(null) + .filterIsInstance().first().getChildren(null) +} diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialManagerTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialManagerTest.kt index ef2c8cf3f4..d4357a4000 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialManagerTest.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialManagerTest.kt @@ -14,11 +14,12 @@ import software.amazon.awssdk.auth.credentials.AwsBasicCredentials import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider import software.amazon.awssdk.http.SdkHttpClient +import software.aws.toolkits.core.credentials.CredentialIdentifier import software.aws.toolkits.core.credentials.CredentialProviderFactory import software.aws.toolkits.core.credentials.CredentialProviderNotFoundException import software.aws.toolkits.core.credentials.CredentialsChangeEvent import software.aws.toolkits.core.credentials.CredentialsChangeListener -import software.aws.toolkits.core.credentials.ToolkitCredentialsIdentifier +import software.aws.toolkits.core.credentials.CredentialIdentifierBase import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.jetbrains.core.region.MockRegionProvider import kotlin.test.assertNotNull @@ -231,7 +232,7 @@ class CredentialManagerTest { } override fun createAwsCredentialProvider( - providerId: ToolkitCredentialsIdentifier, + providerId: CredentialIdentifier, region: AwsRegion, sdkHttpClientSupplier: () -> SdkHttpClient ): AwsCredentialsProvider = credentialsMapping.getValue(providerId.id).getValue(region) @@ -261,7 +262,7 @@ class CredentialManagerTest { } } - private class TestCredentialProviderIdentifier(override val id: String, override val factoryId: String) : ToolkitCredentialsIdentifier() { + private class TestCredentialProviderIdentifier(override val id: String, override val factoryId: String) : CredentialIdentifierBase() { override val displayName: String = "$factoryId:$id" } } diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialProcessOutputParserTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialProcessOutputParserTest.kt new file mode 100644 index 0000000000..77d5bf026a --- /dev/null +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialProcessOutputParserTest.kt @@ -0,0 +1,56 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import com.fasterxml.jackson.core.JsonParseException +import com.fasterxml.jackson.core.JsonProcessingException +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.intellij.lang.annotations.Language +import org.junit.Test +import software.aws.toolkits.jetbrains.utils.isInstanceOf +import java.time.Instant + +class CredentialProcessOutputParserTest { + private val sut = DefaultCredentialProcessOutputParser + + @Test + fun `can parse a basic input`() = runTest( + """{"Version":1, "AccessKeyId":"foo", "SecretAccessKey":"bar"}""", + CredentialProcessOutput("foo", "bar", null, null) + ) + + @Test + fun `can parse a basic input with expiration`() = runTest( + """{"Version":1, "AccessKeyId":"foo", "SecretAccessKey":"bar", "Expiration":"1970-01-01T00:00:00Z"}""", + CredentialProcessOutput("foo", "bar", null, Instant.EPOCH) + ) + + @Test + fun `can parse a basic input with session`() = runTest( + """{"Version":1, "AccessKeyId":"foo", "SecretAccessKey":"bar", "SessionToken":"session"}""", + CredentialProcessOutput("foo", "bar", "session", null) + ) + + @Test + fun `non JSON throws`() { + assertThatThrownBy { sut.parse("hello") }.isInstanceOf() + } + + @Test + fun `valid JSON missing required properties fails`() { + assertThatThrownBy { sut.parse("""{"AccessKeyId": "foo"}""") }.hasMessageContaining("secretAccessKey") + } + + @Test + fun `exception does not contain raw JSON data`() { + assertThatThrownBy { sut.parse("""{"hello": "world"}""") }.isInstanceOfSatisfying(JsonProcessingException::class.java) { + assertThat(it.message).doesNotContain("hello") + } + } + + private fun runTest(@Language("JSON") input: String, expected: CredentialProcessOutput) { + assertThat(sut.parse(input)).isEqualTo(expected) + } +} diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialsRegionHandlerTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialsRegionHandlerTest.kt new file mode 100644 index 0000000000..2257d8df72 --- /dev/null +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialsRegionHandlerTest.kt @@ -0,0 +1,184 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import com.intellij.notification.Notification +import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.TestDataProvider +import com.intellij.testFramework.runInEdtAndWait +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.core.credentials.aCredentialsIdentifier +import software.aws.toolkits.core.region.anAwsRegion +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider.RegionProviderRule +import software.aws.toolkits.jetbrains.settings.AwsSettings +import software.aws.toolkits.jetbrains.settings.AwsSettingsRule +import software.aws.toolkits.jetbrains.settings.UseAwsCredentialRegion +import software.aws.toolkits.jetbrains.utils.NotificationListenerRule +import software.aws.toolkits.resources.message + +class CredentialsRegionHandlerTest { + + @Rule + @JvmField + val projectRule = ProjectRule() + + @Rule + @JvmField + val regionProviderRule = RegionProviderRule() + + @Rule + @JvmField + val notificationListener = NotificationListenerRule(projectRule) + + private lateinit var sut: DefaultCredentialsRegionHandler + + @Rule + @JvmField + val settingsRule = AwsSettingsRule() + + @Before + fun setup() { + sut = DefaultCredentialsRegionHandler(projectRule.project) + AwsSettings.getInstance().useDefaultCredentialRegion = UseAwsCredentialRegion.Always + } + + @Test + fun `Credential with no default region returns selected region`() { + val identifier = aCredentialsIdentifier(defaultRegionId = null) + val region = anAwsRegion() + + assertThat(sut.determineSelectedRegion(identifier, region)).isEqualTo(region) + } + + @Test + fun `When selected region is null always use credential region`() { + val defaultRegion = regionProviderRule.createAwsRegion() + val identifier = aCredentialsIdentifier(defaultRegionId = defaultRegion.id) + + assertThat(sut.determineSelectedRegion(identifier, selectedRegion = null)).isEqualTo(defaultRegion) + } + + @Test + fun `Always use credential region if its partition is different from the selected region`() { + val defaultRegion = regionProviderRule.createAwsRegion() + val identifier = aCredentialsIdentifier(defaultRegionId = defaultRegion.id) + + assertThat(sut.determineSelectedRegion(identifier, selectedRegion = regionProviderRule.createAwsRegion())).isEqualTo(defaultRegion) + } + + @Test + fun `Always use credential region if setting is set to Always`() { + AwsSettings.getInstance().useDefaultCredentialRegion = UseAwsCredentialRegion.Always + val defaultRegion = regionProviderRule.createAwsRegion() + val selectedRegion = regionProviderRule.createAwsRegion(partitionId = defaultRegion.partitionId) + val identifier = aCredentialsIdentifier(defaultRegionId = defaultRegion.id) + + assertThat(sut.determineSelectedRegion(identifier, selectedRegion = selectedRegion)).isEqualTo(defaultRegion) + } + + @Test + fun `Do not use credential region if setting is set to Never`() { + AwsSettings.getInstance().useDefaultCredentialRegion = UseAwsCredentialRegion.Never + val defaultRegion = regionProviderRule.createAwsRegion() + val selectedRegion = regionProviderRule.createAwsRegion(partitionId = defaultRegion.partitionId) + val identifier = aCredentialsIdentifier(defaultRegionId = defaultRegion.id) + + assertThat(sut.determineSelectedRegion(identifier, selectedRegion = selectedRegion)).isEqualTo(selectedRegion) + } + + @Test + fun `Do not use credential region if setting is set to Never, even if the partition is different`() { + settingsRule.settings.useDefaultCredentialRegion = UseAwsCredentialRegion.Never + val defaultRegion = regionProviderRule.createAwsRegion() + val selectedRegion = regionProviderRule.createAwsRegion() + val identifier = aCredentialsIdentifier(defaultRegionId = defaultRegion.id) + + assertThat(sut.determineSelectedRegion(identifier, selectedRegion = selectedRegion)).isEqualTo(selectedRegion) + } + + @Test + fun `Do not use credential region if setting is set to Never, even if the region is null`() { + settingsRule.settings.useDefaultCredentialRegion = UseAwsCredentialRegion.Never + val defaultRegion = regionProviderRule.createAwsRegion() + val identifier = aCredentialsIdentifier(defaultRegionId = defaultRegion.id) + + assertThat(sut.determineSelectedRegion(identifier, selectedRegion = null)).isNull() + } + + @Test + fun `Prompt appears when setting is set to prompt, selected region remains active`() { + settingsRule.settings.useDefaultCredentialRegion = UseAwsCredentialRegion.Prompt + + val defaultRegion = regionProviderRule.createAwsRegion() + val selectedRegion = regionProviderRule.createAwsRegion(partitionId = defaultRegion.partitionId) + val identifier = aCredentialsIdentifier(defaultRegionId = defaultRegion.id) + + val newSelected = sut.determineSelectedRegion(identifier, selectedRegion = selectedRegion) + + assertThat(newSelected).isEqualTo(selectedRegion) + val notification = getOnlyNotification() + assertThat(notification.actions).hasSize(3) + } + + @Test + fun `Prompt only appears when region is different than default`() { + settingsRule.settings.useDefaultCredentialRegion = UseAwsCredentialRegion.Prompt + + val defaultRegion = regionProviderRule.createAwsRegion() + val identifier = aCredentialsIdentifier(defaultRegionId = defaultRegion.id) + + val newSelected = sut.determineSelectedRegion(identifier, selectedRegion = defaultRegion) + + assertThat(newSelected).isEqualTo(defaultRegion) + assertThat(notificationListener.notifications.filter { it.title == message("aws.notification.title") }).isEmpty() + } + + @Test + fun `Selecting Never at the prompt sets setting to Never`() { + settingsRule.settings.useDefaultCredentialRegion = UseAwsCredentialRegion.Prompt + + val defaultRegion = regionProviderRule.createAwsRegion() + val selectedRegion = regionProviderRule.createAwsRegion(partitionId = defaultRegion.partitionId) + val identifier = aCredentialsIdentifier(defaultRegionId = defaultRegion.id) + + sut.determineSelectedRegion(identifier, selectedRegion = selectedRegion) + + val notification = getOnlyNotification() + + runInEdtAndWait { + Notification.fire(notification, notification.actions.first { it.templateText == "Never" }) + } + + assertThat(AwsSettings.getInstance().useDefaultCredentialRegion).isEqualTo(UseAwsCredentialRegion.Never) + } + + @Test + fun `Selecting Always at the prompt sets setting to Always`() { + settingsRule.settings.useDefaultCredentialRegion = UseAwsCredentialRegion.Prompt + + val defaultRegion = regionProviderRule.createAwsRegion() + val selectedRegion = regionProviderRule.createAwsRegion(partitionId = defaultRegion.partitionId) + val identifier = aCredentialsIdentifier(defaultRegionId = defaultRegion.id) + + sut.determineSelectedRegion(identifier, selectedRegion = selectedRegion) + + val notification = getOnlyNotification() + + runInEdtAndWait { + Notification.fire(notification, notification.actions.first { it.templateText == "Always" }, TestDataProvider(projectRule.project)) + } + + assertThat(AwsSettings.getInstance().useDefaultCredentialRegion).isEqualTo(UseAwsCredentialRegion.Always) + } + + private fun getOnlyNotification(): Notification { + val credentialNotifications = notificationListener.notifications.filter { it.title == message("aws.notification.title") } + assertThat(credentialNotifications).hasSize(1) + + return credentialNotifications.first() + } +} diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultProjectAccountSettingsManagerTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManagerTest.kt similarity index 61% rename from jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultProjectAccountSettingsManagerTest.kt rename to jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManagerTest.kt index 19f83c6e37..60b7338dab 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultProjectAccountSettingsManagerTest.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManagerTest.kt @@ -1,4 +1,4 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package software.aws.toolkits.jetbrains.core.credentials @@ -13,19 +13,21 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test -import software.aws.toolkits.core.credentials.ToolkitCredentialsIdentifier +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.core.credentials.aCredentialsIdentifier import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.core.rules.EnvironmentVariableHelper +import software.aws.toolkits.core.utils.test.notNull import software.aws.toolkits.jetbrains.core.MockResourceCache +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager.Companion.selectedPartition import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider.RegionProviderRule import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule -import software.aws.toolkits.jetbrains.utils.spinUntil import software.aws.toolkits.jetbrains.utils.toElement import java.nio.file.Files -import java.time.Duration -class DefaultProjectAccountSettingsManagerTest { +class DefaultAwsConnectionManagerTest { @Rule @JvmField val projectRule = HeavyJavaCodeInsightTestFixtureRule() @@ -34,11 +36,13 @@ class DefaultProjectAccountSettingsManagerTest { @JvmField val environmentVariableHelper = EnvironmentVariableHelper() - private lateinit var mockRegionManager: MockRegionProvider + @Rule + @JvmField + val regionProviderRule = RegionProviderRule() + private lateinit var mockCredentialManager: MockCredentialsManager - private lateinit var manager: DefaultProjectAccountSettingsManager + private lateinit var manager: DefaultAwsConnectionManager private lateinit var mockResourceCache: MockResourceCache - private lateinit var queue: MutableList @Before fun setUp() { @@ -48,29 +52,61 @@ class DefaultProjectAccountSettingsManagerTest { System.getProperties().remove("aws.region") environmentVariableHelper.remove("AWS_REGION") - queue = mutableListOf() - - mockRegionManager = MockRegionProvider.getInstance() mockCredentialManager = MockCredentialsManager.getInstance() - manager = DefaultProjectAccountSettingsManager(projectRule.project) + manager = DefaultAwsConnectionManager(projectRule.project) mockResourceCache = MockResourceCache.getInstance(projectRule.project) } @After fun tearDown() { - mockRegionManager.reset() mockCredentialManager.reset() mockResourceCache.clear() } @Test - fun testNoActiveCredentials() { + fun `Starts with no active credentials`() { assertThat(manager.isValidConnectionSettings()).isFalse() assertThat(manager.recentlyUsedCredentials()).isEmpty() } @Test - fun testMakingCredentialActive() { + fun `On load, automatically selects default profile if present and no other active credentials`() { + val credentials = mockCredentialManager.addCredentials(DEFAULT_PROFILE_ID) + markConnectionSettingsAsValid(credentials, AwsRegionProvider.getInstance().defaultRegion()) + + manager.noStateLoaded() + manager.waitUntilConnectionStateIsStable() + + assertThat(manager.selectedCredentialIdentifier).notNull.satisfies { + assertThat(it.id).isEqualTo(credentials.id) + } + } + + @Test + fun `On load, default region of credential is used if there is no other active region`() { + val element = """ + + + """.toElement() + + val credentials = mockCredentialManager.addCredentials("Mock", regionId = "us-west-2") + with(MockRegionProvider.getInstance()) { + markConnectionSettingsAsValid(credentials, defaultRegion()) + addRegion(AwsRegion("us-west-2", "Oregon", "AWS")) + } + + deserializeAndLoadState(manager, element) + + manager.waitUntilConnectionStateIsStable() + + assertThat(manager.selectedRegion).notNull.satisfies { + assertThat(it.id).isEqualTo("us-west-2") + } + } + + @Test + fun `Activated credential are validated and added to the recently used list`() { changeRegion(AwsRegionProvider.getInstance().defaultRegion()) assertThat(manager.recentlyUsedCredentials()).isEmpty() @@ -98,7 +134,7 @@ class DefaultProjectAccountSettingsManagerTest { } @Test - fun testMakingRegionActive() { + fun `Activated regions are validated and added to the recently used list`() { val mockRegionProvider = MockRegionProvider.getInstance() val mockRegion1 = mockRegionProvider.addRegion(AwsRegion("MockRegion-1", "MockRegion-1", "aws")) val mockRegion2 = mockRegionProvider.addRegion(AwsRegion("MockRegion-2", "MockRegion-2", "aws")) @@ -118,14 +154,14 @@ class DefaultProjectAccountSettingsManagerTest { } @Test - fun testMakingRegionActiveFiresNotification() { + fun `Activating a region fires a state change notification`() { val project = projectRule.project var gotNotification = false val busConnection = project.messageBus.connect() - busConnection.subscribe(ProjectAccountSettingsManager.CONNECTION_SETTINGS_CHANGED, object : ConnectionSettingsChangeNotifier { - override fun settingsChanged(event: ConnectionSettingsChangeEvent) { + busConnection.subscribe(AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, object : ConnectionSettingsStateChangeNotifier { + override fun settingsStateChanged(newState: ConnectionState) { gotNotification = true } }) @@ -136,14 +172,14 @@ class DefaultProjectAccountSettingsManagerTest { } @Test - fun testMakingCredentialsActiveFiresNotification() { + fun `Activating a credential fires a state change notification`() { val project = projectRule.project var gotNotification = false val busConnection = project.messageBus.connect() - busConnection.subscribe(ProjectAccountSettingsManager.CONNECTION_SETTINGS_CHANGED, object : ConnectionSettingsChangeNotifier { - override fun settingsChanged(event: ConnectionSettingsChangeEvent) { + busConnection.subscribe(AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, object : ConnectionSettingsStateChangeNotifier { + override fun settingsStateChanged(newState: ConnectionState) { gotNotification = true } }) @@ -156,7 +192,7 @@ class DefaultProjectAccountSettingsManagerTest { } @Test - fun testSavingActiveRegion() { + fun `Active region is persisted`() { manager.changeRegion(AwsRegion.GLOBAL) val element = Element("AccountState") serializeStateInto(manager, element) @@ -175,7 +211,7 @@ class DefaultProjectAccountSettingsManagerTest { } @Test - fun testSavingActiveCredential() { + fun `Active credential is persisted`() { mockResourceCache.addValidAwsCredential(manager.activeRegion.id, "Mock", "222222222222") changeCredentialProvider(mockCredentialManager.addCredentials("Mock")) val element = Element("AccountState") @@ -195,7 +231,7 @@ class DefaultProjectAccountSettingsManagerTest { } @Test - fun testLoadingActiveCredential() { + fun `Active credential can be restored from persistence`() { val element = """
diff --git a/jetbrains-rider/ReSharper.AWS/ReSharper.AWS.sln b/jetbrains-rider/ReSharper.AWS/ReSharper.AWS.sln index f56b49d236..c8a058195b 100644 --- a/jetbrains-rider/ReSharper.AWS/ReSharper.AWS.sln +++ b/jetbrains-rider/ReSharper.AWS/ReSharper.AWS.sln @@ -12,8 +12,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWS.Localization", "src\AWS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWS.Settings", "src\AWS.Settings\AWS.Settings.csproj", "{03F889DD-2864-4EC5-911E-FC3AB883FA94}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWS.DebuggerTools", "src\AWS.DebuggerTools\AWS.DebuggerTools.csproj", "{494B1237-D2E3-45AD-8AA0-22C24A57741C}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWS.Project", "src\AWS.Project\AWS.Project.csproj", "{8E30D4BE-8055-4773-8B9B-3158979DEA6C}" EndProject Global @@ -38,10 +36,6 @@ Global {03F889DD-2864-4EC5-911E-FC3AB883FA94}.Debug|Any CPU.Build.0 = Debug|Any CPU {03F889DD-2864-4EC5-911E-FC3AB883FA94}.Release|Any CPU.ActiveCfg = Release|Any CPU {03F889DD-2864-4EC5-911E-FC3AB883FA94}.Release|Any CPU.Build.0 = Release|Any CPU - {494B1237-D2E3-45AD-8AA0-22C24A57741C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {494B1237-D2E3-45AD-8AA0-22C24A57741C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {494B1237-D2E3-45AD-8AA0-22C24A57741C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {494B1237-D2E3-45AD-8AA0-22C24A57741C}.Release|Any CPU.Build.0 = Release|Any CPU {8E30D4BE-8055-4773-8B9B-3158979DEA6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8E30D4BE-8055-4773-8B9B-3158979DEA6C}.Debug|Any CPU.Build.0 = Debug|Any CPU {8E30D4BE-8055-4773-8B9B-3158979DEA6C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -52,7 +46,6 @@ Global {A6C3DF73-FEA6-4A20-B45C-051D7D02C510} = {242F86AA-AA1D-40B5-97DE-0C139E777832} {A7D464FD-2D77-4BDB-9F89-A60C090B67FA} = {242F86AA-AA1D-40B5-97DE-0C139E777832} {03F889DD-2864-4EC5-911E-FC3AB883FA94} = {242F86AA-AA1D-40B5-97DE-0C139E777832} - {494B1237-D2E3-45AD-8AA0-22C24A57741C} = {242F86AA-AA1D-40B5-97DE-0C139E777832} {8E30D4BE-8055-4773-8B9B-3158979DEA6C} = {242F86AA-AA1D-40B5-97DE-0C139E777832} EndGlobalSection EndGlobal diff --git a/jetbrains-rider/ReSharper.AWS/ReSharper.AWS.snk b/jetbrains-rider/ReSharper.AWS/ReSharper.AWS.snk deleted file mode 100644 index 30b5e1ba23..0000000000 Binary files a/jetbrains-rider/ReSharper.AWS/ReSharper.AWS.snk and /dev/null differ diff --git a/jetbrains-rider/ReSharper.AWS/src/AWS.Daemon/RunMarkers/LambdaRunMarkerAttributeIds.cs b/jetbrains-rider/ReSharper.AWS/src/AWS.Daemon/RunMarkers/LambdaRunMarkerAttributeIds.cs index b958437a41..76330f3e9e 100644 --- a/jetbrains-rider/ReSharper.AWS/src/AWS.Daemon/RunMarkers/LambdaRunMarkerAttributeIds.cs +++ b/jetbrains-rider/ReSharper.AWS/src/AWS.Daemon/RunMarkers/LambdaRunMarkerAttributeIds.cs @@ -1,6 +1,7 @@ using AWS.Daemon.RunMarkers; using JetBrains.TextControl.DocumentMarkup; +#if !PROFILE_2020_2 // TODO: Remove preprocessor conditions FIX_WHEN_MIN_IS_202 [assembly: RegisterHighlighter( LambdaRunMarkerAttributeIds.LAMBDA_RUN_METHOD_MARKER_ID, @@ -9,9 +10,18 @@ Layer = HighlighterLayer.SYNTAX + 1 ) ] +#endif namespace AWS.Daemon.RunMarkers { +#if PROFILE_2020_2 // TODO: Remove preprocessor conditions FIX_WHEN_MIN_IS_202 + [RegisterHighlighter( + LambdaRunMarkerAttributeIds.LAMBDA_RUN_METHOD_MARKER_ID, + GutterMarkType = typeof(LambdaMethodRunMarkerGutterMark), + EffectType = EffectType.GUTTER_MARK, + Layer = HighlighterLayer.SYNTAX + 1 + )] +#endif public static class LambdaRunMarkerAttributeIds { public const string LAMBDA_RUN_METHOD_MARKER_ID = "AWS Lambda Run Method Gutter Mark"; diff --git a/jetbrains-rider/ReSharper.AWS/src/AWS.Daemon/RunMarkers/LambdaRunMarkersThemedIcons.cs b/jetbrains-rider/ReSharper.AWS/src/AWS.Daemon/RunMarkers/LambdaRunMarkersThemedIcons.cs index 0eeccc7d03..8e96d661c5 100644 --- a/jetbrains-rider/ReSharper.AWS/src/AWS.Daemon/RunMarkers/LambdaRunMarkersThemedIcons.cs +++ b/jetbrains-rider/ReSharper.AWS/src/AWS.Daemon/RunMarkers/LambdaRunMarkersThemedIcons.cs @@ -8,7 +8,15 @@ // //------------------------------------------------------------------------------ -namespace JetBrains.UI.ThemedIcons +#if PROFILE_2020_2 // TODO: Remove preprocessor conditions FIX_WHEN_MIN_IS_202 +using TiImage = global::JetBrains.Util.Icons.TiImage; +using TiImageConverter = global::JetBrains.Util.Icons.TiImageConverter; +#else +using TiImage = global::JetBrains.Application.UI.Icons.Images.TiImage; +using TiImageConverter = global::JetBrains.Application.UI.Icons.Images.TiImageConverter; +#endif + +namespace AWS.Daemon.RunMarkers { /// /// @@ -98,24 +106,24 @@ public sealed class RunThis : global::JetBrains.Application.Icons.CompiledIconsC public static global::JetBrains.UI.Icons.IconId Id = new global::JetBrains.Application.Icons.CompiledIconsCs.CompiledIconCsId(typeof(RunThis)); /// Loads the image for Themed Icon RunThis theme aspect Color. - public global::JetBrains.Application.UI.Icons.Images.TiImage Load_Color() + public TiImage Load_Color() { - return global::JetBrains.Application.UI.Icons.Images.TiImageConverter.FromTiSvg(@""); + return TiImageConverter.FromTiSvg(@""); } /// Loads the image for Themed Icon RunThis theme aspect Gray. - public global::JetBrains.Application.UI.Icons.Images.TiImage Load_Gray() + public TiImage Load_Gray() { - return global::JetBrains.Application.UI.Icons.Images.TiImageConverter.FromTiSvg(""); } /// Loads the image for Themed Icon RunThis theme aspect GrayDark. - public global::JetBrains.Application.UI.Icons.Images.TiImage Load_GrayDark() + public TiImage Load_GrayDark() { - return global::JetBrains.Application.UI.Icons.Images.TiImageConverter.FromTiSvg(""); @@ -183,9 +191,9 @@ public sealed class DebugThis : global::JetBrains.Application.Icons.CompiledIcon public static global::JetBrains.UI.Icons.IconId Id = new global::JetBrains.Application.Icons.CompiledIconsCs.CompiledIconCsId(typeof(DebugThis)); /// Loads the image for Themed Icon DebugThis theme aspect Color. - public global::JetBrains.Application.UI.Icons.Images.TiImage Load_Color() + public TiImage Load_Color() { - return global::JetBrains.Application.UI.Icons.Images.TiImageConverter.FromTiSvg("Loads the image for Themed Icon DebugThis theme aspect Gray. - public global::JetBrains.Application.UI.Icons.Images.TiImage Load_Gray() + public TiImage Load_Gray() { - return global::JetBrains.Application.UI.Icons.Images.TiImageConverter.FromTiSvg("Loads the image for Themed Icon DebugThis theme aspect GrayDark. - public global::JetBrains.Application.UI.Icons.Images.TiImage Load_GrayDark() + public TiImage Load_GrayDark() { - return global::JetBrains.Application.UI.Icons.Images.TiImageConverter.FromTiSvg("Loads the image for Themed Icon Lambda theme aspect Color. - public global::JetBrains.Application.UI.Icons.Images.TiImage Load_Color() + public TiImage Load_Color() { - return global::JetBrains.Application.UI.Icons.Images.TiImageConverter.FromTiSvg( + return TiImageConverter.FromTiSvg( ""); } /// Loads the image for Themed Icon Lambda theme aspect Gray. - public global::JetBrains.Application.UI.Icons.Images.TiImage Load_Gray() + public TiImage Load_Gray() { - return global::JetBrains.Application.UI.Icons.Images.TiImageConverter.FromTiSvg( + return TiImageConverter.FromTiSvg( ""); } /// Loads the image for Themed Icon Lambda theme aspect GrayDark. - public global::JetBrains.Application.UI.Icons.Images.TiImage Load_GrayDark() + public TiImage Load_GrayDark() { - return global::JetBrains.Application.UI.Icons.Images.TiImageConverter.FromTiSvg( + return TiImageConverter.FromTiSvg( ""); } @@ -336,23 +344,23 @@ public sealed class CreateNew : global::JetBrains.Application.Icons.CompiledIcon public static global::JetBrains.UI.Icons.IconId Id = new global::JetBrains.Application.Icons.CompiledIconsCs.CompiledIconCsId(typeof(CreateNew)); /// Loads the image for Themed Icon CreateNew theme aspect Color. - public global::JetBrains.Application.UI.Icons.Images.TiImage Load_Color() + public TiImage Load_Color() { - return global::JetBrains.Application.UI.Icons.Images.TiImageConverter.FromTiSvg( + return TiImageConverter.FromTiSvg( ""); } /// Loads the image for Themed Icon CreateNew theme aspect Gray. - public global::JetBrains.Application.UI.Icons.Images.TiImage Load_Gray() + public TiImage Load_Gray() { - return global::JetBrains.Application.UI.Icons.Images.TiImageConverter.FromTiSvg( + return TiImageConverter.FromTiSvg( ""); } /// Loads the image for Themed Icon CreateNew theme aspect GrayDark. - public global::JetBrains.Application.UI.Icons.Images.TiImage Load_GrayDark() + public TiImage Load_GrayDark() { - return global::JetBrains.Application.UI.Icons.Images.TiImageConverter.FromTiSvg( + return TiImageConverter.FromTiSvg( ""); } diff --git a/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/AWS.DebuggerTools.csproj b/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/AWS.DebuggerTools.csproj deleted file mode 100644 index e120fc6bd4..0000000000 --- a/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/AWS.DebuggerTools.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net461 - False - AnyCPU - AWS.RiderDebuggerTools - - - - - - - - - - - Always - - - diff --git a/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/AWS.DebuggerTools.runtimeconfig.json b/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/AWS.DebuggerTools.runtimeconfig.json deleted file mode 100644 index add2a83994..0000000000 --- a/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/AWS.DebuggerTools.runtimeconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "runtimeOptions": { - "tfm": "netcoreapp2.1", - "framework": { - "name": "Microsoft.NETCore.App", - "version": "2.1.0" - }, - "rollForward": "Major" - } -} \ No newline at end of file diff --git a/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/DbgshimDetectUtil.cs b/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/DbgshimDetectUtil.cs deleted file mode 100644 index fce3f437ab..0000000000 --- a/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/DbgshimDetectUtil.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Linq; -using JetBrains.Application.platforms; -using JetBrains.Util; - -namespace AWS.RiderDebuggerTools -{ - public static class DbgshimDetectUtil_Patched - { - private static readonly string ourDotnetName = PlatformUtil.IsRunningUnderWindows ? "dotnet.exe" : "dotnet"; - - private static readonly ILogger ourLogger = JetBrains.Util.Logging.Logger.GetLogger(typeof(DbgshimDetectUtil_Patched)); - - public static FileSystemPath GetDbgshimDirectory(FileSystemPath assemblyPath, FileSystemPath runtimeExecutable) - { - var runtimeInstallDirectory = runtimeExecutable.Parent; - ourLogger.Trace($"Runtime location dir = {runtimeInstallDirectory}"); - var platforms = DotNetCorePlatformsProvider.CollectPlatformsFromInstallationFolder(runtimeInstallDirectory); - - if (ourLogger.IsTraceEnabled()) - { - ourLogger.Trace($"Platforms for the runtime: {string.Join(", ", platforms)}"); - } - - return DotNetCorePlatformDetectUtil_Patched.GetDbgShimDirectory( - assemblyPath, - DotNetCorePlatformDetectUtil.GetCorePlatformRangeFromJson, - range => DotNetCorePlatformDetectUtil.GetBestPlatform(range, platforms, ourLogger), ourLogger); - } - - private static bool IsValidCoreRuntime(FileSystemPath installationFolder) - { - var exePath = installationFolder / ourDotnetName; - ourLogger.Trace($"{exePath} exists={exePath.ExistsFile}"); - var sharedDir = exePath.Directory.Combine("shared"); - ourLogger.Trace($"{sharedDir} exists={sharedDir.ExistsDirectory}"); - return exePath.ExistsFile && sharedDir.ExistsDirectory; - } - - - public static FileSystemPath DetectDotnetCliAutomatically() - { - var cliDirectory = DotNetCoreRuntimesDetector.GetPossibleInstallationFolders().FirstOrDefault(IsValidCoreRuntime); - if (cliDirectory != null) - return cliDirectory / ourDotnetName; - return FileSystemPath.Empty; - } - } -} diff --git a/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/DotNetCorePlatformDetectUtil.cs b/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/DotNetCorePlatformDetectUtil.cs deleted file mode 100644 index 78bf13d5ed..0000000000 --- a/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/DotNetCorePlatformDetectUtil.cs +++ /dev/null @@ -1,334 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using JetBrains.Annotations; -using JetBrains.Application.platforms; -using JetBrains.Util; -using JetBrains.Util.Dotnet.TargetFrameworkIds; -using JetBrains.Util.Extension; -using JetBrains.Util.Logging; -using Newtonsoft.Json.Linq; -using NuGet.Frameworks; -using NuGet.Versioning; - -namespace AWS.RiderDebuggerTools -{ - public static class DotNetCorePlatformDetectUtil_Patched - { - private const string RuntimeConfigExtension = "runtimeconfig.json"; - - private const string RuntimePackPrefix = "runtimepack."; - private const string RuntimeSuffix = ".Runtime."; - - [CanBeNull] - public static PlatformInfo GetBestPlatform(DotNetCorePlatformRange platformRange, List platforms, ILogger logger) - { - if (platformRange.IsEmpty) - { - logger.Trace($"PlatformRange {platformRange} is empty, cannot find appropriate system framework"); - return null; - } - - PlatformInfo result = null; - var platformRangeMinVersion = platformRange.VersionRange.MinVersion; - - if (!platformRange.VersionRange.IsFloating && !platformRange.VersionRange.HasUpperBound) - { - if (platformRange.FrameworkIdentifier == FrameworkIdentifier.ASPNetCoreApp || - platformRange.FrameworkIdentifier == FrameworkIdentifier.ASPNetCoreAll || - platformRange.FrameworkIdentifier == FrameworkIdentifier.WindowsDesktopCore || - platformRange.FrameworkIdentifier == FrameworkIdentifier.NetCoreApp) - { - //see https://github.com/dotnet/core-setup, fx_muxer_t::resolve_framework_version - //we use roll_fwd_on_no_candidate_fx = major_or_minor, patch_roll_fwd = false TODO patch_roll_fwd should be true - - var sameFrameworkPlatforms = platforms - .Where(p => p.TargetFrameworkId.GetFrameworkIdentifier() == platformRange.FrameworkIdentifier).ToList(); - - if (!platformRangeMinVersion.IsPrerelease) - { - logger.Trace($"Trying to find appropriate platform information for Platform Range {platformRange}, " + - "search inside release platforms"); - result = sameFrameworkPlatforms.Where(p => !p.NuGetVersion.IsPrerelease && - p.NuGetVersion >= - platformRangeMinVersion) - .OrderBy(p => p.NuGetVersion).FirstOrDefault(); - - if (result != null) return result; - - logger.Trace("Cannot find appropriate release platform, search inside prerelease platforms"); - result = sameFrameworkPlatforms.Where(p => - p.NuGetVersion.IsPrerelease && - /*p.NuGetVersion >= - platformRangeMinVersion)*/ - p.NuGetVersion.CompareTo(platformRangeMinVersion, VersionComparison.Version) >= 0) - .OrderBy(p => p.NuGetVersion).FirstOrDefault(); - } - else - { - logger.Trace($"Trying to find appropriate platform information for Platform Range {platformRange}, " + - "search inside prerelease platforms with the same version"); - result = sameFrameworkPlatforms.Where(p => p.NuGetVersion.IsPrerelease && - p.NuGetVersion.Major == platformRangeMinVersion.Major && - p.NuGetVersion.Minor == platformRangeMinVersion.Minor && - p.NuGetVersion.Patch == platformRangeMinVersion.Patch && - p.NuGetVersion >= platformRangeMinVersion) - .OrderBy(p => p.NuGetVersion).LastOrDefault(); - } - - return result; - } - } - - var netCoreAppPlatforms = platforms - .Where(p => p.TargetFrameworkId.GetFrameworkIdentifier() == FrameworkIdentifier.NetCoreApp).ToList(); - - if (platformRange.FrameworkIdentifier == FrameworkIdentifier.NetStandart) - { - if (platformRangeMinVersion.Major == 1 - && platformRangeMinVersion.Minor <= 6) - { - return netCoreAppPlatforms.Where(p => p.NuGetVersion.Major >= 1) - .OrderBy(p => p.NuGetVersion).LastOrDefault(); - } - - if (platformRangeMinVersion.Major == 2) - { - return netCoreAppPlatforms.Where(p => p.NuGetVersion.Major >= 2) - .OrderBy(p => p.NuGetVersion).LastOrDefault(); - } - } - logger.Verbose("Use platform version search fallback for range: {0}", platformRange); - var bestVersion = platformRange.VersionRange.FindBestMatch(netCoreAppPlatforms.Select(p => p.NuGetVersion)); - if (bestVersion != null) - { - result = netCoreAppPlatforms.FirstOrDefault(p => p.NuGetVersion == bestVersion); - } - - if (bestVersion == null) - { - logger.Verbose(".NET Core platform not found for range: {0}", platformRange); - } - - return result; - } - - public static FrameworkIdentifier PlatformNameToFrameworkIdentifier(string platformName) - { - FrameworkIdentifier platformId = null; - if (platformName == FrameworkIdentifier.NetCoreApp.PresentableName) - platformId = FrameworkIdentifier.NetCoreApp; - if (platformName == FrameworkIdentifier.NetStandart.PresentableName) - platformId = FrameworkIdentifier.NetStandart; - if (platformName.EndsWith("Microsoft.NETCore.App", StringComparison.OrdinalIgnoreCase)) - platformId = FrameworkIdentifier.NetCoreApp; - if (platformName.EndsWith("NETStandard.Library", StringComparison.OrdinalIgnoreCase)) - platformId = FrameworkIdentifier.NetStandart; - if (platformName.EndsWith("Microsoft.AspNetCore.App", StringComparison.OrdinalIgnoreCase)) - platformId = FrameworkIdentifier.ASPNetCoreApp; - if (platformName.EndsWith("Microsoft.AspNetCore.All", StringComparison.OrdinalIgnoreCase)) - platformId = FrameworkIdentifier.ASPNetCoreAll; - if (platformName.EndsWith("Microsoft.WindowsDesktop.App", StringComparison.OrdinalIgnoreCase)) - platformId = FrameworkIdentifier.WindowsDesktopCore; - if (platformName.EndsWith("Microsoft.NETCore.Platforms", StringComparison.OrdinalIgnoreCase)) - platformId = FrameworkIdentifier.NetCorePlatforms; - return platformId; - } - - public static bool IsDbgShimExists(FileSystemPath frameworkFolder) - { - RelativePath relShimDll; - var runPlatform = PlatformUtil.RuntimePlatform; - switch (runPlatform) - { - case PlatformUtil.Platform.Windows: - relShimDll = "dbgshim.dll"; - break; - case PlatformUtil.Platform.MacOsX: - relShimDll = "libdbgshim.dylib"; - break; - case PlatformUtil.Platform.Linux: - relShimDll = "libdbgshim.so"; - break; - default: - return false; - } - return (frameworkFolder / relShimDll).ExistsFile; - } - - public static DotNetCorePlatformRange ParseRuntimeConfigJsonFile(JObject document) - { - if (!(document.GetValue("runtimeOptions") is JObject runtimeOptions)) return DotNetCorePlatformRange.Empty; - if (!(runtimeOptions.GetValue("framework") is JObject framework)) return DotNetCorePlatformRange.Empty; - var platformNameToken = framework.GetValue("name") as JValue; - var versionToken = framework.GetValue("version") as JValue; - - if (platformNameToken == null || versionToken == null) return DotNetCorePlatformRange.Empty; - - var platformName = platformNameToken.ToString(CultureInfo.InvariantCulture); - var platformId = PlatformNameToFrameworkIdentifier(platformName); - - return new DotNetCorePlatformRange(platformId, versionToken.ToString(CultureInfo.InvariantCulture)); - } - - public static DotNetCorePlatformRange GetCorePlatformRangeFromJson(FileSystemPath jsonPath) - { - if (!jsonPath.ExistsFile) - return DotNetCorePlatformRange.Empty; - using (var reader = new StreamReader(jsonPath.OpenFileForReading())) - { - var document = JObject.Parse(reader.ReadToEnd()); - return ParseRuntimeConfigJsonFile(document); - } - } - - public static FileSystemPath GetJsonFileByLauncher(FileSystemPath launcherPath, string jsonFileExtensionWithoutDot) - { - return PlatformUtil.IsRunningUnderWindows - ? launcherPath.ChangeExtension(jsonFileExtensionWithoutDot) - : launcherPath.Directory.Combine($"{launcherPath.Name}.{jsonFileExtensionWithoutDot}"); - } - - private static JObject GetJObjectFromDeps(FileSystemPath depsJsonPath) - { - return Logger.CatchSilent(() => - { - using (var reader = new StreamReader(depsJsonPath.OpenFileForReading())) - { - return JObject.Parse(reader.ReadToEnd()); - } - }); - } - - [NotNull] - private static DotNetCorePlatformRange GetPlatformRangeFromDepsJson(FileSystemPath depsFilePath) - { - var runtimeId = GetRuntimeIdUsingDepsJson(depsFilePath); - if (runtimeId == null) return DotNetCorePlatformRange.Empty; - var platformIdentifier = runtimeId.Split('/').Skip(1).FirstOrDefault(); - if (platformIdentifier == null) return DotNetCorePlatformRange.Empty; - var depsDocument = GetJObjectFromDeps(depsFilePath); - if (depsDocument == null) return DotNetCorePlatformRange.Empty; - if (!(depsDocument.GetValue("targets") is JObject targetsSection)) return DotNetCorePlatformRange.Empty; - var runtimeTarget = targetsSection.Children().FirstOrDefault(child => child.Name == runtimeId); - if (runtimeTarget == null) return DotNetCorePlatformRange.Empty; - return runtimeTarget.Value.Children().SelectNotNull(dependency => - { - var dependencyName = dependency.Name; - if (!dependencyName.StartsWith(RuntimePackPrefix)) return null; - var dependencyParts = dependencyName.RemoveStart(RuntimePackPrefix).Split("/"); - if (dependencyParts.Length != 2) return null; - var runtimeInfo = GetRuntimeInfo(dependencyParts[0], platformIdentifier); - if (runtimeInfo == null) return null; - var versionInfo = dependencyParts[1]; - return new DotNetCorePlatformRange(PlatformNameToFrameworkIdentifier(runtimeInfo), versionInfo); - }).FirstOrDefault(DotNetCorePlatformRange.Empty); - } - - private static string GetRuntimeInfo(string dependency, string platformIdentifier) - { - var possibleRuntimeInfoSuffix = $"{RuntimeSuffix}{platformIdentifier}"; - if (dependency.EndsWith(possibleRuntimeInfoSuffix)) return dependency.RemoveEnd(possibleRuntimeInfoSuffix); - var portablePlatformIdentifier = GetPortablePlatformIdentifier(platformIdentifier); - var possiblePortableRuntimeInfoSuffix = $"{RuntimeSuffix}{portablePlatformIdentifier}"; - if (dependency.EndsWith(possiblePortableRuntimeInfoSuffix)) return dependency.RemoveEnd(possiblePortableRuntimeInfoSuffix); - return null; - } - - private static string GetPortablePlatformIdentifier(string platformIdentifier) - { - var platformIdentifierParts = platformIdentifier.Split("-"); - if (platformIdentifierParts.Length < 2) - return platformIdentifier; - var architectureType = platformIdentifierParts.Last(); - if (platformIdentifier.StartsWith("win")) return $"win-{architectureType}"; - if (platformIdentifier.StartsWith("osx")) return $"osx-{architectureType}"; - return platformIdentifier; - } - - [CanBeNull] - public static string GetRuntimeIdUsingOutputStructure(FileSystemPath executePath) - { - var tfmKey = executePath.Parent.Parent.Name; - var runtimeIdentifier = executePath.Directory.Name; - var nuGetFramework = NuGetFramework.ParseFolder(tfmKey, DefaultFrameworkNameProvider.Instance); - if (nuGetFramework == null) return null; - var runtimeId = $"{nuGetFramework.DotNetFrameworkName}/{runtimeIdentifier}"; - return runtimeId; - } - - [CanBeNull] - public static string GetRuntimeIdUsingDepsJson(FileSystemPath depsJsonPath) - { - var document = GetJObjectFromDeps(depsJsonPath); - var runtimeTarget = document?.GetValue("runtimeTarget") as JObject; - var runtimeTargetName = runtimeTarget?.GetValue("name") as JValue; - return runtimeTargetName?.Value as string; - } - - [NotNull] - public delegate DotNetCorePlatformRange RuntimeConfigToPlatformRange([NotNull] FileSystemPath runtimeConfigPath); - [CanBeNull] - public delegate PlatformInfo PlatformRangeToPlatformInfo([NotNull] DotNetCorePlatformRange platformRange); - - public static FileSystemPath GetDbgShimDirectory(FileSystemPath assemblyPath, - RuntimeConfigToPlatformRange runtimeConfigJsonToPlatformRange, PlatformRangeToPlatformInfo platformRangeToInfo, ILogger logger) - { - var runtimeConfigPath = assemblyPath.ChangeExtension(RuntimeConfigExtension); - logger.Trace($"Using runtime config: {runtimeConfigPath}"); - var platform = platformRangeToInfo(runtimeConfigJsonToPlatformRange(runtimeConfigPath)); - - if (platform == null) - { - logger.Trace("Detecting platform using deps.json..."); - var depsFile = GetJsonFileByLauncher(assemblyPath, "deps.json"); - var platformRangeFromDepsJson = GetPlatformRangeFromDepsJson(depsFile); - platform = platformRangeToInfo(platformRangeFromDepsJson); - } - - logger.Trace($"Detected platform is {platform?.ToString() ?? ""}"); - - while (platform != null && !IsDbgShimExists(platform.TargetFrameworkFolder)) - { - logger.Trace($"dbgshim was not found in {platform.TargetFrameworkFolder}"); - var frameworkRuntimeConfigPath = platform.TargetFrameworkFolder / - RelativePath.TryParse( - $"{platform.TargetFrameworkId.PresentableString}.{RuntimeConfigExtension}"); - platform = platformRangeToInfo(runtimeConfigJsonToPlatformRange(frameworkRuntimeConfigPath)); - logger.Trace($"The next platform is {platform?.ToString() ?? ""}"); - } - - logger.Trace( - $"The final platform is {platform?.ToString() ?? ""}. Shim directory: {platform?.TargetFrameworkFolder?.ToString() ?? ""}"); - - return platform != null ? platform.TargetFrameworkFolder : FileSystemPath.Empty; - } - - public static FileSystemPath GetRuntimeConfigFromAssemblyPath(FileSystemPath assemblyPath) - { - return assemblyPath.ChangeExtension(RuntimeConfigExtension); - } - - [CanBeNull] - public static FrameworkIdentifier PlatformLikeStringToFrameworkIdentifier(string platformName) - { - while (platformName.Length > 0) - { - var identifier = PlatformNameToFrameworkIdentifier(platformName); - if (identifier != null) - { - return identifier; - } - - var lastDotIndex = platformName.LastIndexOf('.'); - if (lastDotIndex == -1) break; - - platformName = platformName.Substring(0, lastDotIndex); - } - - return null; - } - } -} diff --git a/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/EntryPoint.cs b/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/EntryPoint.cs deleted file mode 100644 index 2fe8d4383b..0000000000 --- a/jetbrains-rider/ReSharper.AWS/src/AWS.DebuggerTools/EntryPoint.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using JetBrains.Application.BuildScript; -using JetBrains.Lifetimes; -using JetBrains.Util; -using JetBrains.Util.CommandLineMapper; -using JetBrains.Util.CommandLineMapper.Attributes; -using JetBrains.Util.Logging; - -namespace AWS.RiderDebuggerTools -{ - enum Command - { - DbgShimDetect - } - - [AppName("AWS.DebuggerTools")] - class ToolsOptions - { - [EnumOption("command", typeof(Command), IsRequired = true, HelpText = "command to execute")] - public readonly Command Command; - - [FileOption("assembly-path", IsRequired = false, HelpText = "Path to an assemly")] - public FileSystemPath AssemblyPath; - } - - static class EntryPoint - { - private static ILogger Log = Logger.GetLogger(typeof(EntryPoint)); - - static int Main(string[] args) - { - SetUpLogging(); - - var commandLineMapper = CLI.Mapper.Default(); - var commandLineParser = CLI.Parser.Universal(args); - var options = commandLineMapper.Map(commandLineParser); - if (options == null) - { - Console.WriteLine(commandLineMapper.HelpGenerator.GenerateHelp()); - Environment.Exit(-1); - } - - switch (options.Command) - { - case Command.DbgShimDetect: - { - if (options.AssemblyPath == null) - { - Console.WriteLine("Assembly path must be specified"); - return -1; - } - - return DetectCLIAndDbgShim(options.AssemblyPath); - } - default: - throw new ArgumentOutOfRangeException(); - } - } - - private static int DetectCLIAndDbgShim(FileSystemPath assemblyPath) - { - var dotnetCli = DbgshimDetectUtil_Patched.DetectDotnetCliAutomatically(); - Log.Trace($"'dotnet' symlink path={dotnetCli}"); - dotnetCli = FileSystemUtil.GetFinalPathName(dotnetCli); - Log.Trace($"'dotnet' real path={dotnetCli}"); - if (!dotnetCli.IsValidAndExistFile()) - { - Console.WriteLine($"Failed to detect 'dotnet' location. See logs at {Environment.GetEnvironmentVariable("RESHARPER_HOST_LOG_DIR")}"); - return 500; - } - - var dbgshimDirectory = DbgshimDetectUtil_Patched.GetDbgshimDirectory(assemblyPath, dotnetCli); - if (!dbgshimDirectory.ExistsDirectory) - { - Console.WriteLine($"Failed to detect 'dbgshim' locations. See logs at {Environment.GetEnvironmentVariable("RESHARPER_HOST_LOG_DIR")}"); - return 500; - } - - Console.WriteLine(dotnetCli); - Console.WriteLine(dbgshimDirectory); - return 0; - } - - private static void SetUpLogging() - { - if (Environment.GetEnvironmentVariable("RESHARPER_HOST_LOG_DIR") == null) - { - return; - } - - var logConfig = FileSystemPath.TryParse(Environment.GetEnvironmentVariable("RESHARPER_LOG_CONF")); - if (logConfig.IsValidAndExistFile()) - { - LogManager.Instance.Initialize(logConfig, LogSubconfiguration.Debug); - } - else - { - // fall back for remote debugger - // get log dir from env - Logger.AttachListener(Lifetime.Eternal, () => new FileLogEventListener("{env.RESHARPER_HOST_LOG_DIR}/{pname}_{date}.log", true), "", "file-listener"); - } - } - } -} diff --git a/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/Lambda/LambdaFinder.cs b/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/Lambda/LambdaFinder.cs index eb79391891..dc1d975736 100644 --- a/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/Lambda/LambdaFinder.cs +++ b/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/Lambda/LambdaFinder.cs @@ -9,7 +9,6 @@ using JetBrains.ReSharper.Psi.CSharp; using JetBrains.ReSharper.Psi.Resolve; using JetBrains.ReSharper.Psi.Util; -using JetBrains.UI.ThemedIcons; using JetBrains.Util; using JetBrains.Util.Logging; diff --git a/jetbrains-rider/backend.gradle b/jetbrains-rider/backend.gradle deleted file mode 100644 index 2fedc3ce04..0000000000 --- a/jetbrains-rider/backend.gradle +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -def backendGroup = 'backend' - -ext.nugetConfigPath = new File(projectDir, "NuGet.Config") -ext.riderSdkVersionPropsPath = new File(resharperPluginPath, "RiderSdkPackageVersion.props") - -task prepareBuildProps { - group = backendGroup - - doLast { - def riderSdkVersion = riderNugetSdkVersion() - def configText = """ - - [$riderSdkVersion] - - -""" - writeTextIfChanged(riderSdkVersionPropsPath, configText) - } -} - -task prepareNuGetConfig { - group = backendGroup - - doLast { - def nugetPath = getNugetPackagesPath() - def configText = """ - - - - - -""" - writeTextIfChanged(nugetConfigPath, configText) - } -} - -task restoreReSharperPluginPackages() { - group = backendGroup - description = 'Restores packages for backend plugin' - dependsOn prepareBuildProps, prepareNuGetConfig - - doLast { - exec { - executable = "dotnet" - args = ["restore", "${resharperPluginPath.canonicalPath}/ReSharper.AWS.sln" ] - } - } -} - -task buildReSharperPlugin { - group = backendGroup - description = 'Builds the full ReSharper backend plugin solution' - dependsOn restoreReSharperPluginPackages, generateModel - - doLast { - // Build using Mono MSBuild on Mac OS and Linux and use dotnet core MSBuild on Windows - def isWindows = System.properties['os.name'].toLowerCase().contains('windows') - def executableName = (isWindows) ? "dotnet" : "msbuild" - def arguments = (isWindows) ? [ "build" ] : [] - arguments << "${resharperPluginPath.canonicalPath}/ReSharper.AWS.sln" - - exec { - executable = executableName - args = arguments - } - } - - outputs.files({ - fileTree(file("${resharperPluginPath.absolutePath}/src")).matching { - include "**/bin/Debug/**/AWS*.dll" - include "**/bin/Debug/**/AWS*.pdb" - include "**/bin/Debug/**/AWS.DebuggerTools.exe" - include "**/bin/Debug/**/AWS.DebuggerTools.runtimeconfig.json" - }.collect() - }) -} - -project.tasks.clean.dependsOn(project.tasks.cleanBuildReSharperPlugin) - -private File getNugetPackagesPath() { - def sdkPath = intellij.ideaDependency.classes - println("SDK path: $sdkPath") - - // 2019 - def riderSdk = new File(sdkPath, "lib/ReSharperHostSdk") - // 2020.1 - if (!riderSdk.exists()) { - riderSdk = new File(sdkPath, "lib/DotNetSdkForRdPlugins") - } - - println("NuGet packages: $riderSdk") - if (!riderSdk.isDirectory()) throw new IllegalStateException("${riderSdk} does not exist or not a directory") - - return riderSdk -} - -private static void writeTextIfChanged(File file, String content) { - def bytes = content.bytes - - if (!file.isFile() || byteArrayToHexString(file.readBytes()) != byteArrayToHexString(bytes)) { - println("Writing ${file.canonicalPath}") - file.withOutputStream { it.write(bytes) } - } -} - -private static String byteArrayToHexString(byte[] byteArray) { - return byteArray.encodeHex().toString() -} diff --git a/jetbrains-rider/build.gradle b/jetbrains-rider/build.gradle deleted file mode 100644 index bf9eb86756..0000000000 --- a/jetbrains-rider/build.gradle +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -buildscript { - ext.rd_version = rdGenVersion() - - println("Using rd-gen: $rd_version") - - repositories { - maven { url 'https://www.myget.org/F/rd-snapshots/maven/' } - mavenCentral() - } - - dependencies { - classpath "com.jetbrains.rd:rd-gen:$rd_version" - } -} - -ext.resharperPluginPath = new File(projectDir, "ReSharper.AWS") - -try { - InetAddress.getByName("repo.labs.intellij.net") - ext.localEnv = false -} catch (UnknownHostException ignored) { - println("Not running in JetBrains' network") - ext.localEnv = true -} - -apply plugin: 'org.jetbrains.intellij' -apply plugin: 'com.jetbrains.rdgen' - -apply from: 'protocol.gradle' -apply from: 'backend.gradle' - -dependencies { - compile project(":jetbrains-core") - testImplementation project(path: ":jetbrains-core", configuration: 'testArtifacts') -} - -intellij { - def parentIntellijTask = project(':jetbrains-core').intellij - version ideSdkVersion("RD") - pluginName parentIntellijTask.pluginName - updateSinceUntilBuild parentIntellijTask.updateSinceUntilBuild - - // Workaround for https://youtrack.jetbrains.com/issue/IDEA-179607 - def extraPlugins = [ "rider-plugins-appender" ] - plugins = idePlugins("RD") + extraPlugins - - // Disable downloading source to avoid issues related to Rider SDK naming that is missed in Idea - // snapshots repository. The task is failed because if is unable to find related IC sources. - downloadSources = false - instrumentCode = false -} - -// Tasks: -// -// `buildPlugin` depends on `prepareSandbox` task and then zips up the sandbox dir and puts the file in rider/build/distributions -// `runIde` depends on `prepareSandbox` task and then executes IJ inside the sandbox dir -// `prepareSandbox` depends on the standard Java `jar` and then copies everything into the sandbox dir - -tasks.withType(prepareSandbox.class).all { - dependsOn buildReSharperPlugin - - from(buildReSharperPlugin.outputs, { - into("${intellij.pluginName}/dotnet") - }) -} - -compileKotlin.dependsOn(generateModel) - -test { - systemProperty("log.dir", "${project.intellij.sandboxDirectory}-test/logs") - useTestNG() - environment("LOCAL_ENV_RUN", localEnv) - maxHeapSize("1024m") -} - -tasks.integrationTest { - useTestNG() - environment("LOCAL_ENV_RUN", localEnv) -} - -jar { - archiveBaseName.set('aws-intellij-toolkit-rider') -} diff --git a/jetbrains-rider/build.gradle.kts b/jetbrains-rider/build.gradle.kts new file mode 100644 index 0000000000..84813ce4bf --- /dev/null +++ b/jetbrains-rider/build.gradle.kts @@ -0,0 +1,410 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// Cannot be removed or else it will fail to compile +import com.jetbrains.rd.generator.gradle.RdgenParams +import com.jetbrains.rd.generator.gradle.RdgenTask +import org.jetbrains.intellij.tasks.PrepareSandboxTask + +buildscript { + val rdGenVersion: groovy.lang.Closure by project + val rdversion = rdGenVersion() + project.extra["rd_version"] = rdversion + + logger.info("Using rd-gen: $rdversion") + + repositories { + maven("https://www.myget.org/F/rd-snapshots/maven/") + mavenCentral() + } + + dependencies { + classpath("com.jetbrains.rd:rd-gen:$rdversion") + } +} + +plugins { + id("org.jetbrains.intellij") +} + +apply(plugin = "com.jetbrains.rdgen") + +// IntellijVerison things +val rdGenVersion: groovy.lang.Closure by project +val ideSdkVersion: groovy.lang.Closure by project +val riderNugetSdkVersion: groovy.lang.Closure by project +val resolveIdeProfileName: groovy.lang.Closure by project +val idePlugins: groovy.lang.Closure> by project + +val resharperPluginPath = File(projectDir, "ReSharper.AWS") +val resharperBuildPath = File(project.buildDir, "dotnetBuild") + +val buildConfiguration = project.extra.properties["BuildConfiguration"] ?: "Debug" // TODO: Do we ever want to make a release build? + +// Protocol +val protocolGroup = "protocol" + +val csDaemonGeneratedOutput = File(resharperPluginPath, "src/AWS.Daemon/Protocol") +val csPsiGeneratedOutput = File(resharperPluginPath, "src/AWS.Psi/Protocol") +val csAwsSettingGeneratedOutput = File(resharperPluginPath, "src/AWS.Settings/Protocol") +val csAwsProjectGeneratedOutput = File(resharperPluginPath, "src/AWS.Project/Protocol") +val riderGeneratedSources = "$buildDir/generated-src/software/aws/toolkits/jetbrains/protocol" + +val modelDir = File(projectDir, "protocol/model") +val rdgenDir = File("${project.buildDir}/rdgen/") + +rdgenDir.mkdirs() + +intellij { + val parentIntellijTask = rootProject.intellij + version = ideSdkVersion("RD") + pluginName = parentIntellijTask.pluginName + updateSinceUntilBuild = parentIntellijTask.updateSinceUntilBuild + + // Workaround for https://youtrack.jetbrains.com/issue/IDEA-179607 + val extraPlugins = arrayOf("rider-plugins-appender") + setPlugins(*(idePlugins("RD") + extraPlugins).toTypedArray()) + + // Disable downloading source to avoid issues related to Rider SDK naming that is missed in Idea + // snapshots repository. The task is failed because if is unable to find related IC sources. + downloadSources = false + instrumentCode = false +} + +val generateDaemonModel = tasks.register("generateDaemonModel") { + val daemonModelSource = File(modelDir, "daemon").canonicalPath + val ktOutput = File(riderGeneratedSources, "DaemonProtocol") + + inputs.property("rdgen", rdGenVersion()) + inputs.dir(daemonModelSource) + outputs.dirs(ktOutput, csDaemonGeneratedOutput) + + // NOTE: classpath is evaluated lazily, at execution time, because it comes from the unzipped + // intellij SDK, which is extracted in afterEvaluate + configure { + verbose = true + hashFolder = rdgenDir.toString() + + logger.info("Configuring rdgen params") + + classpath({ + logger.info("Calculating classpath for rdgen, intellij.ideaDependency is: ${intellij.ideaDependency}") + val sdkPath = intellij.ideaDependency.classes + val rdLibDirectory = File("$sdkPath/lib/rd").canonicalFile + "$rdLibDirectory/rider-model.jar" + }) + + sources(daemonModelSource) + packages = "protocol.model.daemon" + + generator { + language = "kotlin" + transform = "asis" + root = "com.jetbrains.rider.model.nova.ide.IdeRoot" + namespace = "com.jetbrains.rider.model" + directory = "$ktOutput" + } + + generator { + language = "csharp" + transform = "reversed" + root = "com.jetbrains.rider.model.nova.ide.IdeRoot" + namespace = "JetBrains.Rider.Model" + directory = "$csDaemonGeneratedOutput" + } + } +} + +val generatePsiModel = tasks.register("generatePsiModel") { + val psiModelSource = File(modelDir, "psi").canonicalPath + val ktOutput = File(riderGeneratedSources, "PsiProtocol") + + inputs.property("rdgen", rdGenVersion()) + inputs.dir(psiModelSource) + outputs.dirs(ktOutput, csPsiGeneratedOutput) + + // NOTE: classpath is evaluated lazily, at execution time, because it comes from the unzipped + // intellij SDK, which is extracted in afterEvaluate + configure { + verbose = true + hashFolder = rdgenDir.toString() + + logger.info("Configuring rdgen params") + + classpath({ + logger.info("Calculating classpath for rdgen, intellij.ideaDependency is: ${intellij.ideaDependency}") + val sdkPath = intellij.ideaDependency.classes + val rdLibDirectory = File(sdkPath, "lib/rd").canonicalFile + "$rdLibDirectory/rider-model.jar" + }) + + sources(psiModelSource) + packages = "protocol.model.psi" + + generator { + language = "kotlin" + transform = "asis" + root = "com.jetbrains.rider.model.nova.ide.IdeRoot" + namespace = "com.jetbrains.rider.model" + directory = "$ktOutput" + } + + generator { + language = "csharp" + transform = "reversed" + root = "com.jetbrains.rider.model.nova.ide.IdeRoot" + namespace = "JetBrains.Rider.Model" + directory = "$csPsiGeneratedOutput" + } + } +} + +val generateAwsSettingModel = tasks.register("generateAwsSettingModel") { + val settingModelSource = File(modelDir, "setting").canonicalPath + val ktOutput = File(riderGeneratedSources, "AwsSettingsProtocol") + + inputs.property("rdgen", rdGenVersion()) + inputs.dir(settingModelSource) + outputs.dirs(ktOutput, csAwsSettingGeneratedOutput) + + // NOTE: classpath is evaluated lazily, at execution time, because it comes from the unzipped + // intellij SDK, which is extracted in afterEvaluate + configure { + verbose = true + hashFolder = rdgenDir.toString() + + logger.info("Configuring rdgen params") + + classpath({ + logger.info("Calculating classpath for rdgen, intellij.ideaDependency is: ${intellij.ideaDependency}") + val sdkPath = intellij.ideaDependency.classes + val rdLibDirectory = File(sdkPath, "lib/rd").canonicalFile + "$rdLibDirectory/rider-model.jar" + }) + sources(settingModelSource) + packages = "protocol.model.setting" + + generator { + language = "kotlin" + transform = "asis" + root = "com.jetbrains.rider.model.nova.ide.IdeRoot" + namespace = "com.jetbrains.rider.model" + directory = "$ktOutput" + } + + generator { + language = "csharp" + transform = "reversed" + root = "com.jetbrains.rider.model.nova.ide.IdeRoot" + namespace = "JetBrains.Rider.Model" + directory = "$csAwsSettingGeneratedOutput" + } + } +} + +val generateAwsProjectModel = tasks.register("generateAwsProjectModel") { + val projectModelSource = File(modelDir, "project").canonicalPath + val ktOutput = File(riderGeneratedSources, "AwsProjectProtocol") + + inputs.property("rdgen", rdGenVersion()) + inputs.dir(projectModelSource) + outputs.dirs(ktOutput, csAwsProjectGeneratedOutput) + + // NOTE: classpath is evaluated lazily, at execution time, because it comes from the unzipped + // intellij SDK, which is extracted in afterEvaluate + configure { + verbose = true + hashFolder = rdgenDir.toString() + + logger.info("Configuring rdgen params") + + classpath({ + logger.info("Calculating classpath for rdgen, intellij.ideaDependency is: ${intellij.ideaDependency}") + val sdkPath = intellij.ideaDependency.classes + val rdLibDirectory = File(sdkPath, "lib/rd").canonicalFile + "$rdLibDirectory/rider-model.jar" + }) + + sources(projectModelSource) + packages = "protocol.model.project" + + generator { + language = "kotlin" + transform = "asis" + root = "com.jetbrains.rider.model.nova.ide.IdeRoot" + namespace = "com.jetbrains.rider.model" + directory = "$ktOutput" + } + + generator { + language = "csharp" + transform = "reversed" + root = "com.jetbrains.rider.model.nova.ide.IdeRoot" + namespace = "JetBrains.Rider.Model" + directory = "$csAwsProjectGeneratedOutput" + } + } +} + +val generateModels = tasks.register("generateModels") { + group = protocolGroup + description = "Generates protocol models" + + dependsOn(generateDaemonModel, generatePsiModel, generateAwsSettingModel, generateAwsProjectModel) +} + +val cleanGenerateModels = tasks.register("cleanGenerateModels") { + group = protocolGroup + description = "Clean up generated protocol models" + + // TODO fix + dependsOn("cleanGenerateDaemonModel")//, cleanGeneratePsiModel, cleanGenerateAwsSettingModel, cleanGenerateAwsProjectModel) +} + +project.tasks.clean { + dependsOn(cleanGenerateModels) +} + +// Backend +val backendGroup = "backend" + +val prepareBuildProps = tasks.register("prepareBuildProps") { + val riderSdkVersionPropsPath = File(resharperPluginPath, "RiderSdkPackageVersion.props") + group = backendGroup + + inputs.property("riderNugetSdkVersion", riderNugetSdkVersion()) + outputs.file(riderSdkVersionPropsPath) + + doLast { + val riderSdkVersion = riderNugetSdkVersion() + val configText = """ + + [$riderSdkVersion] + + +""" + riderSdkVersionPropsPath.writeText(configText) + } +} + +val prepareNuGetConfig = tasks.register("prepareNuGetConfig") { + group = backendGroup + + val nugetConfigPath = File(projectDir, "NuGet.Config") + + inputs.property("rdVersion", ideSdkVersion("RD")) + outputs.file(nugetConfigPath) + + doLast { + val nugetPath = getNugetPackagesPath() + val configText = """ + + + + + +""" + nugetConfigPath.writeText(configText) + } +} + +val buildReSharperPlugin = tasks.register("buildReSharperPlugin") { + group = backendGroup + description = "Builds the full ReSharper backend plugin solution" + dependsOn(generateModels, prepareBuildProps, prepareNuGetConfig) + + inputs.dir(resharperPluginPath) + outputs.dir(resharperBuildPath) + + outputs.files({ + fileTree(file("${resharperPluginPath.absolutePath}/src")).matching { + include("**/bin/Debug/**/AWS*.dll") + include("**/bin/Debug/**/AWS*.pdb") + } + }) + + doLast { + val arguments = listOf( + "build", + "${resharperPluginPath.canonicalPath}/ReSharper.AWS.sln", + "/p:DefineConstants=\"PROFILE_${resolveIdeProfileName().replace(".", "_")}\"" + ) + exec { + executable = "dotnet" + args = arguments + } + } +} + +// TODO +/* +project.tasks.clean.dependsOn(cleanPrepareBuildProps, cleanPrepareNuGetConfig, cleanBuildReSharperPlugin) +*/ +fun getNugetPackagesPath(): File { + val sdkPath = intellij.ideaDependency.classes + println("SDK path: $sdkPath") + + // 2019 + var riderSdk = File(sdkPath, "lib/ReSharperHostSdk") + // 2020.1 + if (!riderSdk.exists()) { + riderSdk = File(sdkPath, "lib/DotNetSdkForRdPlugins") + } + + println("NuGet packages: $riderSdk") + if (!riderSdk.isDirectory) throw IllegalStateException("$riderSdk does not exist or not a directory") + + return riderSdk +} + +dependencies { + compile(project(":jetbrains-core")) + testImplementation(project(":jetbrains-core", "testArtifacts")) +} + +sourceSets { + main.get().java.srcDirs("$buildDir/generated-src") +} + +val resharperParts = listOf( + "AWS.Daemon", + "AWS.Localization", + "AWS.Project", + "AWS.Psi", + "AWS.Settings" +) + +// Tasks: +// +// `buildPlugin` depends on `prepareSandbox` task and then zips up the sandbox dir and puts the file in rider/build/distributions +// `runIde` depends on `prepareSandbox` task and then executes IJ inside the sandbox dir +// `prepareSandbox` depends on the standard Java `jar` and then copies everything into the sandbox dir + +tasks.withType(PrepareSandboxTask::class.java).configureEach { + dependsOn(buildReSharperPlugin) + + val files = resharperParts.map { "$resharperBuildPath/bin/$it/$buildConfiguration/${it}.dll" } + + resharperParts.map { "$resharperBuildPath/bin/$it/$buildConfiguration/${it}.pdb" } + from(files) { + into("${intellij.pluginName}/dotnet") + } +} + +tasks.compileKotlin { + dependsOn(generateModels) +} + +tasks.test { + systemProperty("log.dir", "${intellij.sandboxDirectory}-test/logs") + useTestNG() + environment("LOCAL_ENV_RUN", true) + maxHeapSize = "1024m" +} + +tasks.integrationTest { + useTestNG() + environment("LOCAL_ENV_RUN", true) +} + +tasks.jar { + archiveBaseName.set("aws-intellij-toolkit-rider") +} diff --git a/jetbrains-rider/it/software/aws/toolkits/jetbrains/services/lambda/dotnet/DotNetLambdaBuilderTest.kt b/jetbrains-rider/it/software/aws/toolkits/jetbrains/services/lambda/dotnet/DotNetLambdaBuilderTest.kt new file mode 100644 index 0000000000..ad494da400 --- /dev/null +++ b/jetbrains-rider/it/software/aws/toolkits/jetbrains/services/lambda/dotnet/DotNetLambdaBuilderTest.kt @@ -0,0 +1,83 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.dotnet + +import base.AwsReuseSolutionTestBase +import com.intellij.openapi.module.ModuleManager +import com.jetbrains.rider.projectView.solutionDirectory +import com.jetbrains.rider.test.scriptingApi.relativePathToVirtualFile +import org.assertj.core.api.Assertions.assertThat +import org.testng.annotations.BeforeClass +import org.testng.annotations.Test +import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils.buildLambdaFromTemplate +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils.packageLambda +import software.aws.toolkits.jetbrains.services.lambda.dotnet.element.RiderLambdaHandlerFakePsiElement +import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions +import software.aws.toolkits.jetbrains.utils.setSamExecutableFromEnvironment +import java.nio.file.Paths + +class DotNetLambdaBuilderTest : AwsReuseSolutionTestBase() { + override fun getSolutionDirectoryName(): String = "SamHelloWorldApp" + + private val sut = DotNetLambdaBuilder() + + @BeforeClass + fun setUp() { + setSamExecutableFromEnvironment() + } + + @Test + fun buildFromHandler() { + val handler = "HelloWorld::HelloWorld.Function::FunctionHandler" + val module = ModuleManager.getInstance(project).modules.first() + + val handlerResolver = DotNetLambdaHandlerResolver() + val fieldId = handlerResolver.getFieldIdByHandlerName(project, handler) + val psiElement = RiderLambdaHandlerFakePsiElement(project, handler, fieldId).navigationElement + + val builtLambda = sut.buildLambda(module, psiElement, handler, Runtime.DOTNETCORE2_1, 0, 0, emptyMap(), SamOptions()) + LambdaBuilderTestUtils.verifyEntries( + builtLambda, + "HelloWorld.dll", + "HelloWorld.pdb" + ) + + assertThat(builtLambda.codeLocation).startsWith(project.solutionDirectory.toPath()) + } + + @Test + fun buildFromTemplate() { + val template = relativePathToVirtualFile("template.yaml", project.solutionDirectory) + val templatePath = Paths.get(template.path) + val module = ModuleManager.getInstance(project).modules.first() + + val builtLambda = sut.buildLambdaFromTemplate(module, templatePath, "HelloWorldFunction") + LambdaBuilderTestUtils.verifyEntries( + builtLambda, + "HelloWorld.dll", + "HelloWorld.pdb" + ) + + assertThat(builtLambda.codeLocation).startsWith(project.solutionDirectory.toPath()) + } + + @Test + fun packageLambda() { + val handler = "HelloWorld::HelloWorld.Function::FunctionHandler" + val module = ModuleManager.getInstance(project).modules.first() + + val handlerResolver = DotNetLambdaHandlerResolver() + val fieldId = handlerResolver.getFieldIdByHandlerName(project, handler) + val psiElement = RiderLambdaHandlerFakePsiElement(project, handler, fieldId).navigationElement + + val packagedLambda = sut.packageLambda(module, psiElement, Runtime.DOTNETCORE2_1, handler) + LambdaBuilderTestUtils.verifyZipEntries( + packagedLambda, + "HelloWorld.dll", + "HelloWorld.pdb" + ) + } +} diff --git a/jetbrains-rider/protocol.gradle b/jetbrains-rider/protocol.gradle deleted file mode 100644 index 5cd28a1d2e..0000000000 --- a/jetbrains-rider/protocol.gradle +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -def protocolGroup = 'protocol' - -ext.csDaemonGeneratedOutput = new File(resharperPluginPath, "src/AWS.Daemon/Protocol") -ext.csPsiGeneratedOutput = new File(resharperPluginPath, "src/AWS.Psi/Protocol") -ext.csAwsSettingGeneratedOutput = new File(resharperPluginPath, "src/AWS.Settings/Protocol") -ext.csAwsProjectGeneratedOutput = new File(resharperPluginPath, "src/AWS.Project/Protocol") - -ext.ktGeneratedOutput = new File(projectDir, "src/software/aws/toolkits/jetbrains/protocol") - -ext.modelDir = new File(projectDir, "protocol/model") -ext.rdgenDir = file("${project.buildDir}/rdgen/") -rdgenDir.mkdirs() - -task generateDaemonModel(type: tasks.getByName("rdgen").class) { - def daemonModelSource = new File(modelDir, "daemon").canonicalPath - - // NOTE: classpath is evaluated lazily, at execution time, because it comes from the unzipped - // intellij SDK, which is extracted in afterEvaluate - params { - verbose = true - hashFolder = rdgenDir - - logger.info("Configuring rdgen params") - classpath { - logger.info("Calculating classpath for rdgen, intellij.ideaDependency is: ${intellij.ideaDependency}") - def sdkPath = intellij.ideaDependency.classes - def rdLibDirectory = new File(sdkPath, "lib/rd").canonicalFile - - "$rdLibDirectory/rider-model.jar" - } - sources daemonModelSource - packages = "protocol.model.daemon" - - generator { - language = "kotlin" - transform = "asis" - root = "com.jetbrains.rider.model.nova.ide.IdeRoot" - namespace = "com.jetbrains.rider.model" - directory = "$ktGeneratedOutput" - } - - generator { - language = "csharp" - transform = "reversed" - root = "com.jetbrains.rider.model.nova.ide.IdeRoot" - namespace = "JetBrains.Rider.Model" - directory = "$csDaemonGeneratedOutput" - } - } -} - -task generatePsiModel(type: tasks.getByName("rdgen").class) { - def psiModelSource = new File(modelDir, "psi").canonicalPath - - // NOTE: classpath is evaluated lazily, at execution time, because it comes from the unzipped - // intellij SDK, which is extracted in afterEvaluate - params { - verbose = true - hashFolder = rdgenDir - - logger.info("Configuring rdgen params") - classpath { - logger.info("Calculating classpath for rdgen, intellij.ideaDependency is: ${intellij.ideaDependency}") - def sdkPath = intellij.ideaDependency.classes - def rdLibDirectory = new File(sdkPath, "lib/rd").canonicalFile - - "$rdLibDirectory/rider-model.jar" - } - sources psiModelSource - packages = "protocol.model.psi" - - generator { - language = "kotlin" - transform = "asis" - root = "com.jetbrains.rider.model.nova.ide.IdeRoot" - namespace = "com.jetbrains.rider.model" - directory = "$ktGeneratedOutput" - } - - generator { - language = "csharp" - transform = "reversed" - root = "com.jetbrains.rider.model.nova.ide.IdeRoot" - namespace = "JetBrains.Rider.Model" - directory = "$csPsiGeneratedOutput" - } - } -} - -task generateAwsSettingModel(type: tasks.getByName("rdgen").class) { - def settingModelSource = new File(modelDir, "setting").canonicalPath - - // NOTE: classpath is evaluated lazily, at execution time, because it comes from the unzipped - // intellij SDK, which is extracted in afterEvaluate - params { - verbose = true - hashFolder = rdgenDir - - logger.info("Configuring rdgen params") - classpath { - logger.info("Calculating classpath for rdgen, intellij.ideaDependency is: ${intellij.ideaDependency}") - def sdkPath = intellij.ideaDependency.classes - def rdLibDirectory = new File(sdkPath, "lib/rd").canonicalFile - - "$rdLibDirectory/rider-model.jar" - } - sources settingModelSource - packages = "protocol.model.setting" - - generator { - language = "kotlin" - transform = "asis" - root = "com.jetbrains.rider.model.nova.ide.IdeRoot" - namespace = "com.jetbrains.rider.model" - directory = "$ktGeneratedOutput" - } - - generator { - language = "csharp" - transform = "reversed" - root = "com.jetbrains.rider.model.nova.ide.IdeRoot" - namespace = "JetBrains.Rider.Model" - directory = "$csAwsSettingGeneratedOutput" - } - } -} - -task generateAwsProjectModel(type: tasks.getByName("rdgen").class) { - def projectModelSource = new File(modelDir, "project").canonicalPath - - // NOTE: classpath is evaluated lazily, at execution time, because it comes from the unzipped - // intellij SDK, which is extracted in afterEvaluate - params { - verbose = true - hashFolder = rdgenDir - - logger.info("Configuring rdgen params") - classpath { - logger.info("Calculating classpath for rdgen, intellij.ideaDependency is: ${intellij.ideaDependency}") - def sdkPath = intellij.ideaDependency.classes - def rdLibDirectory = new File(sdkPath, "lib/rd").canonicalFile - - "$rdLibDirectory/rider-model.jar" - } - sources projectModelSource - packages = "protocol.model.project" - - generator { - language = "kotlin" - transform = "asis" - root = "com.jetbrains.rider.model.nova.ide.IdeRoot" - namespace = "com.jetbrains.rider.model" - directory = "$ktGeneratedOutput" - } - - generator { - language = "csharp" - transform = "reversed" - root = "com.jetbrains.rider.model.nova.ide.IdeRoot" - namespace = "JetBrains.Rider.Model" - directory = "$csAwsProjectGeneratedOutput" - } - } -} - -task generateModel { - group = protocolGroup - description = 'Generates protocol models' - - dependsOn generateDaemonModel, generatePsiModel, generateAwsSettingModel, generateAwsProjectModel -} - -task cleanProtocolModels { - group = protocolGroup - description = 'Clean up generated protocol models' - - def protocolOutDirs = [ ktGeneratedOutput, csDaemonGeneratedOutput, csPsiGeneratedOutput, csAwsSettingGeneratedOutput ] - - for (dir in protocolOutDirs) { - if (dir.isDirectory()) { - dir.deleteDir() - } - } -} -project.tasks.clean.dependsOn(cleanProtocolModels) - -jar.dependsOn generateModel diff --git a/jetbrains-rider/resources/META-INF/ext-rider.xml b/jetbrains-rider/resources/META-INF/ext-rider.xml index 4f513edecb..6ce7f40f20 100644 --- a/jetbrains-rider/resources/META-INF/ext-rider.xml +++ b/jetbrains-rider/resources/META-INF/ext-rider.xml @@ -21,7 +21,6 @@ - diff --git a/jetbrains-rider/src/software/aws/toolkits/jetbrains/icons/RiderAwsIconsPatcher.kt b/jetbrains-rider/src/software/aws/toolkits/jetbrains/icons/RiderAwsIconsPatcher.kt index a692081e5d..1210934a5e 100644 --- a/jetbrains-rider/src/software/aws/toolkits/jetbrains/icons/RiderAwsIconsPatcher.kt +++ b/jetbrains-rider/src/software/aws/toolkits/jetbrains/icons/RiderAwsIconsPatcher.kt @@ -3,11 +3,8 @@ package software.aws.toolkits.jetbrains.icons -import com.intellij.icons.AllIcons import com.intellij.openapi.util.IconLoader import com.intellij.openapi.util.IconPathPatcher -import icons.AwsIcons -import javax.swing.Icon /** * Icons Patcher for icons set from Rider backend (R#). @@ -23,14 +20,6 @@ internal class RiderAwsIconsPatcher : IconPathPatcher() { private val myInstallPatcher: Unit by lazy { IconLoader.installPathPatcher(RiderAwsIconsPatcher()) } - - private fun path(icon: Icon): String { - val iconToProcess = icon as? IconLoader.CachedImageIcon - ?: throw RuntimeException("${icon.javaClass.simpleName} should be CachedImageIcon") - - return iconToProcess.originalPath - ?: throw RuntimeException("Unable to get original path for icon: ${iconToProcess.javaClass.simpleName}") - } } override fun patchPath(path: String, classLoader: ClassLoader?): String? = myIconsOverrideMap[path] @@ -40,7 +29,7 @@ internal class RiderAwsIconsPatcher : IconPathPatcher() { else originalClassLoader private val myIconsOverrideMap = mapOf( - "/resharper/LambdaRunMarkers/Lambda.svg" to path(AwsIcons.Resources.LAMBDA_FUNCTION), - "/resharper/LambdaRunMarkers/CreateNew.svg" to path(AllIcons.Actions.New) + "/resharper/LambdaRunMarkers/Lambda.svg" to "AwsIcons.Resources.LAMBDA_FUNCTION", + "/resharper/LambdaRunMarkers/CreateNew.svg" to "AllIcons.Actions.New" ) } diff --git a/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/clouddebug/DotNetDebuggerSupport.kt b/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/clouddebug/DotNetDebuggerSupport.kt index f39cdccf89..65b287a097 100644 --- a/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/clouddebug/DotNetDebuggerSupport.kt +++ b/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/clouddebug/DotNetDebuggerSupport.kt @@ -5,22 +5,14 @@ package software.aws.toolkits.jetbrains.services.clouddebug import com.intellij.execution.configurations.RuntimeConfigurationError import com.intellij.execution.filters.TextConsoleBuilderFactory -import com.intellij.execution.process.CapturingProcessHandler import com.intellij.execution.process.ProcessAdapter import com.intellij.execution.process.ProcessEvent import com.intellij.execution.process.ProcessHandler import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.execution.ui.RunContentDescriptor -import com.intellij.ide.plugins.PluginManager -import com.intellij.ide.plugins.PluginManagerCore -import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.runInEdt import com.intellij.openapi.rd.defineNestedLifetime -import com.intellij.openapi.util.BuildNumber import com.intellij.openapi.util.io.FileUtil -import com.intellij.openapi.util.registry.Registry -import com.intellij.openapi.util.text.StringUtil -import com.intellij.util.execution.ParametersListUtil import com.intellij.xdebugger.XDebugProcessStarter import com.intellij.xdebugger.XDebuggerManager import com.jetbrains.rd.framework.IdKind @@ -51,8 +43,6 @@ import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.trace import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.steps.CloudDebugCliValidate -import software.aws.toolkits.jetbrains.services.clouddebug.execution.steps.ResourceInstrumenter import software.aws.toolkits.jetbrains.services.clouddebug.execution.steps.ResourceTransferStep import software.aws.toolkits.jetbrains.services.ecs.execution.ImmutableContainerOptions import software.aws.toolkits.jetbrains.services.lambda.dotnet.DotNetSamDebugSupport @@ -67,20 +57,12 @@ import java.util.concurrent.CompletableFuture import kotlin.concurrent.schedule class DotNetDebuggerSupport : DebuggerSupport() { - companion object { private val logger = getLogger() - const val USE_DOTNET_CORE_RUNTIME_FLAG_NAME = "software.aws.toolkits.jetbrains.rider.debugger.useDotnetCoreRuntime" private const val DOTNET_EXECUTABLE = "dotnet" private const val START_COMMAND_ASSEMBLY_PLACEHOLDER = "$DOTNET_EXECUTABLE " private const val DEBUGGER_MODE = "server" - private const val BUSYBOX_PATH = "/aws/cloud-debug/common/busybox" - - val useDotnetCoreRuntime - get() = Registry.`is`(USE_DOTNET_CORE_RUNTIME_FLAG_NAME) && isRider20193OrLater - - val isRider20193OrLater = BuildNumber.fromString("193.0")?.let { ApplicationInfo.getInstance().build >= it } == true } private var exeRemotePath: String = "" @@ -118,26 +100,6 @@ class DotNetDebuggerSupport : DebuggerSupport() { val manager = XDebuggerManager.getInstance(environment.project) val future = CompletableFuture() - val applicationInfo = ApplicationInfo.getInstance() - val dbgshimPath = - // Run custom dbgshim detection logic on Rider 2019.2 only. Further versions has correct autodetection logic. - if (!isRider20193OrLater) { - logger.info { "Rider build is ${applicationInfo.build}. Using dbgdhim detecting tool." } - val dbgshimTool = - "${debuggerPath.getRemoteDebuggerPath()}/${DotNetDebuggerUtils.cloudDebuggerTempDirName}/${DotNetDebuggerUtils.cloudDebuggerToolsName}.exe" - - getRiderDebuggerDbgshimPath( - context = context, - debuggerToolRemotePath = dbgshimTool, - userAssemblyRemotePath = exeRemotePath, - selector = containerName, - target = ResourceInstrumenter.getTargetForContainer(context, containerName) - ) - } else { - logger.info { "Rider build is ${applicationInfo.build}. DbgShim will be detected automatically by debugger." } - null - } - if (exeRemotePath.isEmpty()) { future.completeExceptionally(RuntimeException("DotNet executable to debug is not specified")) return future @@ -153,7 +115,7 @@ class DotNetDebuggerSupport : DebuggerSupport() { runInEdt { try { - createDebugProcessAsync(environment, frontendPort, backendPort, exeRemotePath, dbgshimPath).then { + createDebugProcessAsync(environment, frontendPort, backendPort, exeRemotePath).then { it?.let { future.complete( manager.startSessionAndShowTab( @@ -174,7 +136,6 @@ class DotNetDebuggerSupport : DebuggerSupport() { } override fun createDebuggerUploadStep(context: Context, containerName: String): ResourceTransferStep? { - val debuggerAssemblyNames = DebuggerHelperHost.getInstance(context.project).model.getDebuggerAssemblies.sync(Unit, RpcTimeouts.longRunning) @@ -197,22 +158,6 @@ class DotNetDebuggerSupport : DebuggerSupport() { ) } - override fun augmentStatement(input: String, ports: List, debuggerPath: String): String = - if (useDotnetCoreRuntime) - super.augmentStatement(input, ports, debuggerPath) - else { - // Rsync on Windows drops the 'executable' flag when copy files into a remote container. Set the flag explicitly. - val runtimeSh = "${this.debuggerPath.getRemoteDebuggerPath()}/${DotNetDebuggerUtils.cloudDebuggerTempDirName}/runtime.sh" - val monoSgen = "${this.debuggerPath.getRemoteDebuggerPath()}/${DotNetDebuggerUtils.cloudDebuggerTempDirName}/linux-x64/mono/bin/mono-sgen" - val chmodCommand = ParametersListUtil.join(BUSYBOX_PATH, "chmod", "+x", runtimeSh, monoSgen) - ParametersListUtil.join( - BUSYBOX_PATH, - "sh", - "-c", - "$chmodCommand && ${super.augmentStatement(input, ports, debuggerPath)}" - ) - } - override fun automaticallyAugmentable(input: List): Boolean { if (input.first().trim() != DOTNET_EXECUTABLE) throw RuntimeConfigurationError(message("cloud_debug.run_configuration.dotnet.start_command.miss_runtime", DOTNET_EXECUTABLE)) @@ -234,7 +179,6 @@ class DotNetDebuggerSupport : DebuggerSupport() { val debuggerRemoteDirPath = "${this.debuggerPath.getRemoteDebuggerPath()}/${DotNetDebuggerUtils.cloudDebuggerTempDirName}" val remoteDebuggerLogPath = "$debuggerRemoteDirPath/Logs" - val remoteDebuggerRuntimeShPath = "$debuggerRemoteDirPath/runtime.sh" if (ports.size < 2) { val message = message("cloud_debug.step.dotnet.two_ports_required") @@ -247,7 +191,7 @@ class DotNetDebuggerSupport : DebuggerSupport() { val debugArgs = StringBuilder() .append("RESHARPER_HOST_LOG_DIR=$remoteDebuggerLogPath ") - .append(if (useDotnetCoreRuntime) "dotnet " else "$remoteDebuggerRuntimeShPath ") + .append("dotnet ") .append("$debuggerPath ") .append("--mode=$DEBUGGER_MODE ") .append("--frontend-port=$frontendPort ") @@ -261,8 +205,7 @@ class DotNetDebuggerSupport : DebuggerSupport() { environment: ExecutionEnvironment, frontendPort: Int, backendPort: Int, - exeRemotePath: String, - dbgshimPath: String? + exeRemotePath: String ): Promise { val promise = AsyncPromise() val project = environment.project @@ -273,11 +216,11 @@ class DotNetDebuggerSupport : DebuggerSupport() { val scheduler = RdDispatcher(debuggerLifetime) val startInfo = createNetCoreStartInfo( - exePath = exeRemotePath, - dbgshimPath = dbgshimPath + exePath = exeRemotePath ) val protocol = Protocol( + name = "", serializers = Serializers(), identity = Identities(IdKind.Client), scheduler = scheduler, @@ -338,7 +281,9 @@ class DotNetDebuggerSupport : DebuggerSupport() { processHandler.addProcessListener(object : ProcessAdapter() { override fun processTerminated(event: ProcessEvent) { logger.trace { "Process exited. Terminating debugger lifetime" } - debuggerLifetimeDefinition.terminate() + runInEdt { + debuggerLifetimeDefinition.terminate() + } } }) @@ -383,9 +328,9 @@ class DotNetDebuggerSupport : DebuggerSupport() { return promise } - private fun createNetCoreStartInfo(exePath: String, dbgshimPath: String?): DotNetCoreExeStartInfo = + private fun createNetCoreStartInfo(exePath: String): DotNetCoreExeStartInfo = DotNetCoreExeStartInfo( - dotNetCoreInfo = DotNetCoreInfo(null, dbgshimPath), + dotNetCoreInfo = DotNetCoreInfo(null, null), exePath = exePath, workingDirectory = "", arguments = "", @@ -396,50 +341,7 @@ class DotNetDebuggerSupport : DebuggerSupport() { needToBeInitializedImmediately = true ) - private fun getRiderDebuggerDbgshimPath( - context: Context, - debuggerToolRemotePath: String, - userAssemblyRemotePath: String, - selector: String, - target: String - ): String { - val cloudDebugExec = context.getRequiredAttribute(CloudDebugCliValidate.EXECUTABLE_ATTRIBUTE).getCommandLine() - - val remoteDebuggerLogPath = "${this.debuggerPath.getRemoteDebuggerPath()}/${DotNetDebuggerUtils.cloudDebuggerTempDirName}/Logs" - - val getDbgshimCmd = cloudDebugExec - .withParameters("--target") - .withParameters(target) - .withParameters("--selector") - .withParameters(selector) - .withParameters("exec") - .withParameters( - "env", - "RESHARPER_HOST_LOG_DIR=$remoteDebuggerLogPath", - "RESHARPER_TRACE=AWS.RiderDebuggerTools", - "dotnet", - debuggerToolRemotePath, - "--command=DbgShimDetect", - "--assembly-path=$userAssemblyRemotePath") - - val process = CapturingProcessHandler(getDbgshimCmd).runProcess() - if (process.exitCode != 0 || process.isTimeout) { - throw IllegalStateException("Dbgshim detection process has exit with code: '${process.exitCode}'. Message: '${process.stderr}'") - } - - val response = CliOutputParser.parseLogEvent(process.stdout) - val outString = response.text - logger.trace { "DbgShim detector has returned output: $outString" } - val detectorResult = StringUtil.splitByLines(outString) - if (detectorResult.size != 2) - throw IllegalStateException("DbgShim detector returned unexpected output: $outString") - val dbgshimPath = detectorResult[1] // the second line is a path to dbgshim directory - logger.info { "DbgShim path is $dbgshimPath" } - return dbgshimPath - } - private fun prepareDebuggerArtifacts(targetPath: File, assemblyNames: Array) { - val assemblyFileErrors = mutableListOf() for (assemblyName in assemblyNames) { @@ -494,44 +396,14 @@ class DotNetDebuggerSupport : DebuggerSupport() { val linuxSubdirectoryName = "linux-x64" - if (!useDotnetCoreRuntime) { - // Copy runtime.sh - val runtimeSh = RiderEnvironment.getBundledFile("runtime.sh") - FileUtil.copy(runtimeSh, File(targetPath, runtimeSh.name)) - - // Copy mono - val linuxMono = RiderEnvironment.getBundledFile(linuxSubdirectoryName, allowDir = true) - FileUtil.copyDir(linuxMono, File(targetPath, linuxMono.name)) - } else { - val linuxMonoSubdirectory = File(targetPath, linuxSubdirectoryName) - if (linuxMonoSubdirectory.isDirectory) { - try { - // remove existing linux Mono distribution since we run debugger on container .net core - linuxMonoSubdirectory.deleteRecursively() - } catch (e: Throwable) { - logger.trace(e) { "Error while trying to delete unused linux Mono directory ${linuxMonoSubdirectory.absolutePath}" } - } + val linuxMonoSubdirectory = File(targetPath, linuxSubdirectoryName) + if (linuxMonoSubdirectory.isDirectory) { + try { + // remove existing linux Mono distribution since we run debugger on container .net core + linuxMonoSubdirectory.deleteRecursively() + } catch (e: Throwable) { + logger.trace(e) { "Error while trying to delete unused linux Mono directory ${linuxMonoSubdirectory.absolutePath}" } } } - - // Copy tool to detect dbgshim 'AWS.DebuggerTools' - val pluginId = PluginManagerCore.getPluginByClassName(this.javaClass.name) - ?: throw IllegalStateException("Unable to find plugin id") - - val pluginBasePath = PluginManager.getPlugin(pluginId)?.path - ?: throw IllegalStateException("Unable to find plugin with id: '$pluginId'") - - val dotnetPluginDir = File(pluginBasePath, "dotnet") - if (!dotnetPluginDir.exists()) throw IllegalStateException("Unable to find 'dotnet' folder inside path: '$dotnetPluginDir'") - - FileUtil.copy( - File(dotnetPluginDir, "${DotNetDebuggerUtils.cloudDebuggerToolsName}.exe"), - File(targetPath, "${DotNetDebuggerUtils.cloudDebuggerToolsName}.exe") - ) - - FileUtil.copy( - File(dotnetPluginDir, "${DotNetDebuggerUtils.cloudDebuggerToolsName}.runtimeconfig.json"), - File(targetPath, "${DotNetDebuggerUtils.cloudDebuggerToolsName}.runtimeconfig.json") - ) } } diff --git a/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/lambda/completion/DotNetHandlerCompletion.kt b/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/lambda/completion/DotNetHandlerCompletion.kt index 6c02b3eafa..2a5c01420c 100644 --- a/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/lambda/completion/DotNetHandlerCompletion.kt +++ b/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/lambda/completion/DotNetHandlerCompletion.kt @@ -13,7 +13,6 @@ import com.jetbrains.rdclient.icons.FrontendIconHost import com.jetbrains.rider.model.HandlerCompletionItem import com.jetbrains.rider.model.lambdaPsiModel import com.jetbrains.rider.projectView.solution -import org.jetbrains.annotations.TestOnly class DotNetHandlerCompletion : HandlerCompletion { @@ -22,14 +21,15 @@ class DotNetHandlerCompletion : HandlerCompletion { override fun getLookupElements(project: Project): Collection { val completionItems = getHandlersFromBackend(project) return completionItems.map { completionItem -> - LookupElementBuilder.create(completionItem.handler).let { - if (completionItem.iconId != null) it.withIcon(FrontendIconHost.getInstance(project).toIdeaIcon(completionItem.iconId)) - else it - } + LookupElementBuilder.create(completionItem.handler).let { element -> + if (completionItem.iconId != null) + element.withIcon(FrontendIconHost.getInstance(project).toIdeaIcon(completionItem.iconId)) + else + element + }.withInsertHandler { context, item -> context.document.setText(item.lookupString) } } } - @TestOnly fun getHandlersFromBackend(project: Project): List = project.solution.lambdaPsiModel.determineHandlers.sync(Unit, RpcTimeouts.default) } diff --git a/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/lambda/dotnet/DotNetLambdaBuilder.kt b/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/lambda/dotnet/DotNetLambdaBuilder.kt index 723ff29290..bf82ceb81f 100644 --- a/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/lambda/dotnet/DotNetLambdaBuilder.kt +++ b/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/lambda/dotnet/DotNetLambdaBuilder.kt @@ -5,15 +5,19 @@ package software.aws.toolkits.jetbrains.services.lambda.dotnet import com.intellij.openapi.module.Module import com.intellij.psi.PsiElement +import com.jetbrains.rider.projectView.solutionDirectory import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder import software.aws.toolkits.jetbrains.services.lambda.dotnet.element.RiderLambdaHandlerFakePsiElement import software.aws.toolkits.resources.message +import java.nio.file.Path +import java.nio.file.Paths class DotNetLambdaBuilder : LambdaBuilder() { - override fun baseDirectory(module: Module, handlerElement: PsiElement): String { val element = handlerElement as RiderLambdaHandlerFakePsiElement return element.getContainingProjectFile()?.parent?.path ?: throw IllegalStateException(message("lambda.run.configuration.handler_root_not_found")) } + + override fun getBuildDirectory(module: Module): Path = Paths.get(module.project.solutionDirectory.path, ".aws-sam", "build") } diff --git a/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/lambda/dotnet/DotNetSamDebugSupport.kt b/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/lambda/dotnet/DotNetSamDebugSupport.kt index 1db5042335..2e1203d84f 100644 --- a/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/lambda/dotnet/DotNetSamDebugSupport.kt +++ b/jetbrains-rider/src/software/aws/toolkits/jetbrains/services/lambda/dotnet/DotNetSamDebugSupport.kt @@ -5,13 +5,20 @@ package software.aws.toolkits.jetbrains.services.lambda.dotnet import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.filters.TextConsoleBuilderFactory -import com.intellij.execution.process.BaseProcessHandler +import com.intellij.execution.process.OSProcessHandler import com.intellij.execution.process.ProcessAdapter import com.intellij.execution.process.ProcessEvent -import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.process.ProcessHandlerFactory import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.util.ExecUtil +import com.intellij.openapi.application.ExpirableExecutor +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.impl.coroutineDispatchingContext +import com.intellij.openapi.application.runInEdt import com.intellij.openapi.rd.defineNestedLifetime +import com.intellij.util.concurrency.AppExecutorUtil import com.intellij.util.net.NetUtils +import com.intellij.util.text.nullize import com.intellij.xdebugger.XDebugProcessStarter import com.jetbrains.rd.framework.IdKind import com.jetbrains.rd.framework.Identities @@ -24,60 +31,47 @@ import com.jetbrains.rd.util.put import com.jetbrains.rd.util.reactive.adviseUntil import com.jetbrains.rdclient.protocol.RdDispatcher import com.jetbrains.rider.debugger.RiderDebuggerWorkerModelManager -import com.jetbrains.rider.model.debuggerWorker.DotNetCoreExeStartInfo -import com.jetbrains.rider.model.debuggerWorker.DotNetCoreInfo +import com.jetbrains.rider.model.debuggerWorker.DotNetCoreAttachStartInfo import com.jetbrains.rider.model.debuggerWorker.DotNetDebuggerSessionModel import com.jetbrains.rider.model.debuggerWorkerConnectionHelperModel import com.jetbrains.rider.projectView.solution import com.jetbrains.rider.run.IDebuggerOutputListener import com.jetbrains.rider.run.bindToSettings +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.concurrency.AsyncPromise import org.jetbrains.concurrency.Promise import software.amazon.awssdk.services.lambda.model.Runtime -import software.aws.toolkits.core.utils.error -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.trace import software.aws.toolkits.jetbrains.services.lambda.execution.local.SamDebugSupport import software.aws.toolkits.jetbrains.services.lambda.execution.local.SamRunningState +import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope import software.aws.toolkits.jetbrains.utils.DotNetDebuggerUtils +import software.aws.toolkits.jetbrains.utils.getCoroutineUiContext +import software.aws.toolkits.jetbrains.utils.spinUntilResult import software.aws.toolkits.resources.message -import java.io.File -import java.io.OutputStream import java.net.InetAddress +import java.time.Duration import java.util.Timer import kotlin.concurrent.schedule /** * Rider uses it's own DebuggerWorker process that run under .NET with extra parameters to configure: - * - path to runtime - * - path to DLL * - debugger mode (client/server) * - frontend port * - backend port * - * Debugger is launched via SAM CLI command: '$ sam local invoke' and pass debugger parameters --debugger-path and --debugger-args. - * Under SAM CLI this command is translated into: - * - volume that is mounted in Docker to launch debugger (--debugger-path). - * This volume is set to path with debugger process used in Rider - DebuggerWorker - inside Rider SDK. - * - additional debugger arguments that are applied when launching debugger inside Docker (--debugger-args): - * '$ dotnet MockBootstraps.dll --debugger-spin-wait' - * - * We use to inject a simple custom dotnet core application 'JetBrains.Rider.Debugger.Launcher' - * that run Rider's debugger. This app starts Rider's debugger under Mono runtime that is bundled with Rider SDK (runtime.sh). - * The app read arguments and start a Rider's DebuggerWorker process with ports and compiled lambda DLL - MockBootstraps.dll - * - * 'JetBrains.Rider.Debugger.Launcher' app is hosted on github repo - https://github.com/JetBrains/JetBrains.Rider.Debugger.Launcher. - * The app is packed to NuGet package and is accessible in Rider SDK in '/lib/ReSharperHost/' directory. + * Workflow: + * 1. Find the correct container by filtering `docker ps` for one of the published port of the debugger + * 2. Use `docker exec` and a shell script to locate the dotnet PID of the runtime. + * 3. Launch the Debugger worker in server mode using `docker exec` + * 4. Send the command to the worker to attach to the correct PID. */ class DotNetSamDebugSupport : SamDebugSupport { companion object { - private val logger = getLogger() - private const val DEBUGGER_MODE = "server" private const val REMOTE_DEBUGGER_DIR = "/tmp/lambci_debug_files" private const val REMOTE_NETCORE_CLI_PATH = "/var/lang/bin/dotnet" - private const val REMOTE_LAMBDA_COMPILED_PATH = "/var/runtime/MockBootstraps.dll" private const val NUMBER_OF_DEBUG_PORTS = 2 } @@ -92,32 +86,12 @@ class DotNetSamDebugSupport : SamDebugSupport { return false } - val debugLauncherFile = File(DotNetDebuggerUtils.debuggerBinDir, "${DotNetDebuggerUtils.dotnetCoreDebuggerLauncherName}.dll") - - val debuggerLauncherExists = debugLauncherFile.exists() - if (!debuggerLauncherExists) { - logger.error { "${DotNetDebuggerUtils.dotnetCoreDebuggerLauncherName} runnable does not exists" } - } - return debuggerLauncherExists + return true } override fun patchCommandLine(debugPorts: List, commandLine: GeneralCommandLine) { - val frontendPort = debugPorts[0] - val backendPort = debugPorts[1] - - val debugArgs = StringBuilder() - .append("$REMOTE_DEBUGGER_DIR/${DotNetDebuggerUtils.dotnetCoreDebuggerLauncherName}.dll ") - .append("$REMOTE_DEBUGGER_DIR/runtime.sh ") - .append("$REMOTE_DEBUGGER_DIR/${DotNetDebuggerUtils.debuggerAssemblyFile.name} ") - .append("$DEBUGGER_MODE ") - .append("$frontendPort ") - .append("$backendPort") - .toString() - commandLine.withParameters("--debugger-path") .withParameters(DotNetDebuggerUtils.debuggerBinDir.path) - .withParameters("--debug-args") - .withParameters(debugArgs) super.patchCommandLine(debugPorts, commandLine) } @@ -139,20 +113,18 @@ class DotNetSamDebugSupport : SamDebugSupport { ): Promise { val frontendPort = debugPorts[0] val backendPort = debugPorts[1] - val promise = AsyncPromise() - val project = environment.project // Define a debugger lifetime to be able to dispose the debugger process and all nested component on termination - val debuggerLifetimeDefinition = project.defineNestedLifetime() + val debuggerLifetimeDefinition = environment.project.defineNestedLifetime() val debuggerLifetime = debuggerLifetimeDefinition.lifetime val scheduler = RdDispatcher(debuggerLifetime) - val startInfo = createNetCoreStartInfo(state) val debugHostAddress = InetAddress.getByName(debugHost) val protocol = Protocol( + name = environment.runProfile.name, serializers = Serializers(), identity = Identities(IdKind.Client), scheduler = scheduler, @@ -160,97 +132,98 @@ class DotNetSamDebugSupport : SamDebugSupport { lifetime = debuggerLifetime ) + val workerModel = RiderDebuggerWorkerModelManager.createDebuggerModel(debuggerLifetime, protocol) + val executionResult = state.execute(environment.executor, environment.runner) + val console = TextConsoleBuilderFactory.getInstance().createBuilder(environment.project).console + val samProcessHandle = executionResult.processHandler + console.attachToProcess(samProcessHandle) + + // If we have not started the process's notification system, start it now. + // This is needed to pipe the SAM output to the Console view of the debugger panel + if (!samProcessHandle.isStartNotified) { + samProcessHandle.startNotify() + } - protocol.wire.connected.adviseUntil(debuggerLifetime) connected@{ isConnected -> - if (!isConnected) { - return@connected false - } + val bgContext = ExpirableExecutor.on(AppExecutorUtil.getAppExecutorService()).expireWith(environment).coroutineDispatchingContext() + val edtContext = getCoroutineUiContext(ModalityState.any(), environment) + ApplicationThreadPoolScope(environment.runProfile.name).launch(bgContext) { try { - val workerModel = RiderDebuggerWorkerModelManager.createDebuggerModel(debuggerLifetime, protocol) - - workerModel.initialized.adviseUntil(debuggerLifetime) initialized@{ isInitialized -> - if (!isInitialized) { - return@initialized false - } - - // Fire backend to connect to debugger. - environment.project.solution.debuggerWorkerConnectionHelperModel.ports.put( - debuggerLifetime, - environment.executionId, - backendPort - ) - - val sessionModel = DotNetDebuggerSessionModel(startInfo) - sessionModel.sessionProperties.bindToSettings(debuggerLifetime).apply { - enableHeuristicPathResolve.set(true) + val dockerContainer = findDockerContainer(frontendPort) + val pid = findDotnetPid(dockerContainer) + val riderDebuggerProcessHandler = startDebugWorker(dockerContainer, backendPort, frontendPort) + + // Link worker process to SAM process lifetime + samProcessHandle.addProcessListener(object : ProcessAdapter() { + override fun processTerminated(event: ProcessEvent) { + runInEdt { + debuggerLifetimeDefinition.terminate() + riderDebuggerProcessHandler.destroyProcess() + } } + }, environment) - workerModel.activeSession.set(sessionModel) - val console = TextConsoleBuilderFactory.getInstance().createBuilder(environment.project).console - val processHandler = object : ProcessHandler() { - override fun detachProcessImpl() { - destroyProcessImpl() + withContext(edtContext) { + protocol.wire.connected.adviseUntil(debuggerLifetime) connected@{ isConnected -> + if (!isConnected) { + return@connected false } - override fun detachIsDefault(): Boolean = false - override fun getProcessInput(): OutputStream? = null + workerModel.initialized.adviseUntil(debuggerLifetime) initialized@{ isInitialized -> + if (!isInitialized) { + return@initialized false + } - override fun destroyProcessImpl() { - val process = - (executionResult.processHandler as? BaseProcessHandler<*>)?.process - if (process == null) { - logger.error { "Unable to get process handler for SAM CLI invoke" } - return + // Fire backend to connect to debugger. + environment.project.solution.debuggerWorkerConnectionHelperModel.ports.put( + debuggerLifetime, + environment.executionId, + backendPort + ) + + val startInfo = DotNetCoreAttachStartInfo( + processId = pid, + needToBeInitializedImmediately = true + ) + + val sessionModel = DotNetDebuggerSessionModel(startInfo) + sessionModel.sessionProperties.bindToSettings(debuggerLifetime).apply { + enableHeuristicPathResolve.set(true) } - process.destroy() - notifyProcessTerminated(0) - } - fun notifyProcessDestroyed(exitCode: Int) { - notifyProcessTerminated(exitCode) + workerModel.activeSession.set(sessionModel) + + promise.setResult( + DotNetDebuggerUtils.createAndStartSession( + executionConsole = console, + env = environment, + sessionLifetime = debuggerLifetime, + processHandler = samProcessHandle, + protocol = protocol, + sessionModel = sessionModel, + outputEventsListener = object : IDebuggerOutputListener {} + ) + ) + + return@initialized true } - } - processHandler.addProcessListener(object : ProcessAdapter() { - override fun processTerminated(event: ProcessEvent) { - logger.trace { "Process exited. Terminating debugger lifetime" } - debuggerLifetimeDefinition.terminate() - } - }) - - workerModel.targetExited.advise(debuggerLifetime) { - logger.trace { "Target exited" } - // We should try to kill deployment there because it's already stopped, - // just notify debugger session about termination via its process handler. - processHandler.notifyProcessDestroyed(it.exitCode ?: 0) + return@connected true } - - promise.setResult( - DotNetDebuggerUtils.createAndStartSession( - executionConsole = console, - env = environment, - sessionLifetime = debuggerLifetime, - processHandler = processHandler, - protocol = protocol, - sessionModel = sessionModel, - outputEventsListener = object : IDebuggerOutputListener {}) - ) - - return@initialized true } } catch (t: Throwable) { - debuggerLifetimeDefinition.terminate() + debuggerLifetimeDefinition.terminate(true) promise.setError(t) } - return@connected true } val checkDebuggerTask = Timer("Debugger Worker launch timer", true).schedule(debuggerAttachTimeoutMs) { if (debuggerLifetimeDefinition.isAlive && !protocol.wire.connected.value) { - debuggerLifetimeDefinition.terminate() - promise.setError(message("lambda.debug.process.start.timeout")) + runInEdt { + debuggerLifetimeDefinition.terminate() + promise.setError(message("lambda.debug.process.start.timeout")) + } } } @@ -261,16 +234,45 @@ class DotNetSamDebugSupport : SamDebugSupport { return promise } - private fun createNetCoreStartInfo(state: SamRunningState): DotNetCoreExeStartInfo = - DotNetCoreExeStartInfo( - dotNetCoreInfo = DotNetCoreInfo(REMOTE_NETCORE_CLI_PATH, null), - exePath = REMOTE_LAMBDA_COMPILED_PATH, - workingDirectory = "", - arguments = state.settings.handler, - environmentVariables = emptyList(), - runtimeArguments = null, - executeAsIs = false, - useExternalConsole = false, - needToBeInitializedImmediately = true + private suspend fun findDockerContainer(frontendPort: Int): String = spinUntilResult(Duration.ofSeconds(30)) { + ExecUtil.execAndGetOutput( + GeneralCommandLine( + "docker", + "ps", + "-q", + "-f", + "publish=$frontendPort" + ) + ).stdout.trim().nullize() + } + + private suspend fun findDotnetPid(dockerContainer: String): Int = spinUntilResult(Duration.ofSeconds(30)) { + ExecUtil.execAndGetOutput( + GeneralCommandLine( + "docker", + "exec", + "-i", + dockerContainer, + "/bin/sh", + "-c", + """find /proc -mindepth 2 -maxdepth 2 -name exe -exec ls -l {} \; 2>/dev/null | grep -e '/dotnet$' | sed -n 's/.*\/proc\/\(.*\)\/exe.*/\1/p'""" + ) + ).stdout.trim().nullize() + }.toInt() + + private fun startDebugWorker(dockerContainer: String, backendPort: Int, frontendPort: Int): OSProcessHandler { + val runDebuggerCommand = GeneralCommandLine( + "docker", + "exec", + "-i", + dockerContainer, + REMOTE_NETCORE_CLI_PATH, + "$REMOTE_DEBUGGER_DIR/${DotNetDebuggerUtils.debuggerAssemblyFile.name}", + "--mode=$DEBUGGER_MODE", + "--frontend-port=$frontendPort", + "--backend-port=$backendPort" ) + + return ProcessHandlerFactory.getInstance().createProcessHandler(runDebuggerCommand) + } } diff --git a/jetbrains-rider/src/software/aws/toolkits/jetbrains/utils/DotNetDebuggerUtils.kt b/jetbrains-rider/src/software/aws/toolkits/jetbrains/utils/DotNetDebuggerUtils.kt index 22f4019e66..00c05e2176 100644 --- a/jetbrains-rider/src/software/aws/toolkits/jetbrains/utils/DotNetDebuggerUtils.kt +++ b/jetbrains-rider/src/software/aws/toolkits/jetbrains/utils/DotNetDebuggerUtils.kt @@ -21,18 +21,13 @@ import com.jetbrains.rider.run.IDebuggerOutputListener import java.io.File object DotNetDebuggerUtils { + val debuggerName = DebuggerWorkerPlatform.AnyCpu.assemblyName - val debuggerAssemblyFile: File = RiderEnvironment.getBundledFile(DebuggerWorkerPlatform.AnyCpu.assemblyName) + val debuggerAssemblyFile: File = RiderEnvironment.getBundledFile(debuggerName) val debuggerBinDir: File = debuggerAssemblyFile.parentFile - val cloudDebuggerTempDirName = "aws_rider_debugger_files" - - // This tool is used to detect dbgshim inside a remote container to replace Rider dbgshim autodetection logic - // that works not correctly in 192 Rider. It is fixed in 193 and should not be used. - val cloudDebuggerToolsName = "AWS.DebuggerTools" - - val dotnetCoreDebuggerLauncherName = "JetBrains.Rider.Debugger.Launcher" + const val cloudDebuggerTempDirName = "aws_rider_debugger_files" fun createAndStartSession( executionConsole: ExecutionConsole, @@ -43,23 +38,22 @@ object DotNetDebuggerUtils { sessionModel: DotNetDebuggerSessionModel, outputEventsListener: IDebuggerOutputListener ): XDebugProcessStarter { - val fireInitializedManually = env.getUserData(DotNetDebugRunner.FIRE_INITIALIZED_MANUALLY) ?: false return object : XDebugProcessStarter() { - override fun start(session: XDebugSession): XDebugProcess = - // TODO: Update to use 'sessionId' parameter in ctr when min SDK version is 193 FIX_WHEN_MIN_IS_193. - DotNetDebugProcess( - sessionLifetime = sessionLifetime, - session = session, - debuggerWorkerProcessHandler = processHandler, - console = executionConsole, - protocol = protocol, - sessionProxy = sessionModel, - fireInitializedManually = fireInitializedManually, - customListener = outputEventsListener, - debugKind = OptionsUtil.toDebugKind(sessionModel.sessionProperties.debugKind.valueOrNull), - project = env.project) + override fun start(session: XDebugSession): XDebugProcess = DotNetDebugProcess( + sessionLifetime, + session, + processHandler, + executionConsole, + protocol, + sessionModel, + fireInitializedManually, + outputEventsListener, + OptionsUtil.toDebugKind(sessionModel.sessionProperties.debugKind.valueOrNull), + env.project, + env.executionId + ) } } } diff --git a/jetbrains-rider/testData/solutions/SamHelloWorldApp/template.yaml b/jetbrains-rider/testData/solutions/SamHelloWorldApp/template.yaml new file mode 100644 index 0000000000..e9935cd8e1 --- /dev/null +++ b/jetbrains-rider/testData/solutions/SamHelloWorldApp/template.yaml @@ -0,0 +1,11 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 + +Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: ./src/HelloWorld/ + Handler: HelloWorld::HelloWorld.Function::FunctionHandler + Runtime: dotnetcore3.1 + diff --git a/jetbrains-rider/tst/base/AwsMarkupBaseTest.kt b/jetbrains-rider/tst/base/AwsMarkupBaseTest.kt index 0c09609262..606e80deb9 100644 --- a/jetbrains-rider/tst/base/AwsMarkupBaseTest.kt +++ b/jetbrains-rider/tst/base/AwsMarkupBaseTest.kt @@ -3,10 +3,9 @@ package base -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.execution.util.ExecUtil import com.intellij.openapi.util.SystemInfo import com.jetbrains.rider.test.base.BaseTestWithMarkup +import com.jetbrains.rider.test.base.PrepareTestEnvironment import com.jetbrains.rider.test.scriptingApi.setUpCustomToolset import com.jetbrains.rider.test.scriptingApi.setUpDotNetCoreCliPath import org.testng.annotations.BeforeClass @@ -21,23 +20,12 @@ import org.testng.annotations.BeforeClass // To avoid such errors we need to explicitly set toolset and MSBuild to be selected on an instance. // Please use this class for any Highlighting tests open class AwsMarkupBaseTest : BaseTestWithMarkup() { - private val dotNetSdk by lazy { - val output = ExecUtil.execAndGetOutput(GeneralCommandLine("dotnet", "--version")) - if (output.exitCode == 0) { - "C:\\Program Files\\dotnet\\sdk\\${output.stdout.trim()}\\MSBuild.dll".also { - println("Using MSBuild.dll at $it") - } - } else { - throw IllegalStateException("Failed to locate dotnet version: ${output.stderr}") - } - } - @BeforeClass fun setUpBuildToolPath() { if (SystemInfo.isWindows) { - dotnetCoreCliPath = "C:\\Program Files\\dotnet\\dotnet.exe" - setUpDotNetCoreCliPath(dotnetCoreCliPath) - setUpCustomToolset(dotNetSdk) + PrepareTestEnvironment.dotnetCoreCliPath = "C:\\Program Files\\dotnet\\dotnet.exe" + setUpDotNetCoreCliPath(PrepareTestEnvironment.dotnetCoreCliPath) + setUpCustomToolset(msBuild) } } } diff --git a/jetbrains-rider/tst/base/AwsReuseSolutionTestBase.kt b/jetbrains-rider/tst/base/AwsReuseSolutionTestBase.kt index 69d373376b..6a194afe1b 100644 --- a/jetbrains-rider/tst/base/AwsReuseSolutionTestBase.kt +++ b/jetbrains-rider/tst/base/AwsReuseSolutionTestBase.kt @@ -36,6 +36,12 @@ abstract class AwsReuseSolutionTestBase : BaseTestWithSolutionBase() { override val testCaseNameToTempDir: String get() = getSolutionDirectoryName() + // TODO: Remove when https://youtrack.jetbrains.com/issue/RIDER-47995 is fixed FIX_WHEN_MIN_IS_203 + @BeforeClass + fun allowDotnetRoots() { + allowCustomDotnetRoots() + } + @BeforeClass(alwaysRun = true) fun setUpClassSolution() { openSolution(getSolutionDirectoryName()) diff --git a/jetbrains-rider/tst/base/RiderTestFrameworkUtils.kt b/jetbrains-rider/tst/base/RiderTestFrameworkUtils.kt new file mode 100644 index 0000000000..87fb935ade --- /dev/null +++ b/jetbrains-rider/tst/base/RiderTestFrameworkUtils.kt @@ -0,0 +1,36 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package base + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.util.ExecUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess +import com.jetbrains.rider.test.base.PrepareTestEnvironment +import java.io.File + +val dotNetSdk by lazy { + val output = ExecUtil.execAndGetOutput(GeneralCommandLine("dotnet", "--version")) + if (output.exitCode == 0) { + "C:\\Program Files\\dotnet\\sdk\\${output.stdout.trim()}".also { + println("Using dotnet SDK at $it") + } + } else { + throw IllegalStateException("Failed to locate dotnet version: ${output.stderr}") + } +} + +val msBuild by lazy { + "${dotNetSdk}\\MSBuild.dll" +} + +// TODO: Remove when https://youtrack.jetbrains.com/issue/RIDER-47995 is fixed FIX_WHEN_MIN_IS_203 +fun allowCustomDotnetRoots() { + // Rider Test Framework miss VFS root access for the case when running tests on local environment with custom SDK path + // This should be fixed on Rider Test Framework level. Workaround it until related ticket RIDER-47995 is fixed. + VfsRootAccess.allowRootAccess(ApplicationManager.getApplication(), + dotNetSdk, + File(PrepareTestEnvironment.dotnetCoreCliPath).parentFile.absolutePath + ) +} diff --git a/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/clouddebug/DotNetStartupCommandAugmenterTest.kt b/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/clouddebug/DotNetStartupCommandAugmenterTest.kt index 3b0804843a..b7a22f5f77 100644 --- a/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/clouddebug/DotNetStartupCommandAugmenterTest.kt +++ b/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/clouddebug/DotNetStartupCommandAugmenterTest.kt @@ -6,12 +6,9 @@ package software.aws.toolkits.jetbrains.services.clouddebug import base.AwsReuseSolutionTestBase import com.intellij.execution.configurations.RuntimeConfigurationError import com.intellij.execution.configurations.RuntimeConfigurationException -import com.intellij.openapi.util.registry.Registry import com.intellij.util.execution.ParametersListUtil import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy -import org.testng.annotations.AfterMethod -import org.testng.annotations.BeforeMethod import org.testng.annotations.Test import software.aws.toolkits.resources.message @@ -23,18 +20,6 @@ class DotNetStartupCommandAugmenterTest : AwsReuseSolutionTestBase() { private const val DEFAULT_STARTUP_COMMAND = "dotnet /prog/netcoreapp2.1/ConsoleApp.dll" } - var useNetCoreDebuggerOriginal: Boolean = true - - @BeforeMethod(alwaysRun = true) - fun setRegistry() { - useNetCoreDebuggerOriginal = Registry.get(DotNetDebuggerSupport.USE_DOTNET_CORE_RUNTIME_FLAG_NAME).asBoolean() - } - - @AfterMethod(alwaysRun = true) - fun resetRegistry() { - Registry.get(DotNetDebuggerSupport.USE_DOTNET_CORE_RUNTIME_FLAG_NAME).setValue(useNetCoreDebuggerOriginal) - } - @Test fun testAugmentStatement_NoDebugPort_Exception() { assertThatThrownBy { DotNetDebuggerSupport().augmentStatement(DEFAULT_STARTUP_COMMAND, listOf(), "") } @@ -50,40 +35,7 @@ class DotNetStartupCommandAugmenterTest : AwsReuseSolutionTestBase() { } @Test - fun testAugmentStatement_MonoRuntime() { - Registry.get(DotNetDebuggerSupport.USE_DOTNET_CORE_RUNTIME_FLAG_NAME).setValue(false) - - val pathToDebugger = "/path/to/debugger" - val statement = DotNetDebuggerSupport().augmentStatement(DEFAULT_STARTUP_COMMAND, listOf(123, 456), pathToDebugger) - val expectedCommand = - ParametersListUtil.join( - "/aws/cloud-debug/common/busybox", - "sh", - "-c", - ParametersListUtil.join( - "/aws/cloud-debug/common/busybox", - "chmod", - "+x", - "/aws/DOTNET/aws_rider_debugger_files/runtime.sh", - "/aws/DOTNET/aws_rider_debugger_files/linux-x64/mono/bin/mono-sgen", - "&&", - "env", - "REMOTE_DEBUG_PORT=123", - "RESHARPER_HOST_LOG_DIR=/aws/DOTNET/aws_rider_debugger_files/Logs", - "/aws/DOTNET/aws_rider_debugger_files/runtime.sh", - pathToDebugger, - "--mode=server", - "--frontend-port=123", - "--backend-port=456" - ) - ) - assertThat(statement).isEqualTo(expectedCommand) - } - - @Test - fun testAugmentStatement_DotNetCoreRuntime() { - Registry.get(DotNetDebuggerSupport.USE_DOTNET_CORE_RUNTIME_FLAG_NAME).setValue(true) - + fun testAugmentStatement() { val pathToDebugger = "/path/to/debugger" val statement = DotNetDebuggerSupport().augmentStatement(DEFAULT_STARTUP_COMMAND, listOf(123, 456), pathToDebugger) val expectedCommand = diff --git a/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/clouddebug/DotNetStartupCommandTest.kt b/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/clouddebug/DotNetStartupCommandTest.kt index 4cd47b559d..181bdf093e 100644 --- a/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/clouddebug/DotNetStartupCommandTest.kt +++ b/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/clouddebug/DotNetStartupCommandTest.kt @@ -8,7 +8,7 @@ import com.jetbrains.rdclient.util.idea.pumpMessages import com.jetbrains.rider.projectView.solutionDirectory import com.jetbrains.rider.test.asserts.shouldBeTrue import com.jetbrains.rider.test.scriptingApi.buildSolutionWithReSharperBuild -import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat import org.testng.annotations.DataProvider import org.testng.annotations.Test import software.aws.toolkits.jetbrains.services.ecs.execution.ArtifactMapping @@ -39,7 +39,7 @@ class DotNetStartupCommandTest : AwsReuseSolutionTestBase() { ) pumpMessages(Duration.ofSeconds(2).toMillis()) { command.isNotEmpty() } - Assertions.assertThat(command).isEqualTo(originalCommand) + assertThat(command).isEqualTo(originalCommand) } @Test @@ -69,6 +69,6 @@ class DotNetStartupCommandTest : AwsReuseSolutionTestBase() { pumpMessages(Duration.ofSeconds(2).toMillis()) { command.isNotEmpty() } val expectedCommand = "dotnet /tmp/remote/path/netcoreapp2.1/HelloWorld.dll" - Assertions.assertThat(command).isEqualTo(expectedCommand) + assertThat(command).isEqualTo(expectedCommand) } } diff --git a/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/ecs/execution/DotNetEcsCloudDebugRunConfigurationTest.kt b/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/ecs/execution/DotNetEcsCloudDebugRunConfigurationTest.kt index 9fd83098bf..1345db36a6 100644 --- a/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/ecs/execution/DotNetEcsCloudDebugRunConfigurationTest.kt +++ b/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/ecs/execution/DotNetEcsCloudDebugRunConfigurationTest.kt @@ -13,7 +13,7 @@ import software.amazon.awssdk.auth.credentials.AwsBasicCredentials import software.amazon.awssdk.services.ecs.model.ContainerDefinition import software.amazon.awssdk.services.ecs.model.Service import software.amazon.awssdk.services.ecs.model.TaskDefinition -import software.aws.toolkits.core.credentials.ToolkitCredentialsIdentifier +import software.aws.toolkits.core.credentials.CredentialIdentifier import software.aws.toolkits.jetbrains.core.MockResourceCache import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebuggingPlatform @@ -77,7 +77,7 @@ class DotNetEcsCloudDebugRunConfigurationTest : AwsReuseSolutionTestBase() { clusterArn: String = defaultClusterArn, serviceArn: String = defaultServiceArn, regionId: String = defaultRegion, - credentialsIdentifier: ToolkitCredentialsIdentifier = mockCredentials + credentialsIdentifier: CredentialIdentifier = mockCredentials ) { val resourceCache = MockResourceCache.getInstance(project) val taskDefinitionName = "taskDefinition" @@ -95,7 +95,7 @@ class DotNetEcsCloudDebugRunConfigurationTest : AwsReuseSolutionTestBase() { resourceCache.addEntry(EcsResources.describeTaskDefinition(taskDefinitionName), regionId, credentialsIdentifier.id, fakeTaskDefinition) } - private val mockCredentials: ToolkitCredentialsIdentifier + private val mockCredentials: CredentialIdentifier get() = MockCredentialsManager.getInstance().addCredentials( "mockCreds", AwsBasicCredentials.create("foo", "bar") diff --git a/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/lambda/completion/DotNetHandlerCompletionProviderTest.kt b/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/lambda/completion/DotNetHandlerCompletionProviderTest.kt new file mode 100644 index 0000000000..15736496cc --- /dev/null +++ b/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/lambda/completion/DotNetHandlerCompletionProviderTest.kt @@ -0,0 +1,32 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.completion + +import base.AwsReuseSolutionTestBase +import org.assertj.core.api.Assertions.assertThat +import org.testng.annotations.DataProvider +import org.testng.annotations.Test +import software.amazon.awssdk.services.lambda.model.Runtime + +class DotNetHandlerCompletionProviderTest : AwsReuseSolutionTestBase() { + + override fun getSolutionDirectoryName(): String = "SamHelloWorldApp" + + override val waitForCaches = true + + @DataProvider(name = "handlerCompletionSupportedData") + fun handlerCompletionSupportData() = arrayOf( + arrayOf("DotNet10", Runtime.DOTNETCORE1_0), + arrayOf("DotNet20", Runtime.DOTNETCORE2_0), + arrayOf("DotNet21", Runtime.DOTNETCORE2_1), + arrayOf("DotNet31", Runtime.DOTNETCORE3_1) + ) + + @Test(dataProvider = "handlerCompletionSupportedData", + description = "Check completion in run configuration feature is enabled for DOTNET runtime.") + fun testCompletion_IsSupportedForDotNetRuntime(name: String, runtime: Runtime) { + val provider = HandlerCompletionProvider(project, runtime) + assertThat(provider.isCompletionSupported).isTrue() + } +} diff --git a/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/lambda/completion/DotNetHandlerCompletionTest.kt b/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/lambda/completion/DotNetHandlerCompletionTest.kt index d6be34d847..19fe32f987 100644 --- a/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/lambda/completion/DotNetHandlerCompletionTest.kt +++ b/jetbrains-rider/tst/software/aws/toolkits/jetbrains/services/lambda/completion/DotNetHandlerCompletionTest.kt @@ -3,14 +3,15 @@ package software.aws.toolkits.jetbrains.services.lambda.completion +import base.allowCustomDotnetRoots import com.intellij.openapi.util.IconLoader import com.jetbrains.rdclient.icons.toIdeaIcon import com.jetbrains.rider.model.IconModel import com.jetbrains.rider.test.annotations.TestEnvironment import com.jetbrains.rider.test.base.BaseTestWithSolution import org.assertj.core.api.Assertions.assertThat +import org.testng.annotations.BeforeSuite import org.testng.annotations.Test -import software.amazon.awssdk.services.lambda.model.Runtime class DotNetHandlerCompletionTest : BaseTestWithSolution() { @@ -18,14 +19,13 @@ class DotNetHandlerCompletionTest : BaseTestWithSolution() { override val waitForCaches = true - @Test - @TestEnvironment(solution = "SamHelloWorldApp") - fun testCompletion_IsSupportedForDotNetRuntime() { - val provider = HandlerCompletionProvider(project, Runtime.DOTNETCORE2_1) - assertThat(provider.isCompletionSupported).isTrue() + // TODO: Remove when https://youtrack.jetbrains.com/issue/RIDER-47995 is fixed FIX_WHEN_MIN_IS_203 + @BeforeSuite + fun allowDotnetRoots() { + allowCustomDotnetRoots() } - @Test + @Test(description = "Check a single handler is show in lookup when one is defined in a project.") @TestEnvironment(solution = "SamHelloWorldApp") fun testDetermineHandlers_SingleHandler() { val handlers = DotNetHandlerCompletion().getHandlersFromBackend(project) @@ -36,6 +36,8 @@ class DotNetHandlerCompletionTest : BaseTestWithSolution() { } // TODO this test only works on 2019.2. Which we don't support anymore. Fix the test + // TODO: This test is failing due to handlers detection logic. I assume it need to be fixed if test is correct. + @Test(enabled = false, description = "Check all handlers are show in completion lookup when multiple handlers are defined in a project.") @TestEnvironment(solution = "SamMultipleHandlersApp") fun testDetermineHandlers_MultipleHandlers() { val handlers = DotNetHandlerCompletion().getHandlersFromBackend(project).sortedBy { it.handler } diff --git a/jetbrains-ultimate/build.gradle b/jetbrains-ultimate/build.gradle deleted file mode 100644 index d181a3ccc0..0000000000 --- a/jetbrains-ultimate/build.gradle +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -apply plugin: 'org.jetbrains.intellij' - -dependencies { - compile project(":jetbrains-core") - testImplementation project(path: ":jetbrains-core", configuration: 'testArtifacts') - testImplementation project(path: ":core", configuration: 'testArtifacts') - integrationTestImplementation project(path: ":jetbrains-core", configuration: 'testArtifacts') -} - -intellij { - def parentIntellijTask = project(':jetbrains-core').intellij - version ideSdkVersion("IU") - pluginName parentIntellijTask.pluginName - updateSinceUntilBuild parentIntellijTask.updateSinceUntilBuild - downloadSources = parentIntellijTask.downloadSources - plugins = idePlugins("IU") -} - -test { - systemProperty("log.dir", "${project.intellij.sandboxDirectory}-test/logs") -} - -jar { - archiveBaseName = 'aws-intellij-toolkit-ultimate' -} diff --git a/jetbrains-ultimate/build.gradle.kts b/jetbrains-ultimate/build.gradle.kts new file mode 100644 index 0000000000..d670c8e544 --- /dev/null +++ b/jetbrains-ultimate/build.gradle.kts @@ -0,0 +1,37 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import groovy.lang.Closure +import org.jetbrains.intellij.IntelliJPluginExtension + +plugins { + id("org.jetbrains.intellij") +} +apply(from = "../intellijJVersions.gradle") + +val ideSdkVersion: Closure by ext +val idePlugins: Closure> by ext + +dependencies { + api(project(":jetbrains-core")) + testImplementation(project(path = ":jetbrains-core", configuration = "testArtifacts")) + testImplementation(project(path = ":core", configuration = "testArtifacts")) + integrationTestImplementation(project(path = ":jetbrains-core", configuration = "testArtifacts")) +} + +intellij { + val parentIntellijTask = rootProject.intellij + version = ideSdkVersion("IU") + setPlugins(*(idePlugins("IU").toArray())) + pluginName = parentIntellijTask.pluginName + updateSinceUntilBuild = parentIntellijTask.updateSinceUntilBuild + downloadSources = parentIntellijTask.downloadSources +} + +tasks.test { + systemProperty("log.dir", "${(project.extensions["intellij"] as IntelliJPluginExtension).sandboxDirectory}-test/logs") +} + +tasks.jar { + archiveBaseName.set("aws-intellij-toolkit-ultimate") +} diff --git a/jetbrains-ultimate/it/software/aws/toolkits/jetbrains/services/clouddebug/nodejs/NodeJsDebugEndToEndTest.kt b/jetbrains-ultimate/it/software/aws/toolkits/jetbrains/services/clouddebug/nodejs/NodeJsDebugEndToEndTest.kt index e218933cd8..00305b7809 100644 --- a/jetbrains-ultimate/it/software/aws/toolkits/jetbrains/services/clouddebug/nodejs/NodeJsDebugEndToEndTest.kt +++ b/jetbrains-ultimate/it/software/aws/toolkits/jetbrains/services/clouddebug/nodejs/NodeJsDebugEndToEndTest.kt @@ -8,7 +8,7 @@ import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.util.registry.Registry import com.intellij.testFramework.runInEdtAndWait import com.intellij.xdebugger.XDebuggerUtil -import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat import org.junit.After import org.junit.Assume.assumeTrue import org.junit.Before @@ -102,7 +102,7 @@ class NodeJsDebugEndToEndTest : CloudDebugTestCase("CloudDebugTestECSClusterTask configuration.checkConfiguration() executeRunConfiguration(configuration, DefaultDebugExecutor.EXECUTOR_ID) } - Assertions.assertThat(debuggerIsHit.get()).isTrue() + assertThat(debuggerIsHit.get()).isTrue() } private fun addNodeFile(): String { diff --git a/jetbrains-ultimate/it/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsLambdaBuilderTest.kt b/jetbrains-ultimate/it/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsLambdaBuilderTest.kt index ba1921bbf5..494a9c8220 100644 --- a/jetbrains-ultimate/it/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsLambdaBuilderTest.kt +++ b/jetbrains-ultimate/it/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsLambdaBuilderTest.kt @@ -7,26 +7,34 @@ import com.intellij.openapi.application.invokeAndWaitIfNeeded import com.intellij.testFramework.PsiTestUtil import com.intellij.testFramework.runInEdtAndWait import org.assertj.core.api.Assertions.assertThat +import org.junit.Before import org.junit.Rule import org.junit.Test import software.amazon.awssdk.services.lambda.model.Runtime import software.aws.toolkits.jetbrains.services.lambda.Lambda -import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder -import software.aws.toolkits.jetbrains.services.lambda.java.BaseLambdaBuilderTest +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils.buildLambda +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils.buildLambdaFromTemplate +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderTestUtils.packageLambda import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon import software.aws.toolkits.jetbrains.utils.rules.NodeJsCodeInsightTestFixtureRule import software.aws.toolkits.jetbrains.utils.rules.addLambdaHandler import software.aws.toolkits.jetbrains.utils.rules.addPackageJsonFile import software.aws.toolkits.jetbrains.utils.rules.addSamTemplate +import software.aws.toolkits.jetbrains.utils.setSamExecutableFromEnvironment import java.nio.file.Paths -class NodeJsLambdaBuilderTest : BaseLambdaBuilderTest() { +class NodeJsLambdaBuilderTest { @Rule @JvmField val projectRule = NodeJsCodeInsightTestFixtureRule() - override val lambdaBuilder: LambdaBuilder - get() = NodeJsLambdaBuilder() + private val sut = NodeJsLambdaBuilder() + + @Before + fun setUp() { + setSamExecutableFromEnvironment() + } @Test fun findHandlerElementsIgnoresSamBuildLocation() { @@ -60,13 +68,13 @@ class NodeJsLambdaBuilderTest : BaseLambdaBuilderTest() { val module = projectRule.module val handler = projectRule.fixture.addLambdaHandler(subPath, fileName, handlerName) projectRule.fixture.addPackageJsonFile() - val builtLambda = buildLambda(module, handler, Runtime.NODEJS12_X, "$subPath/$fileName.$handlerName") - verifyEntries( + val builtLambda = sut.buildLambda(module, handler, Runtime.NODEJS12_X, "$subPath/$fileName.$handlerName") + LambdaBuilderTestUtils.verifyEntries( builtLambda, "$subPath/$fileName.js", "package.json" ) - verifyPathMappings( + LambdaBuilderTestUtils.verifyPathMappings( module, builtLambda, "%PROJECT_ROOT%" to "/", @@ -88,13 +96,13 @@ class NodeJsLambdaBuilderTest : BaseLambdaBuilderTest() { PsiTestUtil.addSourceRoot(module, handler.containingFile.virtualFile.parent) } - val builtLambda = buildLambda(module, handler, Runtime.NODEJS12_X, "$fileName.$handlerName") - verifyEntries( + val builtLambda = sut.buildLambda(module, handler, Runtime.NODEJS12_X, "$fileName.$handlerName") + LambdaBuilderTestUtils.verifyEntries( builtLambda, "$fileName.js", "package.json" ) - verifyPathMappings( + LambdaBuilderTestUtils.verifyPathMappings( module, builtLambda, "%PROJECT_ROOT%/$subPath" to "/", @@ -120,14 +128,14 @@ class NodeJsLambdaBuilderTest : BaseLambdaBuilderTest() { ) val templatePath = Paths.get(templateFile.virtualFile.path) - val builtLambda = buildLambdaFromTemplate(projectRule.module, templatePath, logicalName) + val builtLambda = sut.buildLambdaFromTemplate(projectRule.module, templatePath, logicalName) - verifyEntries( + LambdaBuilderTestUtils.verifyEntries( builtLambda, "$fileName.js", "package.json" ) - verifyPathMappings( + LambdaBuilderTestUtils.verifyPathMappings( projectRule.module, builtLambda, "%PROJECT_ROOT%/$subPath" to "/", @@ -154,14 +162,14 @@ class NodeJsLambdaBuilderTest : BaseLambdaBuilderTest() { } """.trimIndent() ) - val builtLambda = buildLambda(module, handler, Runtime.NODEJS12_X, "$subPath/$fileName.$handlerName") - verifyEntries( + val builtLambda = sut.buildLambda(module, handler, Runtime.NODEJS12_X, "$subPath/$fileName.$handlerName") + LambdaBuilderTestUtils.verifyEntries( builtLambda, "$subPath/$fileName.js", "node_modules/axios/package.json", "package.json" ) - verifyPathMappings( + LambdaBuilderTestUtils.verifyPathMappings( module, builtLambda, "%PROJECT_ROOT%" to "/", @@ -178,8 +186,8 @@ class NodeJsLambdaBuilderTest : BaseLambdaBuilderTest() { val handler = projectRule.fixture.addLambdaHandler(subPath) projectRule.fixture.addPackageJsonFile() - val lambdaPackage = packageLambda(projectRule.module, handler, Runtime.NODEJS12_X, "$subPath/$fileName.$handlerName") - verifyZipEntries( + val lambdaPackage = sut.packageLambda(projectRule.module, handler, Runtime.NODEJS12_X, "$subPath/$fileName.$handlerName") + LambdaBuilderTestUtils.verifyZipEntries( lambdaPackage, "$subPath/$fileName.js", "package.json" @@ -195,13 +203,13 @@ class NodeJsLambdaBuilderTest : BaseLambdaBuilderTest() { val handler = projectRule.fixture.addLambdaHandler(subPath) projectRule.fixture.addPackageJsonFile() - val builtLambda = buildLambda(projectRule.module, handler, Runtime.NODEJS12_X, "$subPath/$fileName.$handlerName", true) - verifyEntries( + val builtLambda = sut.buildLambda(projectRule.module, handler, Runtime.NODEJS12_X, "$subPath/$fileName.$handlerName", true) + LambdaBuilderTestUtils.verifyEntries( builtLambda, "$subPath/$fileName.js", "package.json" ) - verifyPathMappings( + LambdaBuilderTestUtils.verifyPathMappings( projectRule.module, builtLambda, "%PROJECT_ROOT%" to "/", @@ -218,9 +226,8 @@ class NodeJsLambdaBuilderTest : BaseLambdaBuilderTest() { val handler = projectRule.fixture.addLambdaHandler(subPath) projectRule.fixture.addPackageJsonFile() - val lambdaPackage = packageLambda(projectRule.module, handler, Runtime.NODEJS12_X, "$subPath/$fileName.$handlerName", true) - - verifyZipEntries( + val lambdaPackage = sut.packageLambda(projectRule.module, handler, Runtime.NODEJS12_X, "$subPath/$fileName.$handlerName", true) + LambdaBuilderTestUtils.verifyZipEntries( lambdaPackage, "$subPath/$fileName.js", "package.json" diff --git a/jetbrains-ultimate/it/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsLocalLambdaRunConfigurationIntegrationTest.kt b/jetbrains-ultimate/it/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsLocalLambdaRunConfigurationIntegrationTest.kt index 74a580d086..5c25855555 100644 --- a/jetbrains-ultimate/it/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsLocalLambdaRunConfigurationIntegrationTest.kt +++ b/jetbrains-ultimate/it/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsLocalLambdaRunConfigurationIntegrationTest.kt @@ -171,7 +171,7 @@ class NodeJsLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runt val debuggerIsHit = checkBreakPointHit(projectRule.project) val executeLambda = executeRunConfiguration(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) - assertThat(executeLambda.exitCode).isEqualTo(137) + assertThat(executeLambda.exitCode).isEqualTo(0) assertThat(executeLambda.stdout).contains("Hello World") assertThat(debuggerIsHit.get()).isTrue() @@ -202,7 +202,7 @@ class NodeJsLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runt val debuggerIsHit = checkBreakPointHit(projectRule.project) val executeLambda = executeRunConfiguration(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) - assertThat(executeLambda.exitCode).isEqualTo(137) + assertThat(executeLambda.exitCode).isEqualTo(0) assertThat(executeLambda.stdout).contains("Hello World") assertThat(debuggerIsHit.get()).isTrue() diff --git a/jetbrains-ultimate/resources-201+/META-INF/ext-datagrip.xml b/jetbrains-ultimate/resources-201+/META-INF/ext-datagrip.xml new file mode 100644 index 0000000000..266cfb082e --- /dev/null +++ b/jetbrains-ultimate/resources-201+/META-INF/ext-datagrip.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/core/explorer/nodes/RdsExplorerRootNode.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/core/explorer/nodes/RdsExplorerRootNode.kt new file mode 100644 index 0000000000..8ed849fe01 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/core/explorer/nodes/RdsExplorerRootNode.kt @@ -0,0 +1,16 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.nodes + +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.rds.RdsClient +import software.aws.toolkits.jetbrains.services.rds.RdsExplorerParentNode +import software.aws.toolkits.resources.message + +class RdsExplorerRootNode : AwsExplorerServiceNode { + override val serviceId: String = RdsClient.SERVICE_NAME + override val displayName: String = message("explorer.node.rds") + + override fun buildServiceRootNode(project: Project) = RdsExplorerParentNode(project, this) +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/core/explorer/nodes/RedshiftExplorerRootNode.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/core/explorer/nodes/RedshiftExplorerRootNode.kt new file mode 100644 index 0000000000..7623aae6c3 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/core/explorer/nodes/RedshiftExplorerRootNode.kt @@ -0,0 +1,16 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.nodes + +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.redshift.RedshiftClient +import software.aws.toolkits.jetbrains.services.redshift.RedshiftExplorerParentNode +import software.aws.toolkits.resources.message + +class RedshiftExplorerRootNode : AwsExplorerServiceNode { + override val serviceId: String = RedshiftClient.SERVICE_NAME + override val displayName: String = message("explorer.node.redshift") + + override fun buildServiceRootNode(project: Project) = RedshiftExplorerParentNode(project, this) +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/DatabaseSecret.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/DatabaseSecret.kt new file mode 100644 index 0000000000..af8eb100b6 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/DatabaseSecret.kt @@ -0,0 +1,73 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.datagrip + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ValidationInfo +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient +import software.amazon.awssdk.services.secretsmanager.model.SecretListEntry +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode +import software.aws.toolkits.jetbrains.datagrip.auth.SecretsManagerDbSecret +import software.aws.toolkits.jetbrains.services.rds.RdsNode +import software.aws.toolkits.jetbrains.services.redshift.RedshiftExplorerNode +import software.aws.toolkits.jetbrains.services.redshift.RedshiftResources.redshiftEngineType +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message + +object DatabaseSecret { + private val objectMapper = jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + + fun getSecret(project: Project, secret: SecretListEntry?): Pair? { + secret ?: return null + return try { + val value = AwsClientManager.getInstance(project).getClient().getSecretValue { it.secretId(secret.arn()) } + val dbSecret = objectMapper.readValue(value.secretString()) + Pair(dbSecret, secret.arn()) + } catch (e: Exception) { + notifyError( + title = message("datagrip.secretsmanager.validation.failed_to_get", secret.name()), + content = e.message ?: e.toString() + ) + null + } + } + + fun validateSecret(node: AwsExplorerNode<*>, dbSecret: SecretsManagerDbSecret, secretName: String): ValidationInfo? { + // Validate the secret has the bare minimum + dbSecret.username ?: return ValidationInfo(message("datagrip.secretsmanager.validation.no_username", secretName)) + dbSecret.password ?: return ValidationInfo(message("datagrip.secretsmanager.validation.no_password", secretName)) + // If it is a resource node, validate that it is the same resource + when (node) { + is RdsNode -> { + if (node.dbInstance.engine() != dbSecret.engine) return ValidationInfo( + message( + "datagrip.secretsmanager.validation.different_engine", + secretName, + dbSecret.engine.toString() + ) + ) + if (node.dbInstance.endpoint().address() != dbSecret.host) return ValidationInfo( + message("datagrip.secretsmanager.validation.different_address", secretName, dbSecret.host.toString()) + ) + } + is RedshiftExplorerNode -> { + if (dbSecret.engine != redshiftEngineType) return ValidationInfo( + message( + "datagrip.secretsmanager.validation.different_engine", + secretName, + dbSecret.engine.toString() + ) + ) + if (node.cluster.endpoint().address() != dbSecret.host) return ValidationInfo( + message("datagrip.secretsmanager.validation.different_address", secretName, dbSecret.host.toString()) + ) + } + } + return null + } +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/DatagripUtils.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/DatagripUtils.kt new file mode 100644 index 0000000000..d2b97b7aad --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/DatagripUtils.kt @@ -0,0 +1,51 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.datagrip + +import com.intellij.database.dataSource.DataSourceSslConfiguration +import com.intellij.database.dataSource.DatabaseConnectionInterceptor.ProtoConnection +import com.intellij.database.remote.jdbc.helpers.JdbcSettings +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettings +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.jetbrains.services.rds.jdbcMysql +import software.aws.toolkits.jetbrains.services.rds.jdbcPostgres +import software.aws.toolkits.jetbrains.services.rds.mysqlEngineType +import software.aws.toolkits.jetbrains.services.rds.postgresEngineType +import software.aws.toolkits.jetbrains.services.redshift.RedshiftResources.jdbcRedshift +import software.aws.toolkits.jetbrains.services.redshift.RedshiftResources.redshiftEngineType +import software.aws.toolkits.resources.message + +const val CREDENTIAL_ID_PROPERTY = "AWS.CredentialId" +const val REGION_ID_PROPERTY = "AWS.RegionId" + +val FullSslValidation = DataSourceSslConfiguration("", "", "", true, JdbcSettings.SslMode.VERIFY_FULL) + +fun ProtoConnection.getAwsConnectionSettings(): ConnectionSettings { + val credentialManager = CredentialManager.getInstance() + val regionId = connectionPoint.additionalJdbcProperties[REGION_ID_PROPERTY] + ?: throw IllegalArgumentException(message("settings.regions.none_selected")) + val region = AwsRegionProvider.getInstance().allRegions()[regionId] + ?: throw IllegalArgumentException( + message("datagrip.validation.invalid_region_specified", regionId) + ) + val credentialId = connectionPoint.additionalJdbcProperties[CREDENTIAL_ID_PROPERTY] + ?: throw IllegalArgumentException(message("settings.credentials.none_selected")) + val credentials = credentialManager.getCredentialIdentifierById(credentialId)?.let { + credentialManager.getAwsCredentialProvider(it, region) + } ?: throw IllegalArgumentException(message("datagrip.validation.invalid_credential_specified", credentialId)) + return ConnectionSettings(credentials, region) +} + +fun jdbcAdapterFromRuntime(runtime: String?): String? = when (runtime) { + postgresEngineType -> jdbcPostgres + mysqlEngineType -> jdbcMysql + redshiftEngineType -> jdbcRedshift + else -> null +} + +// We don't have access to service information when we need this. What we do have access to is the database driver +// which matches up with the engine field for mysql, postgres, and redshift which is what we currently +// support. TODO find a more direct way to do this +fun ProtoConnection.getDatabaseEngine() = connectionPoint.databaseDriver.id diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/actions/AddSecretsManagerConnection.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/actions/AddSecretsManagerConnection.kt new file mode 100644 index 0000000000..45a9e31792 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/actions/AddSecretsManagerConnection.kt @@ -0,0 +1,101 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.datagrip.actions + +import com.intellij.database.autoconfig.DataSourceRegistry +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.FullSslValidation +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.auth.SECRET_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.auth.SecretsManagerAuth +import software.aws.toolkits.jetbrains.datagrip.auth.SecretsManagerDbSecret +import software.aws.toolkits.jetbrains.datagrip.jdbcAdapterFromRuntime +import software.aws.toolkits.jetbrains.services.rds.RdsNode +import software.aws.toolkits.jetbrains.services.redshift.RedshiftExplorerNode +import software.aws.toolkits.jetbrains.services.redshift.RedshiftExplorerParentNode +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DatabaseCredentials +import software.aws.toolkits.telemetry.RdsTelemetry +import software.aws.toolkits.telemetry.RedshiftTelemetry +import software.aws.toolkits.telemetry.Result + +// It is registered in ext-datagrip.xml FIX_WHEN_MIN_IS_201 +@Suppress("ComponentNotRegistered") +class AddSecretsManagerConnection : SingleExplorerNodeAction>(message("datagrip.secretsmanager.action")), DumbAware { + override fun actionPerformed(selected: AwsExplorerNode<*>, e: AnActionEvent) { + var result = Result.Succeeded + var engine: String? = null + try { + val dialogWrapper = SecretsManagerDialogWrapper(selected) + val ok = dialogWrapper.showAndGet() + if (!ok) { + result = Result.Cancelled + return + } + val secret = dialogWrapper.dbSecret + val secretArn = dialogWrapper.dbSecretArn + + engine = secret.engine + val registry = DataSourceRegistry(selected.nodeProject) + val adapter = jdbcAdapterFromRuntime(engine) + ?: throw IllegalStateException(message("datagrip.secretsmanager.validation.unkown_engine", secret.engine.toString())) + registry.createDatasource(selected.nodeProject, secret, secretArn, adapter) + // Show the user the configuration dialog to let them save/edit/test the profile + runInEdt { + registry.showDialog() + } + } catch (e: Throwable) { + result = Result.Failed + throw e + } finally { + recordTelemetry(selected, result, engine) + } + } + + private fun recordTelemetry(selected: AwsExplorerNode<*>, result: Result, engine: String? = null) { + val dbEngine = engine ?: if (selected is RdsNode) { + selected.dbInstance.engine() + } else { + null + } + if (selected is RedshiftExplorerParentNode || selected is RedshiftExplorerNode) { + RedshiftTelemetry.createConnectionConfiguration( + selected.nodeProject, + result, + DatabaseCredentials.SecretsManager + ) + } else { + RdsTelemetry.createConnectionConfiguration( + selected.nodeProject, + result, + DatabaseCredentials.SecretsManager, + dbEngine + ) + } + } +} + +fun DataSourceRegistry.createDatasource(project: Project, secret: SecretsManagerDbSecret, secretArn: String, jdbcAdapter: String) { + val connectionSettings = AwsConnectionManager.getInstance(project).connectionSettings() + builder + .withJdbcAdditionalProperty(CREDENTIAL_ID_PROPERTY, connectionSettings?.credentials?.id) + .withJdbcAdditionalProperty(REGION_ID_PROPERTY, connectionSettings?.region?.id) + .withJdbcAdditionalProperty(SECRET_ID_PROPERTY, secretArn) + .withUrl(secret.host) + .withUser(secret.username) + .withUrl("jdbc:$jdbcAdapter://${secret.host}:${secret.port}") + .commit() + // TODO FIX_WHEN_MIN_IS_202 set auth provider ID in builder + newDataSources.firstOrNull()?.let { + it.authProviderId = SecretsManagerAuth.providerId + it.sslCfg = FullSslValidation + } +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/actions/SecretsManagerDialogWrapper.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/actions/SecretsManagerDialogWrapper.kt new file mode 100644 index 0000000000..2bc79d4c8f --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/actions/SecretsManagerDialogWrapper.kt @@ -0,0 +1,112 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.datagrip.actions + +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.progress.PerformInBackgroundOption +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task.Backgroundable +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import software.amazon.awssdk.services.secretsmanager.model.SecretListEntry +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode +import software.aws.toolkits.jetbrains.datagrip.DatabaseSecret +import software.aws.toolkits.jetbrains.datagrip.auth.SecretsManagerDbSecret +import software.aws.toolkits.jetbrains.services.secretsmanager.SecretsManagerResources +import software.aws.toolkits.jetbrains.services.secretsmanager.arnToName +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel + +class SecretsManagerDialogWrapper(private val selected: AwsExplorerNode<*>) : DialogWrapper(selected.nodeProject) { + private lateinit var secrets: ResourceSelector + lateinit var dbSecret: SecretsManagerDbSecret + private set + lateinit var dbSecretArn: String + private set + + init { + title = message("datagrip.secretsmanager.action.title") + setOKButtonText(message("general.create_button")) + init() + } + + override fun createCenterPanel(): JComponent? { + secrets = ResourceSelector.builder(selected.nodeProject) + .resource(SecretsManagerResources.secrets) + .customRenderer { entry, renderer -> renderer.append(entry.name()); renderer } + .build().also { + // When it is changed, make sure the OK button is re-enabled + it.addActionListener { + isOKActionEnabled = true + } + } + val panel = JPanel(BorderLayout()) + panel.add(secrets) + return panel + } + + override fun doOKAction() { + if (!okAction.isEnabled) { + return + } + object : Backgroundable( + selected.nodeProject, + message("datagrip.secretsmanager.validating"), + false, + PerformInBackgroundOption.ALWAYS_BACKGROUND + ) { + override fun run(indicator: ProgressIndicator) { + try { + validateConfiguration() + } catch (e: Exception) { + notifyError( + project = selected.nodeProject, + title = message("datagrip.secretsmanager.validation.exception"), + content = e.message ?: e.toString() + ) + } + } + }.queue() + } + + private fun validateConfiguration() { + val selectedSecret = secrets.selected() + val response = DatabaseSecret.getSecret(selected.nodeProject, selectedSecret) + if (response == null) { + runInEdt(ModalityState.any()) { + super.doCancelAction() + notifyError(content = message("datagrip.secretsmanager.validation.failed_to_get", selectedSecret?.arn().toString())) + } + return + } + // Cache content and arn so we don't have to retrieve them again + dbSecret = response.first + dbSecretArn = response.second + // validate the content of the secret + val validationInfo = DatabaseSecret.validateSecret(selected, response.first, response.second.arnToName()) + + runInEdt(ModalityState.any()) { + if (validationInfo == null) { + super.doOKAction() + } else { + val result = Messages.showOkCancelDialog( + selected.nodeProject, + message("datagrip.secretsmanager.action.confirm_continue", validationInfo.message), + message("datagrip.secretsmanager.action.confirm_continue_title"), + Messages.getOkButton(), + Messages.getCancelButton(), + Messages.getWarningIcon() + ) + if (result == Messages.OK) { + super.doOKAction() + } + } + } + } +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerAuth.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerAuth.kt new file mode 100644 index 0000000000..33043b8485 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerAuth.kt @@ -0,0 +1,103 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.datagrip.auth + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.credentialStore.Credentials +import com.intellij.database.Dbms +import com.intellij.database.access.DatabaseCredentials +import com.intellij.database.dataSource.DatabaseAuthProvider +import com.intellij.database.dataSource.DatabaseAuthProvider.AuthWidget +import com.intellij.database.dataSource.DatabaseConnectionInterceptor.ProtoConnection +import com.intellij.database.dataSource.DatabaseCredentialsAuthProvider +import com.intellij.database.dataSource.LocalDataSource +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.future.future +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettings +import software.aws.toolkits.jetbrains.datagrip.getAwsConnectionSettings +import software.aws.toolkits.jetbrains.datagrip.getDatabaseEngine +import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DatabaseCredentials.SecretsManager +import software.aws.toolkits.telemetry.RdsTelemetry +import software.aws.toolkits.telemetry.RedshiftTelemetry +import software.aws.toolkits.telemetry.Result +import java.util.concurrent.CompletionStage + +data class SecretsManagerConfiguration( + val connectionSettings: ConnectionSettings, + val secretId: String +) + +class SecretsManagerAuth : DatabaseAuthProvider, CoroutineScope by ApplicationThreadPoolScope("RedshiftSecretsManagerAuth") { + private val objectMapper = jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + + override fun getId(): String = providerId + override fun isApplicable(dataSource: LocalDataSource): Boolean { + val dbms = dataSource.dbms + return dbms == Dbms.MYSQL || dbms == Dbms.POSTGRES || dbms == Dbms.REDSHIFT + } + + override fun getDisplayName(): String = message("datagrip.auth.secrets_manager") + + override fun createWidget(creds: DatabaseCredentials, source: LocalDataSource): AuthWidget? = + SecretsManagerAuthWidget() + + override fun intercept( + connection: ProtoConnection, + silent: Boolean + ): CompletionStage? { + LOG.info { "Intercepting db connection [$connection]" } + return future { + var result = Result.Succeeded + val project = connection.runConfiguration.project + try { + val connectionSettings = getConfiguration(connection) + val credentials = getCredentials(connection.runConfiguration.project, connectionSettings) + DatabaseCredentialsAuthProvider.applyCredentials(connection, credentials, true) + } catch (e: Throwable) { + result = Result.Failed + throw e + } finally { + val engine = connection.getDatabaseEngine() + if (engine == "redshift") { + RedshiftTelemetry.getCredentials(project, result, SecretsManager) + } else { + RdsTelemetry.getCredentials(project, result, SecretsManager, engine) + } + } + } + } + + private fun getConfiguration(connection: ProtoConnection): SecretsManagerConfiguration { + val connectionSettings = connection.getAwsConnectionSettings() + val secretId = connection.connectionPoint.additionalJdbcProperties[SECRET_ID_PROPERTY] + ?: throw IllegalArgumentException(message("datagrip.secretsmanager.validation.no_secret")) + return SecretsManagerConfiguration( + connectionSettings, + secretId + ) + } + + private fun getCredentials(project: Project, configuration: SecretsManagerConfiguration): Credentials { + val client = project.awsClient(configuration.connectionSettings.credentials, configuration.connectionSettings.region) + val secret = client.getSecretValue { it.secretId(configuration.secretId) } + val dbSecret = objectMapper.readValue(secret.secretString()) + dbSecret.username ?: throw IllegalArgumentException(message("datagrip.secretsmanager.validation.no_username", secret.name())) + dbSecret.password ?: throw IllegalArgumentException(message("datagrip.secretsmanager.validation.no_password", secret.name())) + return Credentials(dbSecret.username, dbSecret.password) + } + + companion object { + const val providerId = "aws.secretsmanager" + private val LOG = getLogger() + } +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerAuthWidget.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerAuthWidget.kt new file mode 100644 index 0000000000..3b821b127a --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerAuthWidget.kt @@ -0,0 +1,54 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.datagrip.auth + +import com.intellij.database.dataSource.DataSourceUiUtil +import com.intellij.database.dataSource.LocalDataSource +import com.intellij.database.dataSource.url.ui.UrlPropertiesPanel +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.util.text.nullize +import org.jetbrains.annotations.TestOnly +import software.aws.toolkits.jetbrains.services.rds.RdsResources +import software.aws.toolkits.jetbrains.services.redshift.RedshiftUtils +import software.aws.toolkits.jetbrains.ui.AwsAuthWidget +import software.aws.toolkits.resources.message +import javax.swing.JPanel + +const val SECRET_ID_PROPERTY = "AWS.SecretId" + +class SecretsManagerAuthWidget : AwsAuthWidget(userField = false) { + private val secretIdSelector = JBTextField() + + override val rowCount = 4 + override fun getRegionFromUrl(url: String?): String? = RdsResources.extractRegionFromUrl(url) ?: RedshiftUtils.extractRegionFromUrl(url) + + override fun createPanel(): JPanel { + val panel = super.createPanel() + val label = JBLabel(message("datagrip.secret_id")) + panel.add(label, UrlPropertiesPanel.createLabelConstraints(3, 0, label.preferredSize.getWidth())) + panel.add(secretIdSelector, UrlPropertiesPanel.createSimpleConstraints(3, 1, 3)) + return panel + } + + override fun save(dataSource: LocalDataSource, copyCredentials: Boolean) { + super.save(dataSource, copyCredentials) + + DataSourceUiUtil.putOrRemove( + dataSource.additionalJdbcProperties, + SECRET_ID_PROPERTY, + secretIdSelector.text.nullize() + ) + } + + override fun reset(dataSource: LocalDataSource, resetCredentials: Boolean) { + super.reset(dataSource, resetCredentials) + dataSource.additionalJdbcProperties[SECRET_ID_PROPERTY]?.nullize()?.let { + secretIdSelector.text = it + } + } + + @TestOnly + internal fun getSecretId() = secretIdSelector.text +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerDbSecret.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerDbSecret.kt new file mode 100644 index 0000000000..09a9fb942b --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerDbSecret.kt @@ -0,0 +1,14 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.datagrip.auth + +// Data class that represents the schema used for DB secrets in secretsmanger +// used by RDS and Redshift +data class SecretsManagerDbSecret( + val username: String?, + val password: String?, + val engine: String?, + val host: String?, + val port: String? +) diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/RdsDatasourceConfiguration.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/RdsDatasourceConfiguration.kt new file mode 100644 index 0000000000..52d8986c85 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/RdsDatasourceConfiguration.kt @@ -0,0 +1,13 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.rds + +import software.amazon.awssdk.services.rds.model.DBInstance + +data class RdsDatasourceConfiguration( + val regionId: String, + val credentialId: String, + val dbInstance: DBInstance, + val username: String +) diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/RdsExplorerNodes.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/RdsExplorerNodes.kt new file mode 100644 index 0000000000..942f3d6183 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/RdsExplorerNodes.kt @@ -0,0 +1,74 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.rds + +import com.intellij.openapi.project.Project +import icons.AwsIcons +import software.amazon.awssdk.services.rds.RdsClient +import software.amazon.awssdk.services.rds.model.DBInstance +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.Resource +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerResourceNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerServiceNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerServiceRootNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.ResourceParentNode +import software.aws.toolkits.resources.message +import javax.swing.Icon + +class RdsExplorerParentNode(project: Project, private val service: AwsExplorerServiceNode) : AwsExplorerServiceRootNode(project, service) { + override fun getChildrenInternal(): List> = listOf( + AuroraParentNode(nodeProject, message("rds.aurora")), + RdsParentNode(nodeProject, message("rds.mysql"), AwsIcons.Resources.Rds.MYSQL, RdsResources.LIST_INSTANCES_MYSQL), + RdsParentNode(nodeProject, message("rds.postgres"), AwsIcons.Resources.Rds.POSTGRES, RdsResources.LIST_INSTANCES_POSTGRES) + ) +} + +class AuroraParentNode( + project: Project, + type: String +) : AwsExplorerNode(project, type, null), ResourceParentNode { + override fun isAlwaysShowPlus(): Boolean = true + override fun getChildren(): List> = super.getChildren() + override fun getChildrenInternal(): List> = listOf( + RdsParentNode(nodeProject, message("rds.mysql"), AwsIcons.Resources.Rds.MYSQL, RdsResources.LIST_INSTANCES_AURORA_MYSQL, aurora = true), + RdsParentNode(nodeProject, message("rds.postgres"), AwsIcons.Resources.Rds.POSTGRES, RdsResources.LIST_INSTANCES_AURORA_POSTGRES, aurora = true) + ) +} + +class RdsParentNode( + project: Project, + type: String, + private val childIcon: Icon, + private val method: Resource.Cached>, + private val aurora: Boolean = false +) : AwsExplorerNode(project, type, null), ResourceParentNode { + override fun isAlwaysShowPlus(): Boolean = true + override fun getChildren(): List> = super.getChildren() + override fun getChildrenInternal(): List> = AwsResourceCache.getInstance(nodeProject) + .getResourceNow(method) + .map { + RdsNode( + nodeProject, + if (aurora) { + "aurora.${it.engine().getEngineFromAuroraEngine()}" + } else { + it.engine() + }, + childIcon, + it + ) + } +} + +class RdsNode(project: Project, private val resourceType: String, icon: Icon, val dbInstance: DBInstance) : AwsExplorerResourceNode( + project, + RdsClient.SERVICE_NAME, + dbInstance.dbInstanceArn(), + icon +) { + override fun displayName(): String = dbInstance.dbInstanceIdentifier() + override fun resourceArn(): String = dbInstance.dbInstanceArn() + override fun resourceType(): String = resourceType +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/RdsResources.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/RdsResources.kt new file mode 100644 index 0000000000..3f78ad7981 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/RdsResources.kt @@ -0,0 +1,48 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.rds + +import software.amazon.awssdk.services.rds.RdsClient +import software.amazon.awssdk.services.rds.model.DBInstance +import software.amazon.awssdk.services.rds.model.Filter +import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource +import software.aws.toolkits.jetbrains.core.Resource + +// These are the member engine in DBInstance, but it is a string +const val mysqlEngineType = "mysql" +const val postgresEngineType = "postgres" +const val auroraMysqlEngineType = "aurora" +const val auroraPostgresEngineType = "aurora-postgresql" + +fun String.getEngineFromAuroraEngine(): String = when (this) { + auroraMysqlEngineType -> mysqlEngineType + auroraPostgresEngineType -> postgresEngineType + else -> throw IllegalArgumentException("Unknown Aurora engine $this") +} + +const val jdbcMysql = "mysql" +const val jdbcMariadb = "mariadb" +const val jdbcPostgres = "postgresql" + +// Filters are also just a string +const val engineFilter = "engine" + +object RdsResources { + private val RDS_REGION_REGEX = """.*\.(.+).rds\.""".toRegex() + private val RDS_IDENTIFIER_REGEX = """.*//(.+)\..*\..*.rds\..""".toRegex() + + fun extractRegionFromUrl(url: String?): String? = url?.let { RDS_REGION_REGEX.find(url)?.groupValues?.get(1) } + fun extractIdentifierFromUrl(url: String?): String? = url?.let { RDS_IDENTIFIER_REGEX.find(url)?.groupValues?.get(1) } + + val LIST_INSTANCES_MYSQL: Resource.Cached> = listInstancesFilter(mysqlEngineType) + val LIST_INSTANCES_POSTGRES: Resource.Cached> = listInstancesFilter(postgresEngineType) + val LIST_INSTANCES_AURORA_MYSQL: Resource.Cached> = listInstancesFilter(auroraMysqlEngineType) + val LIST_INSTANCES_AURORA_POSTGRES: Resource.Cached> = listInstancesFilter(auroraPostgresEngineType) + + private fun listInstancesFilter(engine: String) = ClientBackedCachedResource(RdsClient::class, "rds.list_instances.$engine") { + this.describeDBInstancesPaginator { + it.filters(Filter.builder().name(engineFilter).values(engine).build()) + }.toList().flatMap { it.dbInstances() } + } +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/actions/CreateIamDataSourceAction.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/actions/CreateIamDataSourceAction.kt new file mode 100644 index 0000000000..33970057c1 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/actions/CreateIamDataSourceAction.kt @@ -0,0 +1,150 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.rds.actions + +import com.intellij.database.DatabaseBundle +import com.intellij.database.autoconfig.DataSourceRegistry +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.progress.PerformInBackgroundOption +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.credentials.activeCredentialProvider +import software.aws.toolkits.jetbrains.core.credentials.activeRegion +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY +import software.aws.toolkits.jetbrains.services.rds.RdsDatasourceConfiguration +import software.aws.toolkits.jetbrains.services.rds.RdsNode +import software.aws.toolkits.jetbrains.services.rds.auroraMysqlEngineType +import software.aws.toolkits.jetbrains.services.rds.auroraPostgresEngineType +import software.aws.toolkits.jetbrains.services.rds.auth.IamAuth +import software.aws.toolkits.jetbrains.services.rds.jdbcMariadb +import software.aws.toolkits.jetbrains.services.rds.jdbcMysql +import software.aws.toolkits.jetbrains.services.rds.jdbcPostgres +import software.aws.toolkits.jetbrains.services.rds.mysqlEngineType +import software.aws.toolkits.jetbrains.services.rds.postgresEngineType +import software.aws.toolkits.jetbrains.services.sts.StsResources +import software.aws.toolkits.jetbrains.utils.actions.OpenBrowserAction +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DatabaseCredentials +import software.aws.toolkits.telemetry.RdsTelemetry +import software.aws.toolkits.telemetry.Result + +// It is registered in ext-datagrip.xml FIX_WHEN_MIN_IS_201 +@Suppress("ComponentNotRegistered") +class CreateIamDataSourceAction : SingleExplorerNodeAction(message("rds.iam_config")), DumbAware { + override fun actionPerformed(selected: RdsNode, e: AnActionEvent) { + if (!checkPrerequisites(selected)) { + return + } + object : Task.Backgroundable( + selected.nodeProject, + DatabaseBundle.message("message.text.refreshing.data.source"), + true, + PerformInBackgroundOption.ALWAYS_BACKGROUND + ) { + override fun run(indicator: ProgressIndicator) { + val registry = DataSourceRegistry(selected.nodeProject) + createDatasource(selected, registry) + // Asynchronously show the user the configuration dialog to let them save/edit/test the profile + runInEdt { + registry.showDialog() + } + } + + override fun onCancel() = recordTelemetry(Result.Cancelled) + override fun onThrowable(error: Throwable) = recordTelemetry(Result.Failed) + override fun onSuccess() = recordTelemetry(Result.Succeeded) + + private fun recordTelemetry(result: Result) = RdsTelemetry.createConnectionConfiguration( + selected.nodeProject, + result, + DatabaseCredentials.IAM, + selected.dbInstance.engine() + ) + }.queue() + } + + internal fun checkPrerequisites(node: RdsNode): Boolean { + // Assert IAM auth enabled + if (!node.dbInstance.iamDatabaseAuthenticationEnabled()) { + notifyError( + project = node.nodeProject, + title = message("aws.notification.title"), + content = message("rds.validation.no_iam_auth", node.dbInstance.dbName()), + action = OpenBrowserAction(message("rds.validation.setup_guide"), null, HelpIds.RDS_SETUP_IAM_AUTH.url) + ) + return false + } + return true + } + + internal fun createDatasource(node: RdsNode, registry: DataSourceRegistry) { + val username = try { + // use current STS user as username. Split on : because it comes back id:username + AwsResourceCache.getInstance(node.nodeProject).getResourceNow(StsResources.USER).substringAfter(':') + } catch (e: Exception) { + LOG.warn(e) { "Getting username from STS failed, falling back to master username" } + node.dbInstance.masterUsername() + } + registry.createRdsDatasource( + RdsDatasourceConfiguration( + regionId = node.nodeProject.activeRegion().id, + credentialId = node.nodeProject.activeCredentialProvider().id, + dbInstance = node.dbInstance, + username = username + ) + ) + } + + private companion object { + val LOG = getLogger() + } +} + +fun DataSourceRegistry.createRdsDatasource(config: RdsDatasourceConfiguration) { + val dbEngine = config.dbInstance.engine() + val url = "${config.dbInstance.endpoint().address()}:${config.dbInstance.endpoint().port()}" + + val builder = builder + .withJdbcAdditionalProperty(CREDENTIAL_ID_PROPERTY, config.credentialId) + .withJdbcAdditionalProperty(REGION_ID_PROPERTY, config.regionId) + when (dbEngine) { + mysqlEngineType -> { + builder + .withUrl("jdbc:$jdbcMysql://$url/") + .withUser(config.username) + } + postgresEngineType, auroraPostgresEngineType -> { + builder + .withUrl("jdbc:$jdbcPostgres://$url/") + // In postgres this is case sensitive as lower case. If you add a db user for + // IAM role "Admin", it is inserted as "admin" + .withUser(config.username.toLowerCase()) + } + auroraMysqlEngineType -> { + builder + // The docs recommend using MariaDB instead of MySQL to connect to MySQL Aurora DBs: + // https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Connecting.html#Aurora.Connecting.AuroraMySQL + .withUrl("jdbc:$jdbcMariadb://$url/") + .withUser(config.username) + } + else -> throw IllegalArgumentException("Engine $dbEngine is not supported for IAM auth!") + } + builder.commit() + // TODO FIX_WHEN_MIN_IS_202 set auth provider ID in builder. There is no way to set it in the builder, + // so we have to set it after the fact. However, that means we need to pull it out after it is built. + // The builder doesn't return a reference to it, so we have to pull it out of the committed data sources. + // newDataSources contains the list of ones added just now, so add it to that + newDataSources.firstOrNull()?.let { + it.authProviderId = IamAuth.providerId + } ?: throw IllegalStateException("Newly inserted data source is not in the data source registry!") +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/auth/IamAuth.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/auth/IamAuth.kt new file mode 100644 index 0000000000..213e3bda23 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/auth/IamAuth.kt @@ -0,0 +1,136 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.rds.auth + +import com.intellij.credentialStore.Credentials +import com.intellij.database.Dbms +import com.intellij.database.access.DatabaseCredentials +import com.intellij.database.dataSource.DatabaseAuthProvider +import com.intellij.database.dataSource.DatabaseAuthProvider.AuthWidget +import com.intellij.database.dataSource.DatabaseConnectionInterceptor.ProtoConnection +import com.intellij.database.dataSource.DatabaseCredentialsAuthProvider +import com.intellij.database.dataSource.LocalDataSource +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.future.future +import software.amazon.awssdk.auth.signer.Aws4Signer +import software.amazon.awssdk.auth.signer.params.Aws4PresignerParams +import software.amazon.awssdk.http.SdkHttpFullRequest +import software.amazon.awssdk.http.SdkHttpMethod +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.rds.RdsClient +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettings +import software.aws.toolkits.jetbrains.datagrip.getAwsConnectionSettings +import software.aws.toolkits.jetbrains.datagrip.getDatabaseEngine +import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DatabaseCredentials.IAM +import software.aws.toolkits.telemetry.RdsTelemetry +import software.aws.toolkits.telemetry.Result +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.CompletionStage + +data class RdsAuth( + val address: String, + val port: Int, + val user: String, + val dbIdentifier: String, + val connectionSettings: ConnectionSettings +) + +// [DatabaseAuthProvider] is marked as internal, but JetBrains advised this was a correct usage +class IamAuth : DatabaseAuthProvider, CoroutineScope by ApplicationThreadPoolScope("RdsIamAuth") { + override fun getId(): String = providerId + override fun getDisplayName(): String = message("rds.iam_connection_display_name") + + override fun isApplicable(dataSource: LocalDataSource): Boolean = dataSource.dbms == Dbms.MYSQL || dataSource.dbms == Dbms.POSTGRES + + override fun createWidget(credentials: DatabaseCredentials, dataSource: LocalDataSource): AuthWidget? = IamAuthWidget() + + override fun intercept( + connection: ProtoConnection, + silent: Boolean + ): CompletionStage? { + LOG.info { "Intercepting db connection [$connection]" } + return future { + var result = Result.Succeeded + val project = connection.runConfiguration.project + try { + val credentials = getCredentials(project, connection) + DatabaseCredentialsAuthProvider.applyCredentials(connection, credentials, true) + } catch (e: Throwable) { + result = Result.Failed + throw e + } finally { + RdsTelemetry.getCredentials(project, result, IAM, connection.getDatabaseEngine()) + } + } + } + + private fun getCredentials(project: Project, connection: ProtoConnection): Credentials { + val authInformation = getAuthInformation(project, connection) + val authToken = generateAuthToken(authInformation) + return Credentials(authInformation.user, authToken) + } + + internal fun getAuthInformation(project: Project, connection: ProtoConnection): RdsAuth { + val awsConnection = connection.getAwsConnectionSettings() + val instanceId = connection.connectionPoint.additionalJdbcProperties[INSTANCE_ID_PROPERTY] + ?: throw IllegalArgumentException(message("rds.validation.no_instance_id")) + val user = connection.connectionPoint.dataSource.username + + if (user.isBlank()) { + throw IllegalArgumentException(message("rds.validation.username")) + } + + // Get the endpoint so that we can get the correct URL and port. If a proxy is used, + // or ip is used, we need to get the port and address the service expects + val endpoint = project.awsClient(awsConnection) + .describeDBInstances { it.dbInstanceIdentifier(instanceId) } + .dbInstances() + .first() + .endpoint() + + return RdsAuth( + endpoint.address(), + endpoint.port(), + user, + instanceId, + awsConnection + ) + } + + internal fun generateAuthToken(auth: RdsAuth): String { + // TODO: Replace when SDK V2 backfills the pre-signer for rds auth token + val httpRequest = SdkHttpFullRequest.builder() + .method(SdkHttpMethod.GET) + .protocol("https") + .host(auth.address) + .port(auth.port) + .encodedPath("/") + .putRawQueryParameter("DBUser", auth.user) + .putRawQueryParameter("Action", "connect") + .build() + + // TODO consider configurable expiration time + val expirationTime = Instant.now().plus(15, ChronoUnit.MINUTES) + val presignRequest = Aws4PresignerParams.builder() + .expirationTime(expirationTime) + .awsCredentials(auth.connectionSettings.credentials.resolveCredentials()) + .signingName("rds-db") + .signingRegion(Region.of(auth.connectionSettings.region.id)) + .build() + + return Aws4Signer.create().presign(httpRequest, presignRequest).uri.toString().removePrefix("https://") + } + + companion object { + const val providerId = "aws.rds.iam" + private val LOG = getLogger() + } +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/auth/IamAuthWidget.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/auth/IamAuthWidget.kt new file mode 100644 index 0000000000..b543b4f186 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/rds/auth/IamAuthWidget.kt @@ -0,0 +1,63 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.rds.auth + +import com.intellij.database.dataSource.DataSourceUiUtil +import com.intellij.database.dataSource.LocalDataSource +import com.intellij.database.dataSource.url.template.ParametersHolder +import com.intellij.database.dataSource.url.template.UrlEditorModel +import com.intellij.database.dataSource.url.ui.UrlPropertiesPanel +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import org.jetbrains.annotations.TestOnly +import software.aws.toolkits.jetbrains.services.rds.RdsResources +import software.aws.toolkits.jetbrains.ui.AwsAuthWidget +import software.aws.toolkits.resources.message +import javax.swing.JPanel + +const val INSTANCE_ID_PROPERTY = "AWS.RdsInstanceId" + +class IamAuthWidget : AwsAuthWidget() { + private val instanceIdTextField = JBTextField() + + override val rowCount = 4 + override fun getRegionFromUrl(url: String?): String? = RdsResources.extractRegionFromUrl(url) + + override fun createPanel(): JPanel { + val panel = super.createPanel() + val regionLabel = JBLabel(message("rds.instance_id")) + panel.add(regionLabel, UrlPropertiesPanel.createLabelConstraints(3, 0, regionLabel.preferredSize.getWidth())) + panel.add(instanceIdTextField, UrlPropertiesPanel.createSimpleConstraints(3, 1, 3)) + return panel + } + + override fun save(dataSource: LocalDataSource, copyCredentials: Boolean) { + super.save(dataSource, copyCredentials) + + DataSourceUiUtil.putOrRemove( + dataSource.additionalJdbcProperties, + INSTANCE_ID_PROPERTY, + instanceIdTextField.text + ) + } + + override fun reset(dataSource: LocalDataSource, resetCredentials: Boolean) { + super.reset(dataSource, resetCredentials) + instanceIdTextField.text = dataSource.additionalJdbcProperties[INSTANCE_ID_PROPERTY] + } + + override fun updateFromUrl(holder: ParametersHolder) { + super.updateFromUrl(holder) + // cluster id does not always match what is in the url (like Aurora), so if we already + // have something in the box, don't update it + if (instanceIdTextField.text.isNullOrEmpty()) { + val url = (holder as? UrlEditorModel)?.url + val clusterId = RdsResources.extractIdentifierFromUrl(url) + clusterId?.let { instanceIdTextField.text = it } + } + } + + @TestOnly + internal fun getInstanceId() = instanceIdTextField.text +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftExplorerNodes.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftExplorerNodes.kt new file mode 100644 index 0000000000..e2efde21ad --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftExplorerNodes.kt @@ -0,0 +1,31 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift + +import icons.AwsIcons +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.redshift.RedshiftClient +import software.amazon.awssdk.services.redshift.model.Cluster +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerResourceNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerServiceNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.CacheBackedAwsExplorerServiceRootNode + +class RedshiftExplorerParentNode( + project: Project, + service: AwsExplorerServiceNode +) : CacheBackedAwsExplorerServiceRootNode(project, service, RedshiftResources.LIST_CLUSTERS) { + override fun toNode(child: Cluster): AwsExplorerNode<*> = RedshiftExplorerNode(nodeProject, child) +} + +class RedshiftExplorerNode(project: Project, val cluster: Cluster) : AwsExplorerResourceNode( + project, + RedshiftClient.SERVICE_NAME, + cluster, + AwsIcons.Resources.REDSHIFT +) { + override fun displayName(): String = cluster.clusterIdentifier() + override fun resourceType(): String = "cluster" + override fun resourceArn(): String = nodeProject.clusterArn(cluster, region) +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftResources.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftResources.kt new file mode 100644 index 0000000000..03a69713be --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftResources.kt @@ -0,0 +1,18 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift + +import software.amazon.awssdk.services.redshift.RedshiftClient +import software.amazon.awssdk.services.redshift.model.Cluster +import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource +import software.aws.toolkits.jetbrains.core.Resource + +object RedshiftResources { + val LIST_CLUSTERS: Resource.Cached> = ClientBackedCachedResource(RedshiftClient::class, "redshift.list_instances") { + describeClustersPaginator().clusters().toList() + } + + const val jdbcRedshift = "redshift" + const val redshiftEngineType = "redshift" +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtils.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtils.kt new file mode 100644 index 0000000000..e6fe864cdf --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtils.kt @@ -0,0 +1,49 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift + +import com.intellij.database.autoconfig.DataSourceRegistry +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.redshift.model.Cluster +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.FullSslValidation +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY +import software.aws.toolkits.jetbrains.services.redshift.auth.CLUSTER_ID_PROPERTY +import software.aws.toolkits.jetbrains.services.redshift.auth.IamAuth +import software.aws.toolkits.jetbrains.services.sts.StsResources + +object RedshiftUtils { + private val REDSHIFT_REGION_REGEX = """.*\..*\.(.+).redshift\.""".toRegex() + private val REDSHIFT_IDENTIFIER_REGEX = """.*//(.+)\..*\..*.redshift\..""".toRegex() + + fun extractRegionFromUrl(url: String?): String? = url?.let { REDSHIFT_REGION_REGEX.find(url)?.groupValues?.get(1) } + fun extractClusterIdFromUrl(url: String?): String? = url?.let { REDSHIFT_IDENTIFIER_REGEX.find(url)?.groupValues?.get(1) } +} + +fun Project.clusterArn(cluster: Cluster, region: AwsRegion): String { + // Attempt to get account out of the cache. If not, it's empty so, it is still a valid arn + val account = tryOrNull { AwsResourceCache.getInstance(this).getResourceIfPresent(StsResources.ACCOUNT) } ?: "" + return "arn:${region.partitionId}:redshift:${region.id}:$account:cluster:${cluster.clusterIdentifier()}" +} + +fun DataSourceRegistry.createDatasource(project: Project, cluster: Cluster) { + val connectionSettings = AwsConnectionManager.getInstance(project).connectionSettings() + builder + .withJdbcAdditionalProperty(CREDENTIAL_ID_PROPERTY, connectionSettings?.credentials?.id) + .withJdbcAdditionalProperty(REGION_ID_PROPERTY, connectionSettings?.region?.id) + .withJdbcAdditionalProperty(CLUSTER_ID_PROPERTY, cluster.clusterIdentifier()) + .withUser(cluster.masterUsername()) + .withUrl("jdbc:redshift://${cluster.endpoint().address()}:${cluster.endpoint().port()}/${cluster.dbName()}") + .commit() + // TODO FIX_WHEN_MIN_IS_202 set auth provider ID in builder + newDataSources.firstOrNull()?.let { + it.authProviderId = IamAuth.providerId + // Force SSL on + it.sslCfg = FullSslValidation + } ?: throw IllegalStateException("Newly inserted data source is not in the data source registry!") +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/actions/CreateDataSourceAction.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/actions/CreateDataSourceAction.kt new file mode 100644 index 0000000000..740a637123 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/actions/CreateDataSourceAction.kt @@ -0,0 +1,52 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift.actions + +import com.intellij.database.DatabaseBundle +import com.intellij.database.autoconfig.DataSourceRegistry +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.progress.PerformInBackgroundOption +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task.Backgroundable +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction +import software.aws.toolkits.jetbrains.services.redshift.RedshiftExplorerNode +import software.aws.toolkits.jetbrains.services.redshift.createDatasource +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DatabaseCredentials.IAM +import software.aws.toolkits.telemetry.RedshiftTelemetry +import software.aws.toolkits.telemetry.Result + +// It is registered in ext-datagrip.xml FIX_WHEN_MIN_IS_201 +@Suppress("ComponentNotRegistered") +class CreateDataSourceAction : SingleExplorerNodeAction(message("redshift.connect_aws_credentials")), DumbAware { + override fun actionPerformed(selected: RedshiftExplorerNode, e: AnActionEvent) { + object : Backgroundable( + selected.nodeProject, + DatabaseBundle.message("message.text.refreshing.data.source"), + true, + PerformInBackgroundOption.ALWAYS_BACKGROUND + ) { + override fun run(indicator: ProgressIndicator) { + val registry = DataSourceRegistry(selected.nodeProject) + registry.createDatasource(selected.nodeProject, selected.cluster) + // Asynchronously show the user the configuration dialog to let them save/edit/test the profile + runInEdt { + registry.showDialog() + } + } + + override fun onCancel() = recordTelemetry(Result.Cancelled) + override fun onThrowable(error: Throwable) = recordTelemetry(Result.Failed) + override fun onSuccess() = recordTelemetry(Result.Succeeded) + + private fun recordTelemetry(result: Result) = RedshiftTelemetry.createConnectionConfiguration( + selected.nodeProject, + result, + IAM + ) + }.queue() + } +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/auth/IamAuth.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/auth/IamAuth.kt new file mode 100644 index 0000000000..ce8e40b580 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/auth/IamAuth.kt @@ -0,0 +1,92 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift.auth + +import com.intellij.credentialStore.Credentials +import com.intellij.database.access.DatabaseCredentials +import com.intellij.database.dataSource.DatabaseAuthProvider +import com.intellij.database.dataSource.DatabaseAuthProvider.AuthWidget +import com.intellij.database.dataSource.DatabaseConnectionInterceptor.ProtoConnection +import com.intellij.database.dataSource.DatabaseCredentialsAuthProvider +import com.intellij.database.dataSource.LocalDataSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.future.future +import software.amazon.awssdk.services.redshift.RedshiftClient +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettings +import software.aws.toolkits.jetbrains.datagrip.getAwsConnectionSettings +import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DatabaseCredentials.IAM +import software.aws.toolkits.telemetry.RedshiftTelemetry +import software.aws.toolkits.telemetry.Result +import java.util.concurrent.CompletionStage + +data class RedshiftSettings( + val clusterId: String, + val username: String, + val connectionSettings: ConnectionSettings +) + +// [DatabaseAuthProvider] is marked as internal, but JetBrains advised this was a correct usage +class IamAuth : DatabaseAuthProvider, CoroutineScope by ApplicationThreadPoolScope("RedshiftIamAuth") { + override fun getId(): String = providerId + override fun isApplicable(dataSource: LocalDataSource): Boolean = dataSource.dbms.isRedshift + override fun getDisplayName(): String = message("redshift.auth.aws") + + override fun createWidget(creds: DatabaseCredentials, source: LocalDataSource): AuthWidget? = IamAuthWidget() + override fun intercept(connection: ProtoConnection, silent: Boolean): CompletionStage? { + LOG.info { "Intercepting db connection [$connection]" } + return future { + var result = Result.Succeeded + val project = connection.runConfiguration.project + try { + val auth = validateConnection(connection) + val client = project.awsClient(auth.connectionSettings.credentials, auth.connectionSettings.region) + val credentials = getCredentials(auth, client) + DatabaseCredentialsAuthProvider.applyCredentials(connection, credentials, true) + } catch (e: Throwable) { + result = Result.Failed + throw e + } finally { + RedshiftTelemetry.getCredentials(project, result, IAM) + } + } + } + + internal fun validateConnection(connection: ProtoConnection): RedshiftSettings { + val auth = connection.getAwsConnectionSettings() + val clusterIdentifier = connection.connectionPoint.additionalJdbcProperties[CLUSTER_ID_PROPERTY] + ?: throw IllegalArgumentException(message("redshift.validation.no_cluster_id")) + val username = connection.connectionPoint.dataSource.username + if (username.isEmpty()) { + throw IllegalArgumentException(message("redshift.validation.username")) + } + return RedshiftSettings( + clusterIdentifier, + username, + auth + ) + } + + internal fun getCredentials(settings: RedshiftSettings, client: RedshiftClient): Credentials { + if (client.describeClusters { it.clusterIdentifier(settings.clusterId).build() }.clusters().isEmpty()) { + throw IllegalArgumentException(message("redshift.validation.cluster_does_not_exist", settings.clusterId, settings.connectionSettings.region.id)) + } + val creds = client.getClusterCredentials { + it.clusterIdentifier(settings.clusterId) + it.dbUser(settings.username) + // By default it auto-creates the user if it doesn't exist, which we don't want? + it.autoCreate(false) + } + return Credentials(creds.dbUser(), creds.dbPassword()) + } + + companion object { + const val providerId = "aws.redshift.iam" + private val LOG = getLogger() + } +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/auth/IamAuthWidget.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/auth/IamAuthWidget.kt new file mode 100644 index 0000000000..8dc75b1ac7 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/services/redshift/auth/IamAuthWidget.kt @@ -0,0 +1,59 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift.auth + +import com.intellij.database.dataSource.DataSourceUiUtil +import com.intellij.database.dataSource.LocalDataSource +import com.intellij.database.dataSource.url.template.ParametersHolder +import com.intellij.database.dataSource.url.template.UrlEditorModel +import com.intellij.database.dataSource.url.ui.UrlPropertiesPanel +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import org.jetbrains.annotations.TestOnly +import software.aws.toolkits.jetbrains.services.redshift.RedshiftUtils +import software.aws.toolkits.jetbrains.ui.AwsAuthWidget +import software.aws.toolkits.resources.message +import javax.swing.JPanel + +const val CLUSTER_ID_PROPERTY = "AWS.RedshiftClusterId" + +class IamAuthWidget : AwsAuthWidget() { + private val clusterIdSelector = JBTextField() + + override val rowCount = 4 + override fun getRegionFromUrl(url: String?): String? = RedshiftUtils.extractRegionFromUrl(url) + + override fun createPanel(): JPanel { + val panel = super.createPanel() + val regionLabel = JBLabel(message("redshift.cluster_id")) + panel.add(regionLabel, UrlPropertiesPanel.createLabelConstraints(3, 0, regionLabel.preferredSize.getWidth())) + panel.add(clusterIdSelector, UrlPropertiesPanel.createSimpleConstraints(3, 1, 3)) + return panel + } + + override fun save(dataSource: LocalDataSource, copyCredentials: Boolean) { + super.save(dataSource, copyCredentials) + + DataSourceUiUtil.putOrRemove( + dataSource.additionalJdbcProperties, + CLUSTER_ID_PROPERTY, + clusterIdSelector.text + ) + } + + override fun reset(dataSource: LocalDataSource, resetCredentials: Boolean) { + super.reset(dataSource, resetCredentials) + clusterIdSelector.text = dataSource.additionalJdbcProperties[CLUSTER_ID_PROPERTY] + } + + override fun updateFromUrl(holder: ParametersHolder) { + super.updateFromUrl(holder) + val url = (holder as? UrlEditorModel)?.url + val clusterId = RedshiftUtils.extractClusterIdFromUrl(url) + clusterId?.let { clusterIdSelector.text = it } + } + + @TestOnly + internal fun getClusterId() = clusterIdSelector.text +} diff --git a/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/ui/AwsAuthWidget.kt b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/ui/AwsAuthWidget.kt new file mode 100644 index 0000000000..d83b424a03 --- /dev/null +++ b/jetbrains-ultimate/src-201+/software/aws/toolkits/jetbrains/ui/AwsAuthWidget.kt @@ -0,0 +1,120 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.ui + +import com.intellij.database.dataSource.DataSourceUiUtil +import com.intellij.database.dataSource.DatabaseCredentialsAuthProvider +import com.intellij.database.dataSource.LocalDataSource +import com.intellij.database.dataSource.url.template.ParametersHolder +import com.intellij.database.dataSource.url.template.UrlEditorModel +import com.intellij.database.dataSource.url.ui.UrlPropertiesPanel +import com.intellij.ui.components.JBLabel +import com.intellij.uiDesigner.core.GridLayoutManager +import com.intellij.util.text.nullize +import org.jetbrains.annotations.TestOnly +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY +import software.aws.toolkits.jetbrains.utils.ui.selected +import software.aws.toolkits.resources.message +import javax.swing.JPanel +import javax.swing.event.DocumentListener + +abstract class AwsAuthWidget(private val userField: Boolean = true) : DatabaseCredentialsAuthProvider.UserWidget() { + private val credentialSelector = CredentialProviderSelector() + private val regionSelector = RegionSelector() + + abstract fun getRegionFromUrl(url: String?): String? + + open val rowCount: Int = 3 + open val columnCount: Int = 6 + + override fun createPanel(): JPanel { + val panel = JPanel(GridLayoutManager(rowCount, columnCount)) + if (userField) { + addUserField(panel, 0) + } + val credsLabel = JBLabel(message("aws_connection.credentials.label")) + val regionLabel = JBLabel(message("aws_connection.region.label")) + panel.add(credsLabel, UrlPropertiesPanel.createLabelConstraints(1, 0, credsLabel.preferredSize.getWidth())) + panel.add(credentialSelector, UrlPropertiesPanel.createSimpleConstraints(1, 1, 3)) + panel.add(regionLabel, UrlPropertiesPanel.createLabelConstraints(2, 0, regionLabel.preferredSize.getWidth())) + panel.add(regionSelector, UrlPropertiesPanel.createSimpleConstraints(2, 1, 3)) + + return panel + } + + override fun save(dataSource: LocalDataSource, copyCredentials: Boolean) { + // Tries to set username so if we don't have one, don't set + if (userField) { + super.save(dataSource, copyCredentials) + } + + DataSourceUiUtil.putOrRemove( + dataSource.additionalJdbcProperties, + CREDENTIAL_ID_PROPERTY, credentialSelector.getSelectedCredentialsProvider() + ) + DataSourceUiUtil.putOrRemove( + dataSource.additionalJdbcProperties, + REGION_ID_PROPERTY, regionSelector.selectedRegion?.id + ) + } + + override fun reset(dataSource: LocalDataSource, resetCredentials: Boolean) { + // Tries to set username so if we don't have one, don't set + if (userField) { + super.reset(dataSource, resetCredentials) + } + + val regionProvider = AwsRegionProvider.getInstance() + val allRegions = regionProvider.allRegions() + regionSelector.setRegions(allRegions.values.toMutableList()) + val regionId = dataSource.additionalJdbcProperties[REGION_ID_PROPERTY]?.nullize() + regionId?.let { + allRegions[regionId]?.let { + regionSelector.selectedRegion = it + } + } + + val credentialManager = CredentialManager.getInstance() + credentialSelector.setCredentialsProviders(credentialManager.getCredentialIdentifiers()) + val credentialId = dataSource.additionalJdbcProperties[CREDENTIAL_ID_PROPERTY]?.nullize() + if (credentialId != null) { + val credentialIdentifierById = credentialManager.getCredentialIdentifierById(credentialId) + if (credentialIdentifierById != null) { + credentialSelector.setSelectedCredentialsProvider(credentialIdentifierById) + } else { + credentialSelector.setSelectedInvalidCredentialsProvider(credentialId) + } + } else { + credentialSelector.model.selectedItem = null + } + } + + override fun isPasswordChanged(): Boolean = false + override fun onChanged(r: DocumentListener) { + // Tries to set username so if we don't have one, don't set + if (userField) { + super.onChanged(r) + } + } + + override fun updateFromUrl(holder: ParametersHolder) { + // Try to get region from url and set the region box on a best effort basis + val url = (holder as? UrlEditorModel)?.url + val regionId = getRegionFromUrl(url) + val region = AwsRegionProvider.getInstance().allRegions()[regionId] + region?.let { + regionSelector.selectedRegion = it + } + super.updateFromUrl(holder) + } + + @TestOnly + internal fun getSelectedCredential() = credentialSelector.getSelectedCredentialsProvider() + + @TestOnly + internal fun getSelectedRegion() = regionSelector.selected() +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/DatabaseSecretTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/DatabaseSecretTest.kt new file mode 100644 index 0000000000..9217dc09cb --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/DatabaseSecretTest.kt @@ -0,0 +1,188 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.datagrip + +import com.intellij.testFramework.ProjectRule +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.doThrow +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.stub +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient +import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest +import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse +import software.amazon.awssdk.services.secretsmanager.model.SecretListEntry +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.MockClientManagerRule +import software.aws.toolkits.jetbrains.datagrip.auth.SecretsManagerDbSecret +import software.aws.toolkits.jetbrains.services.rds.RdsNode +import software.aws.toolkits.jetbrains.services.redshift.RedshiftExplorerNode +import software.aws.toolkits.jetbrains.services.redshift.RedshiftResources.redshiftEngineType + +class DatabaseSecretTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + @JvmField + @Rule + val mockClientManagerRule = MockClientManagerRule(projectRule) + + private val secretName = RuleUtils.randomName() + private val randomHost = RuleUtils.randomName() + private val randomEngine = RuleUtils.randomName() + private val secret = SecretListEntry.builder().name(secretName).arn("arn").build() + + @Test + fun `Get secret null secret returns null`() { + assertThat(DatabaseSecret.getSecret(projectRule.project, null)).isNull() + } + + @Test + fun `Get secret fails returns null`() { + mockClientManagerRule.create().stub { + on { getSecretValue(any()) } doThrow IllegalStateException("bad error") + } + assertThat(DatabaseSecret.getSecret(projectRule.project, secret)).isNull() + } + + @Test + fun `Get secret invalid json returns null`() { + mockClientManagerRule.create().stub { + on { getSecretValue(any()) } doReturn GetSecretValueResponse.builder().secretString("{{{").build() + } + assertThat(DatabaseSecret.getSecret(projectRule.project, secret)).isNull() + } + + @Test + fun `Get secret missing all fields returns properly`() { + mockClientManagerRule.create().stub { + on { getSecretValue(any()) } doReturn GetSecretValueResponse.builder().secretString("{}").build() + } + val response = DatabaseSecret.getSecret(projectRule.project, secret) + assertThat(response).isNotNull + assertThat(response!!.second).isEqualTo("arn") + assertThat(response.first.username).isNull() + } + + @Test + fun `Get secret works`() { + mockClientManagerRule.create().stub { + on { getSecretValue(any()) } doReturn GetSecretValueResponse.builder() + .secretString( + """{ + "username": "awsuser", + "password": "password", + "engine": "redshift", + "host": "redshift-cluster.55555.us-west-2.redshift.amazonaws.com", + "port": 5000, + "dbClusterIdentifier": "redshift-cluster" + }""" + ) + .build() + } + val response = DatabaseSecret.getSecret(projectRule.project, secret) + assertThat(response).isNotNull + assertThat(response!!.second).isEqualTo("arn") + assertThat(response.first.username).isEqualTo("awsuser") + assertThat(response.first.engine).isEqualTo("redshift") + assertThat(response.first.port).isEqualTo("5000") + assertThat(response.first.password).isEqualTo("password") + assertThat(response.first.host).isEqualTo("redshift-cluster.55555.us-west-2.redshift.amazonaws.com") + } + + @Test + fun `Validate secret no username`() { + val dbSecret = buildSecretsManagerDbSecret(username = null) + assertThat(DatabaseSecret.validateSecret(mock(), dbSecret, "")).isNotNull + } + + @Test + fun `Validate secret no password`() { + val dbSecret = buildSecretsManagerDbSecret(password = null) + assertThat(DatabaseSecret.validateSecret(mock(), dbSecret, "")).isNotNull + } + + @Test + fun `Validate secret root node`() { + val dbSecret = buildSecretsManagerDbSecret() + assertThat(DatabaseSecret.validateSecret(mock(), dbSecret, "")).isNull() + } + + @Test + fun `Validate secret RDS node`() { + assertThat(DatabaseSecret.validateSecret(buildMockRdsNode(), buildSecretsManagerDbSecret(), "")).isNull() + } + + @Test + fun `Validate secret RDS node wrong endpoint`() { + assertThat(DatabaseSecret.validateSecret(buildMockRdsNode(validEndpoint = false), buildSecretsManagerDbSecret(), "")).isNotNull + } + + @Test + fun `Validate secret RDS node wrong engine`() { + assertThat(DatabaseSecret.validateSecret(buildMockRdsNode(validEngine = false), buildSecretsManagerDbSecret(), "")).isNotNull + } + + @Test + fun `Validate secret Redshift node`() { + assertThat(DatabaseSecret.validateSecret(buildMockRedshiftNode(), buildSecretsManagerDbSecret(engine = redshiftEngineType), "")).isNull() + } + + @Test + fun `Validate secret Redshift node wrong endpoint`() { + assertThat( + DatabaseSecret.validateSecret( + buildMockRedshiftNode(validEndpoint = false), + buildSecretsManagerDbSecret(engine = redshiftEngineType), + "" + ) + ).isNotNull + } + + @Test + fun `Validate secret Redshift node wrong engine`() { + assertThat( + DatabaseSecret.validateSecret( + buildMockRedshiftNode(validEndpoint = false), + buildSecretsManagerDbSecret(engine = "notRedshift"), + "" + ) + ).isNotNull + } + + private fun buildMockRedshiftNode( + validEndpoint: Boolean = true + ): RedshiftExplorerNode = mock { + on { cluster } doAnswer { + mock { + on { endpoint() } doAnswer { mock { on { address() } doReturn if (validEndpoint) randomHost else "invalidHost" } } + } + } + } + + private fun buildMockRdsNode( + validEndpoint: Boolean = true, + validEngine: Boolean = true + ): RdsNode = mock { + on { dbInstance } doAnswer { + mock { + on { engine() } doAnswer { if (validEngine) randomEngine else "notAValidEngine" } + on { endpoint() } doAnswer { mock { on { address() } doReturn if (validEndpoint) randomHost else "invalidHost" } } + } + } + } + + private fun buildSecretsManagerDbSecret( + username: String? = "username", + password: String? = "password", + engine: String? = randomEngine, + host: String? = randomHost, + port: String? = "5000" + ) = SecretsManagerDbSecret(username, password, engine, host, port) +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/DatagripUtilsTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/DatagripUtilsTest.kt new file mode 100644 index 0000000000..1341fc0dc6 --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/DatagripUtilsTest.kt @@ -0,0 +1,86 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.datagrip + +import com.intellij.database.dataSource.DatabaseConnectionInterceptor +import com.intellij.testFramework.ProjectRule +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.mock +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider + +class DatagripUtilsTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + private val credentialId = RuleUtils.randomName() + private val defaultRegion = RuleUtils.randomName() + + private val mockCreds = AwsBasicCredentials.create("Access", "ItsASecret") + + @Before + fun setUp() { + MockCredentialsManager.getInstance().addCredentials(credentialId, mockCreds) + MockRegionProvider.getInstance().addRegion(AwsRegion(defaultRegion, RuleUtils.randomName(), RuleUtils.randomName())) + } + + @Test(expected = IllegalArgumentException::class) + fun `No credentials getAwsConnectionSettings`() { + buildConnection(null, defaultRegion).getAwsConnectionSettings() + } + + @Test(expected = IllegalArgumentException::class) + fun `No region getAwsConnectionSettings`() { + buildConnection(credentialId, null).getAwsConnectionSettings() + } + + @Test(expected = IllegalArgumentException::class) + fun `Invalid credentials getAwsConnectionSettings`() { + buildConnection(credentialId + "INVALID", defaultRegion).getAwsConnectionSettings() + } + + @Test(expected = IllegalArgumentException::class) + fun `Invalid region getAwsConnectionSettings`() { + buildConnection(credentialId, defaultRegion + "INVALID").getAwsConnectionSettings() + } + + @Test + fun `Working getAwsConnectionSettings`() { + val creds = buildConnection(credentialId, defaultRegion).getAwsConnectionSettings() + assertThat(creds.region.id).isEqualTo(defaultRegion) + assertThat(creds.credentials.id).isEqualTo(credentialId) + } + + @Test + fun `jdbcAdapterFromRuntime works`() { + assertThat(jdbcAdapterFromRuntime("postgres")).isEqualTo("postgresql") + assertThat(jdbcAdapterFromRuntime("mysql")).isEqualTo("mysql") + assertThat(jdbcAdapterFromRuntime("redshift")).isEqualTo("redshift") + assertThat(jdbcAdapterFromRuntime("mongo")).isNull() + } + + private fun buildConnection( + credentials: String? = null, + region: String? = null + ): DatabaseConnectionInterceptor.ProtoConnection = mock { + on { connectionPoint } doAnswer { + mock { + on { additionalJdbcProperties } doAnswer { + val m = mutableMapOf() + m[CREDENTIAL_ID_PROPERTY] = credentials + m[REGION_ID_PROPERTY] = region + m + } + } + } + } +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/actions/AddSecretsManagerConnectionTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/actions/AddSecretsManagerConnectionTest.kt new file mode 100644 index 0000000000..c9d57a5e1d --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/actions/AddSecretsManagerConnectionTest.kt @@ -0,0 +1,50 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.datagrip.actions + +import com.intellij.database.autoconfig.DataSourceRegistry +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.auth.SECRET_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.auth.SecretsManagerAuth +import software.aws.toolkits.jetbrains.datagrip.auth.SecretsManagerDbSecret + +class AddSecretsManagerConnectionTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + @Test + fun `Add data source`() { + val port = RuleUtils.randomNumber() + val address = RuleUtils.randomName() + val username = RuleUtils.randomName() + val password = RuleUtils.randomName() + val secretArn = RuleUtils.randomName() + val engine = RuleUtils.randomName() + val registry = DataSourceRegistry(projectRule.project) + registry.createDatasource( + projectRule.project, + SecretsManagerDbSecret(username, password, engine, address, port.toString()), + secretArn, + "adapter" + ) + assertThat(registry.newDataSources).hasOnlyOneElementSatisfying { + assertThat(it.isTemporary).isFalse() + assertThat(it.sslCfg?.myEnabled).isTrue() + assertThat(it.url).isEqualTo("jdbc:adapter://$address:$port") + assertThat(it.additionalJdbcProperties[CREDENTIAL_ID_PROPERTY]).isEqualTo(MockCredentialsManager.DUMMY_PROVIDER_IDENTIFIER.displayName) + assertThat(it.additionalJdbcProperties[REGION_ID_PROPERTY]).isEqualTo(MockRegionProvider.getInstance().defaultRegion().id) + assertThat(it.additionalJdbcProperties[SECRET_ID_PROPERTY]).isEqualTo(secretArn) + assertThat(it.authProviderId).isEqualTo(SecretsManagerAuth.providerId) + } + } +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerAuthTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerAuthTest.kt new file mode 100644 index 0000000000..30974b92e4 --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerAuthTest.kt @@ -0,0 +1,176 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.datagrip.auth + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.database.dataSource.DatabaseConnectionInterceptor +import com.intellij.database.dataSource.DatabaseConnectionPoint +import com.intellij.database.dataSource.LocalDataSource +import com.intellij.testFramework.ProjectRule +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.doThrow +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.stub +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient +import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest +import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.core.utils.unwrap +import software.aws.toolkits.jetbrains.core.MockClientManagerRule +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY + +class SecretsManagerAuthTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + @Rule + @JvmField + val clientManager = MockClientManagerRule(projectRule) + + private val objectMapper = jacksonObjectMapper() + + private val sAuth = SecretsManagerAuth() + private val username = RuleUtils.randomName() + private val password = RuleUtils.randomName() + private val secret = RuleUtils.randomName() + private val credentialId = RuleUtils.randomName() + private val defaultRegion = RuleUtils.randomName() + private val dbHost = "${RuleUtils.randomName()}.555555.us-west-2.rds.amazonaws.com" + private val port = 5432 + + private val mockCreds = AwsBasicCredentials.create("Access", "ItsASecret") + + @Before + fun setUp() { + MockCredentialsManager.getInstance().addCredentials(credentialId, mockCreds) + MockRegionProvider.getInstance().addRegion(AwsRegion(defaultRegion, RuleUtils.randomName(), RuleUtils.randomName())) + } + + @Test + fun `Intercept credentials succeeds`() { + createSecretsManagerClient() + val connection = sAuth.intercept(buildConnection(), false)?.toCompletableFuture()?.get() + assertThat(connection).isNotNull + assertThat(connection!!.connectionProperties).containsKey("user") + assertThat(connection.connectionProperties["user"]).isEqualTo(username) + assertThat(connection.connectionProperties).containsKey("password") + assertThat(connection.connectionProperties["password"]).isEqualTo(password) + } + + @Test(expected = IllegalArgumentException::class) + fun `No secret fails`() { + sAuth.intercept(buildConnection(hasSecret = false), false)?.unwrap() + } + + @Test(expected = IllegalArgumentException::class) + fun `Bad AWS connection fails`() { + sAuth.intercept(buildConnection(hasCredentials = false), false)?.unwrap() + } + + @Test(expected = IllegalArgumentException::class) + fun `No username in credentials fails`() { + createSecretsManagerClient(hasUsername = false) + sAuth.intercept(buildConnection(), false)?.unwrap() + } + + @Test(expected = IllegalArgumentException::class) + fun `No password in credentials fails`() { + createSecretsManagerClient(hasPassword = false) + sAuth.intercept(buildConnection(), false)?.unwrap() + } + + @Test(expected = RuntimeException::class) + fun `Secrets Manager client throws fails`() { + createSecretsManagerClient(succeeds = false) + sAuth.intercept(buildConnection(), false)?.unwrap() + } + + private fun createSecretsManagerClient( + succeeds: Boolean = true, + hasUsername: Boolean = true, + hasPassword: Boolean = true + ): SecretsManagerClient { + val client = clientManager.create() + val secretMap = mutableMapOf() + if (hasUsername) { + secretMap["username"] = username + } + if (hasPassword) { + secretMap["password"] = password + } + + client.stub { + if (succeeds) { + on { getSecretValue(any()) } doAnswer { + GetSecretValueResponse.builder().name(secret).secretString(objectMapper.writeValueAsString(secretMap)).build() + } + } else { + on { getSecretValue(any()) } doThrow RuntimeException("Terrible exception") + } + } + return client + } + + private fun buildConnection( + hasUrl: Boolean = true, + hasRegion: Boolean = true, + hasCredentials: Boolean = true, + hasHost: Boolean = true, + hasPort: Boolean = true, + hasSecret: Boolean = true + ): DatabaseConnectionInterceptor.ProtoConnection { + val mockConnection = mock { + on { url } doReturn if (hasUrl) { + "jdbc:postgresql://${if (hasHost) dbHost else ""}${if (hasPort) ":$port" else ""}/dev" + } else { + null + } + on { databaseDriver } doReturn null + on { driverClass } doReturn "org.postgresql.Driver" + } + val dbConnectionPoint = mock { + on { additionalJdbcProperties } doAnswer { + val m = mutableMapOf() + if (hasCredentials) { + m[CREDENTIAL_ID_PROPERTY] = credentialId + } + if (hasRegion) { + m[REGION_ID_PROPERTY] = defaultRegion + } + if (hasSecret) { + m[SECRET_ID_PROPERTY] = secret + } + m + } + on { dataSource } doReturn mockConnection + on { databaseDriver } doAnswer { + mock { + on { id } doReturn "id" + } + } + } + return mock { + val m = mutableMapOf() + on { connectionPoint } doReturn dbConnectionPoint + on { runConfiguration } doAnswer { + mock { + on { project } doAnswer { projectRule.project } + } + } + on { connectionProperties } doReturn m + } + } +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerAuthWidgetTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerAuthWidgetTest.kt new file mode 100644 index 0000000000..30d6468ff7 --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/datagrip/auth/SecretsManagerAuthWidgetTest.kt @@ -0,0 +1,91 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.datagrip.auth + +import com.intellij.database.dataSource.LocalDataSource +import com.intellij.database.dataSource.url.template.UrlEditorModel +import com.intellij.testFramework.ProjectRule +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY + +class SecretsManagerAuthWidgetTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + private lateinit var widget: SecretsManagerAuthWidget + private val credentialId = RuleUtils.randomName() + private val defaultRegion = RuleUtils.randomName() + private val defaultSecretId = RuleUtils.randomName() + private val mockCreds = AwsBasicCredentials.create("Access", "ItsASecret") + + @Before + fun setUp() { + widget = SecretsManagerAuthWidget() + MockCredentialsManager.getInstance().addCredentials(credentialId, mockCreds) + MockRegionProvider.getInstance().addRegion(AwsRegion(defaultRegion, RuleUtils.randomName(), RuleUtils.randomName())) + } + + @Test + fun `No secret set is empty in widget`() { + widget.reset(buildDataSource(hasSecret = false), false) + assertThat(widget.getSecretId()).isEmpty() + } + + @Test + fun `Secret set from widget`() { + widget.reset(buildDataSource(hasSecret = true), false) + assertThat(widget.getSecretId()).isEqualTo(defaultSecretId) + } + + @Test + fun `Sets region from Redshift URL`() { + widget.reset(mock(), false) + val endpointUrl = "jdbc:redshift://redshift-cluster.host.$defaultRegion.redshift.amazonaws.com:5439/dev" + widget.updateFromUrl(mock { on { url } doReturn endpointUrl }) + assertThat(widget.getSelectedRegion()?.id).isEqualTo(defaultRegion) + } + + @Test + fun `Sets region from RDS URL`() { + widget.reset(mock(), false) + val endpointUrl = "jdbc:postgresql://abc.host.$defaultRegion.rds.amazonaws.com:5432/dev" + widget.updateFromUrl(mock { on { url } doReturn endpointUrl }) + assertThat(widget.getSelectedRegion()?.id).isEqualTo(defaultRegion) + } + + @Test + fun `Does not unset region on invalid url`() { + widget.reset(mock(), false) + val endpointUrl = "jdbc:postgresql://abc.host.$defaultRegion.rds.amazonaws.com:5432/dev" + widget.updateFromUrl(mock { on { url } doReturn endpointUrl }) + val badUrl = "jdbc:postgresql://abc.host.1000000%invalidregion.rds.amazonaws.com:5432/dev" + widget.updateFromUrl(mock { on { url } doReturn badUrl }) + assertThat(widget.getSelectedRegion()?.id).isEqualTo(defaultRegion) + } + + private fun buildDataSource(hasSecret: Boolean = true): LocalDataSource = mock { + on { additionalJdbcProperties } doAnswer { + mutableMapOf().also { + it[CREDENTIAL_ID_PROPERTY] = credentialId + it[REGION_ID_PROPERTY] = defaultRegion + if (hasSecret) { + it[SECRET_ID_PROPERTY] = defaultSecretId + } + } + } + } +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/rds/RdsExplorerNodeTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/rds/RdsExplorerNodeTest.kt new file mode 100644 index 0000000000..9d2ec79631 --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/rds/RdsExplorerNodeTest.kt @@ -0,0 +1,151 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.rds + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.services.rds.model.DBInstance +import software.amazon.awssdk.utils.CompletableFutureUtils +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.MockResourceCacheRule +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerEmptyNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerErrorNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.RdsExplorerRootNode +import software.aws.toolkits.resources.message + +class RdsExplorerNodeTest { + @JvmField + @Rule + val projectRule = ProjectRule() + + @JvmField + @Rule + val resourceCache = MockResourceCacheRule(projectRule) + + @Test + fun `MySQL resources are listed`() { + val name = RuleUtils.randomName() + val name2 = RuleUtils.randomName() + resourceCache.get().addEntry( + RdsResources.LIST_INSTANCES_MYSQL, listOf( + DBInstance.builder().engine(mysqlEngineType).dbName(name).dbInstanceArn("").build(), + DBInstance.builder().engine(mysqlEngineType).dbName(name2).dbInstanceArn("").build() + ) + ) + val serviceRootNode = sut.buildServiceRootNode(projectRule.project) + assertThat(serviceRootNode.children).anyMatch { it.displayName() == message("rds.mysql") } + val mySqlNode = serviceRootNode.children.first { it.displayName() == message("rds.mysql") } + assertThat(mySqlNode.children).hasSize(2) + assertThat(mySqlNode.children).anyMatch { + (it as RdsNode).dbInstance.dbName() == name + } + assertThat(mySqlNode.children).anyMatch { + (it as RdsNode).dbInstance.dbName() == name2 + } + } + + @Test + fun `Aurora MySQL resources are listed`() { + val name = RuleUtils.randomName() + val name2 = RuleUtils.randomName() + resourceCache.get().addEntry( + RdsResources.LIST_INSTANCES_AURORA_MYSQL, listOf( + DBInstance.builder().engine(auroraMysqlEngineType).dbName(name).dbInstanceArn("").build(), + DBInstance.builder().engine(auroraMysqlEngineType).dbName(name2).dbInstanceArn("").build() + ) + ) + val serviceRootNode = sut.buildServiceRootNode(projectRule.project) + assertThat(serviceRootNode.children).anyMatch { it.displayName() == message("rds.aurora") } + val auroraNode = serviceRootNode.children.first { it.displayName() == message("rds.aurora") } + assertThat(auroraNode).isInstanceOf(AuroraParentNode::class.java) + assertThat(auroraNode.children).hasSize(2) + val mysqlNode = (auroraNode as AuroraParentNode).children.first { it.displayName() == message("rds.mysql") } + assertThat(mysqlNode.children).hasSize(2) + assertThat(mysqlNode.children).anyMatch { (it as RdsNode).dbInstance.dbName() == name } + assertThat(mysqlNode.children).anyMatch { (it as RdsNode).dbInstance.dbName() == name2 } + } + + @Test + fun `PostgreSQL resources are listed`() { + val name = RuleUtils.randomName() + val name2 = RuleUtils.randomName() + resourceCache.get().addEntry( + RdsResources.LIST_INSTANCES_POSTGRES, listOf( + DBInstance.builder().engine(postgresEngineType).dbName(name).dbInstanceArn("").build(), + DBInstance.builder().engine(postgresEngineType).dbName(name2).dbInstanceArn("").build() + ) + ) + val serviceRootNode = sut.buildServiceRootNode(projectRule.project) + assertThat(serviceRootNode.children).anyMatch { it.displayName() == message("rds.postgres") } + val postgresNode = serviceRootNode.children.first { it.displayName() == message("rds.postgres") } + assertThat(postgresNode.children).hasSize(2) + assertThat(postgresNode.children).anyMatch { (it as RdsNode).dbInstance.dbName() == name } + assertThat(postgresNode.children).anyMatch { (it as RdsNode).dbInstance.dbName() == name2 } + } + + @Test + fun `Aurora PostgreSQL resources are listed`() { + val name = RuleUtils.randomName() + val name2 = RuleUtils.randomName() + resourceCache.get().addEntry( + RdsResources.LIST_INSTANCES_AURORA_POSTGRES, listOf( + DBInstance.builder().engine(auroraPostgresEngineType).dbName(name).dbInstanceArn("").build(), + DBInstance.builder().engine(auroraPostgresEngineType).dbName(name2).dbInstanceArn("").build() + ) + ) + val serviceRootNode = sut.buildServiceRootNode(projectRule.project) + assertThat(serviceRootNode.children).anyMatch { it.displayName() == message("rds.aurora") } + val auroraNode = serviceRootNode.children.first { it.displayName() == message("rds.aurora") } + assertThat(auroraNode).isInstanceOf(AuroraParentNode::class.java) + assertThat(auroraNode.children).hasSize(2) + val postgresNode = (auroraNode as AuroraParentNode).children.first { it.displayName() == message("rds.postgres") } + assertThat(postgresNode.children).hasSize(2) + assertThat(postgresNode.children).anyMatch { (it as RdsNode).dbInstance.dbName() == name } + assertThat(postgresNode.children).anyMatch { (it as RdsNode).dbInstance.dbName() == name2 } + } + + @Test + fun `No resources leads to empty nodes`() { + resourceCache.get().addEntry(RdsResources.LIST_INSTANCES_MYSQL, listOf()) + resourceCache.get().addEntry(RdsResources.LIST_INSTANCES_POSTGRES, listOf()) + resourceCache.get().addEntry(RdsResources.LIST_INSTANCES_AURORA_MYSQL, listOf()) + resourceCache.get().addEntry(RdsResources.LIST_INSTANCES_AURORA_POSTGRES, listOf()) + val serviceRootNode = sut.buildServiceRootNode(projectRule.project) + assertThat(serviceRootNode.children).isNotEmpty + serviceRootNode.children.forEach { node -> + if (node is AuroraParentNode) { + node.children.forEach { + assertThat(it.children).hasOnlyOneElementSatisfying { it is AwsExplorerEmptyNode } + } + } else { + assertThat(node.children).hasOnlyOneElementSatisfying { it is AwsExplorerEmptyNode } + } + } + } + + @Test + fun `Exception makes error nodes`() { + resourceCache.get().addEntry(RdsResources.LIST_INSTANCES_MYSQL, CompletableFutureUtils.failedFuture(RuntimeException("Simulated error"))) + resourceCache.get().addEntry(RdsResources.LIST_INSTANCES_POSTGRES, CompletableFutureUtils.failedFuture(RuntimeException("Simulated error"))) + resourceCache.get().addEntry(RdsResources.LIST_INSTANCES_AURORA_MYSQL, CompletableFutureUtils.failedFuture(RuntimeException("Simulated error"))) + resourceCache.get().addEntry(RdsResources.LIST_INSTANCES_AURORA_POSTGRES, CompletableFutureUtils.failedFuture(RuntimeException("Simulated error"))) + val serviceRootNode = sut.buildServiceRootNode(projectRule.project) + assertThat(serviceRootNode.children).isNotEmpty + serviceRootNode.children.forEach { node -> + if (node is AuroraParentNode) { + node.children.forEach { + assertThat(it.children).hasOnlyOneElementSatisfying { it is AwsExplorerErrorNode } + } + } else { + assertThat(node.children).hasOnlyOneElementSatisfying { it is AwsExplorerErrorNode } + } + } + } + + private companion object { + val sut = RdsExplorerRootNode() + } +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/rds/actions/CreateConfigurationActionTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/rds/actions/CreateConfigurationActionTest.kt new file mode 100644 index 0000000000..3ea1bdee3c --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/rds/actions/CreateConfigurationActionTest.kt @@ -0,0 +1,227 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.rds.actions + +import com.intellij.database.autoconfig.DataSourceRegistry +import com.intellij.testFramework.ProjectRule +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.mock +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.services.rds.model.DBInstance +import software.amazon.awssdk.services.rds.model.Endpoint +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.MockResourceCacheRule +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY +import software.aws.toolkits.jetbrains.services.rds.RdsDatasourceConfiguration +import software.aws.toolkits.jetbrains.services.rds.RdsNode +import software.aws.toolkits.jetbrains.services.rds.auroraMysqlEngineType +import software.aws.toolkits.jetbrains.services.rds.auroraPostgresEngineType +import software.aws.toolkits.jetbrains.services.rds.auth.IamAuth +import software.aws.toolkits.jetbrains.services.rds.jdbcMariadb +import software.aws.toolkits.jetbrains.services.rds.jdbcMysql +import software.aws.toolkits.jetbrains.services.rds.jdbcPostgres +import software.aws.toolkits.jetbrains.services.rds.mysqlEngineType +import software.aws.toolkits.jetbrains.services.rds.postgresEngineType +import software.aws.toolkits.jetbrains.services.sts.StsResources +import java.util.concurrent.CompletableFuture + +class CreateConfigurationActionTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + @Rule + @JvmField + val resourceCache = MockResourceCacheRule(projectRule) + + val port = RuleUtils.randomNumber() + val address = RuleUtils.randomName() + val username = "${RuleUtils.randomName()}CAPITAL" + val masterUsername = RuleUtils.randomName() + + @Test + fun `Prerequisites fails when IAM authentication is disabled`() { + val node = createNode(iamAuthEnabled = false) + assertThat(CreateIamDataSourceAction().checkPrerequisites(node)).isFalse() + } + + @Test + fun `Prerequisites succeeds when all are met`() { + val node = createNode(iamAuthEnabled = true) + assertThat(CreateIamDataSourceAction().checkPrerequisites(node)).isTrue() + } + + @Test + fun `Create data source gets user`() { + resourceCache.get().addEntry(StsResources.USER, username) + val node = createNode() + val registry = DataSourceRegistry(projectRule.project) + CreateIamDataSourceAction().createDatasource(node, registry) + assertThat(registry.newDataSources).hasOnlyOneElementSatisfying { + assertThat(it.isTemporary).isFalse() + assertThat(it.username).isEqualTo(username) + } + } + + @Test + fun `Create data source falls back to master username`() { + resourceCache.get().addEntry(StsResources.USER, CompletableFuture().also { + it.completeExceptionally(RuntimeException("Failed to get current user")) + }) + val node = createNode() + val registry = DataSourceRegistry(projectRule.project) + CreateIamDataSourceAction().createDatasource(node, registry) + assertThat(registry.newDataSources).hasOnlyOneElementSatisfying { + assertThat(it.isTemporary).isFalse() + assertThat(it.username).isEqualTo(masterUsername) + } + } + + // This tests common properties. The ones below test driver specific properties + @Test + fun `Add data source`() { + val instance = createDbInstance(address = address, port = port) + val registry = DataSourceRegistry(projectRule.project) + registry.createRdsDatasource( + RdsDatasourceConfiguration( + username = username, + credentialId = MockCredentialsManager.DUMMY_PROVIDER_IDENTIFIER.id, + regionId = MockRegionProvider.getInstance().defaultRegion().id, + dbInstance = instance + ) + ) + assertThat(registry.newDataSources).hasOnlyOneElementSatisfying { + assertThat(it.isTemporary).isFalse() + assertThat(it.url).contains(port.toString()) + assertThat(it.url).contains(address) + assertThat(it.additionalJdbcProperties[CREDENTIAL_ID_PROPERTY]).isEqualTo(MockCredentialsManager.DUMMY_PROVIDER_IDENTIFIER.displayName) + assertThat(it.additionalJdbcProperties[REGION_ID_PROPERTY]).isEqualTo(MockRegionProvider.getInstance().defaultRegion().id) + assertThat(it.authProviderId).isEqualTo(IamAuth.providerId) + } + } + + @Test + fun `Add postgres data source`() { + val instance = createDbInstance(port = port, address = address, engineType = postgresEngineType) + val registry = DataSourceRegistry(projectRule.project) + registry.createRdsDatasource( + RdsDatasourceConfiguration( + username = username, + credentialId = MockCredentialsManager.DUMMY_PROVIDER_IDENTIFIER.id, + regionId = MockRegionProvider.getInstance().defaultRegion().id, + dbInstance = instance + ) + ) + assertThat(registry.newDataSources).hasOnlyOneElementSatisfying { + assertThat(it.username).isLowerCase().isEqualTo(username.toLowerCase()) + assertThat(it.driverClass).contains("postgres") + assertThat(it.url).contains(jdbcPostgres) + } + } + + @Test + fun `Add Aurora PostgreSQL data source`() { + val instance = createDbInstance(port = port, address = address, engineType = auroraPostgresEngineType) + val registry = DataSourceRegistry(projectRule.project) + registry.createRdsDatasource( + RdsDatasourceConfiguration( + username = username, + credentialId = MockCredentialsManager.DUMMY_PROVIDER_IDENTIFIER.id, + regionId = MockRegionProvider.getInstance().defaultRegion().id, + dbInstance = instance + ) + ) + assertThat(registry.newDataSources).hasOnlyOneElementSatisfying { + assertThat(it.username).isLowerCase().isEqualTo(username.toLowerCase()) + assertThat(it.driverClass).contains("postgres") + assertThat(it.url).contains(jdbcPostgres) + } + } + + @Test + fun `Add mysql data source`() { + val instance = createDbInstance(address = address, port = port, engineType = mysqlEngineType) + val registry = DataSourceRegistry(projectRule.project) + registry.createRdsDatasource( + RdsDatasourceConfiguration( + username = username, + credentialId = MockCredentialsManager.DUMMY_PROVIDER_IDENTIFIER.id, + regionId = MockRegionProvider.getInstance().defaultRegion().id, + dbInstance = instance + ) + ) + assertThat(registry.newDataSources).hasOnlyOneElementSatisfying { + assertThat(it.username).isEqualTo(username) + assertThat(it.driverClass).contains("mysql") + assertThat(it.url).contains(jdbcMysql) + } + } + + @Test + fun `Add Aurora MySQL data source`() { + val instance = createDbInstance(address = address, port = port, engineType = auroraMysqlEngineType) + val registry = DataSourceRegistry(projectRule.project) + registry.createRdsDatasource( + RdsDatasourceConfiguration( + username = username, + credentialId = MockCredentialsManager.DUMMY_PROVIDER_IDENTIFIER.id, + regionId = MockRegionProvider.getInstance().defaultRegion().id, + dbInstance = instance + ) + ) + assertThat(registry.newDataSources).hasOnlyOneElementSatisfying { + assertThat(it.username).isEqualTo(username) + assertThat(it.driverClass).contains("mariadb") + assertThat(it.url).contains(jdbcMariadb) + } + } + + @Test(expected = IllegalArgumentException::class) + fun `Bad engine throws`() { + val instance = createDbInstance(engineType = "NOT SUPPORTED") + val registry = DataSourceRegistry(projectRule.project) + registry.createRdsDatasource( + RdsDatasourceConfiguration( + username = username, + credentialId = MockCredentialsManager.DUMMY_PROVIDER_IDENTIFIER.id, + regionId = MockRegionProvider.getInstance().defaultRegion().id, + dbInstance = instance + ) + ) + } + + private fun createNode( + address: String = RuleUtils.randomName(), + port: Int = RuleUtils.randomNumber(), + dbName: String = RuleUtils.randomName(), + iamAuthEnabled: Boolean = true, + engineType: String = mysqlEngineType + ): RdsNode = mock { + on { nodeProject } doAnswer { projectRule.project } + on { dbInstance } doAnswer { + createDbInstance(address, port, dbName, iamAuthEnabled, engineType) + } + } + + private fun createDbInstance( + address: String = RuleUtils.randomName(), + port: Int = RuleUtils.randomNumber(), + dbName: String = RuleUtils.randomName(), + iamAuthEnabled: Boolean = true, + engineType: String = postgresEngineType + ): DBInstance = mock { + on { iamDatabaseAuthenticationEnabled() } doAnswer { iamAuthEnabled } + on { endpoint() } doAnswer { + Endpoint.builder().address(address).port(port).build() + } + on { engine() } doAnswer { engineType } + on { dbName() } doAnswer { dbName } + on { masterUsername() } doAnswer { masterUsername } + } +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/rds/auth/IamAuthTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/rds/auth/IamAuthTest.kt new file mode 100644 index 0000000000..3bd30355e0 --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/rds/auth/IamAuthTest.kt @@ -0,0 +1,186 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.rds.auth + +import com.intellij.database.dataSource.DatabaseConnectionInterceptor.ProtoConnection +import com.intellij.database.dataSource.DatabaseConnectionPoint +import com.intellij.database.dataSource.LocalDataSource +import com.intellij.testFramework.ProjectRule +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.doThrow +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.stub +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.services.rds.RdsClient +import software.amazon.awssdk.services.rds.model.DBInstance +import software.amazon.awssdk.services.rds.model.DescribeDbInstancesRequest +import software.amazon.awssdk.services.rds.model.DescribeDbInstancesResponse +import software.amazon.awssdk.services.rds.model.Endpoint +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.core.utils.unwrap +import software.aws.toolkits.jetbrains.core.MockClientManagerRule +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY + +class IamAuthTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + @JvmField + @Rule + val mockClientManagerRule = MockClientManagerRule(projectRule) + + private val iamAuth = IamAuth() + private val credentialId = RuleUtils.randomName() + private val defaultRegion = RuleUtils.randomName() + private val username = RuleUtils.randomName() + private val instanceId = RuleUtils.randomName() + private val dbHost = "$instanceId.555555.us-west-2.rds.amazonaws.com" + private val port = 5432 + + private val mockCreds = AwsBasicCredentials.create("Access", "ItsASecret") + + @Before + fun setUp() { + MockCredentialsManager.getInstance().addCredentials(credentialId, mockCreds) + MockRegionProvider.getInstance().addRegion(AwsRegion(defaultRegion, RuleUtils.randomName(), RuleUtils.randomName())) + mockClientManagerRule.create().stub { + on { describeDBInstances(any()) } doAnswer { + DescribeDbInstancesResponse.builder().dbInstances( + DBInstance + .builder() + .dbInstanceIdentifier(instanceId) + .endpoint(Endpoint.builder().address(dbHost).port(port).build()) + .build() + ).build() + } + } + } + + @Test + fun `Intercept credentials succeeds`() { + val connection = iamAuth.intercept(buildConnection(), false)?.unwrap() + assertThat(connection).isNotNull + assertThat(connection!!.connectionProperties).containsKey("user") + assertThat(connection.connectionProperties["user"]).isEqualTo(username) + assertThat(connection.connectionProperties).containsKey("password") + assertThat(connection.connectionProperties["password"]) + .contains("X-Amz-Signature") + .contains("connect") + .contains(username) + .contains(dbHost) + .doesNotStartWith("https://") + } + + @Test(expected = IllegalArgumentException::class) + fun `Intercept credentials fails`() { + iamAuth.intercept(buildConnection(hasInstance = false), false)?.unwrap() + } + + @Test(expected = RuntimeException::class) + fun `Intercept credentials fails invalid instance`() { + // empty mockClientManger + mockClientManagerRule.reset() + mockClientManagerRule.create().stub { + on { describeDBInstances(any()) } doThrow RuntimeException("bad exception") + } + iamAuth.intercept(buildConnection(), false)?.unwrap() + } + + @Test + fun `Valid connection`() { + val authInformation = iamAuth.getAuthInformation(projectRule.project, buildConnection()) + assertThat(authInformation.port).isEqualTo(port) + assertThat(authInformation.user).isEqualTo(username) + assertThat(authInformation.connectionSettings.region.id).isEqualTo(defaultRegion) + assertThat(authInformation.address).isEqualTo(dbHost) + } + + @Test(expected = IllegalArgumentException::class) + fun `No username`() { + iamAuth.getAuthInformation(projectRule.project, buildConnection(hasUsername = false)) + } + + @Test(expected = IllegalArgumentException::class) + fun `No region`() { + iamAuth.getAuthInformation(projectRule.project, buildConnection(hasUsername = false)) + } + + @Test(expected = IllegalArgumentException::class) + fun `No credentials`() { + iamAuth.getAuthInformation(projectRule.project, buildConnection(hasCredentials = false)) + } + + @Test(expected = IllegalArgumentException::class) + fun `No instance id`() { + iamAuth.getAuthInformation(projectRule.project, buildConnection(hasInstance = false)) + } + + @Test + fun `Generate pre-signed auth token request succeeds`() { + val connection = iamAuth.getAuthInformation(projectRule.project, buildConnection()) + val request = iamAuth.generateAuthToken(connection) + assertThat(request) + .contains("X-Amz-Signature") + .contains("connect") + .contains(username) + .contains(dbHost) + .doesNotStartWith("https://") + } + + private fun buildConnection( + hasUsername: Boolean = true, + hasRegion: Boolean = true, + hasInstance: Boolean = true, + hasCredentials: Boolean = true + ): ProtoConnection { + val mockConnection = mock { + on { url } doReturn "jdbc:postgresql://$dbHost:$port/dev" + on { databaseDriver } doReturn null + on { driverClass } doReturn "org.postgresql.Driver" + on { username } doReturn if (hasUsername) username else "" + } + val dbConnectionPoint = mock { + on { additionalJdbcProperties } doAnswer { + val m = mutableMapOf() + if (hasCredentials) { + m[CREDENTIAL_ID_PROPERTY] = credentialId + } + if (hasRegion) { + m[REGION_ID_PROPERTY] = defaultRegion + } + if (hasInstance) { + m[INSTANCE_ID_PROPERTY] = instanceId + } + m + } + on { dataSource } doReturn mockConnection + on { databaseDriver } doAnswer { + mock { + on { id } doReturn "id" + } + } + } + return mock { + val m = mutableMapOf() + on { connectionPoint } doReturn dbConnectionPoint + on { runConfiguration } doAnswer { + mock { + on { project } doAnswer { projectRule.project } + } + } + on { connectionProperties } doReturn m + } + } +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/rds/auth/IamAuthWidgetTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/rds/auth/IamAuthWidgetTest.kt new file mode 100644 index 0000000000..cd4a15bfa1 --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/rds/auth/IamAuthWidgetTest.kt @@ -0,0 +1,130 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.rds.auth + +import com.intellij.database.dataSource.LocalDataSource +import com.intellij.database.dataSource.url.template.UrlEditorModel +import com.intellij.testFramework.ProjectRule +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY + +class IamAuthWidgetTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + private lateinit var widget: IamAuthWidget + private val credentialId = RuleUtils.randomName() + private val defaultRegion = RuleUtils.randomName() + private val mockCreds = AwsBasicCredentials.create("Access", "ItsASecret") + + @Before + fun setUp() { + widget = IamAuthWidget() + MockCredentialsManager.getInstance().addCredentials(credentialId, mockCreds) + MockRegionProvider.getInstance().addRegion(AwsRegion(defaultRegion, RuleUtils.randomName(), RuleUtils.randomName())) + } + + @Test + fun `Reset sets region if valid`() { + widget.reset(buildDataSource(hasRegion = true), false) + assertThat(widget.getSelectedRegion()?.id).isEqualTo(defaultRegion) + } + + @Test + fun `Reset does not set region if invalid`() { + widget.reset(buildDataSource(hasRegion = true), false) + assertThat(widget.getSelectedRegion()?.id).isEqualTo(defaultRegion) + widget.reset(buildDataSource(hasRegion = false), false) + assertThat(widget.getSelectedRegion()?.id).isEqualTo(defaultRegion) + } + + @Test + fun `Reset sets credentials if valid`() { + widget.reset(buildDataSource(hasCredentials = true), false) + assertThat(widget.getSelectedCredential()).isEqualTo(credentialId) + } + + @Test + fun `Reset does not set credentials if invalid`() { + widget.reset(buildDataSource(hasCredentials = true), false) + assertThat(widget.getSelectedCredential()).isEqualTo(credentialId) + widget.reset(buildDataSource(hasCredentials = false), false) + assertThat(widget.getSelectedCredential()).isEqualTo(null) + } + + @Test + fun `Sets region from URL`() { + widget.reset(mock(), false) + val endpointUrl = "jdbc:postgresql://abc.host.$defaultRegion.rds.amazonaws.com:5432/dev" + widget.updateFromUrl(mock { on { url } doReturn endpointUrl }) + assertThat(widget.getSelectedRegion()?.id).isEqualTo(defaultRegion) + } + + @Test + fun `Does not unset region on invalid url`() { + widget.reset(mock(), false) + val endpointUrl = "jdbc:postgresql://abc.host.$defaultRegion.rds.amazonaws.com:5432/dev" + widget.updateFromUrl(mock { on { url } doReturn endpointUrl }) + val badUrl = "jdbc:postgresql://abc.host.1000000%invalidregion.rds.amazonaws.com:5432/dev" + widget.updateFromUrl(mock { on { url } doReturn badUrl }) + assertThat(widget.getSelectedRegion()?.id).isEqualTo(defaultRegion) + } + + @Test + fun `Sets instance from URL`() { + widget.reset(mock(), false) + val endpointUrl = "jdbc:postgresql://abc.host.$defaultRegion.rds.amazonaws.com:5432/dev" + widget.updateFromUrl(mock { on { url } doReturn endpointUrl }) + assertThat(widget.getInstanceId()).isEqualTo("abc") + } + + @Test + fun `Does not unset instance on invalid url`() { + widget.reset(mock(), false) + val endpointUrl = "jdbc:postgresql://abc.host.$defaultRegion.rds.amazonaws.com:5432/dev" + widget.updateFromUrl(mock { on { url } doReturn endpointUrl }) + val badUrl = "jdbc:postgresql://abcdefg/dev" + widget.updateFromUrl(mock { on { url } doReturn badUrl }) + assertThat(widget.getInstanceId()).isEqualTo("abc") + } + + @Test + fun `Does not change instance on editing url`() { + widget.reset(mock(), false) + val endpointUrl = "jdbc:postgresql://def.host.$defaultRegion.rds.amazonaws.com:5432/dev" + widget.updateFromUrl(mock { on { url } doReturn endpointUrl }) + val badUrl = "jdbc:postgresql://aurorawriter.host.$defaultRegion.rds.amazonaws.com:5432/dev" + widget.updateFromUrl(mock { on { url } doReturn badUrl }) + assertThat(widget.getInstanceId()).isEqualTo("def") + } + + private fun buildDataSource( + hasCredentials: Boolean = true, + hasRegion: Boolean = true + ): LocalDataSource = mock { + on { additionalJdbcProperties } doAnswer { + val m = mutableMapOf() + if (hasCredentials) { + m[CREDENTIAL_ID_PROPERTY] = credentialId + } + if (hasRegion) { + m[REGION_ID_PROPERTY] = defaultRegion + } + m + } + } +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftExplorerNodeTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftExplorerNodeTest.kt new file mode 100644 index 0000000000..3c21cf113e --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftExplorerNodeTest.kt @@ -0,0 +1,60 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.services.redshift.model.Cluster +import software.amazon.awssdk.utils.CompletableFutureUtils +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.MockResourceCacheRule +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerEmptyNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerErrorNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.RedshiftExplorerRootNode + +class RedshiftExplorerNodeTest { + @JvmField + @Rule + val projectRule = ProjectRule() + + @JvmField + @Rule + val resourceCache = MockResourceCacheRule(projectRule) + + @Test + fun `Redshift resources are listed`() { + val name = RuleUtils.randomName() + resourceCache.get().addEntry( + RedshiftResources.LIST_CLUSTERS, listOf(Cluster.builder().clusterIdentifier(name).build()) + ) + val serviceRootNode = sut.buildServiceRootNode(projectRule.project) + assertThat(serviceRootNode.children).hasOnlyOneElementSatisfying { + it.displayName() == name + } + } + + @Test + fun `No resources makes empty node`() { + resourceCache.get().addEntry(RedshiftResources.LIST_CLUSTERS, listOf()) + val serviceRootNode = sut.buildServiceRootNode(projectRule.project) + assertThat(serviceRootNode.children).hasOnlyOneElementSatisfying { + it is AwsExplorerEmptyNode + } + } + + @Test + fun `Exception thrown makes error node`() { + resourceCache.get().addEntry(RedshiftResources.LIST_CLUSTERS, CompletableFutureUtils.failedFuture(RuntimeException("Simulated error"))) + val serviceRootNode = sut.buildServiceRootNode(projectRule.project) + assertThat(serviceRootNode.children).hasOnlyOneElementSatisfying { + it is AwsExplorerErrorNode + } + } + + private companion object { + val sut = RedshiftExplorerRootNode() + } +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt new file mode 100644 index 0000000000..c0043de03a --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt @@ -0,0 +1,47 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift + +import com.intellij.testFramework.ProjectRule +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.services.redshift.model.Cluster +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.MockResourceCacheRule +import software.aws.toolkits.jetbrains.services.sts.StsResources + +class RedshiftUtilsTest { + @JvmField + @Rule + val projectRule = ProjectRule() + + @JvmField + @Rule + val resourceCache = MockResourceCacheRule(projectRule) + + private val defaultRegion = RuleUtils.randomName() + private val region = AwsRegion(defaultRegion, RuleUtils.randomName(), RuleUtils.randomName()) + private val clusterId = RuleUtils.randomName() + private val accountId = RuleUtils.randomName() + private val mockCluster = mock { + on { clusterIdentifier() } doReturn clusterId + } + + @Test + fun `Account ID ARN`() { + resourceCache.get().addEntry(StsResources.ACCOUNT, accountId) + val arn = projectRule.project.clusterArn(mockCluster, region) + assertThat(arn).isEqualTo("arn:${region.partitionId}:redshift:${region.id}:$accountId:cluster:$clusterId") + } + + @Test + fun `No account ID ARN`() { + val arn = projectRule.project.clusterArn(mockCluster, region) + assertThat(arn).isEqualTo("arn:${region.partitionId}:redshift:${region.id}::cluster:$clusterId") + } +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/actions/CreateDataSourceActionTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/actions/CreateDataSourceActionTest.kt new file mode 100644 index 0000000000..277aae84ee --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/actions/CreateDataSourceActionTest.kt @@ -0,0 +1,51 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift.actions + +import com.intellij.database.autoconfig.DataSourceRegistry +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.services.redshift.model.Cluster +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.services.redshift.auth.CLUSTER_ID_PROPERTY +import software.aws.toolkits.jetbrains.services.redshift.auth.IamAuth +import software.aws.toolkits.jetbrains.services.redshift.createDatasource + +class CreateDataSourceActionTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + @Test + fun `Add data source`() { + val port = RuleUtils.randomNumber() + val address = RuleUtils.randomName() + val username = RuleUtils.randomName() + val dbName = RuleUtils.randomName() + val registry = DataSourceRegistry(projectRule.project) + registry.createDatasource(projectRule.project, + Cluster.builder() + .endpoint { it.address(address).port(port) } + .masterUsername(username) + .clusterIdentifier(address) + .dbName(dbName) + .build() + ) + assertThat(registry.newDataSources).hasOnlyOneElementSatisfying { + assertThat(it.isTemporary).isFalse() + assertThat(it.sslCfg?.myEnabled).isTrue() + assertThat(it.url).isEqualTo("jdbc:redshift://$address:$port/$dbName") + assertThat(it.additionalJdbcProperties[CREDENTIAL_ID_PROPERTY]).isEqualTo(MockCredentialsManager.DUMMY_PROVIDER_IDENTIFIER.displayName) + assertThat(it.additionalJdbcProperties[REGION_ID_PROPERTY]).isEqualTo(MockRegionProvider.getInstance().defaultRegion().id) + assertThat(it.additionalJdbcProperties[CLUSTER_ID_PROPERTY]).isEqualTo(address) + assertThat(it.authProviderId).isEqualTo(IamAuth.providerId) + } + } +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/auth/IamAuthTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/auth/IamAuthTest.kt new file mode 100644 index 0000000000..05ebe29648 --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/auth/IamAuthTest.kt @@ -0,0 +1,224 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift.auth + +import com.intellij.database.dataSource.DatabaseConnectionInterceptor.ProtoConnection +import com.intellij.database.dataSource.DatabaseConnectionPoint +import com.intellij.database.dataSource.LocalDataSource +import com.intellij.testFramework.ProjectRule +import com.nhaarman.mockitokotlin2.KArgumentCaptor +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.doThrow +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.stub +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.services.redshift.RedshiftClient +import software.amazon.awssdk.services.redshift.model.Cluster +import software.amazon.awssdk.services.redshift.model.DescribeClustersRequest +import software.amazon.awssdk.services.redshift.model.DescribeClustersResponse +import software.amazon.awssdk.services.redshift.model.GetClusterCredentialsRequest +import software.amazon.awssdk.services.redshift.model.GetClusterCredentialsResponse +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.MockClientManagerRule +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettings +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY + +class IamAuthTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + @Rule + @JvmField + val mockClientManager = MockClientManagerRule { projectRule.project } + + private val mockCreds = AwsBasicCredentials.create("Access", "ItsASecret") + + private val apiAuth = IamAuth() + private val credentialId = RuleUtils.randomName() + private val defaultRegion = RuleUtils.randomName() + private val region = AwsRegion(defaultRegion, RuleUtils.randomName(), RuleUtils.randomName()) + private val clusterId = RuleUtils.randomName() + private val username = RuleUtils.randomName() + + private val redshiftSettings = RedshiftSettings( + clusterId = clusterId, + username = username, + connectionSettings = ConnectionSettings(mock(), region) + ) + + @Before + fun setUp() { + MockCredentialsManager.getInstance().addCredentials(credentialId, mockCreds) + MockRegionProvider.getInstance().addRegion(region) + } + + @Test + fun `Validate connection`() { + apiAuth.validateConnection(buildConnection()) + } + + @Test + // We actually don't need the URL at all for Redshift. It's nice for getting things off + // of, but we don't need to directly use it + fun `No URL`() { + apiAuth.validateConnection(buildConnection(hasUrl = true)) + } + + @Test(expected = IllegalArgumentException::class) + fun `No username`() { + apiAuth.validateConnection(buildConnection(hasUsername = false)) + } + + @Test(expected = IllegalArgumentException::class) + fun `No region`() { + apiAuth.validateConnection(buildConnection(hasUsername = false)) + } + + @Test(expected = IllegalArgumentException::class) + fun `No credentials`() { + apiAuth.validateConnection(buildConnection(hasCredentials = false)) + } + + @Test(expected = IllegalArgumentException::class) + fun `No cluster ID`() { + apiAuth.validateConnection(buildConnection(hasClusterId = false)) + } + + @Test + // We don't need the URL at all for Redshift. + fun `No host`() { + apiAuth.validateConnection(buildConnection(hasHost = false)) + } + + @Test + // We don't need the port either + fun `No port`() { + apiAuth.validateConnection(buildConnection(hasPort = false)) + } + + @Test + fun `Get credentials succeeds`() { + val password = RuleUtils.randomName() + val (createCaptor, redshiftMock) = getWorkingRedshiftMock(password) + val creds = apiAuth.getCredentials(redshiftSettings, redshiftMock) + assertThat(creds.userName).isEqualTo(redshiftSettings.username) + assertThat(creds.password).isEqualTo(password) + assertThat(createCaptor.firstValue.autoCreate()).isFalse() + assertThat(createCaptor.firstValue.dbUser()).isEqualTo(redshiftSettings.username) + assertThat(createCaptor.firstValue.clusterIdentifier()).isEqualTo(clusterId) + } + + @Test(expected = Exception::class) + fun `Get credentials fails`() { + val redshiftMock = mockClientManager.create() + redshiftMock.stub { + on { describeClusters(any()) } doReturn DescribeClustersResponse.builder().clusters(mutableListOf()).build() + } + apiAuth.getCredentials(redshiftSettings, redshiftMock) + } + + @Test + fun `Intercept credentials succeeds`() { + val password = RuleUtils.randomName() + // we call this for the side effects only in this function + getWorkingRedshiftMock(password) + val connection = apiAuth.intercept(buildConnection(), false)?.toCompletableFuture()?.get() + assertThat(connection).isNotNull + assertThat(connection!!.connectionProperties).containsKey("user") + assertThat(connection.connectionProperties["user"]).isEqualTo(username) + assertThat(connection.connectionProperties).containsKey("password") + assertThat(connection.connectionProperties["password"]).isEqualTo(password) + } + + @Test(expected = Exception::class) + fun `Intercept credentials fails`() { + apiAuth.intercept(buildConnection(hasUrl = false), false)?.toCompletableFuture()?.get() + } + + @Test(expected = IllegalStateException::class) + fun `Get credentials cluster does not exist`() { + val redshiftMock = mockClientManager.create() + redshiftMock.stub { + on { describeClusters(any()) } doReturn DescribeClustersResponse.builder() + .clusters(Cluster.builder().clusterIdentifier(clusterId).build()) + .build() + on { getClusterCredentials(any()) } doThrow IllegalStateException("Something wrong with creds") + } + apiAuth.getCredentials(redshiftSettings, redshiftMock) + } + + private fun buildConnection( + hasUrl: Boolean = true, + hasUsername: Boolean = true, + hasRegion: Boolean = true, + hasCredentials: Boolean = true, + hasHost: Boolean = true, + hasClusterId: Boolean = true, + hasPort: Boolean = true + ): ProtoConnection { + val mockConnection = mock { + on { url } doReturn if (hasUrl) { + "jdbc:postgresql://${if (hasHost) "redshift-cluster-1.555555.us-west-2.redshift.amazonaws.com" else ""}${if (hasPort) ":5432" else ""}/dev" + } else { + null + } + on { databaseDriver } doReturn null + on { driverClass } doReturn "org.postgresql.Driver" + on { username } doReturn if (hasUsername) username else "" + } + val dbConnectionPoint = mock { + on { additionalJdbcProperties } doAnswer { + val m = mutableMapOf() + if (hasCredentials) { + m[CREDENTIAL_ID_PROPERTY] = credentialId + } + if (hasRegion) { + m[REGION_ID_PROPERTY] = defaultRegion + } + if (hasClusterId) { + m[CLUSTER_ID_PROPERTY] = clusterId + } + m + } + on { dataSource } doReturn mockConnection + } + return mock { + val m = mutableMapOf() + on { connectionPoint } doReturn dbConnectionPoint + on { runConfiguration } doAnswer { + mock { + on { project } doAnswer { projectRule.project } + } + } + on { connectionProperties } doReturn m + } + } + + private fun getWorkingRedshiftMock(password: String): Pair, RedshiftClient> { + val redshiftMock = mockClientManager.create() + val createCaptor = argumentCaptor() + redshiftMock.stub { + on { describeClusters(any()) } doReturn DescribeClustersResponse.builder() + .clusters(Cluster.builder().clusterIdentifier(clusterId).build()) + .build() + on { getClusterCredentials(createCaptor.capture()) } doReturn GetClusterCredentialsResponse.builder() + .dbUser(redshiftSettings.username) + .dbPassword(password) + .build() + } + return Pair(createCaptor, redshiftMock) + } +} diff --git a/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/auth/IamAuthWidgetTest.kt b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/auth/IamAuthWidgetTest.kt new file mode 100644 index 0000000000..037361c5f9 --- /dev/null +++ b/jetbrains-ultimate/tst-201+/software/aws/toolkits/jetbrains/services/redshift/auth/IamAuthWidgetTest.kt @@ -0,0 +1,83 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift.auth + +import com.intellij.database.dataSource.LocalDataSource +import com.intellij.database.dataSource.url.template.UrlEditorModel +import com.intellij.testFramework.ProjectRule +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager +import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.datagrip.CREDENTIAL_ID_PROPERTY +import software.aws.toolkits.jetbrains.datagrip.REGION_ID_PROPERTY + +class IamAuthWidgetTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + private lateinit var widget: IamAuthWidget + private val credentialId = RuleUtils.randomName() + private val defaultRegion = RuleUtils.randomName() + private val defaultClusterId = RuleUtils.randomName() + private val mockCreds = AwsBasicCredentials.create("Access", "ItsASecret") + + @Before + fun setUp() { + widget = IamAuthWidget() + MockCredentialsManager.getInstance().addCredentials(credentialId, mockCreds) + MockRegionProvider.getInstance().addRegion(AwsRegion(defaultRegion, RuleUtils.randomName(), RuleUtils.randomName())) + } + + @Test + fun `No cluster id set is empty in widget`() { + widget.reset(buildDataSource(hasCluster = false), false) + assertThat(widget.getClusterId()).isEmpty() + } + + @Test + fun `Cluster id set from widget`() { + widget.reset(buildDataSource(hasCluster = true), false) + assertThat(widget.getClusterId()).isEqualTo(defaultClusterId) + } + + @Test + fun `Does not unset region on invalid url`() { + widget.reset(mock(), false) + val endpointUrl = "jdbc:redshift://redshift-cluster.host.$defaultRegion.redshift.amazonaws.com:5439/dev" + widget.updateFromUrl(mock { on { url } doReturn endpointUrl }) + val badUrl = "jdbc:redshift://redshift-cluster.host.100000%InvalidRegion.redshift.amazonaws.com:5439/dev" + widget.updateFromUrl(mock { on { url } doReturn badUrl }) + assertThat(widget.getSelectedRegion()?.id).isEqualTo(defaultRegion) + } + + @Test + fun `Sets region from URL`() { + widget.reset(mock(), false) + val endpointUrl = "jdbc:redshift://redshift-cluster.host.$defaultRegion.redshift.amazonaws.com:5439/dev" + widget.updateFromUrl(mock { on { url } doReturn endpointUrl }) + assertThat(widget.getSelectedRegion()?.id).isEqualTo(defaultRegion) + } + + private fun buildDataSource(hasCluster: Boolean = true): LocalDataSource = mock { + on { additionalJdbcProperties } doAnswer { + mutableMapOf().also { + it[CREDENTIAL_ID_PROPERTY] = credentialId + it[REGION_ID_PROPERTY] = defaultRegion + if (hasCluster) { + it[CLUSTER_ID_PROPERTY] = defaultClusterId + } + } + } + } +} diff --git a/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/services/clouddebug/NodejsStartCommandAugmenterTest.kt b/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/services/clouddebug/NodejsStartCommandAugmenterTest.kt index 9d45f3ce1f..f20b6ca136 100644 --- a/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/services/clouddebug/NodejsStartCommandAugmenterTest.kt +++ b/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/services/clouddebug/NodejsStartCommandAugmenterTest.kt @@ -3,64 +3,66 @@ package software.aws.toolkits.jetbrains.services.clouddebug -import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Test import software.aws.toolkits.jetbrains.services.clouddebug.nodejs.NodeJsDebuggerSupport import software.aws.toolkits.resources.message class NodejsStartCommandAugmenterTest { - val augmenter = NodeJsDebuggerSupport() + private val augmenter = NodeJsDebuggerSupport() + private val nodeRun = "node abc.js" @Test fun augmenterAddsEnvironmentVariable() { - Assertions.assertThat(augmenter.augmentStatement("node abc.js", listOf(123), "")) + assertThat(augmenter.augmentStatement(nodeRun, listOf(123), "")) .contains("${CloudDebugConstants.REMOTE_DEBUG_PORT_ENV}=123") - Assertions.assertThat(augmenter.augmentStatement("nodejs abc.js", listOf(123), "")) + assertThat(augmenter.augmentStatement("nodejs abc.js", listOf(123), "")) .contains("${CloudDebugConstants.REMOTE_DEBUG_PORT_ENV}=123") } @Test fun augmenterAddsPort() { - Assertions.assertThat(augmenter.augmentStatement("node abc.js", listOf(123), "")) + assertThat(augmenter.augmentStatement(nodeRun, listOf(123), "")) .contains("--inspect-brk=localhost:123") - Assertions.assertThat(augmenter.augmentStatement("nodejs abc.js", listOf(123), "")) + assertThat(augmenter.augmentStatement("nodejs abc.js", listOf(123), "")) .contains("--inspect-brk=localhost:123") } @Test fun augmenterEmptyPortsArray() { - Assertions.assertThatThrownBy { augmenter.augmentStatement("node abc.js", listOf(), "") } + assertThatThrownBy { augmenter.augmentStatement(nodeRun, listOf(), "") } .isInstanceOf(IllegalStateException::class.java) .hasMessage(message("cloud_debug.step.augment_statement.missing_debug_port")) } @Test fun augmenterIgnoresNonNode() { - Assertions.assertThat(augmenter.automaticallyAugmentable("node.sh abc")).isFalse() - Assertions.assertThat(augmenter.automaticallyAugmentable("java abc")).isFalse() + assertThat(augmenter.automaticallyAugmentable("node.sh abc")).isFalse() + assertThat(augmenter.automaticallyAugmentable("java abc")).isFalse() } @Test fun augmenterWorksForPaths() { - Assertions.assertThat(augmenter.automaticallyAugmentable("/abc/node abc.js")).isTrue() - Assertions.assertThat(augmenter.automaticallyAugmentable("/abc/nodejs abc.js")).isTrue() - Assertions.assertThat(augmenter.automaticallyAugmentable("\"/abc space in path/node\" abc.js")).isTrue() - Assertions.assertThat(augmenter.automaticallyAugmentable("\"/abc space in path/nodejs\" abc.js")).isTrue() + assertThat(augmenter.automaticallyAugmentable("/abc/node abc.js")).isTrue() + assertThat(augmenter.automaticallyAugmentable("/abc/nodejs abc.js")).isTrue() + assertThat(augmenter.automaticallyAugmentable("\"/abc space in path/node\" abc.js")).isTrue() + assertThat(augmenter.automaticallyAugmentable("\"/abc space in path/nodejs\" abc.js")).isTrue() } @Test fun augmenterDoesNotAugmentWeirdPaths() { - Assertions.assertThat(augmenter.automaticallyAugmentable("/abc/notnode abc.js")).isFalse() - Assertions.assertThat(augmenter.automaticallyAugmentable("node.sh abc.js")).isFalse() - Assertions.assertThat(augmenter.automaticallyAugmentable("node")).isFalse() - Assertions.assertThat(augmenter.automaticallyAugmentable("\"/abc space in path/notnode\" abc.js")).isFalse() + assertThat(augmenter.automaticallyAugmentable("/abc/notnode abc.js")).isFalse() + assertThat(augmenter.automaticallyAugmentable("node.sh abc.js")).isFalse() + assertThat(augmenter.automaticallyAugmentable("node")).isFalse() + assertThat(augmenter.automaticallyAugmentable("\"/abc space in path/notnode\" abc.js")).isFalse() } @Test fun augmenterAugmentsPathsCorrectly() { - Assertions.assertThat(augmenter.augmentStatement("/abc/node abc.js", listOf(123), "")) + assertThat(augmenter.augmentStatement("/abc/node abc.js", listOf(123), "")) .contains("/abc/node --inspect-brk=localhost:123 abc.js") - Assertions.assertThat(augmenter.augmentStatement("\"/abc space in path/node\" abc.js", listOf(123), "")) + assertThat(augmenter.augmentStatement("\"/abc space in path/node\" abc.js", listOf(123), "")) .contains("\"/abc space in path/node\" --inspect-brk=localhost:123 abc.js") } } diff --git a/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsHandlerCompletionProviderTest.kt b/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsHandlerCompletionProviderTest.kt new file mode 100644 index 0000000000..2c6fc2e7a6 --- /dev/null +++ b/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsHandlerCompletionProviderTest.kt @@ -0,0 +1,60 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.nodejs + +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.jetbrains.services.lambda.completion.HandlerCompletionProvider +import software.aws.toolkits.jetbrains.utils.rules.NodeJsCodeInsightTestFixtureRule + +class NodeJsHandlerCompletionProviderTest { + + @Rule + @JvmField + val projectRule = NodeJsCodeInsightTestFixtureRule() + + @Test + fun completionIsNotSupportedNodeJs() { + val provider = HandlerCompletionProvider(projectRule.project, Runtime.NODEJS) + assertFalse(provider.isCompletionSupported) + } + + @Test + fun completionIsNotSupportedNodeJs43() { + val provider = HandlerCompletionProvider(projectRule.project, Runtime.NODEJS4_3) + assertFalse(provider.isCompletionSupported) + } + + @Test + fun completionIsNotSupportedNodeJs43Edge() { + val provider = HandlerCompletionProvider(projectRule.project, Runtime.NODEJS4_3_EDGE) + assertFalse(provider.isCompletionSupported) + } + + @Test + fun completionIsNotSupportedNodeJs610() { + val provider = HandlerCompletionProvider(projectRule.project, Runtime.NODEJS6_10) + assertFalse(provider.isCompletionSupported) + } + + @Test + fun completionIsNotSupportedNodeJs810() { + val provider = HandlerCompletionProvider(projectRule.project, Runtime.NODEJS8_10) + assertFalse(provider.isCompletionSupported) + } + + @Test + fun completionIsNotSupportedNodeJs10X() { + val provider = HandlerCompletionProvider(projectRule.project, Runtime.NODEJS10_X) + assertFalse(provider.isCompletionSupported) + } + + @Test + fun completionIsNotSupportedNodeJs12X() { + val provider = HandlerCompletionProvider(projectRule.project, Runtime.NODEJS12_X) + assertFalse(provider.isCompletionSupported) + } +} diff --git a/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/utils/rules/NodeJsCodeInsightTestFixtureRule.kt b/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/utils/rules/NodeJsCodeInsightTestFixtureRule.kt index ddf94b739d..b2a36d6d2d 100644 --- a/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/utils/rules/NodeJsCodeInsightTestFixtureRule.kt +++ b/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/utils/rules/NodeJsCodeInsightTestFixtureRule.kt @@ -10,14 +10,10 @@ import com.intellij.javascript.nodejs.interpreter.local.NodeJsLocalInterpreterMa import com.intellij.lang.javascript.dialects.JSLanguageLevel import com.intellij.lang.javascript.psi.JSFile import com.intellij.lang.javascript.settings.JSRootConfiguration -import com.intellij.openapi.application.WriteAction -import com.intellij.openapi.module.Module -import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.module.WebModuleTypeBase import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.util.Ref -import com.intellij.openapi.util.io.FileUtil import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.testFramework.LightProjectDescriptor @@ -29,7 +25,6 @@ import com.intellij.testFramework.runInEdtAndWait import com.intellij.util.text.SemVer import com.intellij.xdebugger.XDebuggerUtil import software.amazon.awssdk.services.lambda.model.Runtime -import java.io.File /** * JUnit test Rule that will create a Light [Project] and [CodeInsightTestFixture] with NodeJs support. Projects are @@ -69,13 +64,7 @@ class NodeJsCodeInsightTestFixtureRule : CodeInsightTestFixtureRule() { class NodeJsLightProjectDescriptor : LightProjectDescriptor() { override fun getSdk(): Sdk? = null - override fun createModule(project: Project, moduleFilePath: String): Module? = WriteAction.compute { - val imlFile = File(moduleFilePath) - if (imlFile.exists()) { - FileUtil.delete(imlFile) - } - ModuleManager.getInstance(project).newModule(moduleFilePath, WebModuleTypeBase.getInstance().id) - } + override fun getModuleTypeId(): String = WebModuleTypeBase.getInstance().id } class MockNodeJsInterpreter(private var version: SemVer) : NodeJsLocalInterpreter("/path/to/$version/mock/node") { diff --git a/ktlint-rules/build.gradle b/ktlint-rules/build.gradle deleted file mode 100644 index 9ecaf13671..0000000000 --- a/ktlint-rules/build.gradle +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -dependencies { - implementation "com.pinterest.ktlint:ktlint-core:$ktlintVersion" - implementation "com.pinterest.ktlint:ktlint-ruleset-standard:$ktlintVersion" - implementation "com.pinterest.ktlint:ktlint-ruleset-experimental:$ktlintVersion" - testImplementation "com.pinterest.ktlint:ktlint-test:$ktlintVersion" -} \ No newline at end of file diff --git a/ktlint-rules/build.gradle.kts b/ktlint-rules/build.gradle.kts new file mode 100644 index 0000000000..d5b6368ad7 --- /dev/null +++ b/ktlint-rules/build.gradle.kts @@ -0,0 +1,11 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +val ktlintVersion: String by project + +dependencies { + implementation("com.pinterest.ktlint:ktlint-core:$ktlintVersion") + implementation("com.pinterest.ktlint:ktlint-ruleset-standard:$ktlintVersion") + implementation("com.pinterest.ktlint:ktlint-ruleset-experimental:$ktlintVersion") + testImplementation("com.pinterest.ktlint:ktlint-test:$ktlintVersion") +} diff --git a/ktlint-rules/src/software/aws/toolkits/ktlint/rules/BannedImportsRule.kt b/ktlint-rules/src/software/aws/toolkits/ktlint/rules/BannedImportsRule.kt new file mode 100644 index 0000000000..55e1b9fe10 --- /dev/null +++ b/ktlint-rules/src/software/aws/toolkits/ktlint/rules/BannedImportsRule.kt @@ -0,0 +1,27 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.ktlint.rules + +import com.pinterest.ktlint.core.Rule +import org.jetbrains.kotlin.com.intellij.lang.ASTNode +import org.jetbrains.kotlin.psi.KtImportDirective + +class BannedImportsRule : Rule("banned-imports") { + override fun visit( + node: ASTNode, + autoCorrect: Boolean, + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit + ) { + val element = node.psi ?: return + if (element is KtImportDirective) { + if (element.importedFqName?.asString() == "org.assertj.core.api.Assertions") { + emit(node.startOffset, "Import the assertion you want to use directly instead of importing the top level Assertions", false) + } + + if (element.importedFqName?.asString()?.startsWith("org.hamcrest") == true) { + emit(node.startOffset, "Use AssertJ instead of Hamcrest assertions", false) + } + } + } +} diff --git a/ktlint-rules/src/software/aws/toolkits/ktlint/rules/CustomRuleSetProvider.kt b/ktlint-rules/src/software/aws/toolkits/ktlint/rules/CustomRuleSetProvider.kt index 2b0b07f452..243c4c7115 100644 --- a/ktlint-rules/src/software/aws/toolkits/ktlint/rules/CustomRuleSetProvider.kt +++ b/ktlint-rules/src/software/aws/toolkits/ktlint/rules/CustomRuleSetProvider.kt @@ -15,6 +15,7 @@ class CustomRuleSetProvider : RuleSetProvider { ExpressionBodyRule(), LazyLogRule(), DialogModalityRule(), + BannedImportsRule(), NoWildcardImportsRule() // Disabled by default, so including in our rule set ) } diff --git a/ktlint-rules/src/software/aws/toolkits/ktlint/rules/LazyLogRule.kt b/ktlint-rules/src/software/aws/toolkits/ktlint/rules/LazyLogRule.kt index 57d3a38484..e6265cc80e 100644 --- a/ktlint-rules/src/software/aws/toolkits/ktlint/rules/LazyLogRule.kt +++ b/ktlint-rules/src/software/aws/toolkits/ktlint/rules/LazyLogRule.kt @@ -5,6 +5,7 @@ package software.aws.toolkits.ktlint.rules import com.pinterest.ktlint.core.Rule import org.jetbrains.kotlin.com.intellij.lang.ASTNode +import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.psiUtil.getCallNameExpression import org.jetbrains.kotlin.psi.psiUtil.getReceiverExpression @@ -14,6 +15,10 @@ class LazyLogRule : Rule("log-not-lazy") { private val logMethods = setOf("error", "warn", "info", "debug", "trace") private val logNames = setOf("log", "logger") + // TODO updating Ktlint will allow us to disable rules based on editorconfig, but the update is + // non trivial. So, disabled based on package name for now. Remove when ktlint is upgraded + private val optOut = setOf("software.aws.toolkits.jetbrains.uitests") + override fun visit( node: ASTNode, autoCorrect: Boolean, @@ -27,6 +32,11 @@ class LazyLogRule : Rule("log-not-lazy") { return } + // TODO remove when ktlint is upgraded + if (optOut.any { name -> element.containingKtFile.packageFqName.startsWith(Name.identifier(name)) }) { + return + } + val referenceExpression = it.getReceiverExpression()?.referenceExpression() ?: return if (!logNames.contains(referenceExpression.text.toLowerCase())) { diff --git a/ktlint-rules/tst/software/aws/toolkits/ktlint/rules/BannedImportsRuleTest.kt b/ktlint-rules/tst/software/aws/toolkits/ktlint/rules/BannedImportsRuleTest.kt new file mode 100644 index 0000000000..a930e13a0a --- /dev/null +++ b/ktlint-rules/tst/software/aws/toolkits/ktlint/rules/BannedImportsRuleTest.kt @@ -0,0 +1,44 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.ktlint.rules + +import com.pinterest.ktlint.core.LintError +import com.pinterest.ktlint.test.lint +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class BannedImportsRuleTest { + private val rule = BannedImportsRule() + + @Test + fun `Importing Assert fails`() { + assertThat(rule.lint("import org.assertj.core.api.Assertions")) + .containsExactly( + LintError( + 1, + 1, + "banned-imports", + "Import the assertion you want to use directly instead of importing the top level Assertions" + ) + ) + } + + @Test + fun `Importing Hamcrest fails`() { + assertThat(rule.lint("import org.hamcrest.AnyClass")) + .containsExactly( + LintError( + 1, + 1, + "banned-imports", + "Use AssertJ instead of Hamcrest assertions" + ) + ) + } + + @Test + fun `Importing Assert assertThat succeeds`() { + assertThat(rule.lint("import org.assertj.core.api.Assertions.assertThat")).isEmpty() + } +} diff --git a/ktlint-rules/tst/software/aws/toolkits/ktlint/rules/DialogModalityRuleTest.kt b/ktlint-rules/tst/software/aws/toolkits/ktlint/rules/DialogModalityRuleTest.kt index 2a1b36be31..1629e770c0 100644 --- a/ktlint-rules/tst/software/aws/toolkits/ktlint/rules/DialogModalityRuleTest.kt +++ b/ktlint-rules/tst/software/aws/toolkits/ktlint/rules/DialogModalityRuleTest.kt @@ -5,7 +5,7 @@ package software.aws.toolkits.ktlint.rules import com.pinterest.ktlint.core.LintError import com.pinterest.ktlint.test.lint -import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat import org.intellij.lang.annotations.Language import org.junit.Test @@ -16,7 +16,7 @@ class DialogModalityRuleTest { @Test fun runInEdtCallsShouldSpecifyModalityWhenCalledWithinDialog() { assertExpected( - """ + """ class Blah : DialogWrapper { fun blah() { runInEdt { } @@ -29,19 +29,20 @@ class DialogModalityRuleTest { @Test fun callsThatSpecifyModalityAnyAreFine() { assertExpected( - """ + """ class Blah : DialogWrapper { fun blah() { runInEdt(ModalityState.any()) { } } } - """) + """ + ) } @Test fun callsThatSpecifyWrongModalityAreNotFine() { assertExpected( - """ + """ class Blah : DialogWrapper() { fun blah() { runInEdt(ModalityState.current()) { } @@ -52,12 +53,12 @@ class DialogModalityRuleTest { } private fun assertExpected(@Language("kotlin") kotlinText: String, vararg expectedErrors: Pair) { - Assertions.assertThat(rule.lint(kotlinText.trimIndent())).containsExactly(*expectedErrors.map { + assertThat(rule.lint(kotlinText.trimIndent())).containsExactly(*expectedErrors.map { LintError( - it.first, - it.second, - rule.id, - "Call to runInEdt without ModalityState.any() within Dialog will not run until Dialog exits." + it.first, + it.second, + rule.id, + "Call to runInEdt without ModalityState.any() within Dialog will not run until Dialog exits." ) }.toTypedArray()) } diff --git a/ktlint-rules/tst/software/aws/toolkits/ktlint/rules/LazyLogRuleTest.kt b/ktlint-rules/tst/software/aws/toolkits/ktlint/rules/LazyLogRuleTest.kt index 7bba1a8837..d6260e3a5b 100644 --- a/ktlint-rules/tst/software/aws/toolkits/ktlint/rules/LazyLogRuleTest.kt +++ b/ktlint-rules/tst/software/aws/toolkits/ktlint/rules/LazyLogRuleTest.kt @@ -54,6 +54,24 @@ val LOG = LoggerFactory.getLogger(T::class.java) fun foo() { val e = RuntimeException() LOG.debug(e) {"Hi" } +} + """.trimIndent() + ) + ).isEmpty() + } + + @Test + fun methodCallIsUsedToLogInUiTests() { + assertThat( + rule.lint( + """ +package software.aws.toolkits.jetbrains.uitests.really.cool.test + +import org.slf4j.LoggerFactory + +val LOG = LoggerFactory.getLogger(T::class.java) +fun foo() { + LOG.debug("Hi") } """.trimIndent() ) diff --git a/resources/build.gradle b/resources/build.gradle deleted file mode 100644 index d8c1e789bb..0000000000 --- a/resources/build.gradle +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -plugins { - id "de.undercouch.download" -} - -sourceSets { - main.resources.srcDir "$buildDir/downloaded-resources" -} - -task downloadResources(type: Download) { - dest "$buildDir/downloaded-resources/software/aws/toolkits/resources/" - - src([ - "https://aws-toolkit-endpoints.s3.amazonaws.com/endpoints.json" - ]) - - onlyIfModified true - useETag true - - doFirst { - mkdir "$buildDir/downloaded-resources/software/aws/toolkits/resources/" - } -} - -processResources.dependsOn(downloadResources) \ No newline at end of file diff --git a/resources/build.gradle.kts b/resources/build.gradle.kts new file mode 100644 index 0000000000..efc200df59 --- /dev/null +++ b/resources/build.gradle.kts @@ -0,0 +1,24 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import de.undercouch.gradle.tasks.download.Download + +plugins { + id("de.undercouch.download") +} + +sourceSets { + main.get().resources.srcDir("$buildDir/downloaded-resources") +} + +val download = tasks.register("downloadResources") { + dest("$buildDir/downloaded-resources/software/aws/toolkits/resources/") + src(listOf("https://idetoolkits.amazonwebservices.com/endpoints.json")) + onlyIfModified(true) + useETag(true) + doFirst { + mkdir("$buildDir/downloaded-resources/software/aws/toolkits/resources/") + } +} + +tasks["processResources"].dependsOn(download) diff --git a/resources/resources/software/aws/toolkits/resources/localized_messages.properties b/resources/resources/software/aws/toolkits/resources/localized_messages.properties index c269874056..deb253e538 100644 --- a/resources/resources/software/aws/toolkits/resources/localized_messages.properties +++ b/resources/resources/software/aws/toolkits/resources/localized_messages.properties @@ -23,10 +23,11 @@ aws.settings.sam.location=SAM CLI executable: aws.settings.sam.show_all_gutter_icons=Show gutter icons for all potential AWS Lambda handlers aws.settings.sam.show_all_gutter_icons_tooltip=When enabled, show gutter icons in source files for all potential Lambda handlers, even if they are not defined in a template or remotely. aws.settings.serverless_label=Serverless settings +aws.settings.show.label=Show AWS Settings aws.settings.telemetry.description=Help us improve our product! Allow us to collect basic usage data. aws.settings.telemetry.option=Send usage metrics to AWS aws.settings.telemetry.prompt.message=Usage metrics are collected by default. Click here to adjust this behavior. -aws.settings.telemetry.prompt.title=AWS Toolkit +aws.settings.telemetry.prompt.title=AWS Toolkit Metrics aws.settings.title=AWS aws_connection.credentials.label=Credentials: aws_connection.region.label=Region: @@ -249,12 +250,19 @@ configure.toolkit.upsert_credentials.confirm_file_create.okay=Create configure.toolkit.upsert_credentials.confirm_file_create.title=Create Credential File configure.validate.no_region_specified=Must specify a region. credentials.could_not_open=Could not open credential file {0} for editing +credentials.invalid.description=Failed to validate your AWS credentials credentials.invalid.more_info=More info... -credentials.invalid.title=Invalid AWS Credential +credentials.invalid.title=Invalid AWS Connection +credentials.mfa.action=Enter MFA code +credentials.mfa.display=''{0}'' requires MFA to connect +credentials.mfa.display.short=MFA code required +credentials.mfa.message=Enter MFA code for device: {0} +credentials.mfa.title=MFA Required to Connect to AWS Using {0} credentials.profile.circular_profiles=Profile ''{0}'' is invalid due to a circular profile dependency was found between {1} +credentials.profile.credential_process.execution_exception_prefix=Failed to execute credential_process ({0}) +credentials.profile.credential_process.parse_exception_prefix=Failed to parse credential_process response +credentials.profile.credential_process.timeout_exception_prefix=Execution of credential_process ({0}) timed out credentials.profile.failed_load=Failed to load AWS profiles -credentials.profile.mfa.message=Enter MFA code for device: {0} -credentials.profile.mfa.title=MFA Challenge for {0} credentials.profile.missing_property=Profile ''{0}'' is missing required property {1} credentials.profile.name=Profile:{0} credentials.profile.not_configured=No active credential provider configured @@ -265,6 +273,31 @@ credentials.profile.source_profile_not_found=Profile ''{0}'' references source p credentials.profile.unsupported=Profile ''{0}'' is not using role-based, session-based, process-based, or basic credentials. credentials.profile.validation_error=Failed to switch to profile ''{0}'' credentials.retrieving=Retrieving AWS credentials +credentials.sso.action=Start SSO login +credentials.sso.display=''{0}'' requires you to re-login with AWS SSO +credentials.sso.display.short=SSO login required +credentials.sso.login.cancelled=AWS SSO login cancelled +credentials.sso.login.failed=AWS SSO Login Failed +credentials.sso.login.message=Login to AWS at {0}, code: {1} +credentials.sso.login.open_browser=Open Browser +credentials.sso.login.title=AWS SSO Login Required +datagrip.auth.secrets_manager=SecretsManager Auth +datagrip.secret_id=Secret Name/ARN: +datagrip.secretsmanager.action=Connect with Secrets Manager... +datagrip.secretsmanager.action.confirm_continue=

{0}


Continue setting up the database?

+datagrip.secretsmanager.action.confirm_continue_title=The selected secret seems to be for a different database +datagrip.secretsmanager.action.title=Select a Secret +datagrip.secretsmanager.validating=Validating selected secret +datagrip.secretsmanager.validation.different_address=Secret {0} specifies a different database address ''{1}'' +datagrip.secretsmanager.validation.different_engine=Secret {0} specifies a different database engine ''{1}'' +datagrip.secretsmanager.validation.exception=Exception thrown while validating secret +datagrip.secretsmanager.validation.failed_to_get=Failed to retrieve secret {0} +datagrip.secretsmanager.validation.no_password=Secret {0} does not have a field named ''password'' +datagrip.secretsmanager.validation.no_secret=No SecretsManager secret specified +datagrip.secretsmanager.validation.no_username=Secret {0} does not have a field named ''username'' +datagrip.secretsmanager.validation.unkown_engine=Unknown database engine {0} +datagrip.validation.invalid_credential_specified=Invalid credential profile {0} selected! +datagrip.validation.invalid_region_specified=Invalid region {0} selected! delete_resource.delete_failed=Failed to delete {0} ''{1}'' delete_resource.deleted=Deleted {0} ''{1}'' delete_resource.message=Are you sure you want to delete {0} ''{1}''?\n\nType the {0} name below to confirm. @@ -289,20 +322,22 @@ executableCommon.unexpected_output={0} provided unexpected output. Ensure that y executableCommon.version_parse_error=Could not parse {0} executable version from "{1}" executableCommon.version_too_high=Upgrade your AWS Toolkit plugin to resolve this issue. executableCommon.version_too_low=Upgrade your {0} to resolve this issue. -executableCommon.version_wrong=Bad {0} executable version. Expected {1} ≤ version < {2} but was {3}. +executableCommon.version_wrong=Bad {0} executable version. Expected {1} ≤ version < {2} but was {3}. explorer.copy_arn=Copy Arn explorer.create_new_issue=Create a New Issue on GitHub explorer.empty_node=empty explorer.error_loading_resources=Error Loading Resources ({0}) explorer.error_loading_resources_default_details=check log for details +explorer.error_loading_resources_not_connected=check credentials/region explorer.label=AWS Explorer explorer.node.cloudformation=CloudFormation explorer.node.cloudwatch=CloudWatch Logs explorer.node.ecs=ECS explorer.node.lambda=Lambda +explorer.node.rds=RDS +explorer.node.redshift=Redshift explorer.node.s3=S3 explorer.node.schemas=Schemas -explorer.refresh.description=Refresh explorer resources explorer.registry.no.schema.resources=Registry has no Schemas explorer.results_truncated=Results truncated, double click to load more explorer.stack.no.serverless.resources=Stack has no Lambda Functions @@ -311,7 +346,7 @@ explorer.view_source=View Source on GitHub feedback.comment.label=Please enter your feedback feedback.description=Submit quick feedback about the AWS Toolkit for JetBrains feedback.github.link=Have an issue or feature request?
Talk to us on GitHub instead! -feedback.limit.label={0}/{1} characters remaining +feedback.limit.label={0} characters remaining feedback.smiley.happyTooltip=Positive feedback.smiley.question=How was your experience? feedback.smiley.sadTooltip=Negative @@ -457,6 +492,24 @@ loading_resource.loading=Loading... notice.message.jetbrains.minimum.version=Support for version {0} of {1} is being deprecated - the next release will require version {2} or later. notice.suppress=Don't show this again notice.title.jetbrains.minimum.version=AWS Toolkit Deprecation Notice +rds.aurora=Aurora +rds.iam_config=Connect with IAM... +rds.iam_connection_display_name=AWS IAM +rds.instance_id=Database Identifier: +rds.mysql=MySQL +rds.postgres=PostgreSQL +rds.validation.no_iam_auth=Database {0} does not have IAM authentication enabled +rds.validation.no_instance_id=No database identifier specified +rds.validation.setup_guide=See the RDS guide for IAM authentication +rds.validation.username=A non-empty username is required +redshift.auth.aws=IAM Auth +redshift.cluster_id=Cluster ID: +redshift.connect_aws_credentials=Connect with IAM... +redshift.validation.cluster_does_not_exist=Cluster {0} does not exist in region {1}! +redshift.validation.invalid_credential_specified=Invalid credential profile {0} selected! +redshift.validation.invalid_region_specified=Invalid region {0} selected! +redshift.validation.no_cluster_id=No cluster ID specified! +redshift.validation.username=A non-empty username is required s3.bucket.name.label=Bucket Name: s3.copy.bucket.action=Copy Name s3.copy.path=Copy path @@ -518,7 +571,7 @@ sam.init.python.bad_sdk=A valid Python SDK was not selected sam.init.sam_template.tooltip=The name of the AWS Serverless Application Model (AWS SAM) template to use. sam.init.schema.aws_credentials.label=AWS Connection: sam.init.schema.aws_credentials_missing=AWS credentials required to choose a Schema. Click here to pick profile. -sam.init.schema.aws_credentials_select=Select AWS Credentials Profile +sam.init.schema.aws_credentials_select=Select AWS Credentials sam.init.schema.aws_credentials_select_region=Select AWS Region sam.init.schema.label=Event Schema: sam.init.schema.pleaseSelect=Choose schema of the EventBridge serverless event... @@ -628,9 +681,25 @@ serverless.application.deploy_in_progress.title=Deploying Application {0} settings.credentials=Credentials settings.credentials.none_selected=No credentials selected settings.credentials.profile_sub_menu=All Credentials +settings.credentials.prompt_for_default_region_switch=Change region to default region: {0}? +settings.credentials.prompt_for_default_region_switch.always=Always +settings.credentials.prompt_for_default_region_switch.always.description=Always select default region when a credential is selected +settings.credentials.prompt_for_default_region_switch.ask.description=Ask before changing regions when a credential is selected +settings.credentials.prompt_for_default_region_switch.never=Never +settings.credentials.prompt_for_default_region_switch.never.description=Never change region when a credential is selected +settings.credentials.prompt_for_default_region_switch.setting_label=Default region handling: +settings.credentials.prompt_for_default_region_switch.yes=Yes settings.credentials.recent=Recent Credentials -settings.partitions=Partitions +settings.none_selected=No region or credentials selected +settings.partitions=Other Partitions +settings.refresh.description=Refresh AWS Connection settings.regions.none_selected=No region selected settings.regions.recent=Recent Regions settings.regions.region_sub_menu=All Regions +settings.retry=Retry +settings.states.initializing=Toolkit initializing... +settings.states.invalid=Unable to connect to AWS:\n{0} +settings.states.invalid.short=Unable to connect +settings.states.validating=Validating connection to AWS... +settings.states.validating.short=Validating connection settings.title=AWS Connection Settings diff --git a/resources/tst/software/aws/toolkits/resources/BundledResourcesTest.kt b/resources/tst/software/aws/toolkits/resources/BundledResourcesTest.kt index 19c2bd74f6..624fcecd0a 100644 --- a/resources/tst/software/aws/toolkits/resources/BundledResourcesTest.kt +++ b/resources/tst/software/aws/toolkits/resources/BundledResourcesTest.kt @@ -3,8 +3,7 @@ package software.aws.toolkits.resources -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat +import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized @@ -23,7 +22,7 @@ class BundledResourcesTest(private val file: InputStream) { @Test fun fileExistsAndHasContent() { file.use { - assertThat(it.read() > 0, equalTo(true)) + assertThat(it.read()).isGreaterThan(0) } } } diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index ac9f37eff0..0000000000 --- a/settings.gradle +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -rootProject.name = 'aws-jetbrains-toolkit' -include 'ktlint-rules' -include 'resources' -include 'telemetry-client' -include 'core' -include 'jetbrains-core' -// skip gui on 2020.1 -if (properties.get("ideProfileName") != "2020.1" && System.env.ALTERNATIVE_IDE_PROFILE_NAME != "2020.1") { - include 'jetbrains-core-gui' -} -include 'jetbrains-ultimate' -if (!properties.containsKey("skipRider")) { - include 'jetbrains-rider' -} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000000..408c59a02f --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,12 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +rootProject.name = "aws-jetbrains-toolkit" + +include("ktlint-rules") +include("resources") +include("telemetry-client") +include("core") +include("jetbrains-core") +include("jetbrains-ultimate") +include("jetbrains-rider") +include("ui-tests") diff --git a/telemetry-client/build.gradle b/telemetry-client/build.gradle.kts similarity index 50% rename from telemetry-client/build.gradle rename to telemetry-client/build.gradle.kts index 69718a2e6e..09392c25eb 100644 --- a/telemetry-client/build.gradle +++ b/telemetry-client/build.gradle.kts @@ -3,16 +3,18 @@ import toolkits.gradle.sdk.GenerateSdk +val awsSdkVersion: String by project + dependencies { - compile("software.amazon.awssdk:services:$awsSdkVersion") - compile("software.amazon.awssdk:aws-json-protocol:$awsSdkVersion") + implementation("software.amazon.awssdk:services:$awsSdkVersion") + implementation("software.amazon.awssdk:aws-json-protocol:$awsSdkVersion") runtimeOnly("software.amazon.awssdk:core:$awsSdkVersion") } -def generatedSources = "$buildDir/generated-src" +val generatedSources = "$buildDir/generated-src" sourceSets { - main.java.srcDir generatedSources + main.get().java.srcDir(generatedSources) } idea { @@ -21,8 +23,9 @@ idea { } } -task generateTelemetryClient(type: GenerateSdk) { +tasks.register("generateTelemetryClient") { c2jFolder = file("telemetryC2J") outputDir = file(generatedSources) } -compileJava.dependsOn(generateTelemetryClient) + +tasks["compileJava"].dependsOn(tasks.named("generateTelemetryClient")) diff --git a/testdata/testFiles/SQSQueue.yml b/testdata/testFiles/SQSQueue.yml new file mode 100644 index 0000000000..78d3ceda10 --- /dev/null +++ b/testdata/testFiles/SQSQueue.yml @@ -0,0 +1,12 @@ +AWSTemplateFormatVersion: 2010-09-09 +Resources: + SQSQueue: + Type: 'AWS::SQS::Queue' + Properties: {} +Outputs: + QueueArn: + Description: Cool description + Value: + 'Fn::GetAtt': + - SQSQueue + - Arn diff --git a/testdata/testFiles/hello2.json b/testdata/testFiles/hello2.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/testdata/testFiles/hello2.json @@ -0,0 +1 @@ +{} diff --git a/ui-tests/.editorconfig b/ui-tests/.editorconfig new file mode 100644 index 0000000000..79beee88c4 --- /dev/null +++ b/ui-tests/.editorconfig @@ -0,0 +1,4 @@ +[{*.kts,*.kt}] +# Temporary disabled this rule through .editorconfig due to https://youtrack.jetbrains.com/issue/KT-10974 and https://github.com/pinterest/ktlint/issues/527 +# Disable log rule, due to this code is using a logger that isn't compatible with the lazy syntax +disabled_rules = import-ordering, log-not-lazy diff --git a/ui-tests/build.gradle.kts b/ui-tests/build.gradle.kts new file mode 100644 index 0000000000..15bbd2341e --- /dev/null +++ b/ui-tests/build.gradle.kts @@ -0,0 +1,46 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import java.net.URI + +val remoteRobotPort: String by project +val junit5Version: String by project +val remoteRobotVersion: String by project +val uiTestFixturesVersion: String by project +val awsSdkVersion: String by project + +repositories { + maven { url = URI("https://jetbrains.bintray.com/intellij-third-party-dependencies") } +} + +plugins { + jacoco +} + +dependencies { + testImplementation(gradleApi()) + testImplementation("org.junit.jupiter:junit-jupiter-api:$junit5Version") + testImplementation("com.intellij.remoterobot:remote-robot:$remoteRobotVersion") + testImplementation("com.intellij.remoterobot:remote-fixtures:$uiTestFixturesVersion") + testImplementation("software.amazon.awssdk:s3:$awsSdkVersion") + testImplementation("software.amazon.awssdk:cloudformation:$awsSdkVersion") + + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junit5Version") +} + +// don't run gui tests as part of check +tasks["test"].enabled = false + +tasks.register("uiTestCore") { + // we don't want to cache the results of this. + outputs.upToDateWhen { false } + + systemProperty("robot-server.port", remoteRobotPort) + systemProperty("junit.jupiter.extensions.autodetection.enabled", true) + systemProperty("testDataPath", project.rootDir.toPath().resolve("testdata").toString()) + + systemProperty("GRADLE_PROJECT", "jetbrains-core") + useJUnitPlatform { + includeTags("core") + } +} diff --git a/ui-tests/tst-resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/ui-tests/tst-resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 0000000000..acfdb569ec --- /dev/null +++ b/ui-tests/tst-resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +software.aws.toolkits.jetbrains.uitests.extensions.Ide diff --git a/ui-tests/tst-resources/log4j2.xml b/ui-tests/tst-resources/log4j2.xml new file mode 100644 index 0000000000..b70e9eb63f --- /dev/null +++ b/ui-tests/tst-resources/log4j2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/TestFlavors.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/TestFlavors.kt new file mode 100644 index 0000000000..360b0c133b --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/TestFlavors.kt @@ -0,0 +1,16 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests + +import org.junit.jupiter.api.Tag + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@Tag("core") +annotation class CoreTest + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@Tag("ultimate") +annotation class UltimateTest diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/cloudformation/CloudFormationBrowserTest.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/cloudformation/CloudFormationBrowserTest.kt new file mode 100644 index 0000000000..4ab098473c --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/cloudformation/CloudFormationBrowserTest.kt @@ -0,0 +1,139 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.cloudformation + +import com.intellij.remoterobot.stepsProcessing.log +import com.intellij.remoterobot.stepsProcessing.step +import com.intellij.remoterobot.utils.waitFor +import com.intellij.remoterobot.utils.waitForIgnoringError +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle +import org.junit.jupiter.api.io.TempDir +import software.amazon.awssdk.services.cloudformation.CloudFormationClient +import software.amazon.awssdk.services.cloudformation.model.CloudFormationException +import software.amazon.awssdk.services.cloudformation.model.StackStatus +import software.aws.toolkits.jetbrains.uitests.CoreTest +import software.aws.toolkits.jetbrains.uitests.extensions.uiTest +import software.aws.toolkits.jetbrains.uitests.fixtures.IdeaFrame +import software.aws.toolkits.jetbrains.uitests.fixtures.awsExplorer +import software.aws.toolkits.jetbrains.uitests.fixtures.fillSingleTextField +import software.aws.toolkits.jetbrains.uitests.fixtures.findAndClick +import software.aws.toolkits.jetbrains.uitests.fixtures.idea +import software.aws.toolkits.jetbrains.uitests.fixtures.pressOk +import software.aws.toolkits.jetbrains.uitests.fixtures.welcomeFrame +import java.nio.file.Path +import java.nio.file.Paths +import java.time.Duration +import java.util.UUID + +@TestInstance(Lifecycle.PER_CLASS) +class CloudFormationBrowserTest { + private val templateFileName = "SQSQueue.yml" + private val templateFile: Path = Paths.get(System.getProperty("testDataPath")).resolve("testFiles").resolve(templateFileName) + private val stack = "uitest-${UUID.randomUUID()}" + + private val CloudFormation = "CloudFormation" + private val deleteStackText = "Delete Stack..." + + @TempDir + lateinit var tempDir: Path + lateinit var cloudFormationClient: CloudFormationClient + + @BeforeAll + fun deployStack() { + log.info("Deploying stack $stack before the test run") + cloudFormationClient = CloudFormationClient.create() + cloudFormationClient.createStack { it.templateBody(templateFile.toFile().readText()).stackName(stack) } + waitForIgnoringError(Duration.ofSeconds(120), Duration.ofSeconds(5)) { + cloudFormationClient.describeStacks { it.stackName(stack) }.hasStacks() + } + log.info("Successfully deployed $stack") + } + + @Test + @CoreTest + fun testCloudFormationBrowser() = uiTest { + welcomeFrame { + openFolder(tempDir) + } + idea { + waitForBackgroundTasks() + showAwsExplorer() + } + idea { + step("Open stack") { + awsExplorer { + expandExplorerNode(CloudFormation) + doubleClickExplorer(CloudFormation, stack) + } + } + step("Check events") { + clickOnEvents() + step("Assert that there are two CREATE_COMPLETE events shown") { + val createComplete = findAllText("CREATE_COMPLETE") + assertThat(createComplete).hasSize(2) + } + } + step("Check outputs") { + clickOnOutputs() + step("Assert that the stack output is there") { + findText("Cool description") + } + } + step("Delete stack $stack") { + showAwsExplorer() + awsExplorer { + openExplorerActionMenu(CloudFormation, stack) + } + findAndClick("//div[@text='$deleteStackText']") + fillSingleTextField(stack) + pressOk() + } + + waitForStackDeletion() + + step("Check for the stack deletion notification") { + // Sometimes the toast takes a while to show up so give it a longer timeout + val toast = findToast(Duration.ofSeconds(10)) + assertThat(toast.hasText { it.text.contains("Deleted Stack '$stack'") }) + } + } + } + + @AfterAll + fun cleanup() { + // Make sure that we delete the stack even if it fails in the UIs + try { + cloudFormationClient.deleteStack { it.stackName(stack) } + } catch (e: Exception) { + log.error("Delete stack threw an exception", e) + } + waitForStackDeletion() + } + + private fun IdeaFrame.clickOnOutputs() { + findAndClick("//div[@accessiblename='Outputs' and @class='JLabel' and @text='Outputs']") + } + + private fun IdeaFrame.clickOnEvents() { + findAndClick("//div[@accessiblename='Events' and @class='JLabel' and @text='Events']") + } + + private fun waitForStackDeletion() { + log.info("Waiting for the deletion of stack $stack") + waitFor(duration = Duration.ofSeconds(180), interval = Duration.ofSeconds(5)) { + // wait until the stack is gone + try { + cloudFormationClient.describeStacks { it.stackName(stack) }.stacks().first().stackStatus() == StackStatus.DELETE_COMPLETE + } catch (e: Exception) { + e is CloudFormationException && e.awsErrorDetails().errorCode() == "ValidationError" + } + } + log.info("Finished deleting stack $stack") + } +} diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/extensions/IdeExtension.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/extensions/IdeExtension.kt new file mode 100644 index 0000000000..b2009c763e --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/extensions/IdeExtension.kt @@ -0,0 +1,186 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.extensions + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.search.locators.LambdaLocator +import com.intellij.remoterobot.stepsProcessing.StepLogger +import com.intellij.remoterobot.stepsProcessing.StepWorker +import com.intellij.remoterobot.stepsProcessing.log +import com.intellij.remoterobot.stepsProcessing.step +import com.intellij.remoterobot.utils.waitFor +import com.intellij.remoterobot.utils.waitForIgnoringError +import org.gradle.tooling.CancellationTokenSource +import org.gradle.tooling.GradleConnectionException +import org.gradle.tooling.GradleConnector +import org.gradle.tooling.ProjectConnection +import org.gradle.tooling.ResultHandler +import org.junit.jupiter.api.extension.AfterAllCallback +import org.junit.jupiter.api.extension.BeforeAllCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext +import software.aws.toolkits.jetbrains.uitests.fixtures.DialogFixture +import software.aws.toolkits.jetbrains.uitests.fixtures.WelcomeFrame +import java.awt.Window +import java.io.IOException +import java.io.OutputStream +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.file.Files +import java.nio.file.Paths +import java.time.Duration +import java.util.concurrent.atomic.AtomicBoolean + +private val initialSetup = AtomicBoolean(false) +private val robotPort = System.getProperty("robot-server.port")?.toInt() ?: throw IllegalStateException("System Property 'robot-server.port' is not set") + +fun uiTest(test: RemoteRobot.() -> Unit) { + if (!initialSetup.getAndSet(true)) { + StepWorker.registerProcessor(StepLogger()) + } + + RemoteRobot("http://127.0.0.1:$robotPort").apply(test) +} + +class Ide : BeforeAllCallback, BeforeEachCallback, AfterAllCallback { + private val gradleProject = System.getProperty("GRADLE_PROJECT") ?: throw java.lang.IllegalStateException("GRADLE_PROJECT not set") + private val gradleProcess = GradleProcess() + + override fun beforeAll(context: ExtensionContext) { + gradleProcess.startGradleTasks(":$gradleProject:runIdeForUiTests") + log.info("Gradle process started, trying to connect to IDE") + waitForIde() + log.info("Connected to IDE") + } + + override fun beforeEach(context: ExtensionContext) { + uiTest { + waitForIgnoringError( + duration = Duration.ofMinutes(1), + interval = Duration.ofMillis(500), + errorMessage = "Could not get to Welcome Screen in time" + ) { + step("Attempt to reset to Welcome Frame") { + // Make sure we find the welcome screen + if (findAll().isNotEmpty()) { + return@step true + } + + // Try to get back to starting point by closing all windows + val dialogs = findAll(LambdaLocator("any dialog") { + it is Window && it.isShowing + }) + + dialogs.filterNot { it.remoteComponent.className.contains("FlatWelcomeFrame") } + .forEach { + step("Closing ${it.title}") { it.close() } + } + + true // Earlier code will throw + } + } + } + } + + private fun waitForIde() { + waitFor( + duration = Duration.ofMinutes(10), + interval = Duration.ofMillis(500), + errorMessage = "Could not connect to remote robot in time" + ) { + if (!gradleProcess.isRunning()) { + throw IllegalStateException("Gradle task has ended, check log") + } + + canConnectToToRobot() + } + } + + private fun canConnectToToRobot(): Boolean = try { + Socket().use { socket -> + socket.connect(InetSocketAddress("127.0.0.1", robotPort)) + true + } + } catch (e: IOException) { + false + } + + override fun afterAll(context: ExtensionContext) { + log.info("Stopping Gradle process") + gradleProcess.stopGradleTask() + } +} + +private class GradleProcess { + private val gradleConnection: ProjectConnection + private var cancellationTokenSource: CancellationTokenSource? = null + private val isRunning = AtomicBoolean(false) + + init { + val cwd = Paths.get(".").toAbsolutePath() + if (!Files.exists(cwd.resolve("build.gradle.kts"))) { + throw IllegalStateException("Failed to locate build.gradle.kts in $cwd}") + } + + gradleConnection = GradleConnector.newConnector() + .forProjectDirectory(cwd.toFile()) + .connect() + } + + fun isRunning(): Boolean = isRunning.get() + + fun startGradleTasks(vararg gradleTask: String) { + val tokenSource = GradleConnector.newCancellationTokenSource() + + gradleConnection.newBuild() + .forTasks(*gradleTask) + .withCancellationToken(tokenSource.token()) + .setColorOutput(false) + .setStandardOutput(OutputWrapper(true)) + .setStandardError(OutputWrapper(false)) + .run(object : ResultHandler { + override fun onFailure(failure: GradleConnectionException) { + isRunning.set(false) + } + + override fun onComplete(result: Any) { + isRunning.set(false) + } + }) + + isRunning.set(true) + + this.cancellationTokenSource = tokenSource + } + + fun stopGradleTask() { + cancellationTokenSource?.let { + it.cancel() + + cancellationTokenSource = null + } + } +} + +private class OutputWrapper(private val isStdOut: Boolean) : OutputStream() { + private var buffer = StringBuilder() + + override fun write(b: Int) { + val c = b.toChar() + buffer.append(c) + if (c == '\n') { + doFlush() + } + } + + private fun doFlush() { + val message = "[IDE Gradle]: ${buffer.trim()}" + if (isStdOut) { + log.info(message) + } else { + log.error(message) + } + buffer.clear() + } +} diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/ActionMenu.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/ActionMenu.kt new file mode 100644 index 0000000000..4e3711ecf3 --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/ActionMenu.kt @@ -0,0 +1,33 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.fixtures + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.ComponentFixture +import com.intellij.remoterobot.fixtures.FixtureName +import com.intellij.remoterobot.search.locators.byXpath +import com.intellij.remoterobot.utils.waitFor + +fun RemoteRobot.actionMenu(text: String): ActionMenuFixture { + val xpath = byXpath("text '$text'", "//div[@class='ActionMenu' and @text='$text']") + waitFor { + findAll(xpath).isNotEmpty() + } + return findAll(xpath).first() +} + +fun RemoteRobot.actionMenuItem(text: String): ActionMenuItemFixture { + val xpath = byXpath("text '$text'", "//div[@class='ActionMenuItem' and @text='$text']") + waitFor { + findAll(xpath).isNotEmpty() + } + return findAll(xpath).first() +} + +@FixtureName("ActionMenu") +class ActionMenuFixture(remoteRobot: RemoteRobot, remoteComponent: RemoteComponent) : ComponentFixture(remoteRobot, remoteComponent) + +@FixtureName("ActionMenuItem") +class ActionMenuItemFixture(remoteRobot: RemoteRobot, remoteComponent: RemoteComponent) : ComponentFixture(remoteRobot, remoteComponent) diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/AwsExplorer.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/AwsExplorer.kt new file mode 100644 index 0000000000..c7492fc6cf --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/AwsExplorer.kt @@ -0,0 +1,48 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.fixtures + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.FixtureName +import com.intellij.remoterobot.search.locators.byXpath +import com.intellij.remoterobot.stepsProcessing.step +import java.time.Duration + +fun RemoteRobot.awsExplorer( + timeout: Duration = Duration.ofSeconds(20), + function: AwsExplorer.() -> Unit +) { + step("AWS explorer") { + find(byXpath("//div[@class='ExplorerToolWindow']"), timeout).apply(function) + } +} + +@FixtureName("AWSExplorer") +open class AwsExplorer( + remoteRobot: RemoteRobot, + remoteComponent: RemoteComponent +) : DialogFixture(remoteRobot, remoteComponent) { + fun openExplorerActionMenu(vararg path: String) { + findExplorerTree().rightClickPath(*path) + } + + fun expandExplorerNode(vararg path: String) { + findExplorerTree().expandPath(*path) + // wait for loading to disappear + try { + while (true) { + findText("loading...") + Thread.sleep(100) + } + } catch (e: Exception) { + } + } + + fun doubleClickExplorer(vararg nodeElements: String) { + findExplorerTree().doubleClickPath(*nodeElements) + } + + private fun findExplorerTree() = find(byXpath("//div[@class='Tree']"), Duration.ofSeconds(10)) +} diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/Dialog.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/Dialog.kt new file mode 100644 index 0000000000..881f5b2d56 --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/Dialog.kt @@ -0,0 +1,61 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.fixtures + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.CommonContainerFixture +import com.intellij.remoterobot.fixtures.ContainerFixture +import com.intellij.remoterobot.fixtures.FixtureName +import com.intellij.remoterobot.search.locators.byXpath +import com.intellij.remoterobot.stepsProcessing.step +import java.time.Duration + +fun ContainerFixture.dialog( + title: String, + timeout: Duration = Duration.ofSeconds(20), + function: DialogFixture.() -> Unit = {} +) { + step("Search for dialog with title $title") { + val dialog = find(DialogFixture.byTitle(title), timeout) + + dialog.apply(function) + + if (dialog.isShowing) { + dialog.close() + } + + dialog + } +} + +@FixtureName("Dialog") +open class DialogFixture( + remoteRobot: RemoteRobot, + remoteComponent: RemoteComponent +) : CommonContainerFixture(remoteRobot, remoteComponent) { + companion object { + fun byTitle(title: String) = byXpath("title $title", "//div[@title='$title' and @class='MyDialog']") + fun byTitleContains(partial: String) = byXpath("partial title $partial", "//div[contains(@accessiblename, $partial) and @class='MyDialog']") + } + + val title: String + get() = callJs("component.getTitle();") + + fun close() { + runJs("robot.close(component)") + } + + open fun pressOk() { + pressButton("OK") + } + + fun pressCancel() { + pressButton("Cancel") + } + + fun pressButton(text: String) { + button(text).click() + } +} diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/Editor.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/Editor.kt new file mode 100644 index 0000000000..6e5382e400 --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/Editor.kt @@ -0,0 +1,20 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.fixtures + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.CommonContainerFixture +import com.intellij.remoterobot.fixtures.ContainerFixture +import com.intellij.remoterobot.fixtures.FixtureName +import com.intellij.remoterobot.search.locators.byXpath + +fun ContainerFixture.editorTab(title: String, function: EditorTab.() -> Unit = {}): EditorTab { + val editorTabb = find(byXpath("//div[@class='EditorTabs']//div[@accessiblename='$title' and @class='SingleHeightLabel']")) + editorTabb.click() + return editorTabb.apply(function) +} + +@FixtureName("EditorTab") +class EditorTab(remoteRobot: RemoteRobot, remoteComponent: RemoteComponent) : CommonContainerFixture(remoteRobot, remoteComponent) diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/FileBrowserFixture.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/FileBrowserFixture.kt new file mode 100644 index 0000000000..a356b94f35 --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/FileBrowserFixture.kt @@ -0,0 +1,49 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.fixtures + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.ContainerFixture +import com.intellij.remoterobot.fixtures.FixtureName +import com.intellij.remoterobot.stepsProcessing.step +import java.nio.file.Path +import java.time.Duration + +fun ContainerFixture.fileBrowser( + partialTitle: String, + timeout: Duration = Duration.ofSeconds(20), + function: FileBrowserFixture.() -> Unit = {} +) { + step("Search for file explorer with title matching $partialTitle") { + val dialog = find(DialogFixture.byTitleContains(partialTitle), timeout) + + dialog.apply(function) + + if (dialog.isShowing) { + dialog.close() + } + + dialog + } +} + +@FixtureName("FileBrowser") +class FileBrowserFixture( + remoteRobot: RemoteRobot, + remoteComponent: RemoteComponent +) : DialogFixture(remoteRobot, remoteComponent) { + fun selectFile(path: Path) = step("Select ${path.toAbsolutePath()}") { + // Wait for file explorer to load + Thread.sleep(1000) + step("Fill file explorer with ${path.toAbsolutePath()}") { + fillSingleTextField(path.toAbsolutePath().toString()) + } + val file = path.fileName.toString() + step("Refresh file explorer to make sure the file $file is loaded") { + findAndClick("//div[@accessiblename='Refresh']") + } + pressOk() + } +} diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/FixtureExtensions.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/FixtureExtensions.kt new file mode 100644 index 0000000000..78e5dd1f8c --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/FixtureExtensions.kt @@ -0,0 +1,34 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.fixtures + +import com.intellij.remoterobot.fixtures.CommonContainerFixture +import com.intellij.remoterobot.fixtures.ComponentFixture +import com.intellij.remoterobot.fixtures.ContainerFixture +import com.intellij.remoterobot.fixtures.JTextFieldFixture +import com.intellij.remoterobot.search.locators.byXpath +import com.intellij.remoterobot.stepsProcessing.step +import org.intellij.lang.annotations.Language +import java.time.Duration + +fun ComponentFixture.rightClick() = step("Right click") { + runJs("robot.rightClick(component);") +} + +fun ContainerFixture.pressOk() = findAndClick("//div[@text='OK']") +fun ContainerFixture.pressDelete() = findAndClick("//div[@text='Delete']") +fun ContainerFixture.pressCancel() = findAndClick("//div[@text='Cancel']") +fun ContainerFixture.pressClose() = findAndClick("//div[@text='Close']") + +fun ContainerFixture.findAndClick(@Language("XPath") xPath: String) = findByXpath(xPath).click() +fun ContainerFixture.findByXpath(@Language("XPath") xPath: String) = find(byXpath(xPath), Duration.ofSeconds(5)) + +fun ContainerFixture.fillSingleTextField(text: String) = step("Fill single text field with $text") { + find(byXpath("//div[@class='JTextField']"), Duration.ofSeconds(5)).text = text +} + +/* + * Find an action button by button text instead of by xPath + */ +fun CommonContainerFixture.actionButton(buttonText: String) = actionButton(byXpath("//div[@accessiblename='$buttonText' and @class='ActionButton']")) diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/IdeaFrame.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/IdeaFrame.kt new file mode 100644 index 0000000000..a624fed0ad --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/IdeaFrame.kt @@ -0,0 +1,116 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.fixtures + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.CommonContainerFixture +import com.intellij.remoterobot.fixtures.ComponentFixture +import com.intellij.remoterobot.fixtures.ContainerFixture +import com.intellij.remoterobot.fixtures.DefaultXpath +import com.intellij.remoterobot.fixtures.FixtureName +import com.intellij.remoterobot.fixtures.JListFixture +import com.intellij.remoterobot.search.locators.byXpath +import com.intellij.remoterobot.stepsProcessing.step +import com.intellij.remoterobot.utils.keyboard +import com.intellij.remoterobot.utils.waitFor +import java.awt.event.KeyEvent +import java.time.Duration + +fun RemoteRobot.idea(function: IdeaFrame.() -> Unit) { + val frame = find() + // FIX_WHEN_MIN_IS_203 remove this and set the system property "ide.show.tips.on.startup.default.value" + frame.apply { tryCloseTips() } + frame.apply(function) +} + +@FixtureName("Idea frame") +@DefaultXpath("IdeFrameImpl type", "//div[@class='IdeFrameImpl']") +class IdeaFrame(remoteRobot: RemoteRobot, remoteComponent: RemoteComponent) : CommonContainerFixture(remoteRobot, remoteComponent) { + val projectViewTree + get() = find(byXpath("ProjectViewTree", "//div[@class='ProjectViewTree']")) + + val projectName + get() = step("Get project name") { return@step callJs("component.getProject().getName()") } + + fun dumbAware(timeout: Duration = Duration.ofMinutes(5), function: () -> Unit) { + step("Wait for smart mode") { + waitFor(duration = timeout, interval = Duration.ofSeconds(5)) { + runCatching { isDumbMode().not() }.getOrDefault(false) + } + function() + step("..wait for smart mode again") { + waitFor(duration = timeout, interval = Duration.ofSeconds(5)) { + isDumbMode().not() + } + } + } + } + + fun waitForBackgroundTasks(timeout: Duration = Duration.ofMinutes(5)) { + step("Wait for background tasks to finish") { + waitFor(duration = timeout, interval = Duration.ofSeconds(5)) { + // TODO FIX_WHEN_MIN_IS_202 remove the background process one + findAll(byXpath("//div[@myname='Background process']")).isEmpty() && + // search for the progress bar + findAll(byXpath("//div[@class='JProgressBar']")).isEmpty() + } + } + } + + private fun isDumbMode(): Boolean = callJs("com.intellij.openapi. project.DumbService.isDumb(component.project);", true) + + fun openProjectStructure() = step("Open Project Structure dialog") { + if (remoteRobot.isMac()) { + keyboard { hotKey(KeyEvent.VK_META, KeyEvent.VK_SEMICOLON) } + } else { + keyboard { hotKey(KeyEvent.VK_CONTROL, KeyEvent.VK_ALT, KeyEvent.VK_SHIFT, KeyEvent.VK_S) } + } + find(ComponentFixture::class.java, byXpath("//div[@accessiblename='Project Structure']")).click() + } + + // Show AWS Explorer, or leave it open if it is already open + fun showAwsExplorer() { + try { + find(byXpath("//div[@class='ExplorerToolWindow']")) + } catch (e: Exception) { + find(ComponentFixture::class.java, byXpath("//div[@accessiblename='AWS Explorer' and @class='StripeButton' and @text='AWS Explorer']")).click() + } + } + + fun setCredentials(profile: String, region: String) { + openCredentialsPanel() + // This will grab both the region and credentials + findAll(byXpath("//div[@class='MyList']")).forEach { + if (it.items.contains(profile)) { + it.selectItem(profile) + } + } + openCredentialsPanel() + findAll(byXpath("//div[@class='MyList']")).forEach { + if (it.items.contains(region)) { + it.selectItem(region) + } + } + } + + // Tips sometimes open when running, close it if it opens + fun tryCloseTips() { + try { + pressClose() + } catch (e: Exception) { + } + } + + private fun openCredentialsPanel() = try { + // 2020.1 + findAndClick("//div[@class='MultipleTextValues']") + } catch (e: Exception) { + // TODO FIX_WHEN_MIN_IS_201 remove this + // 2019.3 + findAndClick("//div[@class='MultipleTextValuesPresentationWrapper']") + } + + fun findToast(timeout: Duration = Duration.ofSeconds(5)): ComponentFixture = find(byXpath("//div[@class='StatusPanel']"), timeout) +} diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/JTreeFixture.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/JTreeFixture.kt new file mode 100644 index 0000000000..fad7c085d3 --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/JTreeFixture.kt @@ -0,0 +1,45 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.fixtures + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.ComponentFixture +import com.intellij.remoterobot.stepsProcessing.step + +class JTreeFixture( + remoteRobot: RemoteRobot, + remoteComponent: RemoteComponent +) : ComponentFixture(remoteRobot, remoteComponent) { + fun clickPath(vararg paths: String) = runJsPathMethod("clickPath", *paths) + fun expandPath(vararg paths: String) = runJsPathMethod("expandPath", *paths) + fun rightClickPath(vararg paths: String) = runJsPathMethod("rightClickPath", *paths) + fun doubleClickPath(vararg paths: String) = runJsPathMethod("doubleClickPath", *paths) + + fun clickRow(row: Int) = runJsRowMethod("clickRow", row) + fun expandRow(row: Int) = runJsRowMethod("expandRow", row) + + private fun runJsPathMethod(name: String, vararg paths: String) { + val path = paths.joinToString("/") + step("$name $path") { + runJs( + """ + const jTreeFixture = JTreeFixture(robot, component); + jTreeFixture.$name('$path') + """.trimIndent() + ) + } + } + + private fun runJsRowMethod(name: String, row: Int) { + step("$name $row") { + runJs( + """ + const jTreeFixture = JTreeFixture(robot, component); + jTreeFixture.$name($row) + """.trimIndent() + ) + } + } +} diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/NewProjectWizard.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/NewProjectWizard.kt new file mode 100644 index 0000000000..72d1debfea --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/NewProjectWizard.kt @@ -0,0 +1,52 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.fixtures + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.FixtureName +import com.intellij.remoterobot.search.locators.byXpath +import com.intellij.remoterobot.stepsProcessing.step +import java.time.Duration + +fun RemoteRobot.newProjectWizard( + timeout: Duration = Duration.ofSeconds(20), + function: NewProjectWizardDialog.() -> Unit +) { + step("Search for new project wizard dialog") { + val dialog = find(DialogFixture.byTitle("New Project"), timeout) + + dialog.apply(function) + + if (dialog.isShowing) { + dialog.close() + } + } +} + +@FixtureName("New Project Wizard") +open class NewProjectWizardDialog( + remoteRobot: RemoteRobot, + remoteComponent: RemoteComponent +) : DialogFixture(remoteRobot, remoteComponent) { + fun selectProjectCategory(type: String) { + findText(type).click() + } + + fun selectProjectType(type: String) { + jList(byXpath("//div[@class='JBList' and @visible_text='$type']")).click() + } + + fun setProjectLocation(folder: String) { + textField("Project location:").text = folder + } + + fun pressNext() { + pressButton("Next") + } + + fun pressFinish() { + pressButton("Finish") + } +} diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/Preferences.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/Preferences.kt new file mode 100644 index 0000000000..2953977ab9 --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/Preferences.kt @@ -0,0 +1,58 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.fixtures + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.FixtureName +import com.intellij.remoterobot.fixtures.JLabelFixture +import com.intellij.remoterobot.search.locators.byXpath +import com.intellij.remoterobot.stepsProcessing.step +import java.time.Duration + +fun RemoteRobot.preferencesDialog( + timeout: Duration = Duration.ofSeconds(20), + function: PreferencesDialog.() -> Unit +) { + step("Search for preferences dialog") { + val dialog = find(DialogFixture.byTitleContains(preferencesTitle()), timeout) + + dialog.apply(function) + + if (dialog.isShowing) { + dialog.close() + } + } +} + +@FixtureName("Preferences") +open class PreferencesDialog( + remoteRobot: RemoteRobot, + remoteComponent: RemoteComponent +) : DialogFixture(remoteRobot, remoteComponent) { + fun search(query: String) = step("Search $query") { + textField(byXpath("//div[@class='TextFieldWithProcessing']")).text = query + } + + fun selectPreferencePage(vararg crumbs: String) { + val preferencesTree = find(byXpath("//div[@class='MyTree']")) + + preferencesTree.clickPath(*crumbs) + } + + override fun pressOk() { + super.pressOk() + + assertValidSettings() + } + + fun assertValidSettings() { + val invalidSettingsLabel = jLabels(JLabelFixture.byContainsText("Cannot Save Settings")) + if (invalidSettingsLabel.isNotEmpty()) { + throw IllegalStateException("Could not save settings: ${invalidSettingsLabel.first().value}") + } + } +} + +fun RemoteRobot.preferencesTitle() = if (this.isMac()) "Preferences" else "Settings" diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/ProjectStructure.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/ProjectStructure.kt new file mode 100644 index 0000000000..60b4e40ae6 --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/ProjectStructure.kt @@ -0,0 +1,32 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.fixtures + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.FixtureName +import com.intellij.remoterobot.search.locators.byXpath +import com.intellij.remoterobot.stepsProcessing.step +import java.time.Duration + +fun RemoteRobot.projectStructureDialog( + timeout: Duration = Duration.ofSeconds(20), + function: ProjectStructureDialog.() -> Unit +) { + step("Search for Project Structure dialog") { + val dialog = find(byXpath("//div[@accessiblename='Project Structure']"), timeout) + + dialog.apply(function) + + if (dialog.isShowing) { + dialog.close() + } + } +} + +@FixtureName("ProjectStructure") +open class ProjectStructureDialog( + remoteRobot: RemoteRobot, + remoteComponent: RemoteComponent +) : DialogFixture(remoteRobot, remoteComponent) diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/WelcomeFrame.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/WelcomeFrame.kt new file mode 100644 index 0000000000..f162eabc3d --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/fixtures/WelcomeFrame.kt @@ -0,0 +1,41 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.fixtures + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.ActionLinkFixture +import com.intellij.remoterobot.fixtures.CommonContainerFixture +import com.intellij.remoterobot.fixtures.ComponentFixture +import com.intellij.remoterobot.fixtures.DefaultXpath +import com.intellij.remoterobot.fixtures.FixtureName +import com.intellij.remoterobot.search.locators.byXpath +import java.nio.file.Path + +fun RemoteRobot.welcomeFrame(function: WelcomeFrame.() -> Unit) { + find(WelcomeFrame::class.java).apply(function) +} + +@FixtureName("Welcome Frame") +@DefaultXpath("type", "//div[@class='FlatWelcomeFrame' and @visible='true']") +class WelcomeFrame(remoteRobot: RemoteRobot, remoteComponent: RemoteComponent) : CommonContainerFixture(remoteRobot, remoteComponent) { + fun openNewProjectWizard() { + actionLink(ActionLinkFixture.byTextContains("New Project")).click() + } + + fun openPreferences() { + actionLink("Configure").click() + + find(ComponentFixture::class.java, byXpath("//div[@class='MyList']")) + .findText(remoteRobot.preferencesTitle()) + .click() + } + + fun openFolder(path: Path) { + actionLink(ActionLinkFixture.byTextContains("Open")).click() + fileBrowser("Open") { + selectFile(path) + } + } +} diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/projectwizard/SamTemplateProjectWizardTest.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/projectwizard/SamTemplateProjectWizardTest.kt new file mode 100644 index 0000000000..adb8300112 --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/projectwizard/SamTemplateProjectWizardTest.kt @@ -0,0 +1,102 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.projectwizard + +import com.intellij.remoterobot.search.locators.byXpath +import com.intellij.remoterobot.stepsProcessing.log +import com.intellij.remoterobot.stepsProcessing.step +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import software.aws.toolkits.jetbrains.uitests.CoreTest +import software.aws.toolkits.jetbrains.uitests.extensions.uiTest +import software.aws.toolkits.jetbrains.uitests.fixtures.editorTab +import software.aws.toolkits.jetbrains.uitests.fixtures.idea +import software.aws.toolkits.jetbrains.uitests.fixtures.newProjectWizard +import software.aws.toolkits.jetbrains.uitests.fixtures.preferencesDialog +import software.aws.toolkits.jetbrains.uitests.fixtures.projectStructureDialog +import software.aws.toolkits.jetbrains.uitests.fixtures.welcomeFrame +import java.nio.file.Path + +class SamTemplateProjectWizardTest { + companion object { + @JvmStatic + @BeforeAll + fun setUpSamCli() { + val samPath = System.getenv("SAM_CLI_EXEC") + if (samPath.isNullOrEmpty()) { + log.warn("No custom SAM set, skipping setup") + return + } + + uiTest { + welcomeFrame { + step("Open preferences page") { + openPreferences() + + preferencesDialog { + // Search for AWS because sometimes it is off the screen + search("AWS") + + selectPreferencePage("Tools", "AWS") + + step("Set SAM CLI executable path to $samPath") { + textField("SAM CLI executable:").text = samPath + } + + pressOk() + } + } + } + } + } + } + + @TempDir + lateinit var tempDir: Path + + @Test + @CoreTest + fun createSamApp() { + uiTest { + welcomeFrame { + openNewProjectWizard() + + step("Run New Project Wizard") { + newProjectWizard { + selectProjectCategory("AWS") + selectProjectType("AWS Serverless Application") + + pressNext() + + setProjectLocation(tempDir.toAbsolutePath().toString()) + + // TODO: Runtime + // TODO: Sam Template + + pressFinish() + } + } + } + + idea { + waitForBackgroundTasks() + + step("Validate Readme is opened") { + editorTab("README.md") + } + + step("Validate project structure") { + openProjectStructure() + projectStructureDialog { + val fixture = comboBox(byXpath("//div[@class='JdkComboBox']")) + // TODO set based on Runtime + assertThat(fixture.selectedText()).isEqualTo("11") + } + } + } + } + } +} diff --git a/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/s3/S3BrowserTest.kt b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/s3/S3BrowserTest.kt new file mode 100644 index 0000000000..7869548dd2 --- /dev/null +++ b/ui-tests/tst/software/aws/toolkits/jetbrains/uitests/s3/S3BrowserTest.kt @@ -0,0 +1,247 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.uitests.s3 + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.fixtures.ComponentFixture +import com.intellij.remoterobot.fixtures.JTextFieldFixture +import com.intellij.remoterobot.search.locators.byXpath +import com.intellij.remoterobot.stepsProcessing.log +import com.intellij.remoterobot.stepsProcessing.step +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle +import org.junit.jupiter.api.io.TempDir +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.S3Exception +import software.aws.toolkits.jetbrains.uitests.CoreTest +import software.aws.toolkits.jetbrains.uitests.extensions.uiTest +import software.aws.toolkits.jetbrains.uitests.fixtures.JTreeFixture +import software.aws.toolkits.jetbrains.uitests.fixtures.actionButton +import software.aws.toolkits.jetbrains.uitests.fixtures.awsExplorer +import software.aws.toolkits.jetbrains.uitests.fixtures.fileBrowser +import software.aws.toolkits.jetbrains.uitests.fixtures.fillSingleTextField +import software.aws.toolkits.jetbrains.uitests.fixtures.findAndClick +import software.aws.toolkits.jetbrains.uitests.fixtures.idea +import software.aws.toolkits.jetbrains.uitests.fixtures.pressDelete +import software.aws.toolkits.jetbrains.uitests.fixtures.pressOk +import software.aws.toolkits.jetbrains.uitests.fixtures.welcomeFrame +import java.nio.file.Path +import java.nio.file.Paths +import java.time.Duration +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.UUID + +@TestInstance(Lifecycle.PER_CLASS) +class S3BrowserTest { + private val testDataPath: Path = Paths.get(System.getProperty("testDataPath")) + + private val profile = "default" + private val credential = "Profile:$profile" + private val region = "Oregon (us-west-2)" + private val date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + private val bucket = "uitest-$date-${UUID.randomUUID()}" + private val folder = UUID.randomUUID().toString() + + private val S3 = "S3" + private val createBucketText = "Create S3 Bucket" + private val deleteBucketText = "Delete S3 Bucket" + private val upload = "Upload..." + private val newFolder = "New folder..." + private val rename = "Rename..." + private val delete = "Delete..." + + private val jsonFile = "hello.json" + private val jsonFile2 = "hello2.json" + private val newJsonName = "helloooooooooo.json" + + @TempDir + lateinit var tempDir: Path + + @Test + @CoreTest + fun testS3Browser() = uiTest { + welcomeFrame { + openFolder(tempDir) + } + idea { + waitForBackgroundTasks() + showAwsExplorer() + } + idea { + step("Create bucket named $bucket") { + awsExplorer { + openExplorerActionMenu(S3) + } + find(byXpath("//div[@text='$createBucketText']")).click() + find(byXpath("//div[@class='JTextField']"), Duration.ofSeconds(5)).text = bucket + find(byXpath("//div[@text='Create']")).click() + } + + waitForS3BucketCreation() + + awsExplorer { + step("Open editor for bucket $bucket") { + expandExplorerNode(S3) + doubleClickExplorer(S3, bucket) + } + } + + // Click on the tree to make sure it's there + we aren't selecting anything else + s3Tree { click() } + + step("Upload object to top-level") { + actionButton(upload).click() + fileBrowser("Select") { + selectFile(testDataPath.resolve("testFiles").resolve(jsonFile)) + } + // Wait for the item to be uploaded + Thread.sleep(1000) + s3Tree { + findText(jsonFile) + } + } + + step("Create folder") { + actionButton(newFolder).click() + fillSingleTextField(folder) + pressOk() + // Wait for the folder to be created + Thread.sleep(1000) + s3Tree { + findText(folder) + } + } + + step("Upload object to folder") { + // TODO have to use findText instead of the reasonable clickRow or clickPath because + // it can't find anything for some reason + s3Tree { + findText(folder).click() + } + actionButton(upload).click() + fileBrowser("Select") { + selectFile(testDataPath.resolve("testFiles").resolve(jsonFile2)) + } + // Wait for the item to be uploaded + Thread.sleep(1000) + s3Tree { + findText(folder).doubleClick() + findText(jsonFile2) + } + } + + step("Rename a file") { + s3Tree { + findText(jsonFile).click() + } + actionButton(rename).click() + fillSingleTextField(newJsonName) + pressOk() + // Wait for the item to be renamed + Thread.sleep(1000) + s3Tree { + findText(newJsonName) + } + } + + step("Delete a file") { + s3Tree { + // Reopen the folder + findText(folder).doubleClick() + findText(jsonFile2).click() + } + actionButton(delete).click() + pressDelete() + // Wait for the item to be deleted + Thread.sleep(1000) + // make sure it's gone + s3Tree { + // Attempt to reopen the folder + findText(folder).doubleClick() + assertThat(findAllText(jsonFile2)).isEmpty() + } + } + + step("Open known file-types") { + s3Tree { + findText(newJsonName).doubleClick() + } + // Wait for the item to download and open + Thread.sleep(1000) + // Find the title bar + assertThat(findAll(byXpath("//div[@accessiblename='$newJsonName']"))).isNotEmpty + } + } + } + + @AfterAll + fun cleanup() { + step("Delete bucket named $bucket") { + uiTest { + idea { + showAwsExplorer() + awsExplorer { + openExplorerActionMenu(S3, bucket) + } + findAndClick("//div[@text='$deleteBucketText']") + fillSingleTextField(bucket) + pressOk() + waitForS3BucketDeletion() + } + } + } + } + + private fun waitForS3BucketDeletion() { + retryableS3 { client -> + try { + client.headBucket { it.bucket(bucket) } + log.info("The S3 bucket is not deleted yet, retrying") + false + } catch (e: S3Exception) { + log.info("The S3 bucket was deleted successfully") + true + } + } + } + + private fun waitForS3BucketCreation() { + retryableS3 { client -> + try { + client.headBucket { it.bucket(bucket) } + log.info("The S3 bucket was created successfully") + true + } catch (e: S3Exception) { + log.info("The S3 bucket does not exist yet, checking again") + false + } + } + } + + // TODO when the Java SDK v2 supports waiters this can be removed + private fun retryableS3(block: (S3Client) -> Boolean) { + var client: S3Client? = null + try { + client = S3Client.create() + for (i in 1..15) { + if (block(client)) { + break + } + Thread.sleep(250) + } + } catch (e: Exception) { + log.error("Unable to verify the S3 bucket was created, continuing!", e) + } finally { + client?.close() + } + } + + private fun RemoteRobot.s3Tree(func: (JTreeFixture.() -> Unit)) { + find(byXpath("//div[@class='S3TreeTable']"), Duration.ofSeconds(5)).apply(func) + } +}